It should have been easy. The vulnerability, CVE-2026-43499, had a public exploit, ghostlock, and public ports for several devices. The vulnerability class (a pipe-buffer page-reuse race rooted in a futex PI use-after-free) was documented end to end. All we had to do was run it on a Samsung Galaxy A17.
Samsung had other plans. The A17's retail build stacks Kernel Data
Protection at EL2, DEFEX exec restrictions, a compiled-in SELinux, and
panic_on_oops=1 on top of GKI 6.12. Each layer killed a
"standard" step of the public chain, and one of them turned every
successful root into an instant device reset. This post is the full
journey: what broke, the evidence for each failure, and the engineering that
made the chain not just work but work cleanly: root with no panic
and a persistent root shell.
6.12.23-android16-5-abA175FXXS3BZA5-4k (GKI 6.12), BZA5 firmware.uid=0(root) gid=0(root) context=u:r:kernel:s0, clean exploit exit (no panic), persistent root shell via a tiny daemon (g4d/g4sh).Credit where it is due first: CVE-2026-43499 was discovered by Nebula Security and published
as part of their IonStack
research series. The IonStack part 3 write-up roots a Pixel 10 /
Android 17 GKI with a data-only cred-patch endgame: pselect reclaim after
the futex PI use-after-free, a boot_id sysctl .data
repoint to leak the slide, ashmem fops swapped for same-CFI-signature
configfs handlers, constrained R/W, then a pipe_buffer.page
overwrite for full R/W, a walk of the task list, and finally a patch of
cred (uids, caps, securebits, seccomp) plus a flip of
selinux_enforcing.
We started from the public OnePlus
port of ghostlock (JoinChang/ghostlock-oneplus): its entry primitives (pselect stack reclaim, the fake rt_mutex_waiter, the constrained rb-erase pointer write) carry over. Almost everything after the write
primitive was reworked or replaced for this target. Nothing else transferred
either: community payload collections such as Root-My-Galaxy-Payloads
(PR #188) cover
the A17 5G (SM-A176B) on kernel 5.15.189: a different phone, a
different kernel generation, a different physical base
(0x80000000 vs 0x40000000), and different structure
layouts. Nothing fits 6.12.23-android16 on the SM-A175F.
There was a deeper problem too. The original write-up names the assumption
that breaks on Samsung: its endgame writes root by patching
cred. On the A17's BZA5 build, KDP keeps credential and SELinux
state read-only to the EL1 kernel, so a store to a task-slab page is silently
dropped at EL2 (we verified this the hard way: 20+ clean runs with
verified-correct candidates, zero creds landed). So instead of writing
root, our chain gets the kernel to execute it: forge a workqueue
work item whose function is the usermode-helper exec path. The helper runs
with full init creds and no cred write ever happens: the RKP-era answer to
STATIC_USERMODEHELPER, a technique that write-up itself cites
from BH2017.
Before any device cycle was spent, we pulled the A17's stock BZA5
firmware from samfw.com, unpacked the AP tar,
extracted boot.img, and unpacked the kernel to
vmlinux.elf. The kernel ships BTF, so every structure offset the
exploit touches (workqueue_struct, pool_workqueue,
worker_pool, subprocess_info, pipe_inode_info,
pipe_buffer, file) was audited against ground truth
rather than guessed. The boot-time profile table validated 12/12 against the
extracted image.
The piece of infrastructure that made the rest of this project survivable
is the qemu-e2e/ harness (included in the repository). It boots
the real extracted BZA5 kernel Image in QEMU virt on Apple Silicon
(HVF, native execution speed) with two minimal, well-understood patches on a
copy (Image.nokdp): the kdp_enable = 1 store in
start_kernel is NOP'd (there is no EL2 hypervisor in QEMU), and
is_boot_state_unlocked() is forced true (DEFEX otherwise panics
on the missing signed rules file). An initramfs init then runs the exploit
in-guest, checks markers, and powers off.
Two guest deltas mattered and are worth documenting for anyone reusing the
harness: QEMU places the kernel image at physical 0x40200000,
not 0x40000000 (proven by the swapper pgdir physical address; the KPHYS env knob exists for exactly this), and we run with
nokaslr plus guest-only knobs because the slide is known and the
forge budget behaves differently in a throwaway VM.
With offsets audited and the harness green, the remaining fight was four Samsung-specific failure modes. Each one follows the same shape: symptom, evidence, root cause, fix.
The early chain rooted reliably. And then the device reset, every single
time. The panic was not in the exploit path but in the exit path:
when the exploit process exits, its pipes tear down, and the forged slots'
anon_pipe_buf_release runs __folio_put on pages
that were never refcounted: kernel image pages marked
reserved:
[ 22.349122][T16748] BUG: Bad page state in process e pfn:426fd
[ 22.349394][T16748] page: refcount:0 mapcount:0 mapping:0000000000000000 index:0x0 pfn:0x426fd
[ 22.349751][T16748] flags: 0x4000(reserved|zone=0|kasantag=0x0)
[ 22.350983][T16748] Call trace:
[ 22.351149][T16748] bad_page+0x12c/0x134
[ 22.351150][T16748] free_unref_page+0x6e8/0x6f4
[ 22.351152][T16748] __folio_put+0xa4/0xd4
[ 22.351156][T16748] anon_pipe_buf_release+0x68/0xc4
[ 22.351157][T16748] free_pipe_info+0x94/0xf4
[ 22.351157][T16748] pipe_release+0xf4/0x110
On this build panic_on_oops=1, so the WARN is a reset. The
refcount accounting is unavoidable by design: every forged read drains a slot
whose page was never get_page'd, and balancing that with a
tee-into-a-holder-pipe only defers the put to exit time. The
exit-time fix has two layers:
atexit handler forks a child that
inherits every fd and then sleeps forever (g4hold).
While it lives, the parent's exit closes nothing, because every file's refcount
stays above zero, so no pipe teardown ever runs and the whole
anon_pipe_buf_release → bad_page class is suppressed, not just
the forged ring. One sleeping process of residue per run, cleared on
reboot.ops
field of every slot in the reclaimed rd pipe_buffer array;
free_pipe_info skips ops == NULL slots, so even if
the holder child dies, the teardown skips the forged slots.The arm for that configfs write needed one more piece of engineering:
finding our own task_struct without perf (the perf-based task
finder never returns on this busy 8-core target). The answer was a backward
walk of the task list from init_task.tasks.prev, matching on tgid: 1–2 hops in the guest, 5 on the busy device.
Kernel exploit development blogs usually show the success log and skip the graveyard. Here is the graveyard, because it shaped the tooling. From the grind logs (rr_loop4/7/8/9):
| metric | value | source |
|---|---|---|
| per-cycle root rate | ~3% (13/475; 3/85 on the fixed build) | grind logs |
| per-cycle time | ~2.1 min (no exit-panic reboots) | cycle timestamps |
| median time-to-root | ~30 cycles ≈ 1 hour | model matches observed |
| longest in-boot failure streak | 71 consecutive cycles, one boot, 2h20m | rr_loop8.log 14:02–16:22 |
| fuse-bringup/perf successes | 0 in ~700 attempts | "matches no anchor" count |
Two insights hide in those numbers. First, the 71-cycle streak proves
many independent attempts work within a single boot, an important
fact for boot-time retries. Before the clean-exit fix, every failed cycle
ended in a panic-reboot, so attempts were capped by boot survival; with the
fork-hold fix, a healthy boot just keeps grinding, and reboots only come
from channel-install attrition panics. Second, ~700 recorded failures made
the decision to delete the dead fuse-bringup/perf path by default easy: the
perf slide leak and perf FILE leak never once produced a candidate on this
target, each attempt costs a perf_event storm of 10–20 s/cycle plus a share
of the pre-install attrition, and the device
sepolicy allows perf_event_open only to shell anyway. The slide
comes from the boot_id ctl_table anchors; the arm comes from the
fdtable walk. GL_FUSE_BRINGUP=1 re-enables the old path.
[-] fuse-bringup: perf slide failed (att 1)
[-] fuse-bringup: perf slide failed (att 2)
[-] fuse-bringup: perf slide failed (att 3)
[… 700+ times across the logs; removed from the default path ]
A root that only exists while a terminal is open is a parlor trick. We
wanted a durable channel: a tiny static daemon (g4d, ~150 lines,
no dependencies) launched by the usermode helper with init creds, listening
on the abstract unix socket @ghostlockd, gating clients by
SO_PEERCRED (uid 0 or 2000), and serving
/system/bin/sh over a fresh pty per connection; and a client
(g4sh) that bridges your terminal to it, interactively or as
g4sh -c "id". The helper launches it early in
umh.sh, right after dropping the root marker.
It failed twice, each time instructively.
sync. The rooted cycle wrote every
capture file yet produced no daemon and no pidfile; the helper shell most
plausibly died in the sync-heavy tail. Worse, the evidence was invisible on
three independent channels: g4d's errors went to /dev/null, the helper's
stdout is not reliably captured, and the kernel's audit rate-limiter dropped
3165 messages at exactly that second. Fix: launch g4d immediately after the
root marker, and log to files (g4d.rc for the exit code,
g4d.out for stderr) because you cannot trust the console.G4D-RC=137, g4d.out = "Killed") and dmesg held the
verdict:[ 437.627870] [DEFEX] Safeplace violation [task=sh (/system/bin/sh), child=/data/local/tmp/a/g4d, uid=0]
DEFEX's safeplace rule SIGKILLs uid=0 execs from
/data/local/tmp, path-based exec blocking that uid=2000 execs
are exempt from (which is why a manually-started daemon had worked all
along). Samsung documents DEFEX in its own
Knox
mobile-security whitepaper. The fix is delightfully low-tech: the
helper, running with full
kernel creds and CAP_SYS_ADMIN, bind-mounts g4d over a
dormant system daemon binary and execs the shadow path:
if mount --bind $A/g4d /system/bin/lmkd 2>$A/g4d.mnt; then
/system/bin/lmkd > $A/g4d.out 2>&1 # this IS g4d now; DEFEX sees a safe path
umount -l /system/bin/lmkd 2>/dev/null # lazy-detach; g4d already daemonized
else
$A/g4d > $A/g4d.out 2>&1 # fallback where DEFEX is off
fi
echo "G4D-RC=$?" > $A/g4d.rc; sync
lmkd is a long-running daemon that never re-execs at runtime, so the
millisecond shadow window is risk-free, and g4d sets its own
comm via prctl so ps still shows a
sane name. Result, from the 18:02 cycle: G4D-RC=0 and a live
root shell.
For the record, we also derived the heavier hammer and deliberately left
it unused: DEFEX's features word is a u32 at image offset
0x018A287C, from defex_get_features in the extracted vmlinux
disassembles to adrp x8, 0xffffffc0818a2000; ldr w0, [x8, #0x87c]; ret.
Zeroing it via the kernel-write channel disables DEFEX outright. The bind
mount needs no offsets and no global side effects, so it wins; the offset
stays documented as the fallback if Samsung ever patches the mount trick.
Everything in this project is runtime-only, in /data/local/tmp.
The A17 was a stock retail device, fresh from the shop: locked bootloader,
and on this unit the OEM-unlocking toggle is absent entirely, so the classic
unlock-and-flash path was never on the table. Runtime exploitation was the
only route, and Knox stayed untripped throughout: no boot-image flash, no
warranty fuse, no wipe. The device stays stock for vulnerability reporting,
and install-style persistence (Magisk/KernelSU in the boot image) was
deliberately ruled out. Per-boot persistence comes from the exploit itself,
not from modifying the device.
On SELinux: the grind flips the device to Permissive per boot (a
--write1 warm-up write flips selinux_enforcing via
the bug), because parts of the leak path are policy-gated. We checked what
that actually buys by parsing the device's sepolicy.bin
directly.
The grind loop (reboot-aware, ~2.1 min/cycle) drives the whole thing. The replay at the top of this post shows one full run: cycle 9's perf-slide failures, cycle 10, ROOTED at 18:02:51, then the live shell. These are the two proofs side by side, the loop's own log and the live root shell, both captured on this Mac:
ROOTED on cycle 10 at 18:02:51 (rr_loop4.log).
g4sh bridges adb to the daemon's pty; id reports uid=0(root) … context=u:r:kernel:s0.Verification status, stated plainly:
| claim | status |
|---|---|
| Root chain on SM-A175F BZA5 (helper as uid=0, u:r:kernel:s0) | device-verified, repeatedly |
| Clean exit, no panic after rooted cycles | device-verified ×3 consecutive (13:40, 16:36, 18:02) |
| g4d autostart past DEFEX + g4sh uid=0 shell | device-verified (18:02, G4D-RC=0) |
| cfg-forge parked-descriptor forging | guest-verified; observed once on device |
| Current build (fuse-bringup off by default, G4_HOME parameterization) | guest-verified (boots 95–98); device re-verification pending; PC-grind behavior unchanged |
Try it yourself. The ghostlock-a17 repository is public: run the grind on your own A17, or fork it for other Samsung phones and firmware. The offset/BTF audit workflow is documented in the repo, and the qemu-e2e harness that boots the real kernel in-guest is included.
The firmware triage and BTF offset audit behind the QEMU harness in this post are the kind of work djini.ai, MHL's automated research tooling, is built to accelerate.
We did not discover CVE-2026-43499; the original research is credited in the Background section above. This post documents an independent port to a Samsung retail build, including a new final stage (forged workqueue execution instead of credential patching, forced by KDP) and the Samsung-specific engineering (KDP, DEFEX, PANIC_ON_OOPS, large KASLR slides) that the public chains did not need.
All testing was performed on a dedicated research device that we own. The work is published for educational and authorized security research purposes only; do not use it on devices or environments you do not own or lack explicit permission to test. No warranty of any kind; the authors are not responsible for misuse.