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) {