( بِسْمِ اللَّـهِ الرَّحْمَـٰنِ الرَّحِيمِ )
CAUTION#FreePalastine
DirtySepolicy: How Android’s App Zygote Became a Root-Detection Oracle
DirtySepolicy is a method for detecting Android root solutions by abusing a design feature of the App Zygote process. The technique was discovered by the LSPosed team in August 2024, kept private - only god knows why -, and then independently rediscovered and published by FldBudin in Duck Detector on May 10, 2026 [1] [2]. Within hours, KernelSU shipped a kernel-level mitigation (selinux_hide, PR #3457) followed by a fix for the /proc/self/attr/current side-channel (PR #3459) [3] [4]. SukiSU-Ultra merged a similar fix shortly after [5], and KernelSU later extended the hide to the SELinux status page (PR #3495) [6].
The core idea is simple: App Zygote processes need SELinux query privileges to safely fork isolated services. Those same privileges let them inspect the live SELinux policy for “dirty” rules injected by root solutions; rules that are extremely hard to hide from userspace.
This post explains how the oracle works, shows the real code that implements it, and dissects the kernel-level bypass that closes the hole. Before going deeper, I recommned you read more about SELinux from here. Now grab your coffee and lets roll.
1. Why App Zygote Has SELinux Query Privileges
Android introduced App Zygote (API 29+) as an optimization for apps that spawn isolated processes. Instead of forking from the system Zygote, the app gets its own Zygote that preloads the app’s code and resources, then forks isolated services on demand. Read more about Zygote from here
You declare it in AndroidManifest.xml with two key attributes:
android:zygotePreloadNamenames a class that runs inside the App Zygote before any child process is forked.android:useAppZygote="true"on an isolated service tells Android to fork that service from the App Zygote instead of the main Zygote.
<!-- LSPosed DirtySepolicy: app/src/main/AndroidManifest.xml -->
<application
android:zygotePreloadName="org.lsposed.dirtysepolicy.AppZygote">
<service
android:name=".DirtySepolicyService"
android:isolatedProcess="true"
android:useAppZygote="true" />
</application>The App Zygote runs as u:r:app_zygote:s0. When it forks a child into the restricted u:r:isolated_app:s0 domain, it must:
- Transition the child context
process:setcurrent. - Validate the target context string
security:check_context. - Compute what the child will be allowed
security:compute_av.
AOSP therefore grants these permissions to app_zygote. The same permissions let the App Zygote audit the global SELinux policy database something a normal untrusted_app process cannot do.

The left side shows the App Zygote oracle querying the live policy; the right side shows KernelSU redirecting app-UID queries to a clean backup_sepolicy snapshot.
2. The Three SELinux Oracle APIs
DirtySepolicy uses three kernel interfaces to probe for root artifacts. Think of them as three different ways to ask the kernel, “Does this SELinux type or allow-rule exist in the live policy?”
2.1 security:compute_av “Is this access allowed?”
This API computes the set of permissions allowed between a source context, a target context, and an object class. In Android’s framework it is exposed as android.os.SELinux.checkSELinuxAccess(...), but under the hood it writes "scon tcon tclass" to /sys/fs/selinux/access and reads back an access-vector decision.
// LSPosed DirtySepolicy: SELinux.java
public static boolean checkSELinuxAccess(String scon, String tcon, String tclass, String perm) {
var pair = readIndex(tclass, perm); // look up class/permission IDs
var parts = access(scon, tcon, pair.first); // write to /sys/fs/selinux/access
var allowed = Integer.parseUnsignedInt(parts[0], 16);
return (allowed & pair.second) == pair.second;
}If a root solution has injected a rule like allow kernel ksu_file:file read, then:
SELinux.checkSELinuxAccess("u:r:kernel:s0", "u:object_r:adb_data_file:s0", "file", "read")will return true on a KernelSU device and false on a clean device.
2.2 security:check_context “Does this type exist?”
This API validates whether a context string is known to the policy. There is no high-level Java method for it; you invoke it by writing the context string to /sys/fs/selinux/context.
- If the write succeeds, the type exists.
- If the kernel returns
EINVAL, the type is absent. - Any other error means the type exists but something else went wrong.
// LSPosed DirtySepolicy: SELinux.java
public static boolean contextExists(String context) {
var data = context.getBytes(StandardCharsets.UTF_8);
try (var file = new FileOutputStream(SELINUX_FS.resolve("context").toFile())) {
Os.write(file.getFD(), data, 0, data.length);
return true; // type exists
} catch (ErrnoException e) {
if (e.errno == OsConstants.EINVAL) {
return false; // type does not exist
}
throw new RuntimeException("security_check_context errno=" + e.errno, e);
}
}So writing "u:object_r:magisk_file:s0" and getting success tells you Magisk has injected that type. Writing "u:r:ksu:s0" and getting EINVAL tells you the device is clean.
2.3 process:setcurrent the EPERM/EINVAL side-channel
Even if the first two paths are blocked, the kernel still leaks information through /proc/self/attr/current. When a process tries to change its own SELinux context, the kernel checks the requested context in order:
- Invalid / unknown context → returns
EINVAL. - Valid context, but transition forbidden → returns
EPERM. - Valid context and transition allowed → returns success.
The difference between EINVAL and EPERM is the side-channel: if you try to switch to u:r:ksu:s0 and get EPERM, the type exists in the policy even though you cannot transition to it.
// Illustrative native probe for the side-channel
ssize_t ret = write(fd, "u:r:su:s0", strlen("u:r:su:s0"));
if (ret < 0 && errno == EPERM) {
return true; // context exists -> dirty
}
if (ret < 0 && errno == EINVAL) {
return false; // context absent -> clean
}This snippet is illustrative, but it matches exactly what the kernel returns and what the LSPosed reference implementation encodes in Java as a fallback inside contextExists().
3. The Detection Checklist
The actual audit happens inside AppZygote.doPreload(). Android calls this method in the App Zygote process before the isolated service is forked, so it runs with u:r:app_zygote:s0 privileges.
// LSPosed DirtySepolicy: AppZygote.java
@Override
public void doPreload(ApplicationInfo appInfo) {
var uid = Os.getuid();
if (uid != appInfo.uid) {
result = "ERROR: UID mismatch";
return;
}
try {
result = doCheck();
} catch (RuntimeException e) {
result = "ERROR: " + e.getMessage();
}
}doCheck() first makes sure it is really running as app_zygote, then probes for known root artifacts:
// LSPosed DirtySepolicy: AppZygote.java (selected checks)
if (SELinux.contextExists("u:r:adbroot:s0")) {
sb.append("found adb_root; ");
}
if (SELinux.contextExists("u:r:magisk:s0") ||
SELinux.contextExists("u:object_r:magisk_file:s0") ||
SELinux.checkSELinuxAccess("u:r:kernel:s0", "u:object_r:tmpfs:s0", "fifo_file", "open")) {
sb.append("found Magisk; ");
}
if (SELinux.contextExists("u:r:ksu:s0") ||
SELinux.contextExists("u:object_r:ksu_file:s0") ||
SELinux.checkSELinuxAccess("u:r:kernel:s0", "u:object_r:adb_data_file:s0", "file", "read")) {
sb.append("found KernelSU; ");
}
if (SELinux.contextExists("u:object_r:lsposed_file:s0")) {
sb.append("found LSPosed; ");
}The isolated service then returns the cached string to the main app:
// LSPosed DirtySepolicy: DirtySepolicyService.java
public class DirtySepolicyService extends Service {
private final IDirtySepolicyService.Stub binder = new IDirtySepolicyService.Stub() {
@Override
public String getResult() {
return AppZygote.result;
}
};
@Override
public IBinder onBind(Intent intent) {
if (Process.isIsolated()) {
return binder;
}
return null;
}
}Because the probing happens inside the App Zygote, a normal app process never needs special permissions. It just binds to an isolated service and reads the result.
4. Duck Detector PR #22 The First Public Implementation
While LSPosed kept the technique private, Duck Detector was the first public implementation [1]. It uses the same App-Zygote trick but wraps it in a native C++ probe, a JNI bridge, and a Kotlin codec layer.
The manifest is almost identical to DirtySepolicy’s:
<!-- Duck Detector PR #22: app/src/main/AndroidManifest.xml -->
<application
android:zygotePreloadName="com.eltavine.duckdetector.features.selinux.data.service.AppZygotePreload">
<service
android:name=".features.selinux.data.service.SelinuxContextValidityCarrierService"
android:exported="false"
android:isolatedProcess="true"
android:useAppZygote="true" />
</application>The preload entry point delegates to a native probe:
// Duck Detector PR #22: AppZygotePreload.kt
class AppZygotePreload : ZygotePreload {
override fun doPreload(p0: ApplicationInfo) {
if (SelinuxContextValidityBridge.isNativeLibraryLoaded) {
val result = SelinuxContextValidityBridge.nativeCollectContextValiditySnapshot()
SelinuxContextValidityBridge.setPreloadedRawData(result)
}
}
}Duck Detector’s PR #22 specifically implements the security:check_context path. The native probe writes known root contexts to /sys/fs/selinux/context:
// Duck Detector PR #22: context_validity_probe.cpp
constexpr const char *kSelinuxContextPath = "/sys/fs/selinux/context";
constexpr const char *kExpectedCarrierType = "app_zygote";
constexpr const char *kKsuContext = "u:r:ksu:s0";
constexpr const char *kKsuFileContext = "u:object_r:ksu_file:s0";
constexpr const char *magiskFileContext = "u:object_r:magisk_file:s0";
ContextCheckResult check_context_validity(const char *context) {
ContextCheckResult result;
int fd = open(kSelinuxContextPath, O_RDWR | O_CLOEXEC);
if (fd < 0) { result.valid = false; return result; }
const ssize_t written = write(fd, context, std::strlen(context) + 1);
close(fd);
if (written >= 0) {
result.valid = true; // type exists in live policy
} else {
result.valid = false; // type absent (usually EINVAL)
}
return result;
}Duck Detector adds positive and negative controls to avoid false positives. It first checks that the probe’s own app_zygote context and a stock file context are accepted, while synthetic Duck Detector contexts are rejected. Only if those controls pass does it trust the oracle.
If the controls pass, it records whether the KSU and Magisk contexts were accepted:
snapshot.ksu_domain_valid = ksu_domain_result.valid;
snapshot.ksu_file_valid = ksu_file_result.valid;
snapshot.magisk_file_valid = magisk_file_result.valid;The Kotlin layer then classifies the device:
// Duck Detector PR #22: SelinuxContextValidityProbe.kt
internal fun SelinuxContextValiditySnapshot.toProbeResult(): SelinuxContextValidityProbeResult {
val state = when {
ksuDomainValid ?: false || ksuFileValid ?: false || magiskFileValid ?: false ->
SelinuxContextValidityState.ROOT_PRESENT
else -> SelinuxContextValidityState.CLEAN
}Important caveat: Duck Detector PR #22 implements the check_context probe. The broader DirtySepolicy concept also includes compute_av and setcurrent probes, which are present in the LSPosed reference implementation but not in Duck Detector’s first public PR.
5. Why Userspace Bypasses Fail
The LSPosed README originally claimed the detection is “impossible to bypass” from userspace [2]. The reasoning is structural:
/sys/fs/selinux/is implemented entirely in the kernel. There is no userspace daemon to hook.app_zygote.tepermissions are part of AOSP and cannot be altered without modifying the loaded sepolicy binary.- The queries target the single, authoritative policy database in kernel memory.
Any userspace attempt to intercept the App Zygote’s syscalls or block the /sys/fs/selinux/context write tends to crash the App Zygote and a crash or binding timeout is itself treated as a positive signal.
The only viable bypass is inside the kernel.
6. Kernel-Level Mitigation: selinux_hide
KernelSU’s response does not try to clean the live policy. Instead, it presents a different policy to app UIDs [3].
Phase 1: Capture a clean policy snapshot
Before KernelSU injects its own SELinux rules, it clones the current (clean) policy into backup_sepolicy. This snapshot contains no ksu, magisk, su, or other root-related types.
// KernelSU PR #3457: kernel/selinux/rules.c
struct selinux_policy *backup_sepolicy;
void apply_kernelsu_rules()
{
mutex_lock(&selinux_state.policy_mutex);
// FIRST: clone the clean policy
backup_sepolicy = ksu_dup_sepolicy(
rcu_dereference_protected(selinux_state.policy,
lockdep_is_held(&selinux_state.policy_mutex)));
// THEN: create a working copy and inject KernelSU rules into that copy
// ...
}Phase 2: Hook the selinuxfs write operations
selinux_hide patches the kernel’s write_op function-pointer table, which dispatches writes to /sys/fs/selinux/* files. It replaces the handlers for the context and access nodes.
// KernelSU PR #3457: kernel/feature/selinux_hide.c
static ssize_t my_write_context(struct file *file, char *buf, size_t size)
{
// System UIDs see the real (modified) policy
if (likely(current_uid().val < 10000)) {
return orig_context_write(file, buf, size);
}
// App UIDs: validate against the clean backup policy
length = security_context_to_sid_with_policy(backup_sepolicy, buf, size,
&sid, SECSID_NULL, GFP_KERNEL);
if (length)
goto out; // type not in backup policy -> EINVAL to userspace
length = security_sid_to_context_with_policy(backup_sepolicy, sid, &canon, &len);
// ...
}For a system UID, the hook forwards to the original handler. For an app UID, the context is validated against backup_sepolicy. If the type was added after the snapshot, the lookup fails and the caller sees EINVAL the same result as a clean device.
Access-vector queries are handled the same way:
// KernelSU PR #3457: kernel/feature/selinux_hide.c
static ssize_t my_write_access(struct file *file, char *buf, size_t size)
{
if (likely(current_uid().val < 10000)) {
return orig_access_write(file, buf, size);
}
// Parse "scon tcon tclass" from userspace
sscanf(buf, "%s %s %hu", scon, tcon, &tclass);
// Resolve contexts against the backup policy
security_context_to_sid_with_policy(backup_sepolicy, scon, strlen(scon), &ssid, ...);
security_context_to_sid_with_policy(backup_sepolicy, tcon, strlen(tcon), &tsid, ...);
// Compute the access vector from the backup policy
security_compute_av_user_with_policy(backup_sepolicy, ssid, tsid, tclass, &avd);
}The helpers security_context_to_sid_with_policy() and security_compute_av_user_with_policy() are stripped-down copies of the kernel’s own functions that operate on backup_sepolicy instead of selinux_state.policy.
Phase 3: Install and lifecycle the hooks
Hook installation uses runtime kernel text patching. The write_op table is looked up by symbol, and the SEL_CONTEXT and SEL_ACCESS slots are overwritten. The setprocattr LSM hook is registered separately.
// KernelSU PR #3457 / #3459: kernel/feature/selinux_hide.c
selinux_write_op = kallsyms_lookup_name("write_op");
context_write = &selinux_write_op[SEL_CONTEXT];
orig_context_write = *context_write;
ksu_patch_text(context_write, &my_write_context, sizeof(my_write_context), ...);
access_write = &selinux_write_op[SEL_ACCESS];
orig_access_write = *access_write;
ksu_patch_text(access_write, &my_write_access, sizeof(my_write_access), ...);
ksu_lsm_hook(&selinux_setprocattr_hook);The feature is registered during module init:
// KernelSU PR #3459: kernel/core/init.c
#include "feature/selinux_hide.h"
ksu_lsm_hook_init();
ksu_selinux_hide_init();and torn down on exit:
ksu_selinux_hide_exit();
ksu_lsm_hook_exit();If the user never enables the feature after boot, the backup is dropped to save memory:
// KernelSU PR #3457: kernel/runtime/boot_event.c
void on_boot_completed(void)
{
// ...
ksu_selinux_hide_drop_backup_if_unused();
}7. Closing the /proc/self/attr/current Side-Channel
The initial selinux_hide only covered /sys/fs/selinux/*. Detectors could still probe /proc/self/attr/current: if the target context exists, the kernel returns EPERM even though the transition is forbidden. KernelSU PR #3459 adds an LSM hook on selinux_setprocattr that pre-validates the target context against backup_sepolicy [4]:
// KernelSU PR #3459: kernel/feature/selinux_hide.c
static int __nocfi my_setprocattr(const char *name, void *value, size_t size)
{
// Only intercept app UIDs writing to the "current" attribute
if (likely(current_uid().val < 10000)) goto call_orig;
if (strcmp(name, "current")) goto call_orig;
// Validate the requested context against the clean backup policy
error = security_context_to_sid_with_policy(backup_sepolicy, str, size,
&sid, SECSID_NULL, GFP_KERNEL);
if (error) {
return error; // Context not in backup -> EINVAL
}
call_orig:
return ((setprocattr_fn)selinux_setprocattr_hook.original)(name, value, size);
}Because the context is checked against the backup first, a hidden root type returns EINVAL instead of EPERM, flattening the side-channel.
8. Hiding the SELinux Status Page
DirtySepolicy also reads /sys/fs/selinux/status to look for policy-load counts and sequence numbers that reveal runtime modifications. The mitigation family was extended to serve a fake status page to app UIDs. KernelSU added this in PR #3495 [6], and the same logic appears in SukiSU-Ultra’s main branch [7]:
// SukiSU-Ultra main: kernel/feature/selinux_hide.c
static struct page *fake_status = NULL;
static void initialize_fake_status()
{
struct selinux_kernel_status *status = page_address(selinux_state.status_page);
struct page *new_page = alloc_page(GFP_KERNEL | __GFP_ZERO);
struct selinux_kernel_status *new_status = page_address(new_page);
memcpy(new_status, status, sizeof(*status));
if (ksu_late_loaded && !new_status->enforcing) {
new_status->enforcing = 1;
new_status->sequence = new_status->policyload ? 4 : 0;
}
fake_status = new_page;
}
static int my_sel_open_handle_status(struct inode *inode, struct file *filp)
{
if (likely(current_uid().val >= 10000 && ksu_selinux_hide_enabled)) {
filp->private_data = fake_status;
return 0;
}
return orig_sel_open_handle_status(inode, filp);
}This closes the policy-status side-channel as well.
9. Conclusion
DirtySePolicy exploits an architectural necessity App Zygote needs SELinux query rights to function, and those rights expose the policy database. It targets the policy, not artifacts, making file and process hiding irrelevant because the detection queries the kernel’s authoritative access-control rules. Userspace bypasses fail structurally, as blocking the App Zygote's queries crashes the App Zygote itself, which signals tampering. KernelSU's mitigation is elegant, presenting a clean pre-modification snapshot to app UIDs while preserving the real policy for the system instead of cleaning the modified policy. The arms race continues, with future detection potentially focusing on timing differences, kernel symbol scanning, or policy-consistency checks across different query paths.
This is why modern
RASPstry to find different detection surfaces every day which abuses legitimate Android/Linux builtin features.
This was the end, cheers to the Zygote :“D
References
- Duck Detector PR #22
- LSPosed DirtySepolicy
- KernelSU PR #3457
- KernelSU PR #3459
- SukiSU-Ultra PR #891
- KernelSU PR #3495 (fake status page)
- SukiSU-Ultra
selinux_hide.c(fake status) - gm7.org analysis of universal detection and bypass methods
- LineageOS SELinux guide
- Android SELinux validation
- Telegram community summary
- Xinrao blog
- JTech Forums discussion
