diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt index b79aa1181..5d8edbc56 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/ConfigCache.kt @@ -437,6 +437,11 @@ object ConfigCache { Log.d(TAG, "Cache Update Complete. Map Swap successful.") + // Published right after the swap, because it describes the same set of scopes the swap just + // made current: a HyperOS process forked a moment from now has to see the configuration the + // daemon is already answering with, not the one it replaced. + publishHyosRuntimeIndex(newScopes) + // Targets are removed only after the module set has been published. (oldState.modules.keys - newModules.keys).forEach { FrameworkService.forgetHotReloadTargets(it) @@ -456,6 +461,63 @@ object ConfigCache { } } + /** + * Writes the index a process spawned by the HyperOS Rust Runtime's spawner reads to find the + * modules in its scope, and stages the libraries it names. + * + * This is the only one of the two ways a process learns what to load that does not go through the + * daemon's binder: those processes have no JVM, so no daemon service can exist in them, and no + * module list can be fetched over IPC. The scope map is the same one every other reader gets, so + * what a HyperOS process sees is exactly what an ART process of the same name would. + * + * Nothing happens on a device without that runtime, and nothing happens for a module that + * declares no native libraries — there is no `native_init` to call and so nothing to load. + */ + private fun publishHyosRuntimeIndex(scopes: Map>) { + if (!FileSystem.hasHyosRuntime()) return + + setupMiscPath() + val misc = state.miscPath ?: return + + // Each module is extracted once rather than once per process it is scoped to: extraction is the + // expensive half of this, and every reader gets the same file names from the same copy. A null + // is a module that ships nothing this ABI can load — an ordinary answer, and one worth + // remembering so the next scope does not ask again. + val staged = + scopes.values + .asSequence() + .flatten() + .filter { !it.code?.moduleLibraryNames.isNullOrEmpty() } + .distinctBy { it.packageName } + .associate { module -> + module.packageName to + FileSystem.stageHyosNativeLibraries(misc, module.packageName, module.apkPath) + } + + val index = mutableMapOf>() + scopes.forEach { (scope, modules) -> + val libraries = + modules.flatMap { module -> + val dir = staged[module.packageName] ?: return@flatMap emptyList() + module.code?.moduleLibraryNames.orEmpty().mapNotNull { name -> + val path = Paths.get(dir, name) + if (Files.isReadable(path)) { + HyosModuleLibrary(module.packageName, name, path.toString()) + } else { + // The module named a library its APK does not carry for this ABI. It still loads + // everywhere else; only this half of it has nothing to load. + Log.w(TAG, "Module ${module.packageName} declares $name, which is not in $dir") + null + } + } + } + if (libraries.isNotEmpty()) index[scope.processName] = libraries + } + + FileSystem.pruneHyosNativeLibraries(misc, staged.filterValues { it != null }.keys) + FileSystem.publishHyosIndex(misc, index) + } + fun getModulesForProcess(processName: String, uid: Int): List { ensureCacheReady() if (processName == "system_server") { diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt index b4479fd95..3db71a100 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/FileSystem.kt @@ -9,6 +9,7 @@ import android.os.Process import android.os.RemoteException import android.os.SELinux import android.os.SharedMemory +import android.os.SystemProperties import android.system.ErrnoException import android.system.Os import android.system.OsConstants @@ -50,6 +51,48 @@ private const val TAG = "VectorFileSystem" */ private const val SYSTEM_FILE_CONTEXT = "u:object_r:system_file:s0" +/** + * The properties the HyperOS Rust Runtime advertises itself with. + * + * Read rather than looking for `/system_ext/bin/hyos_spawner`: the runtime can be switched off on a + * device that still ships that binary, and an index nothing will ever read is work done for + * nothing. These are the same two properties LSPosed 2.2.0 reads for the same decision, in its + * daemon's `ILSPManagerService` transaction 67; they were recovered from its released daemon. + */ +private const val HYOS_ACTIVE_PROPERTY = "rust.runtime_active" +private const val HYOS_VERSION_PROPERTY = "rust.runtime_version" + +/** + * The index a process spawned by the HyperOS Rust Runtime's spawner reads to find the modules in + * its scope. + * + * Everything here has a counterpart in `zygisk/src/main/cpp/hyos_runtime.cpp`, which is the only + * reader; the two sets of constants have to agree or the feature quietly does nothing. + * + * The pointer file is the part that makes the random directory findable without listing /data/misc, + * which an app-domain process may not do. It sits directly in /data/misc because that directory + * belongs to the system uid with mode 0771: nothing running as an application can create an entry + * there, and the file itself is mode 0600, so only root — which is what the companion runs as — can + * read it. An application therefore cannot learn where the index is, and so cannot ask whether it + * is being hooked. + */ +private const val HYOS_POINTER_PATH = "/data/misc/vector.hyos" +private const val HYOS_INDEX_DIR = "hyos" +private const val HYOS_INDEX_MARKER = ".version" +private const val HYOS_INDEX_MAGIC = "vector-hyos 1" + +/** + * The subdirectory of the misc root holding copies of module libraries that a process cannot map + * out of the module's APK. + * + * Two of them, because the two readers have different lifetimes and each prunes what it no longer + * needs. system_server's copies are pruned down to the modules still bound for system_server; the + * HyperOS Runtime's, to the modules still in some scope. Sharing one directory would have each + * pass delete the other's copies, leaving whichever reader ran first with nothing to load. + */ +private const val STAGED_LIBRARY_DIR = "lib" +private const val HYOS_LIBRARY_DIR = "libhyos" + /** * What came of trying to load a module APK. * @@ -74,6 +117,16 @@ sealed interface ModuleLoad { val ModuleLoad.apkOrNull: ModuleCode? get() = (this as? ModuleLoad.Loaded)?.apk +/** + * One native library the index names for one process: which module asked for it, the name that + * module declared, and the absolute path the process should load it from. + */ +data class HyosModuleLibrary( + val modulePackage: String, + val libraryName: String, + val libraryPath: String, +) + object FileSystem { val basePath: Path = Paths.get("/data/adb/lspd") val logDirPath: Path = basePath.resolve("log") @@ -488,11 +541,19 @@ object FileSystem { * * Returns null when the module ships nothing for this ABI or the copy failed, in which case the * module still loads and only its native part fails, exactly as it does today. + * + * [dirName] selects which of the readers the copy is for; see [STAGED_LIBRARY_DIR]. The two sets + * are kept apart because each is pruned against a different notion of "still needed". */ - fun stageNativeLibraries(root: Path, packageName: String, apkPath: String): String? = + fun stageNativeLibraries( + root: Path, + packageName: String, + apkPath: String, + dirName: String = STAGED_LIBRARY_DIR, + ): String? = runCatching { val apk = File(apkPath) - val dir = root.resolve("lib").resolve(packageName) + val dir = root.resolve(dirName).resolve(packageName) // Re-extract only when the APK behind the copy changed. Getting this wrong in the // lenient direction would leave system_server running a module's superseded native @@ -546,10 +607,14 @@ object FileSystem { * Drops staged libraries belonging to modules that are no longer bound for system_server, so an * uninstalled or rescoped module does not leave a copy of its native code behind for good. */ - fun pruneStagedNativeLibraries(root: Path?, keep: Set) { + fun pruneStagedNativeLibraries( + root: Path?, + keep: Set, + dirName: String = STAGED_LIBRARY_DIR, + ) { if (root == null) return runCatching { - val libRoot = root.resolve("lib") + val libRoot = root.resolve(dirName) if (!libRoot.isDirectory()) return Files.list(libRoot).use { stream -> stream @@ -560,6 +625,112 @@ object FileSystem { .onFailure { Log.e(TAG, "Failed to prune staged native libraries", it) } } + /** Whether this device runs applications on the HyperOS Rust Runtime at all. */ + fun hasHyosRuntime(): Boolean = + SystemProperties.getBoolean(HYOS_ACTIVE_PROPERTY, false) && + SystemProperties.get(HYOS_VERSION_PROPERTY).orEmpty().isNotEmpty() + + /** + * Stages a module's native libraries for the HyperOS Runtime's reader rather than system_server's. + * + * A process there cannot be told a search path the way an injected ART process can, so it is + * handed absolute paths in the index below and needs the libraries to exist at those paths. The + * APK itself would do — /data/app is readable and mappable by any app domain — but the entry has + * to be STORED inside the zip for the loader to open it, and a module that compressed its + * libraries would silently lose its native part. A copy has neither problem. + */ + fun stageHyosNativeLibraries(root: Path, packageName: String, apkPath: String): String? = + stageNativeLibraries(root, packageName, apkPath, HYOS_LIBRARY_DIR) + + /** Drops HyperOS-staged libraries of modules that are in no scope any more. */ + fun pruneHyosNativeLibraries(root: Path?, keep: Set) { + pruneStagedNativeLibraries(root, keep, HYOS_LIBRARY_DIR) + } + + /** + * Publishes the index a process spawned by the HyperOS Rust Runtime reads to find the modules in its + * scope. + * + * The whole file lives inside the daemon's random directory and is readable by path to anything + * that knows the path, which is deliberate: the reader is a process with no binder and no JVM, so + * a file is the only channel there is. What keeps the list private is that the path is not + * discoverable — see [HYOS_POINTER_PATH] for the half of that which the companion reads. + * + * A process with no entry is in nobody's scope, and files for processes that stopped being in + * scope are removed, so the index always describes exactly the current configuration. + */ + fun publishHyosIndex(miscPath: Path, index: Map>) { + runCatching { + val dir = miscPath.resolve(HYOS_INDEX_DIR) + Files.createDirectories(dir) + + val written = mutableSetOf() + index.forEach { (processName, libraries) -> + // A process name is a manifest string that becomes a file name here. Android's own + // validation is not something to lean on for a write performed as root, so anything + // that is not a plain name is refused rather than resolved. + if (processName.isEmpty() || processName.contains('/') || processName == "." || + processName == "..") { + Log.w(TAG, "Refusing to publish an index under the name '$processName'") + return@forEach + } + val target = dir.resolve(processName) + val text = + buildString { + appendLine(HYOS_INDEX_MAGIC) + libraries.forEach { + append(it.modulePackage) + append('\t') + append(it.libraryName) + append('\t') + append(it.libraryPath) + append('\n') + } + } + Files.writeString( + target, + text, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + ) + Os.chmod(target.toString(), "644".toInt(8)) + written.add(processName) + } + + val marker = dir.resolve(HYOS_INDEX_MARKER) + Files.writeString( + marker, + "$HYOS_INDEX_MAGIC\n", + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + ) + Os.chmod(marker.toString(), "644".toInt(8)) + + Files.list(dir).use { stream -> + stream + .filter { + val name = it.fileName.toString() + name != HYOS_INDEX_MARKER && name !in written + } + .forEach { it.toFile().delete() } + } + + // The daemon runs with a zero umask, so every mode here is set rather than inherited. The + // directory is searchable but not listable, the way the rest of the staged tree is, and + // the label is what lets a process running as an application read it at all. + Os.chmod(dir.toString(), "711".toInt(8)) + setSelinuxContextRecursive(dir, "u:object_r:xposed_data:s0") + + val pointer = File(HYOS_POINTER_PATH) + pointer.writeText("misc=$miscPath\n") + // 0600 and owned by the daemon: the companion, which is root, is the only reader meant to + // have this. An application that could read it would learn where the index is and could + // then ask whether it is being hooked. + Os.chmod(pointer.absolutePath, "600".toInt(8)) + } + .onFailure { Log.e(TAG, "Failed to publish the HyperOS Runtime index", it) } + } + fun toGlobalNamespace(path: String): File { return if (path.startsWith("/")) File("/proc/1/root", path) else File("/proc/1/root/$path") } diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/AppInfo.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/AppInfo.kt index 5ccb3e50b..d93340df1 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/model/AppInfo.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/model/AppInfo.kt @@ -9,6 +9,18 @@ data class AppInfo( val appName: String, val isSystemApp: Boolean, val isGame: Boolean, + /** + * Whether the app announced itself as one that runs on the HyperOS Rust Runtime. + * + * HyperOS applications carry the name of the runtime library they want loaded in the + * `hyperos_app_lib_name` metadata entry of their manifest, and the entry's presence is what + * marks them — its value is for the runtime, not for us. That is the same test LSPosed 2.2.0's + * manager applies for the same label, recovered from its released build. + * + * It says nothing about whether the runtime is actually being injected into them; that is a + * property of the device, not of the app, and is answered separately. + */ + val isHyperOsRuntime: Boolean = false, val isSelectedInScope: Boolean, /** * In the scope without anyone having put it there, and not removable. diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt index 6865d71ba..606a1f35d 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/data/repository/AppRepository.kt @@ -16,6 +16,15 @@ import org.matrix.vector.manager.data.model.versionCodeCompat import org.matrix.vector.manager.ipc.DaemonClient import org.matrix.vector.manager.logW +/** + * The manifest metadata entry a HyperOS application uses to name its Rust runtime library. + * + * Recovered from LSPosed 2.2.0's manager, which reads this same key for the same label. The entry + * is written by the application, so what it says is a claim rather than a fact; the framework only + * ever shows it next to the app, which is why that is an acceptable way to learn it. + */ +private const val HYPEROS_APP_LIB_NAME = "hyperos_app_lib_name" + /** Fetches and caches the list of installed applications from the daemon. */ class AppRepository( private val daemonClient: DaemonClient, @@ -122,12 +131,18 @@ class AppRepository( val userId = appInfo.uid / PER_USER_RANGE + // GET_META_DATA is part of the flags the daemon is asked with, so the bundle is + // already here and reading it costs nothing. An absent entry and an empty one mean + // the same thing, and both are the ordinary case. + val hyperOsLibrary = appInfo.metaData?.getString(HYPEROS_APP_LIB_NAME) + AppInfo( packageName = pkg.packageName, userId = userId, appName = appInfo.loadLabel(packageManager).toString(), isSystemApp = isSystem, isGame = isGame, + isHyperOsRuntime = !hyperOsLibrary.isNullOrEmpty(), isSelectedInScope = false, // To be merged later in the ViewModel isRecommended = false, lastUpdateTime = pkg.lastUpdateTime, diff --git a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt index d6b76db81..7c7496436 100644 --- a/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt +++ b/manager/src/main/kotlin/org/matrix/vector/manager/ui/screens/modules/ScopeScreen.kt @@ -991,6 +991,20 @@ private fun AppRow( color = ring, ) } + // A property of the app rather than of the scope, so it is stated in its own colour + // and on its own line: the origin label above says why the row is in the list, and + // this says what the row cannot be expected to behave like. A HyperOS application + // runs on the Rust runtime, where none of the Java side of the framework exists, so + // what a module hooks there is its native half and nothing else — which is a + // difference worth stating before the user wonders why nothing happened. + if (app.isHyperOsRuntime) { + Text( + text = stringResource(R.string.scope_hyperos_runtime), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } // Why this row does not behave like the rest: the framework is one process shared // by every user, and a legacy module's own app is in the scope without anyone // having put it there. Both are things a checkbox cannot say. diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index ec54516e1..f7ff70f54 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -255,6 +255,8 @@ No app matches that search or filter. System apps Games + + HyperOS Runtime Requested by this module This module declares a fixed scope and cannot be pointed at other apps. diff --git a/native/README.md b/native/README.md index fdf947a02..dc7c08c19 100644 --- a/native/README.md +++ b/native/README.md @@ -15,7 +15,7 @@ This module defines the central abstractions and manages the runtime state. It's - **`Context`**: An abstract base class that defines the injection lifecycle. It contains pure virtual methods like `LoadDex` and `SetupEntryClass`. The consumer of this library (e.g., the Zygisk module) must inherit from `Context` and provide the concrete implementations for these steps. - **`ConfigBridge`**: A simple, native-side singleton that acts as a cache for configuration data (specifically, the obfuscation map) that is fetched and provided by the consumer. -- **`native_api`**: Implements the native module support system. It works by hooking the system's `do_dlopen` function. When it detects a registered module library being loaded, it calls that library's `native_init` entry point, providing it with a set of [API](include/core/native_api.h)s for creating its own native hooks. +- **`native_api`**: Implements the native module support system. It works by hooking the system's `do_dlopen` function. When it detects a registered module library being loaded, it calls that library's `native_init` entry point, providing it with a set of [API](include/core/native_api.h)s for creating its own native hooks. The library names normally arrive over JNI from the injected framework, and the hook engine behind the API is Dobby, but neither is required: `SetHookBackend` lets a runtime supply its own primitives, which is how the HyperOS Rust Runtime path (see `zygisk/src/main/cpp/hyos_runtime.cpp`) drives this same code from a process that has no JVM at all. ### `elf` - Symbol Resolution diff --git a/native/include/core/native_api.h b/native/include/core/native_api.h index 1a97e82de..624757d0f 100644 --- a/native/include/core/native_api.h +++ b/native/include/core/native_api.h @@ -4,7 +4,6 @@ #include #include -#include #include "common/config.h" #include "common/logging.h" @@ -95,11 +94,45 @@ namespace vector::native { using NativeInit = NativeOnModuleLoaded (*)(const NativeAPIEntries *entries); /** - * @brief Installs the hooks required for the native API to function. - * @param handler The LSPlant hook handler. - * @return True on success, false on failure. + * @brief Replaces the hooking primitives handed to native modules and used for the API's own + * interception of the dynamic loader. + * + * Dobby is the default and is what an ART process wants. A runtime that brings its own hook engine + * should install it here instead, before the first call to RegisterNativeLib: the engine that + * already owns the runtime's code knows which addresses it has patched, and two engines patching + * the same address destroy each other's trampolines. The HyperOS Rust Runtime path, which receives + * Zygisk Next's hook API, is the reason this exists. + * + * Both callbacks use the same calling convention as Dobby's: return 0 on success. + * + * @param hook The primitives that install a hook. + * @param unhook The primitives that remove one. + */ +void SetHookBackend(HookFunType hook, UnhookFunType unhook); + +/** + * @brief Returns the entries handed to native modules, building them on first use. + * + * A caller that must initialize a module itself — because it cannot rely on the loader hook being + * in place, or because it loads the library deliberately rather than waiting for someone else to — + * needs the same entries the hook would have passed, and needs them whether or not the hook was + * installed. + * + * @return The read-only entries, or nullptr when the page they live on could not be allocated. + */ +const NativeAPIEntries *GetNativeAPIEntries(); + +/** + * @brief Installs the interception of the dynamic loader that the native API is built on. + * + * RegisterNativeLib calls this on its first use; a caller that must own the hook itself — to + * install it before anything it cares about is loaded, or to report the failure — can call it + * earlier. The installed hook lives as long as the process and is never removed. + * + * @return True when the interception is live, false when it could not be placed. A false here is + * not fatal: without it native modules are simply never initialized. */ -bool InstallNativeAPI(const lsplant::HookHandler &handler); +bool InstallNativeAPI(); /** * @brief Registers a native library by its filename for module initialization. diff --git a/native/src/core/native_api.cpp b/native/src/core/native_api.cpp index 308b5a5e9..6bf923bca 100644 --- a/native/src/core/native_api.cpp +++ b/native/src/core/native_api.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include "common/logging.h" #include "elf/elf_image.h" @@ -17,66 +19,24 @@ * @brief Implementation of the native module loading and API provisioning system. */ -using lsplant::operator""_sym; /* * =========================================================================================== - * LSPLANT HOOKING DSL (DOMAIN SPECIFIC LANGUAGE) DOCUMENTATION + * HOW A MODULE'S NATIVE PART GETS INITIALIZED * =========================================================================================== * - * This source file utilizes the 'lsplant' library, which implements a C++20 Hooking DSL. - * Unlike traditional C-style hooking (which relies on void* casting, manual trampolines, - * and global function pointers), this DSL uses compile-time metaprogramming to ensure - * type safety and encapsulate hooking logic. + * The dynamic loader is the one place every native library a process loads passes through, and it + * is the same place whether the process runs ART or not. So the whole mechanism hangs off a single + * inline hook on the linker's `do_dlopen`: * - * ------------------------------------------------------------------------------------------- - * 1. SYNTAX ANATOMY - * ------------------------------------------------------------------------------------------- - * The hooking syntax follows this pattern: - * "SYMBOL_NAME"_sym .hook ->* [] (args...) { ...body... }; + * 1. A module's declared library names are recorded by RegisterNativeLib. + * 2. The first such call installs the `do_dlopen` hook, through whichever hook backend is in + * effect: Dobby by default, or the engine the runtime supplied through SetHookBackend. + * 3. When a matching library is loaded, the hook resolves its `native_init`, calls it with the + * NativeAPIEntries, and keeps the callback it returns to replay on every later load. * - * A. "SYMBOL_NAME"_sym - * - This is a C++ User-Defined Literal (UDL). It converts the string literal into a - * compile-time 'Symbol' type. - * - For C++ mangled names (common in Android system libs), you must provide the full - * mangled signature (e.g., "__dl__Z9do_dlopen..."). - * - * B. Multi-Architecture Support (| Operator) - * - Android often requires different symbol names for 32-bit (ARM) and 64-bit (ARM64). - * - The DSL supports the pipe operator '|' to select the correct symbol at compile time: - * ("Sym32"_sym | "Sym64"_sym) - * - * C. .hook ->* - * - '.hook' accesses the hook injection mechanism. - * - '->*' (Member Pointer Operator) is overloaded to bind the symbol to the lambda. - * - * D. The Template Lambda (The Replacement) - * - Syntax: [] (Type arg1, Type arg2...) { ... } - * - This is a C++20 Template Lambda. - * - 'backup': Represents the ORIGINAL function (trampoline). - * You call this to execute the original system logic. - * - 'args...': Must match the signature of the target function exactly. - * - * ------------------------------------------------------------------------------------------- - * 2. EXAMPLE USAGE - * ------------------------------------------------------------------------------------------- - * inline static auto my_hook = - * "__open"_sym.hook ->* [](const char* path, int flags) { - * // 1. Pre-processing (Before original) - * LOGD("Opening file: %s", path); - * - * // 2. Call Original (The "Backup") - * int result = backup(path, flags); - * - * // 3. Post-processing (After original) - * return result; - * }; - * - * ------------------------------------------------------------------------------------------- - * 3. REGISTRATION - * ------------------------------------------------------------------------------------------- - * Defining the hook variable does not apply it. - * You must pass the variable to the HookHandler to modify memory: handler(my_hook). - * =========================================================================================== + * None of that needs a JVM, which is what lets the same code serve both an ART process, where the + * names arrive over JNI, and a HyperOS Rust Runtime process, where they are read from disk by + * zygisk/src/main/cpp/hyos_runtime.cpp. */ namespace vector::native { @@ -96,6 +56,27 @@ std::unique_ptr> g_api_page( munmap(ptr, 4096); } }); + +// The hooking primitives the API hands out and uses for its own interception of the loader. Dobby +// unless something with a better claim on the process replaced it. +HookFunType g_hook_func = &HookInline; +UnhookFunType g_unhook_func = &UnhookInline; + +// The trampoline the `do_dlopen` hook calls to reach the real loader. Written once, with the hook. +void *g_original_do_dlopen = nullptr; +bool g_native_api_installed = false; + +// Both one-time builds below are reachable from more than one entry point, so each gets its own +// guard rather than a function-local static that only the first caller would share. +std::once_flag g_api_entries_once; +std::once_flag g_install_once; + +using DoDlopenFn = void *(*)(const char *, int, const void *, const void *); + +// `do_dlopen` under its exported, mangled linker name: the linker exports nothing unmangled, and +// this is the entry every load in the process funnels through - libc's `dlopen`, the Android +// extensions, and the loader's own `android_dlopen_ext`. +constexpr auto kDoDlopenSymbol = "__dl__Z9do_dlopenPKciPK17android_dlextinfoPKv"; } // namespace // The read-only, statically available Native API entry points for modules. @@ -105,56 +86,45 @@ const NativeAPIEntries *g_native_api_entries = nullptr; * @brief Initializes the Native API entries struct and makes it read-only. */ void InitializeApiEntries() { - if (g_api_page.get() == MAP_FAILED) { - LOGF("Failed to allocate memory for native API entries."); - LOGD("Release the memory page pointer %p", g_api_page.release()); - return; - } - auto *entries = new (g_api_page.get()) NativeAPIEntries{ - .version = 2, - .hookFunc = &HookInline, - .unhookFunc = &UnhookInline, - }; - if (mprotect(g_api_page.get(), 4096, PROT_READ) != 0) { - PLOGE("Failed to mprotect API page to read-only"); - } - g_native_api_entries = entries; - LOGI("Native API entries initialized and protected."); + std::call_once(g_api_entries_once, []() { + if (g_api_page.get() == MAP_FAILED) { + LOGF("Failed to allocate memory for native API entries."); + LOGD("Release the memory page pointer %p", g_api_page.release()); + return; + } + auto *entries = new (g_api_page.get()) + NativeAPIEntries{.version = 2, .hookFunc = g_hook_func, .unhookFunc = g_unhook_func}; + if (mprotect(g_api_page.get(), 4096, PROT_READ) != 0) { + PLOGE("Failed to mprotect API page to read-only"); + } + g_native_api_entries = entries; + LOGI("Native API entries initialized and protected."); + }); } -void RegisterNativeLib(const std::string &library_name) { - static bool is_initialized = []() { - InitializeApiEntries(); - return InstallNativeAPI(lsplant::InitInfo{ - .inline_hooker = - [](void *target, void *replacement) { - void *backup = nullptr; - return HookInline(target, replacement, &backup) == 0 ? backup : nullptr; - }, - .art_symbol_resolver = - [](auto symbol) { return ElfSymbolCache::GetLinker()->getSymbAddress(symbol); }, - }); - }(); +const NativeAPIEntries *GetNativeAPIEntries() { + InitializeApiEntries(); + return g_native_api_entries; +} - if (!is_initialized) { - LOGE("Cannot register module '{}' because native API failed to initialize.", - library_name.c_str()); +void SetHookBackend(HookFunType hook, UnhookFunType unhook) { + if (hook == nullptr || unhook == nullptr) { + LOGE("Refusing a null hook backend; keeping the default."); return; } - - std::lock_guard lock(g_module_registry_mutex); - // The list is walked on every dlopen in the process and never shrinks - there is no - // unregistration, and hot reload records a module's names again for each new generation - so - // without this it grows without bound and every dlopen pays for the duplicates. - if (std::find(g_module_native_libs.begin(), g_module_native_libs.end(), library_name) != - g_module_native_libs.end()) { - LOGD("Native module library '{}' is already registered.", library_name.c_str()); + if (g_native_api_entries != nullptr) { + // The entries page has been handed to modules and made read-only. Swapping the primitives + // now would leave them holding the old pair with no way to notice. + LOGE("The hook backend must be set before the native API is first used; ignoring."); return; } - g_module_native_libs.push_back(library_name); - LOGD("Native module library '{}' has been registered.", library_name.c_str()); + g_hook_func = hook; + g_unhook_func = unhook; + LOGD("Native API hook backend replaced."); } +namespace { + bool HasEnding(std::string_view fullString, std::string_view ending) { if (fullString.length() >= ending.length()) { return (fullString.compare(fullString.length() - ending.length(), std::string_view::npos, @@ -163,44 +133,124 @@ bool HasEnding(std::string_view fullString, std::string_view ending) { return false; } -inline static auto do_dlopen_hook = - "__dl__Z9do_dlopenPKciPK17android_dlextinfoPKv"_sym.hook->* - [](const char *name, int flags, const void *extinfo, - const void *caller_addr) static -> void * { - void *handle = backup(name, flags, extinfo, caller_addr); +/** + * @brief Hands a newly loaded library to the modules that asked for it, then to every module that + * wants to see every load. + * + * The module code is called with no lock held. `native_init`, and the callbacks it returns, belong + * to somebody else and are free to call `dlopen` -- which re-enters this function on the same + * thread. Holding the registry mutex across those calls would turn that into a self-deadlock, and + * it is not a hypothetical: a module whose whole purpose is native hooking reaches for the loaded + * libraries by name. + */ +void OnLibraryLoaded(const char *name, void *handle) { const std::string lib_name = (name != nullptr) ? name : "null"; LOGV("do_dlopen hook triggered for library: '{}'", lib_name.c_str()); - if (handle == nullptr) return nullptr; + if (handle == nullptr) return; - std::lock_guard lock(g_module_registry_mutex); - - for (std::string_view module_lib : g_module_native_libs) { - if (HasEnding(lib_name, module_lib)) { - LOGI("Detected registered native module being loaded: '{}'", lib_name.c_str()); - void *init_sym = dlsym(handle, "native_init"); - if (init_sym == nullptr) { - LOGW("Library '{}' matches a module name but does not export 'native_init'.", - lib_name.c_str()); + bool matches_a_module = false; + std::vector callbacks; + { + std::lock_guard lock(g_module_registry_mutex); + for (std::string_view module_lib : g_module_native_libs) { + if (HasEnding(lib_name, module_lib)) { + matches_a_module = true; break; } + } + callbacks.assign(g_module_loaded_callbacks.begin(), g_module_loaded_callbacks.end()); + } + + if (matches_a_module) { + LOGI("Detected registered native module being loaded: '{}'", lib_name.c_str()); + void *init_sym = dlsym(handle, "native_init"); + if (init_sym == nullptr) { + LOGW("Library '{}' matches a module name but does not export 'native_init'.", + lib_name.c_str()); + } else { auto native_init = reinterpret_cast(init_sym); if (auto callback = native_init(g_native_api_entries)) { + std::lock_guard lock(g_module_registry_mutex); g_module_loaded_callbacks.push_back(callback); LOGI("Initialized native module '{}' and registered its callback.", lib_name.c_str()); } - break; } } - for (const auto &callback : g_module_loaded_callbacks) { + for (const auto &callback : callbacks) { callback(name, handle); } +} +void *DoDlopenHook(const char *name, int flags, const void *extinfo, const void *caller_addr) { + auto original = reinterpret_cast(g_original_do_dlopen); + if (original == nullptr) { + // Not reachable in practice: the trampoline is stored before the hook can ever be reached. + LOGE("The do_dlopen hook ran without a trampoline; refusing to guess."); + return nullptr; + } + void *handle = original(name, flags, extinfo, caller_addr); + OnLibraryLoaded(name, handle); return handle; -}; +} + +} // namespace + +bool InstallNativeAPI() { + std::call_once(g_install_once, []() { + auto *linker = ElfSymbolCache::GetLinker(); + if (linker == nullptr) { + LOGE("Cannot install the native API: the linker image could not be resolved."); + return; + } + void *target = linker->getSymbAddress(kDoDlopenSymbol); + if (target == nullptr) { + LOGE("Cannot install the native API: {} is not exported by {}.", kDoDlopenSymbol, + linker->GetPath().c_str()); + return; + } + + void *original = nullptr; + if (g_hook_func(target, reinterpret_cast(&DoDlopenHook), &original) != 0 || + original == nullptr) { + LOGE("Cannot install the native API: hooking {} at {} failed.", kDoDlopenSymbol, + target); + return; + } + + g_original_do_dlopen = original; + g_native_api_installed = true; + LOGI("Native API installed: {} hooked at {} (trampoline {}).", kDoDlopenSymbol, target, + original); + }); + return g_native_api_installed; +} + +void RegisterNativeLib(const std::string &library_name) { + static bool is_initialized = []() { + InitializeApiEntries(); + return InstallNativeAPI(); + }(); -bool InstallNativeAPI(const lsplant::HookHandler &handler) { return handler(do_dlopen_hook); } + if (!is_initialized) { + LOGE("Cannot register module '{}' because native API failed to initialize.", + library_name.c_str()); + return; + } + + std::lock_guard lock(g_module_registry_mutex); + // The list is walked on every dlopen in the process and never shrinks - there is no + // unregistration, and hot reload records a module's names again for each new generation - so + // without this it grows without bound and every dlopen pays for the duplicates. + if (std::find(g_module_native_libs.begin(), g_module_native_libs.end(), library_name) != + g_module_native_libs.end()) { + LOGD("Native module library '{}' is already registered.", library_name.c_str()); + return; + } + g_module_native_libs.push_back(library_name); + LOGD("Native module library '{}' has been registered.", library_name.c_str()); +} } // namespace vector::native diff --git a/zygisk/module/customize.sh b/zygisk/module/customize.sh index 049ca7b8e..3e349035d 100644 --- a/zygisk/module/customize.sh +++ b/zygisk/module/customize.sh @@ -82,7 +82,7 @@ esac ui_print "- Device platform: $ARCH ($ABI32 / $ABI64)" ui_print "- Extracting root module files" -for file in module.prop action.sh service.sh uninstall.sh sepolicy.rule framework/vector.dex cli daemon.apk daemon manager.apk; do +for file in module.prop action.sh service.sh uninstall.sh sepolicy.rule framework/vector.dex cli daemon.apk daemon manager.apk zn_modules.txt; do extract "$ZIPFILE" "$file" "$MODPATH" done diff --git a/zygisk/module/zn_modules.txt b/zygisk/module/zn_modules.txt new file mode 100644 index 000000000..c7a37c9f3 --- /dev/null +++ b/zygisk/module/zn_modules.txt @@ -0,0 +1 @@ +path=/system_ext/bin/hyos_spawner companion zygisk/arm64-v8a.so diff --git a/zygisk/src/main/cpp/CMakeLists.txt b/zygisk/src/main/cpp/CMakeLists.txt index 52a4794fa..b587cd24b 100644 --- a/zygisk/src/main/cpp/CMakeLists.txt +++ b/zygisk/src/main/cpp/CMakeLists.txt @@ -7,7 +7,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) add_subdirectory(${VECTOR_ROOT}/native native) -add_library(${PROJECT_NAME} SHARED module.cpp ipc_bridge.cpp) +add_library(${PROJECT_NAME} SHARED module.cpp ipc_bridge.cpp hyos_runtime.cpp) target_include_directories(${PROJECT_NAME} PUBLIC include) target_link_libraries(${PROJECT_NAME} native log) diff --git a/zygisk/src/main/cpp/hyos_runtime.cpp b/zygisk/src/main/cpp/hyos_runtime.cpp new file mode 100644 index 000000000..81b35051a --- /dev/null +++ b/zygisk/src/main/cpp/hyos_runtime.cpp @@ -0,0 +1,682 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "common/logging.h" +#include "core/native_api.h" + +#include "zygisk_next_api.h" + +/** + * @file hyos_runtime.cpp + * @brief Native hooking support for applications running on the HyperOS Rust Runtime. + * + * A process spawned by /system_ext/bin/hyos_spawner is not an ART process. It has no JVM, no + * JNIEnv, and no binder of its own, so none of the machinery the rest of Vector is built on is + * available inside it: the framework DEX cannot be loaded, and the daemon cannot be asked what is + * in scope. What such a process does have is the dynamic loader, and therefore native libraries. + * + * This translation unit is the whole of Vector's presence there. It is compiled into the same + * libzygisk.so the Zygisk loader injects everywhere else, and it is reached through Zygisk Next's + * Runtime API, which the module declares itself for in zn_modules.txt: + * + * path=/system_ext/bin/hyos_spawner companion zygisk/arm64-v8a.so + * + * The flow, in order: + * + * 1. Zygisk Next injects this library into hyos_spawner and calls `zn_module`'s onModuleLoaded. + * getRuntime() reports ZN_RUNTIME_HYOS, and we register `zn_companion_module`'s callbacks. + * 2. The spawner forks an application process, applies its uid, gid, groups and SELinux context, + * and calls onAppSpecialized in the child. + * 3. The child asks the companion -- a root process Zygisk Next forked for us, reached over a + * socket the spawner connected and every child inherited -- for the path the Vector daemon + * publishes its per-package state under. + * 4. The child reads the index for its own package, which names the native libraries of every + * Xposed module in scope for it, and loads each one. Each library's `native_init` is then + * called with the same NativeAPIEntries an ART process gets, so a module's native part needs + * no change to work here: it hooks the HyperOS process with the primitives it was already + * written against. + * + * The hook primitives come from Zygisk Next rather than from Vector's own Dobby, for the reason + * SetHookBackend documents: two engines patching the same address destroy each other's trampolines, + * and the engine that already owns this process has the better claim. + */ + +namespace vector::native::hyos { + +namespace { + +// --- The published state, and where to find it ------------------------------------------------ + +/** + * A fixed path the daemon writes the real location into, because there is nothing to list. + * + * /data/misc belongs to the system uid with mode 0771, so only root and the daemon can create + * entries in it, and this file is mode 0600. That combination is deliberate: a process running as + * an application cannot read it, so an application cannot learn the directory the index lives in + * and therefore cannot ask whether it is being hooked. The companion is the only reader, and it + * runs as root. + */ +constexpr auto kPointerPath = "/data/misc/vector.hyos"; + +/// The prefix of the single line the pointer file carries. +constexpr std::string_view kPointerKey = "misc="; + +/// Where the random Vector directory lives, for the fallback that has to go looking. +constexpr auto kMiscRootPath = "/data/misc"; + +/// The index directory the daemon creates inside its random directory, and its marker file. +constexpr std::string_view kIndexDirName = "hyos"; +constexpr std::string_view kIndexMarkerName = ".version"; +constexpr std::string_view kIndexMagic = "vector-hyos 1"; + +/// The socket message that asks the companion for the index location. +constexpr char kRequestMiscRoot = 'M'; + +/// Longest reply accepted from the companion, so a confused peer cannot exhaust memory. +constexpr size_t kMaxReplyLength = 4096; + +/** + * Where the companion reports whether this process's hyperos support is actually working. + * + * A client connects and reads one byte: `1` means the Zygisk Next Runtime API was registered and + * applications will be specialized, anything else means the runtime did not want us. It is the + * whole interface, and it exists because the daemon has no other way to ask: the daemon is a Java + * process, and the connection the spawner holds is not one it can reach. + * + * The path is the same one LSPosed 2.2.0 uses, recovered from its released daemon -- its + * `ILSPManagerService` transaction 67 connects here and reads exactly that byte, and its manager + * turns the answer into the "HyperOS Runtime injection failed" notice the user sees. Being under + * /data/adb/lspd, which is mode 0700 owned by root, is deliberate: the companion and the daemon are + * both root, and nothing running as an application may ask. + */ +constexpr auto kMonitorPath = "/data/adb/lspd/hyos_monitor"; + +/// How long the companion waits for the spawner's status byte before assuming the worst. +constexpr time_t kStatusByteTimeoutSeconds = 2; + +// --- The Zygisk Next view of this process ----------------------------------------------------- + +const ZygiskNextAPI *g_api = nullptr; + +// How long a child waits for the companion before giving up on it. +// +// This runs inside application startup, ahead of everything the application does, so a companion +// that has wedged must cost a bounded delay rather than the launch itself. The reply is a path +// string written by a process that is already running, so anything above milliseconds means the +// companion is not coming. +constexpr time_t kCompanionReplyTimeoutSeconds = 1; + +// The companion connection, established by the spawner before any fork and inherited by every +// child. -1 when the companion could not be started, which costs the callback delivery. +int g_companion_fd = -1; + +// Whether the HyperOS Runtime API was actually registered here. Zygisk Next's Runtime API is an +// optional feature of that loader: an older build does not offer it, and one that does can still +// refuse the registration. None of that is this process's problem, so every such path turns the +// feature off rather than failing -- an ordinary Zygisk process and a hyos_spawner running without +// us are both perfectly good outcomes. +bool g_registered = false; + +// What the monitor hands out: 1 once the Runtime API is registered, 0 until then and forever if it +// never is. Written by the companion when the spawner reports, read by every monitor client -- two +// threads of one process, which is why it is atomic rather than plain. +std::atomic g_injection_status{0}; + +// Whether this process has already done its work. A child is forked once and specializes once, but +// a runtime is free to call the callback again, and loading a module's libraries twice would run +// its native_init twice. +bool g_specialized = false; + +// --- Reading files without assuming anything -------------------------------------------------- + +/// Reads a whole file, up to a sane bound. False when it cannot be read at all. +bool ReadFile(const std::string &path, std::string &out) { + const int fd = open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) return false; + + out.clear(); + char buf[512]; + for (;;) { + const ssize_t n = read(fd, buf, sizeof(buf)); + if (n < 0) { + close(fd); + out.clear(); + return false; + } + if (n == 0) break; + out.append(buf, static_cast(n)); + // Every file read here is a few hundred bytes of text; a larger one is not one of ours. + if (out.size() > (1u << 20)) { + close(fd); + out.clear(); + return false; + } + } + close(fd); + return true; +} + +/// Writes every byte, or reports that it could not. +bool WriteAll(int fd, const std::string &data) { + size_t written = 0; + while (written < data.size()) { + const ssize_t n = write(fd, data.data() + written, data.size() - written); + if (n <= 0) return false; + written += static_cast(n); + } + return true; +} + +/// The first line of `text`, without its terminator. +std::string FirstLine(const std::string &text) { + const auto end = text.find('\n'); + std::string line = text.substr(0, end == std::string::npos ? text.size() : end); + if (!line.empty() && line.back() == '\r') line.pop_back(); + return line; +} + +/** + * @brief Whether a directory really is the one the Vector daemon publishes into. + * + * The random name is the whole point of the directory, so finding one by looking means finding + * somebody else's directory just as easily. The marker is what tells them apart. + */ +bool IsVectorMiscRoot(const std::string &root) { + std::string content; + const std::string marker = + root + "/" + std::string(kIndexDirName) + "/" + std::string(kIndexMarkerName); + if (!ReadFile(marker, content)) return false; + return FirstLine(content) == kIndexMagic; +} + +/** + * @brief Finds the index location. + * + * The pointer file first, because it needs no directory listing at all. Failing that, look inside + * /data/misc, where the daemon's directory is the only one carrying the marker. Both run in the + * companion, which is root; an application process could do neither. + */ +std::string ResolveMiscRoot() { + std::string pointer; + if (ReadFile(kPointerPath, pointer)) { + const std::string line = FirstLine(pointer); + const std::string_view view{line}; + if (view.rfind(kPointerKey, 0) == 0) { + std::string root{view.substr(kPointerKey.size())}; + if (!root.empty() && root.back() == '/') root.pop_back(); + if (IsVectorMiscRoot(root)) return root; + LOGW("The published Vector directory '{}' carries no index marker.", root.c_str()); + } else { + LOGW("{} does not name a Vector directory.", kPointerPath); + } + } else { + LOGD("No published Vector directory at {}.", kPointerPath); + } + + // Fallback: it is a random name under /data/misc, so the marker is the only way to recognise + // it. Reached when the pointer file is missing, or names something that is not there. + DIR *dir = opendir(kMiscRootPath); + if (dir == nullptr) { + LOGW("Cannot look for the Vector directory in {}: {}.", kMiscRootPath, strerror(errno)); + return {}; + } + std::string found; + while (struct dirent *entry = readdir(dir)) { + if (entry->d_name[0] == '.') continue; + std::string candidate = std::string(kMiscRootPath) + "/" + entry->d_name; + if (IsVectorMiscRoot(candidate)) { + found = std::move(candidate); + break; + } + } + closedir(dir); + if (!found.empty()) LOGI("Found the Vector directory by marker: {}", found.c_str()); + return found; +} + +// --- The application process ------------------------------------------------------------------ + +/** + * @brief Asks the companion where the daemon published its state. + * + * Read one byte at a time up to the first newline rather than a whole buffer, because the socket + * is shared with every sibling process and a longer read could swallow a reply that was never + * meant for this one. Every reply is the same string, so the first line is always the answer. + */ +std::string AskCompanionForMiscRoot() { + if (g_companion_fd < 0) { + LOGW("VectorHyperRuntime: no companion connection; cannot locate the Vector directory."); + return {}; + } + const char request = kRequestMiscRoot; + if (write(g_companion_fd, &request, 1) != 1) { + LOGW("VectorHyperRuntime: cannot ask the companion: {}.", strerror(errno)); + return {}; + } + + std::string line; + bool complete = false; + while (line.size() < kMaxReplyLength) { + char c = 0; + const ssize_t n = read(g_companion_fd, &c, 1); + if (n <= 0) break; + if (c == '\n') { + complete = true; + break; + } + line.push_back(c); + } + if (!complete) { + // A half-read path would be worse than none: it would name a directory that does not exist + // and be reported as a scope miss. The read is also where the timeout lands, so this is the + // branch a wedged companion produces. + LOGW("VectorHyperRuntime: the companion did not answer in time; skipping injection."); + return {}; + } + if (line.empty()) LOGW("VectorHyperRuntime: the companion knows no Vector directory yet."); + return line; +} + +/// One module native library to load into this process. +struct LibraryEntry { + std::string module_package; + std::string library_name; + std::string library_path; +}; + +/// Splits one index line into its three tab-separated fields. +bool ParseIndexLine(const std::string &line, LibraryEntry &out) { + const auto first = line.find('\t'); + if (first == std::string::npos) return false; + const auto second = line.find('\t', first + 1); + if (second == std::string::npos) return false; + + out.module_package = line.substr(0, first); + out.library_name = line.substr(first + 1, second - first - 1); + out.library_path = line.substr(second + 1); + return !out.library_name.empty() && !out.library_path.empty(); +} + +/** + * @brief Reads the libraries the daemon listed for `key`. + * + * A missing file is the ordinary answer for a process that is in nobody's scope, so it is not + * reported as a failure. + */ +std::vector ReadIndex(const std::string &misc_root, const std::string &key) { + std::vector entries; + if (misc_root.empty() || key.empty()) return entries; + + const std::string path = + misc_root + "/" + std::string(kIndexDirName) + "/" + key; + std::string content; + if (!ReadFile(path, content)) { + LOGD("VectorHyperRuntime: no index for '{}'.", key.c_str()); + return entries; + } + + bool first = true; + for (size_t start = 0; start < content.size();) { + const auto end = content.find('\n', start); + const std::string line = + content.substr(start, end == std::string::npos ? std::string::npos : end - start); + start = end == std::string::npos ? content.size() : end + 1; + + if (first) { + first = false; + if (line == kIndexMagic) continue; + LOGE("VectorHyperRuntime: {} is not a Vector index; ignoring it.", path.c_str()); + return {}; + } + if (line.empty() || line[0] == '#') continue; + + LibraryEntry entry; + if (!ParseIndexLine(line, entry)) { + LOGW("VectorHyperRuntime: skipping a malformed line in {}.", path.c_str()); + continue; + } + entries.push_back(std::move(entry)); + } + return entries; +} + +/** + * @brief Brings the native API, and every module library the daemon listed, into this process. + */ +void LoadModuleLibraries(const std::vector &entries) { + // The interception of the loader was installed by the spawner before it forked, so it is + // already in place here and inherited. InstallNativeAPI is idempotent and reports that state. + const bool intercepts_dlopen = InstallNativeAPI(); + if (!intercepts_dlopen) { + LOGW("VectorHyperRuntime: the loader is not intercepted. Module libraries will still be " + "loaded, but nothing will observe the loads that follow."); + } + + const NativeAPIEntries *api_entries = GetNativeAPIEntries(); + if (api_entries == nullptr) { + LOGE("VectorHyperRuntime: the native API entries could not be built; not loading modules."); + return; + } + + for (const auto &entry : entries) { + // Registering first is what lets the loader hook recognize this very dlopen and call + // native_init itself. When the hook is not there it is called by hand below, so a module is + // initialized either way. + if (intercepts_dlopen) RegisterNativeLib(entry.library_name); + + void *handle = dlopen(entry.library_path.c_str(), RTLD_NOW); + if (handle == nullptr) { + LOGE("VectorHyperRuntime: cannot load {} for '{}': {}.", entry.library_path.c_str(), + entry.module_package.c_str(), dlerror()); + continue; + } + LOGI("VectorHyperRuntime: loaded {} for '{}'.", entry.library_path.c_str(), + entry.module_package.c_str()); + + if (intercepts_dlopen) continue; + void *init_sym = dlsym(handle, "native_init"); + if (init_sym == nullptr) { + LOGW("VectorHyperRuntime: {} does not export native_init.", entry.library_path.c_str()); + continue; + } + reinterpret_cast(init_sym)(api_entries); + } +} + +} // namespace + +/** + * @brief Called once per specialized application process, after its uid, gid, groups and SELinux + * context have been applied. + * + * This runs in a process forked from a possibly multithreaded parent, so the work is kept to + * reading two small files and loading libraries: no threads are started, and no lock the parent + * could have caught at fork time is taken beyond the ones the loader itself takes. + */ +void OnAppSpecialized(const ZnHyosAppSpecializeArgs *args) { + if (!g_registered) return; + if (g_specialized) return; + g_specialized = true; + if (args == nullptr || args->package_name == nullptr) { + LOGE("VectorHyperRuntime: specialization arrived without a package name."); + return; + } + + LOGI("VectorHyperRuntime: specializing process '{}' (package '{}').", args->process_name, + args->package_name); + + const std::string misc_root = AskCompanionForMiscRoot(); + if (misc_root.empty()) return; + + // The daemon publishes per process name, because that is what a scope is keyed by, and the + // process name is what this process actually is. It is the first choice for that reason, not + // the second: a package with more than one process has a different scope in each, and looking + // the package up first would hand the main process's modules to the secondary one. + // + // The package name is the fallback, for a runtime that gave us a process name we cannot use -- + // an inherited one, or one truncated to the kernel's fifteen characters. It always resolves to + // the package's main process, so a secondary process that falls back here is served the main + // process's list rather than nothing; erring towards loading is the lesser mistake, because a + // module that meant to hook the main process and hooks a secondary one of the same app has at + // least been given what it asked for, while a module that is silently absent looks broken. + std::vector entries; + if (args->process_name != nullptr) entries = ReadIndex(misc_root, args->process_name); + if (entries.empty()) entries = ReadIndex(misc_root, args->package_name); + if (entries.empty()) { + LOGD("VectorHyperRuntime: '{}' is in no module's scope.", args->package_name); + return; + } + + LOGI("VectorHyperRuntime: '{}' has {} module librar{} to load.", args->package_name, + entries.size(), entries.size() == 1 ? "y" : "ies"); + LoadModuleLibraries(entries); +} + +/** + * @brief Serves the index location to children over the inherited socket. + * + * Every reply is the same string, which is what makes one shared connection safe: children are + * separate processes holding the same socket, so two of them asking at once can each read the + * other's reply -- and it is still the right answer. The loop ends when the peer does. + */ +void *ServeCompanion(void *arg) { + const int fd = static_cast(reinterpret_cast(arg)); + for (;;) { + char request = 0; + if (read(fd, &request, 1) != 1) break; + if (request != kRequestMiscRoot) continue; + + std::string reply = ResolveMiscRoot(); + reply.push_back('\n'); + if (!WriteAll(fd, reply)) break; + } + close(fd); + return nullptr; +} + +/** + * @brief Answers the daemon's question about this runtime. + * + * Started with the companion rather than with the first connection, because "started and nothing + * registered yet" has to be answerable: it is what a runtime the loader refused looks like, and the + * difference between that and no companion at all is the whole reason the daemon asks. + */ +void *ServeMonitor(void *) { + // A socket left behind by a companion that was killed would make every later bind fail, and the + // daemon would keep reading the old inode instead of ours. + unlink(kMonitorPath); + + const int listener = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (listener < 0) { + LOGE("VectorHyperRuntime: cannot create {}: {}.", kMonitorPath, strerror(errno)); + return nullptr; + } + + struct sockaddr_un address {}; + address.sun_family = AF_UNIX; + strlcpy(address.sun_path, kMonitorPath, sizeof(address.sun_path)); + if (bind(listener, reinterpret_cast(&address), sizeof(address)) != 0 || + listen(listener, 8) != 0) { + LOGE("VectorHyperRuntime: cannot listen on {}: {}.", kMonitorPath, strerror(errno)); + close(listener); + return nullptr; + } + // The daemon runs as root and the directory is root-only, so the mode is not what admits it; + // it is set anyway so that a stale socket is never what refuses a legitimate reader. + if (chmod(kMonitorPath, 0666) != 0) { + LOGW("VectorHyperRuntime: cannot relax {}: {}.", kMonitorPath, strerror(errno)); + } + LOGI("VectorHyperRuntime: reporting injection status on {}.", kMonitorPath); + + for (;;) { + const int client = accept(listener, nullptr, nullptr); + if (client < 0) { + if (errno == EINTR) continue; + break; + } + const char status = g_injection_status.load(); + if (write(client, &status, 1) != 1) { + LOGD("VectorHyperRuntime: a monitor client went away before its answer."); + } + close(client); + } + close(listener); + return nullptr; +} + +void OnCompanionLoaded() { + LOGI("VectorHyperRuntime: companion loaded in pid {}.", static_cast(getpid())); + pthread_t thread; + if (pthread_create(&thread, nullptr, ServeMonitor, nullptr) != 0) { + LOGE("VectorHyperRuntime: cannot serve {} on a thread; the daemon will see no runtime.", + kMonitorPath); + return; + } + pthread_detach(thread); +} + +void OnModuleConnected(int fd) { + LOGI("VectorHyperRuntime: companion connected on fd {}.", fd); + + // The spawner's first write is its status byte, and it is written before any fork, so it is + // already on its way when this runs. Bounded anyway: a spawner that died between connecting and + // writing must not leave the companion -- and the loader's own loop that called us -- stuck. + struct timeval timeout {}; + timeout.tv_sec = kStatusByteTimeoutSeconds; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + + char status = 0; + if (read(fd, &status, 1) == 1 && status == 1) { + g_injection_status.store(1); + LOGI("VectorHyperRuntime: the runtime API is registered; the daemon will be told so."); + } else { + g_injection_status.store(0); + LOGW("VectorHyperRuntime: no registration status arrived; the daemon will be told that " + "injection is not working."); + } + + pthread_t thread; + auto *argument = reinterpret_cast(static_cast(fd)); + if (pthread_create(&thread, nullptr, ServeCompanion, argument) == 0) { + pthread_detach(thread); + return; + } + LOGE("VectorHyperRuntime: cannot serve the companion on a thread; serving inline."); + ServeCompanion(argument); +} + +void OnModuleLoaded(void *self_handle, const ZygiskNextAPI *api) { + if (api == nullptr) { + LOGW("VectorHyperRuntime: the loader handed us no API; disabled in this process."); + return; + } + g_api = api; + + // Every refusal below is a warning, not an error. The Runtime API is an optional part of the + // loader: an older build does not have it, one that does can still say no, and in both cases + // the right outcome is the same -- nothing of ours runs here, and the process carries on + // exactly as it would without this feature. Saying so at warning level is the whole report. + const ZygiskNextRuntime *runtime = api->getRuntime ? api->getRuntime() : nullptr; + if (runtime == nullptr) { + LOGW("VectorHyperRuntime: this loader offers no Runtime API; disabled in this process."); + return; + } + if (runtime->type != ZN_RUNTIME_HYOS) { + LOGW("VectorHyperRuntime: runtime type {} is not the HyperOS one; disabled in this process.", + static_cast(runtime->type)); + return; + } + if (runtime->api_version < ZYGISK_NEXT_HYOS_API_VERSION) { + LOGW("VectorHyperRuntime: the runtime speaks API {}, we need {}; disabled in this process.", + runtime->api_version, ZYGISK_NEXT_HYOS_API_VERSION); + return; + } + if (runtime->registerModule == nullptr) { + LOGW("VectorHyperRuntime: the runtime cannot register modules; disabled in this process."); + return; + } + + // Take the runtime's own hook engine before the native API is handed to any module: this is the + // process the hooks will be installed in, and the runtime is the one that knows what it has + // already patched. Failing here is not fatal -- the API keeps Dobby -- but it is worth saying, + // because two engines on one address is exactly what the handover avoids. + if (api->inlineHook == nullptr || api->inlineUnhook == nullptr) { + LOGW("VectorHyperRuntime: the runtime offers no inline hooks; falling back to Dobby."); + } else { + SetHookBackend(api->inlineHook, api->inlineUnhook); + } + + static const ZygiskNextHyosModule hyos_module = { + .target_api_version = ZYGISK_NEXT_HYOS_API_VERSION, + .onAppSpecialized = OnAppSpecialized, + }; + if (runtime->registerModule(&hyos_module) != ZN_SUCCESS) { + LOGW("VectorHyperRuntime: the runtime refused our specialization callback; disabled in this " + "process."); + return; + } + g_registered = true; + LOGI("VectorHyperRuntime: registered; applications spawned here will be specialized."); + + // Installed here, in the spawner, and inherited by every process it forks. Here rather than in + // the child is the point: this runs before main, on one thread, with nothing else in the + // process to race against and no other thread's lock for the fork to have caught mid-hook. + // Only worth doing now that a callback is coming -- without one, nothing would ever load a + // module library, and patching a system process's loader for that is a change with no purpose. + if (!InstallNativeAPI()) { + LOGW("VectorHyperRuntime: the loader cannot be intercepted; module libraries will still be " + "loaded on specialization, but later loads will be invisible to them."); + } + + // One connection, made now and inherited by every child. Children must not ask again: the + // companion serves a single connection, and a second one would arrive at a companion already + // busy with this. + if (api->connectCompanion == nullptr) { + LOGW("VectorHyperRuntime: the loader offers no companion connection; applications spawned " + "here will run unhooked."); + return; + } + g_companion_fd = api->connectCompanion(self_handle); + if (g_companion_fd < 0) { + LOGW("VectorHyperRuntime: no companion, so the target list cannot be obtained; applications " + "spawned here will run unhooked."); + } else { + // Set here rather than in the child, because it is a property of the connection every child + // shares and setting it once is enough. The child's read is on the application's startup + // path; without a bound, a companion that stopped answering would hold the launch open. + struct timeval timeout{}; + timeout.tv_sec = kCompanionReplyTimeoutSeconds; + if (setsockopt(g_companion_fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) != 0) { + LOGW("VectorHyperRuntime: cannot bound the companion reply: {}.", strerror(errno)); + } + LOGI("VectorHyperRuntime: companion connection established on fd {}.", g_companion_fd); + + // The companion's first read is this byte, before it serves anything else, so it has to + // arrive before the first fork -- which it does, because it is written here and children + // only exist once the spawner's main runs. It is the answer the daemon will be handed when + // it asks whether this runtime is being injected at all. + const char status = g_registered ? 1 : 0; + if (write(g_companion_fd, &status, 1) != 1) { + LOGW("VectorHyperRuntime: cannot report the injection status to the companion: {}.", + strerror(errno)); + } + } +} + +} // namespace vector::native::hyos + +// ========================================================================================= +// Zygisk Next module registration +// ========================================================================================= +// +// Both structures are looked up by name in this library when Zygisk Next injects it into +// /system_ext/bin/hyos_spawner, which is what zn_modules.txt asks for. The ordinary Zygisk entry +// point above (REGISTER_ZYGISK_MODULE in module.cpp) is untouched: a library exports as many entry +// points as the loaders loading it need, and the two never run in the same process. + +extern "C" __attribute__((visibility("default"))) ZygiskNextModule zn_module = { + .target_api_version = ZYGISK_NEXT_API_VERSION, + .onModuleLoaded = vector::native::hyos::OnModuleLoaded, +}; + +extern "C" __attribute__((visibility("default"))) ZygiskNextCompanionModule zn_companion_module = { + .target_api_version = ZYGISK_NEXT_API_VERSION, + .onCompanionLoaded = vector::native::hyos::OnCompanionLoaded, + .onModuleConnected = vector::native::hyos::OnModuleConnected, +}; diff --git a/zygisk/src/main/cpp/include/zygisk_next_api.h b/zygisk/src/main/cpp/include/zygisk_next_api.h new file mode 100644 index 000000000..7bcab81c4 --- /dev/null +++ b/zygisk/src/main/cpp/include/zygisk_next_api.h @@ -0,0 +1,158 @@ +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define ZYGISK_NEXT_API_VERSION 4 +#define ZYGISK_NEXT_HYOS_API_VERSION 1 + +#define ZN_SUCCESS 0 +#define ZN_FAILED 1 + +struct ZnSymbolResolver; +struct ZygiskNextRuntime; + +struct ZygiskNextAPI { + // Hook API + + // Do plt hook at symbol specified by the param `symbol` of library + // specified by the param `base_addr` The plt address of `symbol` in the + // library will be replaced with hook_handler, and its original value will + // be put to the address specified by `original` (can be null). You can use + // this api to do caller-oriented hook If you want to unhook, please call + // this function with hook_handler = original If hook succeed, returns + // ZN_SUCCESS, otherwise ZN_FAILED + int (*pltHook)(void *base_addr, const char *symbol, void *hook_handler, void **original); + + // Do inline hook at the address specified by `target`, replace it with a + // new function specified by `addr`, and the param `original` receives the + // address of original function. You can use this api to achieve a global + // hook in current process. In the current implementation , an address can + // only hook once, so the module can't hook an address which is already + // hooked by an another module, except that the module unhooked it. If + // hooking succeed, returns ZN_SUCCESS, otherwise ZN_FAILED + int (*inlineHook)(void *target, void *addr, void **original); + + // Unhook the address which is formerly hooked. + // If hook succeed, returns ZN_SUCCESS, otherwise ZN_FAILED + int (*inlineUnhook)(void *target); + + // Symbol Resolver API + + // Obtain a new ZnSymbolResolver object + // `path` is required, which specifies the path of library to resolve. It + // can be an absolute path or just the file name of library, e.g. + // /system/lib64/libc.so or libc.so . If `base_addr` is non-zero, it will be + // used as the base address of the library. Otherwise, Zygisk Next will try + // to find out the base address of the specified library in this process. If + // succeed, it returns a valid pointer to the symbol resolver, otherwise + // nullptr is returned. + struct ZnSymbolResolver *(*newSymbolResolver)(const char *path, void *base_addr); + + // Release the ZnSymbolResolver object pointed by `resolver`. + void (*freeSymbolResolver)(struct ZnSymbolResolver *resolver); + + // Retrieve the base address of the library of the resolver image in the + // process. + void *(*getBaseAddress)(struct ZnSymbolResolver *resolver); + + // Lookup the address of symbol by name or prefix (if `prefix` is true) + // If the symbol exists, the function returns its address, otherwise returns + // nullptr. If `size` is not nullptr, the size of the symbol will be put to + // *size . In the current implementation, gnu_debugdata resolution is + // supported. + void *(*symbolLookup)(struct ZnSymbolResolver *resolver, const char *name, bool prefix, + size_t *size); + + // Walk through the symbol table of the library, the callback will receive + // the name, the address, and the size of each symbol. Returning false in + // the callback means stop the walking. + void (*forEachSymbols)(struct ZnSymbolResolver *resolver, + bool (*callback)(const char *name, void *addr, size_t size, void *data), + void *data); + + // Companion API + + // Create a unix sock stream connection to your declared companion process. + // The value of `handle` is the `self_handle` which you've received from + // onModuleLoaded. On success, it returns the file descriptor refer to the + // socket, otherwise -1 is returned. Please close this file descriptor by + // yourself. + int (*connectCompanion)(void *handle); + + // Return the runtime-specific API for the current process, or null if the + // process does not expose a supported runtime. The returned object remains + // valid for the lifetime of the process. Inspect its type before passing a + // runtime-specific module structure to registerModule. + const struct ZygiskNextRuntime *(*getRuntime)(void); +}; + +// Callbacks of an injected library +struct ZygiskNextModule { + // Please fill this with the target version of your module, e.g. + // ZYGISK_NEXT_API_VERSION + int target_api_version; + + // This callback will be called after all needed library of the main + // executable are loaded, and before the entry (i.e. `main`) of the main + // executable is called. + void (*onModuleLoaded)(void *self_handle, const struct ZygiskNextAPI *api); +}; + +enum ZygiskNextRuntimeType { + ZN_RUNTIME_HYOS = 1, +}; + +// Read-only process specialization information for applications forked by +// /system_ext/bin/hyos_spawner. The callback runs after the uid, gid, +// supplementary groups, and SELinux app context have been applied. The string +// pointers are non-null and only valid for the duration of the callback. +struct ZnHyosAppSpecializeArgs { + const char *process_name; + const char *package_name; + const char *se_info; +}; + +// HyperOS Rust Runtime callbacks registered through +// ZygiskNextRuntime.registerModule. +struct ZygiskNextHyosModule { + int target_api_version; + void (*onAppSpecialized)(const struct ZnHyosAppSpecializeArgs *args); +}; + +// Runtime-specific API returned by getRuntime. The module structure accepted +// by registerModule is selected by type. The runtime copies the structure +// before registerModule returns. +struct ZygiskNextRuntime { + enum ZygiskNextRuntimeType type; + int api_version; + int (*registerModule)(const void *module); +}; + +// Callbacks of a companion library +struct ZygiskNextCompanionModule { + int target_api_version; + + void (*onCompanionLoaded)(); + + // This callback will be called when your Zygisk Next module is trying to + // establish a connection with your companion module, i.e. + // `connectCompanion` is called. The `fd` param will be a unix sock stream + // file descriptor. Please close this file descriptor after use by yourself. + void (*onModuleConnected)(int fd); +}; + +// Please define your `zn_module` in your source file. +extern __attribute__((visibility("default"), unused)) struct ZygiskNextModule zn_module; +extern __attribute__((visibility("default"), + unused)) struct ZygiskNextCompanionModule zn_companion_module; + +#ifdef __cplusplus +} +#endif +