From 45e085557e1085668b5eb9d08e820da1a5b6a803 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 31 Jul 2026 11:34:57 +0200 Subject: [PATCH] Stage a module's native libraries where system_server may map them (#729) A module loaded into system_server cannot dlopen a library out of its own APK. Everything under /data/app is apk_data_file, and while system_server may read and map such a file it may not execute it, which AOSP states outright and forbids granting: "Executable files in /data are a persistence vector". Every app domain does hold that permission, so the same module loads the same library without trouble in an ordinary process and fails only here, with the linker refusing the first PROT_EXEC mapping: dlopen failed: couldn't map ".../base.apk!/lib/arm64-v8a/libhmahook.so" segment 2: Permission denied The way past it is not a new rule but the one this module already ships. xposed_data is a type we declare ourselves, outside the data_file_type attribute that neverallow is written against, and the existing `allow * xposed_data {file dir} *` already reaches every domain, system_server included. So the daemon now copies a system_server-bound module's libraries into the misc directory it already owns and labels, and hands the loader that directory to search first. Nothing is staged for any other process: they can execute straight out of /data/app, and staging for them would buy nothing but disk. The copy is keyed to the APK's size, mtime and the framework version, so an updated module is re-extracted rather than left running superseded native code, and directories belonging to modules that are no longer bound for system_server are dropped. Staging deliberately ignores moduleLibraryNames: that list only names the libraries whose native_init we are asked to call, and a module may load its own libraries without declaring any -- the module that prompted this does exactly that. While here, register the declared native entrypoints before the entry classes run instead of after. A module may load its libraries from its constructor or from onModuleLoaded, and an entrypoint recorded afterwards is one the dlopen hook has already missed. The legacy loader has always done it in this order. --- .../matrix/vector/daemon/data/ConfigCache.kt | 22 +++++ .../matrix/vector/daemon/data/FileSystem.kt | 97 +++++++++++++++++++ .../de/robv/android/xposed/XposedInit.java | 7 ++ .../org/lsposed/lspd/models/PreLoadedApk.aidl | 4 + .../vector/impl/core/VectorModuleManager.kt | 22 ++++- 5 files changed, 147 insertions(+), 5 deletions(-) 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 0d27fa1d9..b2700306f 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 @@ -396,6 +396,7 @@ object ConfigCache { val cached = currentState.modules[pkgName] if (cached != null) { + stageNativeLibrariesFor(cached) modules.add(cached) return@forEach } @@ -431,14 +432,35 @@ object ConfigCache { FileSystem.loadModule(apkPath, state.isDexObfuscateEnabled).apkOrNull?.let { module.file = it + stageNativeLibrariesFor(module) modules.add(module) // We intentionally don't mutate state.modules here. Cache update will catch it. } } } + FileSystem.pruneStagedNativeLibraries( + state.miscPath, modules.mapTo(mutableSetOf()) { it.packageName }) return modules } + /** + * Hands a module bound for system_server the copy of its native libraries it can actually map. + * + * Only that scope needs one. Every other process may execute straight out of /data/app, so the + * in-APK entries the loader already builds serve them, and staging for them would buy nothing but + * disk. A module that ships no library, or whose staging failed, keeps a null here and loads + * exactly as it did before. + */ + private fun stageNativeLibrariesFor(module: Module) { + val file = module.file ?: return + // system_server asks for its modules early enough that the cache may not have been built yet, + // and this is the same reason getPrefsPath does not trust the field either. + setupMiscPath() + val misc = state.miscPath ?: return + file.nativeLibraryDir = + FileSystem.stageNativeLibraries(misc, module.packageName, module.apkPath) + } + fun getModuleApkPath(info: ApplicationInfo): String? { val apks = mutableListOf() info.sourceDir?.let { apks.add(it) } 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 c3019d347..cd4e90b3a 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 @@ -3,6 +3,7 @@ package org.matrix.vector.daemon.data import android.content.res.AssetManager import android.content.res.Resources import android.os.Binder +import android.os.Build import android.os.ParcelFileDescriptor import android.os.Process import android.os.RemoteException @@ -399,6 +400,102 @@ object FileSystem { return path } + /** + * Copies a module's native libraries out of its APK into a directory this framework owns, and + * answers with that directory. + * + * A module loaded into system_server cannot dlopen a library straight out of its own APK. + * Everything under /data/app is apk_data_file, and while system_server may read and map such a + * file it may not execute it; AOSP says why in so many words - "Executable files in /data are a + * persistence vector" - and forbids granting it. Every app domain does hold that permission, + * which is the whole reason the same module loads the same library without trouble in an ordinary + * process and fails only in system_server. + * + * The way past it is not a new rule but the one this module already ships. xposed_data is a type + * we declare ourselves, outside the data_file_type attribute that neverallow is written against, + * and `allow * xposed_data {file dir} *` already reaches every domain - system_server included. + * A copy placed under it is one system_server may map executable. Extraction has a second + * benefit: the library ends up at offset zero of an ordinary file, so it no longer has to be + * stored uncompressed and page-aligned inside the APK to be mappable at all. + * + * Note that this deliberately does not consult moduleLibraryNames. That list only names the + * libraries whose native_init we are asked to call, and a module is free to load its own + * libraries without declaring any - the module that prompted all this does exactly that. + * + * 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. + */ + fun stageNativeLibraries(root: Path, packageName: String, apkPath: String): String? = + runCatching { + val apk = File(apkPath) + val dir = root.resolve("lib").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 + // code, so the framework's own version is part of the stamp as well. + val stamp = "${apk.length()}:${apk.lastModified()}:${BuildConfig.VERSION_CODE}" + val stampFile = dir.resolve(".stamp").toFile() + if (stampFile.isFile && stampFile.readText() == stamp) return@runCatching dir.toString() + + val abis = + if (Process.is64Bit()) Build.SUPPORTED_64_BIT_ABIS else Build.SUPPORTED_32_BIT_ABIS + + ZipFile(apk).use { zip -> + val libraries = + zip.entries().asSequence().filter { !it.isDirectory && it.name.endsWith(".so") } + .toList() + // A module built for several ABIs keeps them in sibling directories, and only the one + // this process could load is worth copying. + val abi = + abis.firstOrNull { abi -> libraries.any { it.name.startsWith("lib/$abi/") } } + ?: return@runCatching null + + dir.toFile().deleteRecursively() + Files.createDirectories(dir) + + libraries + .filter { it.name.startsWith("lib/$abi/") } + .forEach { entry -> + val target = dir.resolve(entry.name.substringAfterLast('/')) + zip.getInputStream(entry).use { input -> + Files.newOutputStream(target).use { input.copyTo(it) } + } + Os.chmod(target.toString(), "644".toInt(8)) + } + + stampFile.writeText(stamp) + // The daemon runs with a zero umask, so every mode here is set rather than inherited. + Os.chmod(stampFile.absolutePath, "644".toInt(8)) + // The misc root is searchable but not listable; the staged tree keeps that shape, and + // the label is what actually decides whether system_server may map these files. + Os.chmod(dir.parent.toString(), "711".toInt(8)) + Os.chmod(dir.toString(), "711".toInt(8)) + setSelinuxContextRecursive(dir, "u:object_r:xposed_data:s0") + SELinux.setFileContext(dir.parent.toString(), "u:object_r:xposed_data:s0") + dir.toString() + } + } + .onFailure { Log.e(TAG, "Failed to stage the native libraries of $packageName", it) } + .getOrNull() + + /** + * 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) { + if (root == null) return + runCatching { + val libRoot = root.resolve("lib") + if (!libRoot.isDirectory()) return + Files.list(libRoot).use { stream -> + stream + .filter { it.fileName.toString() !in keep } + .forEach { it.toFile().deleteRecursively() } + } + } + .onFailure { Log.e(TAG, "Failed to prune staged native libraries", it) } + } + fun toGlobalNamespace(path: String): File { return if (path.startsWith("/")) File("/proc/1/root", path) else File("/proc/1/root/$path") } diff --git a/legacy/src/main/java/de/robv/android/xposed/XposedInit.java b/legacy/src/main/java/de/robv/android/xposed/XposedInit.java index ba197f5df..595c5c5c2 100644 --- a/legacy/src/main/java/de/robv/android/xposed/XposedInit.java +++ b/legacy/src/main/java/de/robv/android/xposed/XposedInit.java @@ -291,6 +291,13 @@ private static boolean loadModule(String name, String apk, PreLoadedApk file) { Log.v(TAG, "Loading legacy module " + name + " from " + apk); var sb = new StringBuilder(); + // In system_server the in-APK entries below can only ever be refused: /data/app is + // apk_data_file, which that domain may read and map but never execute. The daemon stages a + // copy under a label we own for exactly this reason, and it has to come first, because + // findLibrary answers with the first candidate it can open. + if (startsSystemServer && file.nativeLibraryDir != null) { + sb.append(file.nativeLibraryDir).append(File.pathSeparator); + } var abis = Process.is64Bit() ? Build.SUPPORTED_64_BIT_ABIS : Build.SUPPORTED_32_BIT_ABIS; for (String abi : abis) { sb.append(apk).append("!/lib/").append(abi).append(File.pathSeparator); diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl index 45a96ce3b..96c25a019 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/models/PreLoadedApk.aidl @@ -8,4 +8,8 @@ parcelable PreLoadedApk { // module.prop 'exceptionMode', normalised by the daemon. false, the value an absent key // parses to, is PROTECTIVE - what ExceptionMode.DEFAULT is specified to fall back to. boolean exceptionPassthrough; + // Where the daemon staged this module's native libraries, for the one process that cannot map + // them out of the APK. Null when the module ships none for this ABI, when staging failed, or + // when the module was never destined for system_server in the first place. + @nullable String nativeLibraryDir; } diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt index 05fda8b70..c9983bf0f 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt @@ -30,6 +30,15 @@ object VectorModuleManager { // Construct the native library search path val librarySearchPath = buildString { + // In system_server the in-APK entries below can only ever be refused: /data/app is + // apk_data_file, which that domain may read and map but never execute. The daemon + // stages a copy under a label we own for exactly this reason, and it has to come + // first, because findLibrary answers with the first candidate it can open. + if (isSystemServer) { + module.file.nativeLibraryDir?.let { + append(it).append(File.pathSeparator) + } + } val abis = if (Process.is64Bit()) Build.SUPPORTED_64_BIT_ABIS else Build.SUPPORTED_32_BIT_ABIS @@ -68,6 +77,14 @@ object VectorModuleManager { else ExceptionMode.PROTECTIVE, ) + // Register any native JNI entrypoints declared by the module. This has to happen before + // the entry classes run: a module is free to load its libraries from its constructor or + // from onModuleLoaded, and an entrypoint recorded afterwards is one the dlopen hook has + // already missed. The legacy loader has always done it in this order. + module.file.moduleLibraryNames.forEach { libraryName -> + NativeAPI.recordNativeEntrypoint(libraryName) + } + // Instantiate the module entry classes for (className in module.file.moduleClassNames) { runCatching { @@ -101,11 +118,6 @@ object VectorModuleManager { .onFailure { e -> Log.e(TAG, "Failed to instantiate class $className", e) } } - // Register any native JNI entrypoints declared by the module - module.file.moduleLibraryNames.forEach { libraryName -> - NativeAPI.recordNativeEntrypoint(libraryName) - } - Log.d(TAG, "Loaded module ${module.packageName} successfully.") return true } catch (e: Throwable) {