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.

CVE-2026-43499 on the A17 at a glance

  • Bug: pipe_buffer page-reuse race (futex PI UAF family) → forged pipe_buffer slots → arbitrary physical read/write ("rwforge" channel).
  • Device: Samsung Galaxy A17 (SM-A175F, mt6789 / Mali-G57), kernel 6.12.23-android16-5-abA175FXXS3BZA5-4k (GKI 6.12), BZA5 firmware.
  • Result: usermode helper as 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).
  • Samsung mitigations defeated or bypassed: KDP (EL2 data protection), DEFEX safeplace, SELinux, PANIC_ON_OOPS, ~187 GB-class arm64 KASLR slides.
  • Code: mobilehackinglab/ghostlock-a17: the full source, offsets workflow, and QEMU harness.

Background: the bug and why a new port was needed

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.

Step 1: Firmware, offsets, and a QEMU harness that runs the real kernel

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.

Why this matters: the in-guest chain runs the same binary against the same kernel. Every device-fatal bug in this post (the forged-work blob layout, the read-misalignment, the forge-budget overflow, the exit teardown BUG class) was found and fixed in QEMU first (boots 83–98 in our run logs), at ~30 seconds per iteration instead of a ~60–90 s device reboot plus re-push cycle. The harness caught four distinct panic classes before they cost a single device boot.

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.

Step 2: The Samsung gauntlet

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.

2a. Root always ended in a panic-reset

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:

  • Fork-hold. An 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.
  • Ring disarm as belt-and-suspenders. Via an armed ashmem fd whose configfs descriptor is a virtual write primitive, zero the 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.

Result: three consecutive rooted cycles on device (13:40, 16:36, and 18:02), each with a clean exit and the device still up afterwards. The previous build panic-reset on every rooted exit.

2b. Reliability engineering: what a ~3% per-cycle rate actually means

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):

metricvaluesource
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 hourmodel matches observed
longest in-boot failure streak71 consecutive cycles, one boot, 2h20mrr_loop8.log 14:02–16:22
fuse-bringup/perf successes0 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 ]
The Samsung Galaxy A17 on its 'SAMSUNG Galaxy / Secured by Knox' boot splash, USB cable attached
The grind, literally: the A17 on yet another reboot cycle. Fresh boot, fresh heap; the exploit never touches the bootloader, and Knox stays untripped throughout.

2c. A persistent root shell, and the two launch failures that preceded it

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.

Failure 1: the helper died before the launch line. The first autostart put the g4d line at the end of umh.sh, after ~20 capture commands that each 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.
Failure 2: Samsung DEFEX executed a kill order. The next cycle reached the launch (the new logging worked: 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.

2d. Knox, SELinux, and what we refused to do

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.

Step 3: The result, device-verified

userspace (shell, uid 2000) rwforge physrw channel forged work_struct on system_wq usermode helper (init creds) umh.sh starts g4d g4sh → uid=0(root) shell pselect + PI futex: one aligned qword per round forged pipe_buffer slots → arbitrary physical R/W func = call_usermodehelper_exec_work · ptmx storm wakes the pool /system/bin/sh umh.sh · uid=0(root) u:r:kernel:s0 bind-mount over /system/bin/lmkd (DEFEX-safe) · @ghostlockd SO_PEERCRED uid 0/2000 · pty per client · clean exit (fork-hold) slide oracle cpu_pwq discovery post-root hardening boot_id ctl_table triplet pwqs list walk ashmem arm cfg-forge · repair
The full chain, with the three branch stages (dashed) feeding it. All of it runs at runtime in /data/local/tmp, with no flash and no unlock.

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:

Terminal window showing rr_loop4.log: perf-slide failures across cycles 8 and 9, then the cycle-10 block ending in ROOTED on cycle 10 at 18:02:51
The grind log as it happened: perf-slide failures across cycles 8-9, the cycle-10 block, and ROOTED on cycle 10 at 18:02:51 (rr_loop4.log).
Live adb session: id as shell (uid 2000), then /data/local/tmp/a/g4sh, then id as uid=0(root) context=u:r:kernel:s0
The live root shell: g4sh bridges adb to the daemon's pty; id reports uid=0(root) … context=u:r:kernel:s0.

Verification status, stated plainly:

claimstatus
Root chain on SM-A175F BZA5 (helper as uid=0, u:r:kernel:s0)device-verified, repeatedly
Clean exit, no panic after rooted cyclesdevice-verified ×3 consecutive (13:40, 16:36, 18:02)
g4d autostart past DEFEX + g4sh uid=0 shelldevice-verified (18:02, G4D-RC=0)
cfg-forge parked-descriptor forgingguest-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.

Learn this hands-on

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.

Responsible disclosure & disclaimer

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.


References

  • JoinChang/ghostlock-oneplus, the public OnePlus port this work started from
  • mobilehackinglab/ghostlock-a17, this project: full source, offsets/BTF workflow, qemu-e2e harness
  • kernelnewbies: usermode-helper, the kernel mechanism the forged work item abuses
  • Samsung Knox whitepaper: Defeat Exploit (DEFEX), Samsung's own documentation of the safeplace rule
  • samfw.com, the stock BZA5 firmware source