From 75d9c302bef10d9c8a83d4f35a7d3b1b6c34ac76 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Thu, 5 Nov 2026 22:19:48 +0100 Subject: [PATCH] Let dex obfuscation be turned off, and say which way it is set `enable_dex_obfuscate` has a row in modules_config.db and nothing has ever read it. `DaemonState` carried the answer as a constructor default, `!BuildConfig.DEBUG`, marked as not configurable: a release daemon renamed the legacy Xposed API classes in the framework dex and in every module dex, a debug daemon left them alone, and no setting in the manager and no key in the CLI could say otherwise. The preference is read now, once, while `ConfigCache` is initialized, and `config get` and `config set` take `dex-obfuscation` beside the two keys they already took. Read once because it has to be. `FileSystem.getPreloadDex` caches the framework dex the first time it is asked for, so the answer that produced it is the only one the rest of the process may act on; a module loaded against the other answer would look for class names the framework does not have. A write therefore takes effect on the next boot, and the CLI says so when the daemon it is talking to is running the other way. What makes the setting worth having is that the rewrite reaches a dex's string table and nothing else. A module that names one of those prefixes where a dex writer cannot follow keeps the original name: the JNI signatures compiled into a module's native library still read Lde/robv/android/xposed/..., `RegisterNatives` finds no method of that shape, and the module loads with its native entry points unbound. The library is the module's own build output, so nothing in this daemon can fix it; turning the rewrite off is the way out, and that is now reachable. Also here: the zygote side's "Fetched obfuscation map with N entries" said the same thing in both configurations, because the daemon answers with every prefix either way and maps each to itself when obfuscation is off. It compares the pairs and names the configuration, which is the one thing a reader looking at an unbound native method needs to tell apart. Refs #1054 --- daemon/src/main/jni/obfuscation.cpp | 7 +++++++ .../main/kotlin/org/matrix/vector/daemon/Cli.kt | 11 ++++++++++- .../org/matrix/vector/daemon/data/ConfigCache.kt | 15 +++++++++++++++ .../org/matrix/vector/daemon/data/DaemonState.kt | 10 +++++++++- .../matrix/vector/daemon/data/PreferenceStore.kt | 16 ++++++++++++++++ .../org/matrix/vector/daemon/ipc/CliHandler.kt | 13 ++++++++++++- zygisk/src/main/cpp/ipc_bridge.cpp | 10 +++++++++- 7 files changed, 78 insertions(+), 4 deletions(-) diff --git a/daemon/src/main/jni/obfuscation.cpp b/daemon/src/main/jni/obfuscation.cpp index 42492fa11c..96c7c28588 100644 --- a/daemon/src/main/jni/obfuscation.cpp +++ b/daemon/src/main/jni/obfuscation.cpp @@ -22,6 +22,13 @@ namespace { std::once_flag init_flag; +// What is rewritten, and the whole of what is rewritten: the string table of a dex. A module that +// names one of these prefixes anywhere a dex writer cannot reach keeps the original name and stops +// matching the one its classes now carry. A native library is the case that occurs: the JNI +// signature strings compiled into it still read Lde/robv/android/xposed/..., so RegisterNatives +// finds no method of that shape and the module loads without its native entry points. Nothing here +// can fix that -- the library is the module's own build output -- which is why +// `enable_dex_obfuscate` is a setting a user can turn off rather than a build type. std::map signatures = { {"Lde/robv/android/xposed/", ""}, {"Landroid/app/AndroidApp", ""}, {"Landroid/content/res/XRes", ""}, {"Landroid/content/res/XModule", ""}, diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/Cli.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/Cli.kt index 8786ab82e3..d7e841cb7a 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/Cli.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/Cli.kt @@ -314,7 +314,16 @@ class ScopeCommand { } } -@Command(name = "config", description = ["Manage daemon preferences natively"]) +@Command( + name = "config", + description = + [ + "Manage daemon preferences natively", + "Keys: status-notification, verbose-log, dex-obfuscation.", + "dex-obfuscation renames the legacy Xposed API classes in the framework dex", + "and in every module dex; a module whose native code names those classes", + "cannot bind its methods while it is on. Takes effect on the next boot.", + ]) class ConfigCommand { @ParentCommand lateinit var parent: Cli 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 b79aa11817..454a382fe8 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 @@ -63,6 +63,21 @@ object ConfigCache { } } applySqliteHelperWorkaround() + + // After the workaround, which has to be in place before the configuration database is opened, + // and this read may well be what opens it. Caught, because a daemon that cannot read its own + // preference must still start: the fallback in `DaemonState` is the build-type default this + // setting used to be. + // + // Read once and held, not consulted per use. `FileSystem.getPreloadDex` caches the framework + // dex the first time it is asked for, so the answer that produced it is the only one the rest + // of this process may act on; a module loaded with the other answer would look for class names + // the framework does not have. Writing the preference therefore takes effect on the next boot. + runCatching { PreferenceStore.isDexObfuscateEnabled() } + .onSuccess { synchronized(this) { state = state.copy(isDexObfuscateEnabled = it) } } + .onFailure { + Log.w(TAG, "Cannot read the dex obfuscation preference; using the default", it) + } } private fun ensureCacheReady() { diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/DaemonState.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/DaemonState.kt index fe4a4e5e5b..01623254b2 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/DaemonState.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/DaemonState.kt @@ -11,7 +11,15 @@ data class ProcessScope(val processName: String, val uid: Int) * and atomically swap the reference. */ data class DaemonState( - // State non configurable for users + /** + * Whether the legacy API classes are renamed, seeded from + * [PreferenceStore.isDexObfuscateEnabled] when [ConfigCache] is first touched. + * + * The default here is only what a daemon that cannot read its own configuration falls back to. + * It used to be the entire setting, which is why it was marked as not configurable: the + * framework dex and every module dex were obfuscated in a release build and not in a debug + * one, with nothing able to say otherwise. + */ val isDexObfuscateEnabled: Boolean = !BuildConfig.DEBUG, // States initialized after system services are ready val isCacheReady: Boolean = false, diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt index bf1f4905f5..7fd1e48613 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/data/PreferenceStore.kt @@ -3,6 +3,7 @@ package org.matrix.vector.daemon.data import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import org.apache.commons.lang3.SerializationUtilsX +import org.matrix.vector.daemon.BuildConfig private const val TAG = "VectorPreferenceStore" @@ -97,6 +98,21 @@ object PreferenceStore { fun setVerboseLog(enabled: Boolean) = updateModulePref("lspd", 0, "config", "enable_verbose_log", enabled) + /** + * Whether the legacy Xposed API classes are renamed in the framework dex and in every module + * dex, under the key LSPosed wrote and this daemon has only ever read a build-type default for. + * + * A release build obfuscates and a debug build does not, which is the default below and was the + * whole of it: the row existed in the database, nothing read it, and the manager and the CLI + * offered no way to write it. The difference is not cosmetic — it decides whether a module whose + * native code names those classes can bind its methods at all — so it is a setting. + */ + fun isDexObfuscateEnabled(): Boolean = + getModulePrefs("lspd", 0, "config")["enable_dex_obfuscate"] as? Boolean ?: !BuildConfig.DEBUG + + fun setDexObfuscate(enabled: Boolean) = + updateModulePref("lspd", 0, "config", "enable_dex_obfuscate", enabled) + fun isScopeRequestBlocked(pkg: String): Boolean = (getModulePrefs("lspd", 0, "config")["scope_request_blocked"] as? Set<*>)?.contains(pkg) == true diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt index ffedb0e9f3..796acd6fad 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/CliHandler.kt @@ -186,6 +186,11 @@ object CliHandler { when (key) { "status-notification" -> ManagerService.isStatusNotificationEnabled() "verbose-log" -> ManagerService.isVerboseLogEnabled() + // What the stored preference says, which after a write is not yet what this daemon + // is doing. `ConfigCache.state.isDexObfuscateEnabled` is what it is doing, and the + // two disagree until the next boot; reporting the setting is what lets a reader see + // that their write landed. + "dex-obfuscation" -> PreferenceStore.isDexObfuscateEnabled() else -> throw IllegalArgumentException("Unknown config key: $key") } mapOf("KEY" to key, "VALUE" to value) @@ -200,9 +205,15 @@ object CliHandler { when (key) { "status-notification" -> ManagerService.setStatusNotificationEnabled(value) "verbose-log" -> ManagerService.setVerboseLogEnabled(value) + "dex-obfuscation" -> PreferenceStore.setDexObfuscate(value) else -> throw IllegalArgumentException("Unknown config key: $key") } - "Successfully set $key to $value." + if (key == "dex-obfuscation" && value != ConfigCache.state.isDexObfuscateEnabled) { + "Set $key to $value. The framework dex this daemon handed out names its classes the " + + "other way, so reboot to apply." + } else { + "Successfully set $key to $value." + } } else -> throw IllegalArgumentException("Unknown config action: ${request.action}") } diff --git a/zygisk/src/main/cpp/ipc_bridge.cpp b/zygisk/src/main/cpp/ipc_bridge.cpp index 2cd6bf8089..5d48faf0de 100644 --- a/zygisk/src/main/cpp/ipc_bridge.cpp +++ b/zygisk/src/main/cpp/ipc_bridge.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -469,7 +470,14 @@ std::map IPCBridge::FetchObfuscationMap(JNIEnv *env, j result_map[key_str.get()] = val_str.get(); } - LOGV("Fetched obfuscation map with {} entries.", result_map.size()); + // Whether the names actually differ, and not merely how many arrived. The daemon answers with + // every prefix either way and maps each to itself when obfuscation is off, so a count alone + // reads the same in both configurations -- which is the one thing a reader looking at a module + // that will not bind its native methods needs to tell apart. + const bool renamed = std::any_of(result_map.begin(), result_map.end(), + [](const auto &entry) { return entry.first != entry.second; }); + LOGV("Fetched obfuscation map with {} entries, dex obfuscation {}.", result_map.size(), + renamed ? "on" : "off"); return result_map; }