From de94ee3ac01704befd1f472bda93590ec07c2d35 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Thu, 30 Jul 2026 07:43:26 +0200 Subject: [PATCH 1/2] Let the parasitic manager load its DEX on GrapheneOS GrapheneOS ships a "Restrict dynamic code loading" (memory DCL) exploit-protection setting that is immutable and enabled for system apps. The parasitic manager runs inside the host package com.android.shell (a system app), so GrapheneOS forbids it from loading the manager's DEX and the manager never starts. Hook AswRestrictMemoryDynCodeLoading.getImmutableValue in system_server and return false (allowed) for that single host package, deferring to the original verdict for every other app. GrapheneOS is detected by the class being present, so the hook is a no-op on every other system. The scope is intentionally narrow: only the manager host is affected. Module apps are not, since a normal app already exposes the DCL toggle as user-configurable on GrapheneOS and can be allowed without a patch. Co-authored-by: Enovale <17408285+Enovale@users.noreply.github.com> --- .../org/matrix/vector/GrapheneDclHooker.kt | 57 +++++++++++++++++++ .../kotlin/org/matrix/vector/core/Main.kt | 3 + 2 files changed, 60 insertions(+) create mode 100644 zygisk/src/main/kotlin/org/matrix/vector/GrapheneDclHooker.kt diff --git a/zygisk/src/main/kotlin/org/matrix/vector/GrapheneDclHooker.kt b/zygisk/src/main/kotlin/org/matrix/vector/GrapheneDclHooker.kt new file mode 100644 index 000000000..e303ae997 --- /dev/null +++ b/zygisk/src/main/kotlin/org/matrix/vector/GrapheneDclHooker.kt @@ -0,0 +1,57 @@ +package org.matrix.vector + +import android.content.pm.ApplicationInfo +import de.robv.android.xposed.XC_MethodReplacement +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers +import org.lsposed.lspd.util.Utils + +/** + * Exempts the parasitic manager's host package from GrapheneOS's "Restrict dynamic code loading" + * (memory DCL) exploit protection. + * + * The setting is immutable and enabled for system apps. As the manager runs inside + * [BuildConfig.InjectedPackageName] (`com.android.shell`, a system app), it cannot load its DEX + * and fails to start. The hook forces the restriction to "allowed" for that package alone; every + * other app retains GrapheneOS's verdict. It applies only on GrapheneOS, where the target class + * exists, and only in system_server, where the value is resolved. + */ +object GrapheneDclHooker { + + private const val ASW_CLASS = "android.ext.settings.app.AswRestrictMemoryDynCodeLoading" + + @JvmStatic + fun start() { + val aswClass = + try { + XposedHelpers.findClass(ASW_CLASS, this.javaClass.classLoader) + } catch (_: XposedHelpers.ClassNotFoundError) { + return // Not GrapheneOS. + } + + try { + // Boolean getImmutableValue(Context, int, ApplicationInfo, GosPackageState, StateInfo) + // returns true (restricted), false (allowed), or null (user-configurable). A non-null + // result overrides the user toggle and the default, so false forces DCL to be allowed. + XposedBridge.hookAllMethods( + aswClass, + "getImmutableValue", + object : XC_MethodReplacement() { + override fun replaceHookedMethod(param: MethodHookParam<*>): Any? { + val appInfo = param.args.getOrNull(2) as? ApplicationInfo + if (appInfo?.packageName == BuildConfig.InjectedPackageName) { + return false // Allow the manager host to load its DEX. + } + return XposedBridge.invokeOriginalMethod( + param.method, + param.thisObject, + param.args, + ) + } + }, + ) + } catch (e: Throwable) { + Utils.logE("Failed to patch GrapheneOS DCL restriction", e) + } + } +} diff --git a/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt b/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt index f82f5aa30..275b12ff9 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/core/Main.kt @@ -5,6 +5,7 @@ import android.os.Process import org.lsposed.lspd.service.ILSPApplicationService import org.lsposed.lspd.util.Utils import org.matrix.vector.BuildConfig +import org.matrix.vector.GrapheneDclHooker import org.matrix.vector.ParasiticManagerHooker import org.matrix.vector.ParasiticManagerSystemHooker import org.matrix.vector.Startup @@ -33,6 +34,8 @@ object Main { // Initialize system-specific resolution hooks if in system_server if (isSystem) { ParasiticManagerSystemHooker.start() + // Exempt the manager host from GrapheneOS DCL restriction; no-op on other systems. + GrapheneDclHooker.start() } // Initialize Xposed bridge components From e1ef8a46516234f7db4630e81dd6954d0a68f9d3 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 31 Jul 2026 06:58:02 +0200 Subject: [PATCH 2/2] Also clear GrapheneOS storage and WebView DCL for the manager host The memory-only hook let the manager's DEX map into com.android.shell, but GrapheneOS enforces dynamic code loading through two channels, both fed by the same per-app verdict, and clearing memory alone covers only part of each: - The ART DexFile checks (DynCodeLoading.getAppBindFlags) still rejected the manager's transplanted /proc/self/fd DEX as "DCL via storage". - The kernel grapheneos_flags written at zygote specialize kept DENY_EXECMEM/DENY_EXECMOD set, so LSPlant/Dobby could not make its inline-hook trampoline executable and the process took a SIGSEGV (TSEC_FLAG_DENY_EXECMEM: op denied). The manager's isolated WebView process likewise kept DENY_EXECMEM, disabling Chromium's JIT. Both getAppBindFlags and SELinuxFlags.{get,getForWebViewProcess} read AswRestrict{Memory,Storage,WebView}DynCodeLoading.get(), which honours a non-null getImmutableValue. Extend the existing memory hook to the storage and WebView switches so the verdict is cleared at the shared source, neutralising both channels for every DCL category at once. The manager host ends up as an ordinary DCL-allowed app: the DexFile checks are off and the exec* SELinux flags cleared, while ptrace denial, hardened_malloc and MTE stay intact, and every other app keeps GrapheneOS's verdict. Verified against the GrapheneOS 14, 16 and 17 branches; a no-op on releases without these classes. --- .../org/matrix/vector/GrapheneDclHooker.kt | 90 +++++++++++-------- 1 file changed, 54 insertions(+), 36 deletions(-) diff --git a/zygisk/src/main/kotlin/org/matrix/vector/GrapheneDclHooker.kt b/zygisk/src/main/kotlin/org/matrix/vector/GrapheneDclHooker.kt index e303ae997..3873dd69c 100644 --- a/zygisk/src/main/kotlin/org/matrix/vector/GrapheneDclHooker.kt +++ b/zygisk/src/main/kotlin/org/matrix/vector/GrapheneDclHooker.kt @@ -1,57 +1,75 @@ package org.matrix.vector import android.content.pm.ApplicationInfo -import de.robv.android.xposed.XC_MethodReplacement +import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.XposedHelpers import org.lsposed.lspd.util.Utils /** * Exempts the parasitic manager's host package from GrapheneOS's "Restrict dynamic code loading" - * (memory DCL) exploit protection. + * exploit protections. * - * The setting is immutable and enabled for system apps. As the manager runs inside - * [BuildConfig.InjectedPackageName] (`com.android.shell`, a system app), it cannot load its DEX - * and fails to start. The hook forces the restriction to "allowed" for that package alone; every - * other app retains GrapheneOS's verdict. It applies only on GrapheneOS, where the target class - * exists, and only in system_server, where the value is resolved. + * GrapheneOS splits DCL into three settings — memory, storage and WebView — and enforces each one + * through *two* independent channels, both fed by the same `AswRestrict*DynCodeLoading` verdict: + * + * 1. **ART DexFile checks.** `DynCodeLoading.getAppBindFlags` reads the three settings, ships the + * bitmask to the app and arms `DexFile.enableDynCodeLoadingChecks`, which rejects the manager's + * transplanted `/proc/self/fd/N` DEX with a "DCL via storage" `SecurityException`. + * 2. **Kernel `grapheneos_flags`.** `SELinuxFlags.get` turns the same settings into the + * `DENY_EXECMEM`/`DENY_EXECMOD`/… task flags, written to `/proc/self/attr/grapheneos_flags` + * during zygote specialization (writable only from the zygote context, so it cannot be undone + * later). With `DENY_EXECMEM` set, LSPlant/Dobby cannot allocate executable memory for its + * inline hook and the process aborts with `SIGSEGV` / `TSEC_FLAG_DENY_EXECMEM: op denied`. + * + * As the manager runs inside [BuildConfig.InjectedPackageName] (`com.android.shell`, a system app) + * these verdicts are immutable and enabled. Clearing only channel 1 (e.g. zeroing `getAppBindFlags`) + * lets the DEX load but leaves `DENY_EXECMEM` armed, so hooking still crashes. + * + * Both channels call `AswRestrict*DynCodeLoading.I.get()`, which returns the immutable verdict when + * `getImmutableValue` is non-null. Forcing that method to `false` for the manager host alone marks + * DCL as allowed at the source, clearing both channels at once. Every other app keeps GrapheneOS's + * verdict. It applies only on GrapheneOS, where these classes exist, and only in system_server, + * where the settings are resolved before the process is specialized. */ object GrapheneDclHooker { - private const val ASW_CLASS = "android.ext.settings.app.AswRestrictMemoryDynCodeLoading" + private val ASW_DCL_CLASSES = + arrayOf( + "android.ext.settings.app.AswRestrictMemoryDynCodeLoading", + "android.ext.settings.app.AswRestrictStorageDynCodeLoading", + "android.ext.settings.app.AswRestrictWebViewDynCodeLoading", + ) @JvmStatic fun start() { - val aswClass = - try { - XposedHelpers.findClass(ASW_CLASS, this.javaClass.classLoader) - } catch (_: XposedHelpers.ClassNotFoundError) { - return // Not GrapheneOS. + // Boolean getImmutableValue(Context, int userId, ApplicationInfo, GosPackageState, StateInfo) + // returns true (immutable, restricted) for every system app. Returning false instead marks + // DCL as allowed for the manager host, so neither the DexFile checks nor the kernel + // execmem/execmod flags derived from the same verdict are armed. + val exempt = + object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam<*>) { + val appInfo = param.args.getOrNull(2) as? ApplicationInfo + if (appInfo?.packageName == BuildConfig.InjectedPackageName) { + param.result = false + } + } } - try { - // Boolean getImmutableValue(Context, int, ApplicationInfo, GosPackageState, StateInfo) - // returns true (restricted), false (allowed), or null (user-configurable). A non-null - // result overrides the user toggle and the default, so false forces DCL to be allowed. - XposedBridge.hookAllMethods( - aswClass, - "getImmutableValue", - object : XC_MethodReplacement() { - override fun replaceHookedMethod(param: MethodHookParam<*>): Any? { - val appInfo = param.args.getOrNull(2) as? ApplicationInfo - if (appInfo?.packageName == BuildConfig.InjectedPackageName) { - return false // Allow the manager host to load its DEX. - } - return XposedBridge.invokeOriginalMethod( - param.method, - param.thisObject, - param.args, - ) - } - }, - ) - } catch (e: Throwable) { - Utils.logE("Failed to patch GrapheneOS DCL restriction", e) + for (className in ASW_DCL_CLASSES) { + val aswClass = + try { + XposedHelpers.findClass(className, this.javaClass.classLoader) + } catch (_: XposedHelpers.ClassNotFoundError) { + return // Not GrapheneOS. + } + + try { + XposedBridge.hookAllMethods(aswClass, "getImmutableValue", exempt) + } catch (e: Throwable) { + Utils.logE("Failed to patch GrapheneOS DCL restriction ($className)", e) + } } } }