
Android GKI 6.12 kernel exploit for CVE-2026-43499, chaining an rt_mutex rollback bug with pselect stack overwrite to gain root on Samsung and Pixel devices.
A Linux kernel exploit for the Android GKI 6.12 line (Samsung and Pixel
devices) targeting CVE-2026-43499: a buggy remove_waiter() rollback in
rt_mutex_start_proxy_lock() that uses current instead of waiter::task,
leaving a waiter's pi_blocked_on pointing at its (later-popped) kernel stack
rt_mutex_waiter. Combined with a pselect() fd_set stack overwrite and a
consumer sched_setattr-driven fake rb_tree walk, it yields a deterministic
kernel write primitive and full root.
The exploit runs as an LD_PRELOAD shared library (preload.so) and installs
a root su daemon plus wallpaper as post-root artifacts.
Status: active development. The m1q (ZF1) target is the bring-up focus. The pipei tmp_page-uname bootstrap is the current active route: the walk is proven clean on-device (build #33: per-child seeded rt_mutex regions) and the full 168-candidate sweep is in progress. The configfs CFI route is a dead end on m1q (Rust ashmem — no injectable fops slot) and remains the route for C-ashmem targets. Per-target offsets vary by device — verify against the actual kernel binary before trusting them.
When FUTEX_CMP_REQUEUE_PI requeues a waiter and the requeue's
deadlock-detection chain walk returns -EDEADLK, __rt_mutex_start_proxy_lock()
rolls back via remove_waiter() (rtmutex.c:1535). The rollback correctly
dequeues the waiter from the wait tree but clears the requeue caller's
pi_blocked_on instead of waiter->task->pi_blocked_on. The WAITER's
pi_blocked_on stays dangling at its stack rt_mutex_waiter, which is popped
once the futex times out.
After the timeout, the waiter's kernel stack region is reused: core_sys_select()
copies the three fd_sets into that stack buffer (the nfds < 344 stack path on
ZF1). A crafted fd_set word array re-materializes a fake rt_mutex_waiter /
fake task / fake rt_mutex (with an rb_tree root whose pointers are controlled).
A consumer thread then calls sched_setattr_tid(waiter) →
rt_mutex_adjust_pi() → rb_erase_cached, which produces an arbitrary
address write of a controlled value.
The whole primitive requires the PI chain cycle: the owner holds f_pi_target
and also blocks on f_pi_chain (held by the waiter), so the chain walk hits
owner → chain → waiter → target → owner and fails with -EDEADLK. Removing the
cycle makes the requeue return success with zero kernel effect.
sched_blocked_reason (m1q primary, on-device proven): a
blocked kworker's saved return PC (stack_trace_save_tsk) is read from
the ring buffer and compared against the compiled-in worker_thread
offset. Runs before any boot_id-route words are used so data-alias
write targets (data_addr() = p0 alias + slide_p0_offset) stay
correct at a nonzero slide.SLIDE_LOGGERS_0_1 into the boot_id sysctl data via a linear-map
alias; the leaked value reconstructs stext. Parked on m1q: its W1
target (SLIDE_RANDOM_BOOT_ID_DATA_OFF, low physical RAM) has
page-dependent writability — the slide moves the target across page
boundaries per boot, so ~6/7 runs fault. Only exercised explicitly via
SLIDE_FORCE_BOOTID (mechanism test) with the tree_pc/tree_left
redirected to the sprayed page.mm_struct-sized objects and use a futex-hash
collision to locate an on the heap, leaking a kernel heap page
address used as the fake-object spray base. Waiters are detached (not
joined) on cleanup to avoid the kernel-stack OOM that crashed builds before
#26.The pselect fd_set words re-materialize a fake rt_mutex_waiter on the
waiter's kernel stack; a consumer thread's sched_setattr_tid(waiter) walks
it via rt_mutex_adjust_pi(). ZF1 word map (disasm-verified):
PSELECT_WAITER_WORD_SHIFT = 0, word 12 = task (@+0x50), word 13 = lock
(@+0x58), word 14 = wake_state (@+0x60 = 3). With a seeded fake rt_mutex
the walk completes cleanly: [7]'s rb_erase W1 *(tree_left)=tree_pc / W2
*(tree_pc&~3+8)=tree_left are the only kernel writes; [11] (setprio /
dequeue_pi) and the [9] wake are both skipped (local fake waiter at prio 100
keeps the stack node out of top-waiter). Every walk-entered completion since
build #19 survives on-device; the deterministic crashes before that were an
8-byte payload placement error (SKB_DATA_DELTA), not a walk-body fault.
Optional STAGE3=1 phase that forks a bridge child, swaps its mm->pgd
to a staged fake page table (3-level: PGD→L1→L2, RX loop leaf + RW stack leaf),
and lets the child run a register-only assembly blob that patches its own cred
through a 2MB physical scan window — all-RAM phys R/W without the configfs/pipe
primitives. Currently wired only into the m1q target and has not been run
on-device.
41 targets in src/targets/<codename>-<build>/, each requiring at minimum a
target.h (kernel symbol offsets, KIMAGE_TEXT_BASE, direct-map base, struct
offsets). Pixel targets (comet, tokay, tegu, caiman, komodo,
frankel, mustang, rango, stallion, blazer) override shared sources;
Samsung targets (m1q-*) add device-specific logic.
make list-projects # full list
Kernel struct layouts differ per device — offsets must be verified against the actual kernel binary / kallsyms for each target.
Output: build/<PROJECT>/bin/preload.so (shared lib loaded via LD_PRELOAD).
# Default project
CC=clang make
# Specific device target (default: blazer-CP2A.260605.012)
CC=clang make PROJECT=m1q-BP4A.251205.006
# Without CC=clang: uses NDK if found ($NDK_ROOT / $ANDROID_NDK_HOME /
# $ANDROID_NDK_ROOT), otherwise host clang + Android sysroot
make PROJECT=m1q-BP4A.251205.006
# Show build configuration
make info
# Clean
make clean
The build embeds a PIE su_daemon binary (src/su_daemon.c, built to
build/embed/su_daemon_aarch64_pie) and assets/wallpaper.webp into
preload.so via src/su_blob.S / src/wallpaper_blob.S.
Push the build outputs to /data/local/tmp, then run the exploit under
LD_PRELOAD. Run WITHOUT tee on a live bring-up target — tee buffers its own
stdio and loses the tail of the log on a kernel panic (exploit stdout is
unbuffered, so redirecting straight to a file preserves every line):
adb push build/m1q-BP4A.251205.006/bin/preload.so /data/local/tmp/preload.so
adb push build/embed/su_daemon_aarch64_pie /data/local/tmp/su_daemon_aarch64_pie
adb shell "chmod 755 /data/local/tmp/preload.so /data/local/tmp/su_daemon_aarch64_pie"
adb shell "LD_PRELOAD=/data/local/tmp/preload.so \
/data/local/tmp/su_daemon_aarch64_pie \
> /data/local/tmp/output.log 2>&1"
On success the daemon listens on /data/local/tmp/temp_su.sock and su is
installed under /apex/com.android.virt/bin.
tools/run_pipei.sh pushes the binary, sha256-verifies it on-device (a
stale preload.so produces silently unreadable verdicts — the ladder once ran
a build < #22 whose log format differs), then runs the full 168-candidate
sweep chunked 3x56 so a panic loses at most one chunk (~10 min). The exploit
stdout streams directly through adb (no on-device redirect — a
device-side tee loses the tail on panic); a host-side copy of each chunk is
kept at /tmp/m1q_pipei_chunk{1..3}_stream.log:
sh tools/run_pipei.sh
PASS = any chunk shows tmp_page uname changed + after uname='CatOS.
PIPEI_CHILD_REGIONS must be set to at least the chunk size or the per-child
rt_mutex regions stay unseeded and the sweep panics at candidate 0. Full
coverage also requires TMP_UNAME_PIPEI_SPLIT_ORDER_PAGES=1 — without it only
one page of the freed 8-page mm block is swept. run_pipei_slim.sh is the
single-candidate diagnostic variant; run_m1q_ladder.sh is the
walk/isolation battery (B16-2 oracle, slide mechanism, sweep steps).
exploit/
Makefile
assets/ # wallpaper.webp (embedded post-root artifact)
src/
main.c # run_exploit() entry point (shared)
preload.c # LD_PRELOAD constructor, su/wallpaper install
util.c # addr translation, page prep, configfs helpers
pipe.c # pipe-based physical read/write
root.c # cred patching / task walk
slide.c # KASLR slide (tracefs + boot_id routes)
fops.c # pselect corruption + fops manipulation
faketables.c # stage-3 fake page-table builders (3-level)
stage3.c # pgd-swap bridge child (no-op unless configured)
stage3_loop.S, stage3_poll.S # bridge blob + enter trampoline
su_daemon.c # root daemon (compiled as separate PIE)
su_blob.S, wallpaper_blob.S # embedded binaries
offset.h # pulls per-target target.h via -DTARGET_CONFIG_H
kernelsnitch/ # mm_struct futex-hash leak library
targets/<codename>-<build>/ # per-device target.h + optional overrides
poc/poc.c # standalone POC (separate from the exploit)
Targets override shared sources through the Makefile pick_src mechanism:
if src/targets/<PROJECT>/<file>.c exists it is used instead of the shared
src/<file>.c; otherwise the shared version is used.
-EDEADLK (errno 35). A missing cycle shows up as
success=1 with zero kernel effect.PSELECT_WAITER_WORD_SHIFT must be 0 (ZF1 stack depths verified
from ELF). Other targets still default to 1 — verify per device before
trusting them.p0_data_alias() uses DIRECT_MAP_BASE (the linear map), which differs
from P0_PAGE_OFFSET on ZF1. Data aliases for dereferenced kernel objects
must use the linear map.pr_error() is fatal (exit(-1)), unlike the warning/info macros.SLIDE_LOGGERS_0_1, not SLIDE_NFULNL_LOGGER;
the word is a non-randomized linear-map alias, so the boot_id route is
slide-independent (use it only at slide=0 — run the tracefs leak first).This is exploit code for Android devices and is intended for security research, authorized vulnerability testing, and defense work on hardware you own or have explicit permission to test. Misuse on devices you do not own is illegal. The author is not responsible for bricked devices, bricked kernels, or voided warranties.
mm_structwrite_iter slot and the ASHMEM_SET_NAME heap object is
a KVec whose layout does not line up with configfs_bin_write_iter's
private_data. m1q pivots to a kmalloc write instead: reclaim the leaked mm
order-3 block as pipe_inode_info objects and use the pselect W1 write
(*(tree_left) = tree_pc) to overwrite a candidate's tmp_page slot
(+0x90) with the UTS namespace page (init_uts_ns). A fanout write plants
a marker name at the sysname offset and uname() reports "CatOS" — a
verifiable kernel write with no static-object dependence. C-ashmem targets
keep the configfs path.preload.c installs the embedded su daemon (tmpfs-mounted into
/apex/com.android.virt/bin, plus adbd-namespace and local variants) and
swaps the wallpaper.| Variable | Default | Purpose |
|---|
PSELECT_ROUTE_SHIFT | compile-time | A/B fd_set word shift for the main route (m1q) |
SLIDE_SHIFT | compile-time | A/B fd_set word shift for the slide route |
PSELECT_ROUTE_DELAY_USEC | 50000 | Consumer delay before sched_setattr (must be > 0) |
SLIDE_CONSUME_USEC, SLIDE_ENTER_DELAY_USEC, SLIDE_CONSUMER_CORE | — | Slide route timing/pinning |
SKIP_SLIDE | 0 | Use direct-map fallback (slide = 0) |
SLIDE_ONLY | 0 | Run only the KASLR slide and exit |
SLIDE_P0_OFFSET, SLIDE_BOOTID_OFF | — | Override slide p0-alias / boot_id offsets |
SLIDE_FORCE_BOOTID | 0 | Force the boot_id pselect route after the tracefs slide (mechanism test) |
KSNITCH_COLLISIONS | 4 | KernelSnitch collision count |
STAGE3 | 0 | Enable the pgd-swap bridge child phase (m1q) |
STAGE3_DRYRUN | 0 | Stage/verify tables then abort before the swap |
PAGE_RECLAIM_SENDS, PSELECT_SIMPLE_LAYOUT | — | Spray/fd-set layout tweaks |
TMP_PAGE_UNAME | 0 | Run the m1q pipei tmp_page-uname bootstrap |
TMP_UNAME_PIPEI_SWEEP, TMP_UNAME_PIPEI_ALIGN_ORDER, TMP_UNAME_PIPEI_SPLIT_ORDER_PAGES | — | Sweep mode; split_order sweeps all 8 pages of the freed mm block |
TMP_UNAME_PIPEI_SLOT, TMP_UNAME_PIPEI_SLOT_CANDIDATES | — | Sweep range (chunk start / count) |
PIPEI_CHILD_REGIONS | 0 | Per-child seeded rt_mutex regions; MUST be >= sweep chunk or unseeded regions panic at candidate 0 |
PIPEI_RECLAIM_COUNT, PIPEI_DRAIN_COUNT | — | Reclaim/drain pipe pairs; raise on a reclaim miss |
PIPEI_SPONGE_MB | 3072 | Order-0 flood sponge held across the reclaim (build #29) |
PSELECT_W0_PRIO_OVERRIDE | 130 | Top-waiter prio; inert with seeded regions (legacy stale-tree guard, A/B only) |
PSELECT_SKB_DELTA_OVERRIDE | 0 | A/B payload intra-page offset (only if a device differs from -0xe78) |
TMP_UNAME_NAME, TMP_UNAME_HOLD_SEC | "CatOS", 5 | Marker name / hold time after success |
SKB_DATA_DELTA is -0xe78 (on-device measured, build #19) — the real
skb frag head is 8 bytes less than the disasm-derived -0xe80; that 8-byte
error caused every deterministic pselect crash from builds #6-18
(walk-entered → garbage deref). GKI 6.12 shared value; per-target util.c
copies carry a SKB_DATA_DELTA env A/B knob. Do NOT stack
PSELECT_SKB_DELTA_OVERRIDE on top of the baked constant.ASHMEM_SET_NAME KVec envelope does not line up
with configfs_bin_write_iter. Use the pipei tmp_page bootstrap. The
PSELECT_WRITE_RIGHT_SPRAYED / PSELECT_PI_TOP_TASK_SELF guards in m1q
target.h redirect the walk's [11] writes off the RO static fops.payload+0x6000 + idx*0x40) inside the
reclaimed page. Shared-fake_lock sweeps panicked at candidate ~9-12 (stale
kernel-stack nodes recycled), and build #32's empty regions panicked at
candidate 0 ([9] owner==NULL wake on the 0x41-filled fake_task).
PIPEI_CHILD_REGIONS must be set or the regions stay unseeded.