From bb9a33b7bfbe498d3ae76ab0f5c522735ef33c7a Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Thu, 5 Nov 2026 22:23:01 +0100 Subject: [PATCH] Name the sepolicy access that is missing, not just that one is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isSepolicyLoaded` probed one access of the eighteen statements in `zygisk/module/sepolicy.rule`: whether `dex2oat` may execute `dex2oat_exec`. The manager drew one row from it, "SELinux policy: not loaded", and that was the whole of what a reader could learn — while the access the first probe happens to name is not the one injection depends on. A partly applied policy is an ordinary state rather than a corrupt one. The root solution applies that file, and some apply it a statement at a time and carry on past each one they reject: KernelSU writes `sepol: cmd #N failed` to the kernel log for each, and an LKM loaded after boot may never reach the stage that applies it at all. The one access the whole injection hangs on is `system_server`'s `service_manager find` on `default_android_service` — without it the module inside system_server cannot look up the name the daemon is waiting under, and the result is a daemon running, a CLI answering, and `system framework: not injected` with the first probe still green. So the probe is a list: the rendezvous lookup, the dex2oat wrapper, `dex2oat` reading `xposed_file` and `system_server` reading `xposed_data`. Each stands for the statements it needs as well as its own, since a probe against `xposed_file` answers false when the `type` that declares it never landed either. The manager keeps its single row, which is all it can honestly offer — none of these is separately actionable by a user — and every denied access is written to the log by name, with what stops working without it, once per distinct answer so that a status refresh does not repeat itself. Also here: the one access AOSP grants rather than this file, `system_server self:process execmem`, is named in full where `getModulesForSystemServer` refuses to inject without it. That path ends in an empty module set and a system server that looks injected, and the message was "sepolicy execmem denied", which says neither which domain was refused nor what wanted it. Refs #1053, #1056 --- .../matrix/vector/daemon/data/ConfigCache.kt | 11 ++- .../vector/daemon/ipc/ManagerService.kt | 84 ++++++++++++++++++- 2 files changed, 91 insertions(+), 4 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 b79aa11817..29a89145d1 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 @@ -489,9 +489,18 @@ object ConfigCache { fun getModulesForSystemServer(): List { val modules = mutableListOf() + // AOSP grants this one — `allow system_server self:process execmem` is in system_server.te — + // so unlike the rules in `sepolicy.rule` there is nothing of ours to have failed to apply, and + // nothing this framework can add. Named in full all the same: the message said "execmem + // denied" and left a reader to guess which domain was refused what, on the one path whose + // failure is an empty module set and a system server that looks injected. if (!android.os.SELinux.checkSELinuxAccess( "u:r:system_server:s0", "u:r:system_server:s0", "process", "execmem")) { - Log.e(TAG, "Skipping system_server injection: sepolicy execmem denied") + Log.e( + TAG, + "Skipping system_server injection: sepolicy denies " + + "`u:r:system_server:s0 u:r:system_server:s0:process execmem`, which LSPlant needs " + + "to write a trampoline") return modules } diff --git a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt index dc7437b05d..cd05010cdd 100644 --- a/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt +++ b/daemon/src/main/kotlin/org/matrix/vector/daemon/ipc/ManagerService.kt @@ -443,9 +443,87 @@ object ManagerService : IManagerService.Stub() { return result } - override fun isSepolicyLoaded() = - SELinux.checkSELinuxAccess( - "u:r:dex2oat:s0", "u:object_r:dex2oat_exec:s0", "file", "execute_no_trans") + /** + * One access `sepolicy.rule` grants, and what stops working without it. + * + * Spelled as the four arguments of [SELinux.checkSELinuxAccess], in that order, so that what the + * log names is what a reader can check for themselves. + */ + private class SepolicyGrant( + val source: String, + val target: String, + val clazz: String, + val permission: String, + val needs: String, + ) { + override fun toString() = "$source $target $clazz $permission" + } + + /** + * What `zygisk/module/sepolicy.rule` has to have granted for this framework to work. + * + * Not the whole file: these are the accesses something later fails without, and each one also + * stands for the statements it depends on — a probe against `xposed_file` answers false if the + * `type` that declares it never landed either. + * + * The root solution applies that file, and some of them apply it a statement at a time and + * carry on past one they reject: KernelSU answers `sepol: cmd #N failed` in the kernel log for + * each, and a late-loaded LKM may never reach the stage that applies it at all. So a partly + * applied policy is an ordinary state, not a corrupt one, and it is why one probe is not enough + * to describe it. + */ + private val sepolicyGrants = + listOf( + SepolicyGrant( + "u:r:system_server:s0", + "u:object_r:default_android_service:s0", + "service_manager", + "find", + "system_server cannot look up the rendezvous service, so nothing is injected"), + SepolicyGrant( + "u:r:dex2oat:s0", + "u:object_r:dex2oat_exec:s0", + "file", + "execute_no_trans", + "the dex2oat wrapper cannot run, so module code is not compiled"), + SepolicyGrant( + "u:r:dex2oat:s0", + "u:object_r:xposed_file:s0", + "file", + "read", + "dex2oat cannot read what it is asked to compile"), + SepolicyGrant( + "u:r:system_server:s0", + "u:object_r:xposed_data:s0", + "file", + "read", + "a module cannot read its own preferences in system_server"), + ) + + /** What [isSepolicyLoaded] last wrote to the log, so a status refresh does not repeat it. */ + @Volatile private var reportedSepolicyDenials: List? = null + + /** + * Whether every access in [sepolicyGrants] is granted, naming each one that is not. + * + * The manager shows one row for this, which is all it can show: none of these is individually + * actionable by a user, and what to do about any of them is the same thing. The log is where the + * answer stops being a single bit, because "not loaded" on its own sends a reader looking at + * their modules. Written once per distinct answer, since the status page asks again on every + * refresh. + */ + override fun isSepolicyLoaded(): Boolean { + val denied = + sepolicyGrants.filterNot { + SELinux.checkSELinuxAccess(it.source, it.target, it.clazz, it.permission) + } + val names = denied.map { it.toString() } + if (names != reportedSepolicyDenials) { + reportedSepolicyDenials = names + denied.forEach { Log.w(TAG, "sepolicy denies `$it`: ${it.needs}") } + } + return denied.isEmpty() + } override fun getUsers(): List { return userManager?.getRealUsers()?.map {