Sploitus

Exploit for Use After Free in Arm Bifrost Gpu Kernel Driver

githubexploit Β· 2026-09-11

Exploit Code

README766 lines
## https://sploitus.com/exploit?id=B7F4C122-DCF4-5F88-B125-FCF4479408A9
> **AI-assisted project.** This research, exploit development, and documentation
> were produced with AI assistance using the models **GLM-5.3** and
> **DeepSeek V4.1 Flash**.

# amazon-mustang-hack

Root exploit research for the **Amazon Fire 7 9th gen (mustang, MT8163, Mali-T720)** on the
final firmware β€” **Fire OS 7.3.3.1, PS7331.4463N, kernel 4.9.117 (built 2025-05-03, SPL 2024-08-01)**.

Goal: LineageOS. Bootloader path is dead on this unit (patched bootrom β€” preloader-only via
CMD short), so the only remaining route is a software kernel exploit.

## Quick start

```
# one-shot: build, run the exploit (retries across the probabilistic reclaim),
# install a setuid-root su and verify it as an unprivileged user
nix-shell -p android-tools --run './run.sh'          # add -p zig too if no zig
```

On success:
```
/data/metrics/su id     # run a command as root
/data/metrics/su        # interactive root shell
```

The reclaim wins roughly **1 boot in 3** and a loss panics/reboots the tablet;
`run.sh` just waits for the reboot and retries. SELinux is forced Permissive as
part of the exploit, so root is **runtime-only** β€” a reboot restores stock and
you re-run `run.sh`.

## PRIMARY TARGET (since session 5): kbase CVE-2022-38181 β€” stage 2 PROVEN

GhostLock (below) is parked: MTK's BUG_ON rtmutex variant + zero kernel-address
disclosure from shell = architectural dead end on this build (sessions 2-4).
The kbase JIT UAF was re-diagnosed (the destroy-worker "unconditional panic"
was the JIT_FREE deref, log lost to adbd death mid-panic) and stage 2 is now
oracle-proven β€” see SESSION 5 section.

### PARKED: GhostLock, CVE-2026-43499

rtmutex `remove_waiter()` futex-PI stack-UAF (NebuSec disclosure 2026-07, fix `3bfdc63936dd`
landed 2026-04). Vulnerable range 2.6.39–7.1 β†’ **our 4.9.117 (May 2025) is affected**.

Verified on our exact build:
- `CONFIG_FUTEX=y`, rtmutex compiled in, bug present verbatim:
  `rtmutex.c:1108-1111` uses `current->pi_lock`/`current->pi_blocked_on` (should be
  `waiter->task`); buggy call site `rtmutex.c:1723` (`rt_mutex_start_proxy_lock` error path)
- Trigger surface = pure futex syscalls (`WAIT_REQUEUE_PI`/`CMP_REQUEUE_PI`), no device node,
  nothing SELinux-gated β€” the kbase path's fatal obstacles don't exist here
- Consumer: `sched_setattr` β†’ `__sched_setscheduler` β†’ `rt_mutex_adjust_pi(p)` at
  `sched/core.c:4706` β€” derefs stale `pi_blocked_on` βœ“
- Proxy waiter lives on the **waiter thread's own stack** (`futex.c:1975` passes `this->rt_waiter`,
  declared in `futex_wait_requeue_pi` at `futex.c:2880`) β†’ waiter stamps its own freed frame
  via arm32 `select` (nr 142) fd_sets
- Exploitation climate: **no KASLR** (fixed base 0xc0008000), no PAN, `DEBUG_RT_MUTEXES` off
  β†’ compact 48-byte `rt_mutex_waiter` (tree_entry@0, pi_tree_entry@0xc, task@0x18,
  lock@0x1c, prio@0x20, deadline@0x28)
- **Minimal chain**: 2 write-slots β†’ `modprobe_path` @ **0xc111488c** (string self-located in
  vmlinux; `KALLSYMS_ALL` off so data symbols need this trick) β†’ unknown-binfmt exec β†’ root
  script (setenforce 0, disable OTA, su)
- References in `refs/`: NebuSec/CyberMeowfia (original), GhostLock-5.10 (Fire OS 8 port,
  full 32-bit ARM trigger in `src/exp32/`), ghostlock-...-4.19-k40 (Qualcomm 4.19 Android port)

### TODO (port plan)
1. Write trigger (3-thread requeue-PI deadlock, cores 0-3) β€” port of `exp32/main.c`
2. Stamp geometry: `rt_waiter` frame offset vs `do_sys_select` fd_set area β€” disassemble
   our vmlinux (`do_sys_select` stack_fds vs `futex_wait_requeue_pi` frame), expose
   STAMP_NFDS/STAMP_WAITER_OFF as tunables
3. Fake-writer encoding for arm32 48-byte waiter β†’ "write V to ADDR" slots
4. 2 slots β†’ modprobe_path, fire, root script
5. Fallbacks if select-stamp can't reach: setsockopt(MCAST_JOIN_SOURCE_GROUP) stamp

## Status

- [x] Bootrom (amonet hardware method) β€” patched on this unit, dead end
- [x] mtk-su (CVE-2020-0069) β€” patched, `Failed critical init step 3`
- [x] Attack surface survey β€” `/dev/mali0` world-RW + SELinux `gpu_device`, kbase r26p0-01rel0
- [x] CVE-2022-38181 confirmed in exact-build source; stage-1 trigger works
- [x] CVE-2026-43499 (GhostLock) verified but blocked: MTK BUG_ON rtmutex
      variant + no kernel-address disclosure from shell (sessions 2-4)
- [x] **CVE-2022-38181 stage 2 PROVEN (session 5): destroy-worker panic was
      a misdiagnosis; UAF redirect onto sprayed region, oracle-verified**
- [ ] Stage 1: trigger + stamp + consumer (crash = chain live)
- [x] Stage 2 (kbase path): UAF redirect onto sprayed region β€” PROVEN session 5
- [ ] Stage 2b: raw-byte slot control (xattr stamp churn) β†’ unlink write
- [x] **Stage 3: arbitrary kernel function call β†’ ROOT (session 10)** β€” nf LOCAL_OUT
      hook hijack, `selroot` 2-packet chain: zero `selinux_state.enforcing`, rewrite
      fake entry to `commit_creds(&init_cred)`. `uid=0`, SELinux Permissive.
- [_] Stage 4: root script (su, permissive, OTA off) + persistence β€” root obtained;
      persistence/verity work remains
- [ ] Stage 5: custom OS boot chain


## Key findings

### Device / firmware
- Model KFMUWI, device `mustang`, Fire OS 7.3.3.1 `PS7331.4463N/0031575863040`
- Kernel `4.9.117-g08fe75b-dirty`, built Sat May 3 01:25:15 UTC 2025 (Linaro GCC 6.3-2017.05)
- Amazon silently re-issued 7.3.3.1 in May 2025 (new incremental, same version string)
- Bootrom post-2020 revision: short-to-GND on eMMC CMD gives **preloader only** (patched)
- `/dev/kb`, `/dev/dkb` (Amazon kernel-backup partitions) root:drmrpc 0660 β€” locked

### Why CVE-2022-38181 applies
- Driver: `mali_kbase` **r26p0-01rel0** (Midgard, Mali-T720), **inside NVD affected range r4p0–r31p0**
- Amazon's May 2025 rebuild shipped the 2018 bug verbatim β€” no backport
- Exact vulnerable code, source-verified:
  - `mali_kbase_mem.c:2721` `kbase_jit_destroy_worker` frees region, **never clears `kctx->jit_alloc[id]`**
  - `mali_kbase_softjobs.c:1270` `kbase_jit_free_finish` derefs the stale `jit_alloc[ids[j]]`
  - `mali_kbase_mem.c:3138` `kbase_jit_backing_lost` β†’ destroy path (fires during reclaim)

### Exploitation climate (all verified from live config dump + OTA vmlinux)
- armv7 32-bit, non-LPAE β†’ **no KASLR** (kernel at fixed `0xc0008000` VA / `0x40080000` PA)
- **No `ARM_SW_DOMAIN_PAN`** β†’ ret2usr viable; `CONFIG_PANIC_ON_OOPS=y` (failed attempts = reboot)
- No `SLAB_FREELIST_RANDOM`/`HARDENED`, no `CONFIG_USER_NS`/`USERFAULTFD`/`NF_TABLES`
- `CONFIG_MODULES=y`, no `STATIC_USERMODEHELPER` β†’ `modprobe_path` overwrite = root
- 1 GB RAM β†’ direct reclaim (needed for eviction) trivially reachable; **pressure >~1 GB
  panics the kernel on its own** (unrelated lowmem/OOM bug) β€” keep spray ≀ 900 MB, use ~700 MB

### UAPI quirks hit during PoC development (r26p0, `_IOC_TYPE 0x80`)
- `MEM_ALLOC` union is 32 bytes (`in` has 4 Γ— u64 incl. `extent`)
- flags must include `BASE_MEM_PROT_GPU_RD|WR` (bits 2|3), not legacy R|W
- **tracking-page mmap required before any alloc**: `mmap(fd, offset=3gpu_alloc_addr` (a GPU VA you must
  pre-allocate and pass)

### Stage-1 differential (proof the bug fires)
| evictable object | pressure | result |
|---|---|---|
| none | 900 MB | survived |
| none | 1300 MB | panic (system lowmem bug β€” unrelated) |
| normal region + DONT_NEED | 700 MB | survived |
| **JIT region + DONT_NEED** | **700 MB** | **panic in reclaim path** |

Panic occurs during eviction itself (`evictable_reclaim_scan_objects` β†’ `backing_lost` β†’
destroy worker) β€” the dangling refs (`jit_alloc[]`, evict list) are walked before we ever
submit `JIT_FREE`. Stage 2 must win the race: reallocate the freed `kbase_va_region` with our
own `MEM_ALLOC` spray while pressure is still running.

## Artifacts

- `poc/stage2.c` β€” stage-2 exploit (modes: `step`/`uaf`/`spstep`/`spfree`/`spray`/`keys`)
  β€” `spray 700` = full oracle run; survives and pauses (kill to clean up)
- `poc/mustang_jit_uaf.c` β€” stage-1 PoC (modes: `jit N` / `control N` / `pressure N`)
- `poc/build.sh` β€” zig cross-build (static musl armv7)
- `kernel/vmlinux` β€” symbols recovered from the exact OTA build (vmlinux-to-elf)
- `kernel/config-*` β€” `/proc/config.gz` dump from the running device
- `ksrc/` β€” Amazon OSS source (platform.tar + extracted `midgard-r26p0` tree)
- OTA: `/tmp/opencode/mustang_ota.bin` (sha256 `6068515a…` matches fireos-archive)
  and 2.2 GB kernel-source tarball kept at `~/Desktop/amazon-mustang/`
- Source tree path: `ksrc/kernel/mediatek/mt8163/4.9/drivers/misc/mediatek/gpu/gpu_mali/mali_midgard/midgard-r26p0/`

## Build & run

```
nix-shell -p zig --run 'zig cc -target arm-linux-musleabihf -static -O2 -o juaf poc/mustang_jit_uaf.c'
adb push juaf /data/local/tmp/juaf && adb shell chmod 755 /data/local/tmp/juaf
adb shell /data/local/tmp/juaf jit 700      # full trigger (~reboots device)
adb shell /data/local/tmp/juaf control 700  # no-JIT control
adb shell /data/local/tmp/juaf pressure 700 # raw memory-pressure control
```

## References

- GHSL-2022-054 advisory: https://securitylab.github.com/advisories/GHSL-2022-054_Arm_Mali/
- Mo's Pixel 6 exploit writeup: https://github.blog/2023-01-23-pwning-the-all-google-phone-with-a-non-google-bug/
- Fire HD 10 (trona) precedent, same bug family: ericpardee.github.io/fire-hd-ownership
- Amazon OSS portal: amazon.com gp/help/customer/display.html nodeId=200203720
- XDA unlock thread (dead for this hw rev): xdaforums.com/t/fire-7-2019-mustang-unbrick-downgrade-unlock-root.3944365/


## Session 3 addendum (deep syscall-stamp survey)

Copy-source depths measured (absolute vs syscall-entry sp0; waiter spans -0x1d8..-0x1a8):
- sendto sockaddr @ -0xc4 | process_vm iov @ -0x104 | recvmsg iov @ -0x11c
- sendmsg iov @ -0x12c | recvmmsg iov @ -0x154 | select fds @ -0x174
- pselect6 fds @ -0x19c | **sendmmsg iov @ -0x1bc (BEST β€” 0x1c short of waiter+0x00)**
- poll entries @ -0x3e0 (entirely below; wrong side)

Ruled out this session:
- io_submit chain too shallow (~-0x130) | semtimedop: CONFIG_SYSVIPC=n (stub)
- configfs mounted but ZERO subsystems registered (no mkdir targets)
- /sys/kernel/debug, /config: SELinux-denied for shell
- /proc/sys/kernel: getdents works (29 entries listed), only pid_max OPENABLE;
  kptr_restrict/hotplug/hostname/domainname reads all denied
- Write value is ALWAYS waiter+0 (kernel stack addr, executable, shellcode at
  +0x1c): rb_link_node *link = node, insert_color writes parent-color β€” no
  controlled-value variant possible without tree-field stamping (gap -0x1d8..-0x1bc)
- Double-deref dispatch fields read *(waiter+0)=1, *(waiter+4/+8)=0 β€” BLX 1/0
  (nf_hooks, net_families, inet[6]_protos, seq_file->op all dead)
- timer_list.function@+0xc and work_struct.func@+0xc would read *(waiter+0xc) =
  pi_tree self-ptr = EXECUTABLE waiter+0xc β€” but no path queues waiter+0 as
  timer/work (link corruption / no indirect queue sources)
- Tree-root-nonzero (sysctl handler slot) = deterministic pointer-chase through
  .text as rb-tree; terminates at a zero word β€” offline-simulable, but landing
  in a useful writable slot is implausible

Remaining leads for session 4:
1. ioctl deep paths: dev_ioctl ifreq copy (40B user data) β€” measure
   SyS_ioctl→sock_ioctl→dev_ioctl chain depth vs -0x1d8
2. Any other 0x1c-deeper copy than sendmmsg (nothing found yet)
3. If stamp-surface hunt fails: reconsider walk-chained constructs or
   hunt writable-zero-called slot classes not yet enumerated


## SESSION 4 β€” THE TWO BREAKTHROUGHS

### 1. MTK's rtmutex_common.h is the whole mystery
MTK replaced upstream's NULL-safe rt_mutex_top_waiter with:
```c
w = rb_entry(lock->waiters_leftmost, struct rt_mutex_waiter, tree_entry);
BUG_ON(w->lock != lock);   // compiled to: ldr sb,[lock+8]; ldr r3,[sb+0x1c]; cmp; bne→udf#0x12
```
NO NULL CHECK + BUG_ON. Every all-zero anchor dies at *(NULL+0x1c); garbage
anchors die at the udf. THE WALK REQUIRES: lock->waiters_leftmost (lock+8)
must point at a fake waiter W (writable) with W->lock (+0x1c) == lock.

Walk flow fully mapped (rt_mutex_adjust_prio_chain @ 0xc0189a58):
- 9b44-9b54: retry head; pi_blocked_on==NULL β†’ clean exit ret 0
- 9abc-9adc: orig_waiter==NULL β†’ skip pi_waiters checks (adjust_pi always passes NULL)
- 9b10-9b28: prio check (prio==task->prio + MIN β†’ exit 9b58)
- 9b2c-9b38: trylock(lock+0) β€” ticket; fails β†’ retry loop w/ counter bail (9a90-9aa8, limit @ *(0xc11189c8))
- 9ba4-9bc0: deadlock checks
- 9bcc-9bd8: THE BUG_ON (leftmost→W→W->lock==lock or die)
- 9bdc-9c04: dequeue (leftover tree RB_CLEAR_NODE'd = EMPTY β†’ safe skip), prio/deadline write
- 9c04 bl: rt_mutex_enqueue β†’ *link = waiter+0 at lock+4 ← THE WRITE
- 9c3c+: owner==NULL β†’ clean exit path

### 2. The kernel-stack-address leak (kills the address-free requirement)
/proc/self/task//stat field 28 (kstkesp) returns REAL kernel SP for
syscall-blocked threads from SHELL context (verified: nonzero values observed).
- waiter blocks in read(blocking_pipe) β†’ stat β†’ kstkesp
- stack base = kstkesp & ~0x1fff (arm32 THREAD_SIZE=8192)
- rt_waiter abs addr = base + fixed delta (computable: sp0 = base+0x2000-0x48 pt_regs; waiter = sp0-0x1d8)
- ALL self-referential stamp values become computable!

### Full self-consistent stamp (after leak):
- L = waiter+0x24 (fake lock IN THE WINDOW β€” all 4 words controllable)
- iov[0].base (waiter+0x1c lock) = L
- iov[0].len (waiter+0x20 prio) = 1 (β‰ 139)
- W = waiter+0x1c; stamp *(W+0x1c) = *(waiter+0x38) = L (BUG_ON passes)
- lock+0 (waiter+0x24) = 0; lock+4 (waiter+0x28) = 0 (write lands here);
  lock+8 (waiter+0x2c) = W; lock+0xc (waiter+0x30) = 0 (owner NULL)

### Oracle status
- crash during walk = walk ran (dead-lock probe: deterministic crash, clean code)
- clean walk + no write = trylock-fail retry-bailout (kptr anchor: runtime word nonzero)
- Everything is now deterministic post-pollution-fix.

### Next session TODO
1. Implement leak: waiter blocks on pipe, main reads stat, computes base
2. Stamp self-consistent window, fire walk β†’ crash-free completion = write proven
3. Weaponize: write always lands at lock+4 (rb_link_node) β€” lock must live in
   the window (only fully-controlled memory), so target selection research:
   either find called-slot-in-window trick, or two-stage construction.


## SESSION 4 FINAL STATE β€” THE WALL (precisely characterized)

### The complete picture
The walk fires deterministically (dead-lock probe: crash every time, clean code).
The write cannot land because of a 3-way kernel-hardening coincidence:

1. **MTK rtmutex BUG_ON variant**: lock+8 (leftmost) MUST point at W with
   *(W+0x1c)==lock. All-zero/garbage anchors die. No static self-referential
   pattern exists (6571 candidates scanned, 0 hits). Runtime pointers unknown.
2. **No kernel-address disclosure from shell**:
   - kstkesp on arm32 = USER SP (task_pt_regs->ARM_sp) β€” not kernel stack. DEAD.
   - dmesg/pstore/pagetypeinfo/kallsyms/stack β€” all denied.
   - kptr_restrict=1 at runtime (fops anchor words also runtime-nonzero β€” the
     kptr anchor run exited via trylock-fail retry-bailout, not trylock success)
3. **fops tables in rodata**: trylock strex aborts (session-3 sweep crashes).

The stamp window (waiter+0x1c..0x5b) is the only controlled+known-content
memory, but its ADDRESS is the unknown we need. Self-referential constructions
all require stamping a kernel address as a constant β€” circular without a leak.

### gitchw comparison (why their ARM32 write worked, ours can't yet)
Their 5.4 kernel has UPSTREAM rtmutex_top_waiter (NULL-safe: `if (!leftmost)
return NULL`) β€” empty-tree anchors survive, their write landed on null_fops
(writable on their kernel). Even THEY are stuck at dispatch ("ioctl reboot").
Mustang's 4.9.117 MTK tree has the BUG_ON variant β€” Fire OS 8 tablets
(GhostLock-5.10) succeeded because their 5.10 kernels are upstream-style.

### Session-4 verified facts
- Walk retry loop has a counter bailout (limit @ *(0xc11189c8)); trylock-fail
  on runtime-nonzero anchor words β†’ clean retry-bailout exit (kptr anchor runs)
- No-requeue path (9ce4, FULL walk) also derefs leftmost at 9d64 β€” no escape
- RB_CLEAR_NODE self-pointers exist as leftovers in the window (waiter+0 and
  +0xc contain their own addresses) but no check-comparison uses them in a
  way that avoids stamping known addresses
- Real-mutex candidates (chain mutex has live waiter = BUG_ON would pass) β€”
  but &chain_mutex is a heap address, unreachable without leak

### NEXT SESSION OPTIONS (ranked)
1. **logcat kernel-pointer hunt**: Amazon HALs/daemons are chatty; any logged
   kernel pointer (even stale) unblocks the construction. Cheap to test.
2. **/proc/net %pK behavior on THIS build**: some 4.9 trees print unhashed
   pointers in /proc/net/tcp,udp,unix for unprivileged readers. Test live.
3. **Thread-exit paths on dangling pi_blocked_on** (one-shot, different derefs).
4. Revisit shelved kbase JIT bug with accumulated 4.9 knowledge.


## SESSION 4 ADDENDUM β€” LEAK HUNT: EXHAUSTED (definitive)

Tested and dead from shell domain:
- /proc/net/{tcp,unix,packet,netlink,ptype}: %pK-hashed to 00000000 (kptr_restrict=1)
- /proc/timer_list: READABLE but pointers %pK-zeroed (symbols visible, no addrs)
- logcat: no kernel pointers in Amazon/wpa chatter
- kstkesp (stat f28): USER SP on arm32 (task_pt_regs->ARM_sp)
- MTK nodes (/proc/ged, mtk_cmdq_debug, mtktz, ptp, chip, aed, driver/*): all
  SELinux-denied
- /proc/{iomem,vmstat,kmsg,keys,crypto,slabs...}: denied
- /sys/kernel/notes: denied
- CONFIG_VECTORS_BASE=0xffff0000 (high vectors β€” NULL+0x1c faults)
- CONFIG_KUSER_HELPERS=y (kuser at 0xffff0000, not page 0)

CONCLUSION: GhostLock on mustang requires a kernel-address disclosure that
this kernel does not expose to the shell domain. The self-referential fake
lock cannot be constructed without it.

## DECISION POINT

(a) Boot-deterministic grind: reboot β†’ calibrate stack address via crash
    oracle (~20-30 reboots), verify reproducibility. Long shot β€” late-boot
    thread stack allocation unlikely stable.
(b) PIVOT back to kbase CVE-2022-38181 with accumulated assets: exact-build
    vmlinux + full source + toolchain + O_SYNC trace discipline + deep 4.9
    knowledge. Original blocker (destroy-worker panic during JIT eviction)
    is a spray-timing problem, now better understood.
(c) Stop at honest ~45%: trigger proven, walk mapped to the instruction,
    write blocked by MTK BUG_ON + no-leak.

Recommended: (b) β€” the kbase bug is verified-present in this exact source,
had a working trigger, and its blocker is mechanical, not architectural.

## SESSION 5 β€” STAGE 2 PROVEN (option b executed)

### Re-diagnosis: the "unconditional destroy panic" never existed
`step` mode (alloc id=1 β†’ DONT_NEED β†’ 700MB pressure β†’ MEM_QUERY, NO free)
**survives**: query=-1 (region freed by destroy worker, rbtree-clean).
The worker path is byte-identical to the legal JIT_FREE-under-pressure flow.
Session-1's crash was always the `JIT_FREE` dangling deref; its log line was
lost because the panic kills adbd mid-flush. Verified twice more with `uaf`
mode (bare free β†’ panic, same log cutoff). **The GHSL-2022-054 flow is fully
live on this build.**

### Complete primitive inventory (exact-vmlinux disassembly)
`kbase_jit_free(kctx, reg)` @ 0xc058495c with fully-controlled fake reg:
- `reg->cpu_alloc` NULL β†’ backed size 0 β†’ trim block skipped (0xc0584978)
- bin decrement: `kctx+0x147dd` (byte) + `kctx+0x147de+bin_id` (byte)
- `mark_reclaim(reg->gpu_alloc)` @ 0xc059b158: chain
  `K=*(gpu_alloc+0x38)` β†’ `*(K+0x1429c)==0` skips mm-atomics β†’
  atomic_sub nents@K+0x141c8, `D=*(K+4)` β†’ atomic_sub nents@D+0x538.
  With nents=0 all writes are no-op stores (strex of same value).
- `reg->flags |= 0x100000` (write into fake, benign)
- shrink_cpu_mapping early-exits when new==old (nents=0 β†’ return)
- `list_add(gpu_alloc->evict_node, &kctx->evict_list)`: evict_list head
  @ kctx+0x1427c; writes into gpu_alloc+0x18/0x1c (must be writable)
- WARN path (0xc0584bd4) is nonfatal (no panic_on_warn) and CONTINUES
- **UNLINK @ 0xc0584b08/b0c**: `r3=*(reg+0x3c) prev, r2=*(reg+0x38) next`
  β†’ `*(next+4)=prev; *(prev+0)=next` β€” two arbitrary write-whats-wheres,
  then relink of reg+0x38 into jit_pool_head @ kctx+0x148e8

### Static fake-gpu_alloc chain (offline vmlinux scan, /tmp/opencode/scan_s.py)
9 candidates; **S=0xc118b7ec** (xfrm data, dormant on this device):
`*(S+8)=0` (nents), `*(S+0x18)=S+0x18` (empty evict_node β†’ no WARN),
`K=*(S+0x38)=0xc118b820` β†’ `*(K+0x1429c)=0`, K/D+0x141c8/+0x538 all in
writable data. Oracle targets staged: `init_uts_ns.name.nodename=0xc110d561`
("(none)", readable via uname), scratch P=0xc118bd58 (xfrm zeros).
Avoid S=0xc111cba4 (tracepoint-adjacent). CONFIG_DEBUG_RODATA=y β†’ all write
targets must be in .data/.bss (bss 0xc11d9000-0xc12d9000).

### Spray engineering (what worked, what didn't)
- `add_key` (CONFIG_KEYS=y): SELinux-denied for shell. Dead.
- `setxattr` value buffer: kvmalloc(96)+copy_from_user happens BEFORE the
  SELinux check β†’ alloc dance is SELinux-proof even when the call fails;
  transient (freed at syscall end), bytes persist at +4..95 (freelist ptr
  clobbers +0..3 = rblink, unused by kbase_jit_free)
- **kbase_va_region itself: kzalloc(72) β†’ kmalloc-96!** MEM_ALLOC(va=0x40,
  commit=0x10) puts ONLY the region in kmalloc-96 (phy alloc β†’ 384) β†’
  deterministic reclaim type. A real-region victim makes kbase_jit_free
  complete through fully-legal state (empty jit_node β†’ self-unlink).
- Sequential post-pressure spray: ALWAYS misses β€” the worker frees the slot
  mid-pressure into partial slabs (SLUB: free to non-active slab β‰  cpu
  freelist); under pressure our allocs fail β†’ zero net volume β†’ no rotation
- Pinned sprayers + caps: still miss (512-cap exhausted before eviction;
  worker may run on any cpu)
- **WINNER: commit_pages=0 spray** β€” no phys pages β†’ MEM_ALLOCs succeed
  through the whole pressure storm β†’ ~6000 net allocations β†’ partial-list
  rotation guaranteed. 8 threads (2/cpu, cpus 0-3 hardcoded β€” /proc/cpuinfo
  is shell-filtered to 1 core, use Cpus_allowed_list) + pressure child
  pinned to cpu0 + 16-alloc retention batch after join.
- query(jit_va) trap: post-reclaim, spray regions reuse the freed VA in the
  custom zone β†’ query=0 is ambiguous (live-original vs spray-covering-VA)

### ORACLE HIT β€” machine-verified redirect
`spray 700` run 2026-09-11: 5895 regions sprayed during pressure, JIT_FREE
on dangling id=1 completed on a reclaimed region, then
`JIT_ALLOC(0x40, bin 0)` walked jit_pool_head and returned **sprayed region
#4251's VA (0x142701000)** β€” the exact region the dangling pointer consumed.
Process kill after: kctx teardown clean, no crash. **Stage 2 complete:
deterministic UAF redirect with controlled object type + contents.**

### Stage 3 plan (raw-byte unlink)
Region-type reclaim gives legal-dance survival but jit_node is INIT'd
self β†’ no unlink primitive. Need raw bytes at +0x38/+0x3c:
1. xattr stamping: alternate region-alloc bursts (net volume β†’ slab
   rotation) with xattr storms (stamp every head slot, bytes persist
   post-free) β†’ quiet window β†’ deref
2. or pinned sendmsg cmsgs (optmem_max=10240 β†’ ~106 Γ— 96B held)
3. then: W1 `*(N+4)=P` with P=userland shellcode page (no PAN!) β€”
   candidates: const fops are .rodata (DEBUG_RODATA) β†’ target non-const
   fn ptr in .data, or binfmt `formats` list head, or sysctl proc_handler
   (verify table writability). fallback: modprobe_path via byte-chained
   writes (values must be writable addrs β€” use pointer-shaped targets)
4. no-KASLR + exact vmlinux: prepare_kernel_cred 0xc0149e3c,
   commit_creds 0xc014993c

## SESSION 5B β€” STAGE 3: weapon built, reclaim race not yet won

### Done
- **Stage-3 weapon complete & staged** (`poc/stage3.c`):
  - Target: `kern_table[pid_max].proc_handler @ 0xc1113f40` (writable
    .data, verified via string-pointer scan + handler == proc_dointvec_minmax)
  - N = shellcode entry 0x11111112 (mmap 0x11111000; W1 clobbers entry+4,
    skipped by `b +8`; W2 writes N at P = handler field)
  - arm32 ring0 shellcode hand-encoded: prepare_kernel_cred(0) +
    commit_creds + ret 0; trigger = `read /proc/sys/kernel/pid_max`
    (readable from shell); runs in own task context β†’ creds apply to us
  - benign oracle mode writes uts nodename (0xc110d561, unaligned ok)
- **Spray primitive selection**:
  - NETLINK_USERSOCK sendmsg pins (msg_control kmalloc-96 copy, held
    while blocked, never parsed): **SELinux-denied** (socket create EACCES)
  - unix/UDP sendmsg: cmsg parsing poisons payload bytes βœ—
  - **inotify events**: `inotify_handle_event` kmallocs name_len+0x1d,
    name bytes (fully controlled, NUL/slash-free constraint) at event+0x1c;
    name_len=60 β†’ kmalloc-96; queued β†’ held; 4 instances β†’ 4 events per
    rename; SELinux-OK from shell. Fake redesigned NUL-free: cpu_alloc
    points at S (nents@S+8 = 0 β†’ same semantics as NULL)
- **Mechanical chain validated end-to-end** (drain4, no-eviction run):
  20K drained events + 13.5K multi-cpu trailing renames + JIT_FREE +
  oracle + pause, all clean. O_SYNC log (/data/local/tmp/s3.log) survives
  panics β€” exact crash-point forensics.

### Reclaim attempts on the freed slot (all missed so far)
| variant | result |
|---|---|
| concurrent rename sprayers during storm | renames stall (journal/GFP_NOFS) β†’ 128 total β†’ garbage deref |
| pre-drain 12K events + pressure + small trailing | crash at deref |
| + kill-child-at-eviction (10ms poll) | crash at deref |
| + cpu0-pinned lifecycle (drain3) | crash at deref |
| stepped pressure (drain4 v1) | children freed memory on exit β†’ no eviction (validated legal path) |

Working hypothesis: **storm-junk race** β€” between the destroy worker
freeing the slot (mid-storm) and child-kill/quiet, residual reclaim
activity takes the sole-free-slot of the victim slab with non-payload
bytes. Region spray (stage 2) wins because it allocates continuously
DURING the storm; renames can't.

### Gotchas hit
- spray toggle bug: rename source must be the payload name (was temp
  name β†’ ENOENT after 2 waves β†’ only 512 events ever)
- device hostname is "localhost"/varies β€” oracle compares before/after
- paused st3 + pkill β†’ device WEDGE (teardown with 33K events?!) β€” kill
  paused processes only via reboot; second hard-wedge of the session
- /proc/cpuinfo shows 1 cpu to shell; use Cpus_allowed_list

### Next moves (ranked)
1. drain4 @ 500MB (eviction threshold confirmed there), 1ms poll, instant
   kill, 4-cpu trailing Γ— 3200 β€” shrink the storm window
2. xattr-stamp churn (setxattr alloc-copy happens BEFORE SELinux check β€”
   SELinux-proof) concurrent with storm + region rotation, end-with-stamp
3. accept region-reclaim (proven) + find a second-stage primitive on the
   region-victim state (double jit_free analysis negative so far)

## SESSION 5C β€” the blocker, precisely characterized

### Empirical results this session
- drain4@500 (1ms poll, instant kill, +4.5K renames): still crash at deref
- overlap trailing with kill (drain5): crashes EARLIER (renames during
  kill-recovery storm hit a system-level fault) β€” overlap abandoned
- **ISOLATING EXPERIMENT (`iso` mode)**: identical timing, trailing with
  commit-0 REGIONS + stage-2 pool-reuse oracle β†’ **ORACLE HIT**
  β†’ timing/reachability are FINE; events are the problem
- mask-cycled events (MOVED_TO/CREATE/DELETE rotation to defeat
  inotify_merge): still crash at deref
- CONFIG_MEMCG=n β†’ events and regions share ONE kmalloc-96 (memcg theory
  dead); inotify_merge compares names too (merge theory dead β€” our
  toggling names never merged; events were queued and held all along)
- /proc/slabinfo absent; /proc/self/pagemap readable but PFN-zeroed
  (post-4.0 masking, no CAP_SYS_ADMIN)

### THE ACTUAL BLOCKER (two parts, both proven)
1. **Soft-job finish runs in kbase job-scheduler WORKER context**
   (jd_run_atom ← js dispatch, mali_kbase_jd.c:81-112/677), not inline in
   the submit ioctl β†’ `current->mm` is a kernel thread's β†’ the elegant
   "point the fake's pointers into our own userspace mmap" design (no PAN!)
   FAULTS nondeterministically. 5/5 crashes with an otherwise-perfect fake.
2. Therefore gpu_alloc must point at KERNEL memory with a survivable
   runtime chain: `K=*(S+0x38)` readable, `*(K+0x1429c)==0` at runtime
   (skips mm-atomics), `K+0x141c8` writable, `D=*(K+4)` β†’ `D+0x538`
   writable, nents `*(S+8)` preferably 0. The 9 offline S-candidates were
   validated against FILE bytes β€” runtime drift (xfrm/tracepoint init)
   makes them unverified. A wrong chain = crash = reboot (~3 min cycle).

### Session-6 plans (both fully specified)
A. **Physmap-spray fake (ret2dir, classic arm32 no-PAN)**: spray ~450MB of
   user pages each containing the fake pattern baked for ONE guessed
   address G (G&0xfff = 0x141 for NUL-free name bytes; S=G; K=G-0x141b4 so
   K+4 lands in-page; K+0x141c8/+0x1429c β†’ G+0x10/+0xd4 in-page; D=G+0x300;
   stray sub-0 stores hit random mapped RAM - harmless with nents=0).
   Spray doubles as the eviction pressure (dirty anon = unevictable β†’
   only ~100-200MB extra needed). Odds β‰ˆ 45% (page hit) Γ— ~50% (foreign
   K+0x1429c word is zero... if K+0x1429c kept in-page per layout above,
   odds = page-hit only). Miss = crash = reboot, retry.
B. **Brute-force the 9 static S-candidates** (xfrm 0xc118b7ec first,
   tracepoint-adjacent 0xc111cba4 second...): 1 reboot per candidate,
   benign-oracle payload first, weapon on hit.
C. pagemap-based exact G (dead: PFNs masked) β€” do not revisit.

## SESSION 5D β€” physmap fake built; G-sweep 0/3; confounds eliminated

### Established this session (all binary/device-verified)
- **Cache identity CONFIRMED same**: region = kmem_cache_alloc_trace(
  kmalloc_caches[7], GFP|0x8000, 0x48) [kbase_alloc_free_region disasm];
  event = __kmalloc(89, GFP) β†’ kmalloc-96. Events and regions CAN share
  the victim's cache. (MEMCG off; single cache set.)
- inotify queue: β‰₯5000 events held, no overflow, no merge collapse
  (qmeas 1000 & 5000 runs) β€” the event spray persists
- **iso2 (physmap spray + region trailing + oracle): HIT** β€” the physmap
  spray does NOT break region reclaim; machinery sound
- events vs regions reclaim: regions 3/3 (iso, iso2, stage-2), events 0/10
  BUT the 3 pmap runs are explained by G-misses at 0.3-0.45 odds each
  (P(3 misses|events-work) β‰ˆ 0.2-0.3 β€” not conclusive)
- mix mode confounded: 480MB spray suffocates post-kill renames (+0);
  350MB OK (+4452); spinning failed-rename threads also disturb region
  reclaim (mix crashed, iso2 clean)
- query_commit returns -1 on EINVAL too β€” instrumented; EINVAL observed =
  rbtree lookup miss = genuinely freed βœ“ (not a false positive)

### Current pmap design (in stage3.c, mode pmap/mix/iso2)
- G baked into every sprayed page (offset 0x2a4): nents@+0x2ac=0,
  evict self@+0x2bc/0x2c0, K=G+0x100@+0x2dc, D=G+0x200@+0x3a8;
  K+0x141c8/-0x1429c land ~20 pages up (sub-0 store harmless / read must
  be 0-or-valid). PM_SPRAY_MB 350, G sweep tried: c2a412a4, c2f4b2a4,
  c2a7d2a4 β€” all crash at deref
- physmap range sanity: RAM 1GB β†’ physmap ~0xc0000000-0xc3fffffff;
  MTK carveouts (GPU/M4U/secure) may occupy chunks β€” G landmines

### Session-6 TODO (ranked)
1. **Extract the full kernel source** (2.2GB tarball at
   ~/Desktop/amazon-mustang/ β€” platform.tar): get arch/arm + mm/ +
   drivers/of + MTK reserve mappings β†’ compute the carveout map β†’
   target G into verified-RAM physmap subranges; also verify kmalloc
   cache geometry (ARCH_KMALLOC_MINALIGN!) and the 0x8000 GFP bit
2. G sweep with placement-informed guesses (multiple reboots, vary spray
   size to decorrelate)
3. If G sweep exhausts: reconsider multi-G payload or S-candidates from
   runtime-plausible statics (uts-adjacent pointer fields failed: NULL-K)

## SESSION 6 β€” zram discovery, source extraction, event question still open

### Full kernel source now extracted
`/tmp/opencode/ksrc2/kernel/mediatek/mt8163/4.9/` (arch/arm incl.
mustang.dtsi, mm/, fs/eventpoll.c, fs/notify) from ksrc/platform.tar.
Findings:
- 0x8000 GFP bit = ___GFP_ZERO (just kzalloc; no cache split)
- kmalloc-96 is a true 96-byte cache (no HWCACHE_ALIGN on kmalloc caches)
- mustang.dtsi: memory node 0x40000000/512MB β€” extended by preloader
  (device shows MemTotal 977MB); CONFIG_VMSPLIT_3G, HIGHMEM=y
- **zram0 ACTIVE (SwapCached > 0)** β†’ "dirty anon = unevictable" was
  WRONG: the physmap spray swaps out under pressure β†’ G alias goes stale
  β†’ added `physmap_retouch()` after the kill (faults all spray pages
  back in before the trailing/deref)

### Runs this session (all O_SYNC logged, ~6 reboots)
| run | result |
|---|---|
| pmap G=c2a412a4 400MB | crash at deref |
| pmap G=c2f4b2a4 480MB | crash; +0 post-kill renames (480MB suffocates fs) |
| iso2 (spray + regions + oracle) | **REGION HIT β€” spray doesn't break reclaim** |
| mix v1 (concurrent events+regions) | crash; confounded (spinning event threads) |
| pmap G=c2a7d2a4 350MB + retouch | crash; +4452 renames OK |
| mix2 (sequential: 2s events THEN regions) | crash; +7126 renames (28K event allocs), 2715 regions |

### Verdict on events (Bayesian, honest)
Regions reclaim: 3/3. Events: 0/~12 attempts including 28K exclusive
allocations with head start and correct-by-construction fake. If events
reclaim with p_hit(G)β‰ˆ0.35, five pmap misses β‰ˆ 11.6% β€” possible but now
unlikely (~10-15%). Either events structurally cannot take this slot
(reason unknown β€” same cache, same context, same timing) or our G guesses
are systematically missing (highmem-boundary skew, allocator placement).

### Session-7 decision tree
1. **Settle G first** (cheap, no exploit): temporarily instrument the
   ISO2 flow β€” region trailing + oracle β€” but make the PAYLOAD event a
   physmap fake and check whether ANY G in a swept range produces a
   nodename change without regions racing (pure pmap, G sweep over
   ~0xc1500000-0xc2a00000 lowmem center, 1 G per reboot, 4-5 reboots)
2. If G sweep exhausts β†’ events declared dead β†’ hunt alternative
   raw-byte kmalloc-96 allocators reachable from shell (audit: seq_file,
   tty ldisc, fdtable, sk_filter (blocked: code-field at +0x38),
   netlink nlmsg (skb βœ—), keys (denied)) β€” or revisit two-stage region
   primitives (analysis negative so far)
3. Consider UART/ramoops unlock via root later; do not chase

## SESSION 7 β€” cmdline bombshells; event mystery now precisely bounded

### mustang_defconfig CONFIG_CMDLINE (ground truth for placement):
`vmalloc=496M slub_max_order=0 slub_debug=O loglevel=8 initcall_debug`
1. **vmalloc=496M β†’ physmap = 0xc0008000..~0xc2080000 ONLY (low 520MB
   of RAM)** β€” ALL prior G guesses (0xc2a4xxxx+) were in VMALLOC SPACE.
   Every "G-miss" conclusion from sessions 5D/6 is invalidated; the
   crash-interpretation stands but the sweep was aimed at the wrong map.
2. slub_max_order=0: all slab pages order-0
3. KMALLOC_MIN_SIZE = ARCH_KMALLOC_MINALIGN = 64 (L1_CACHE_SHIFT 6) β†’
   kmalloc_index() special-cases for 96/192 are DISABLED β†’
   region kzalloc(0x48=72) β†’ caches[7]; event __kmalloc(89) β†’ caches[7]
   (disasm + include/linux/slab.h:287 verified) β€” BOTH in the merged
   128-byte "kmalloc-128/96" cache. Cache identity: RE-CONFIRMED equal.
4. zram active β†’ replaced anon physmap spray with **kbm_spray**: 160 Γ—
   2MB kbase MEM_ALLOC regions (GFP_KERNEL β†’ ZONE_NORMAL β†’ lowmem-only,
   pinned β†’ zram-immune), pattern written via CPU mmap β€” correct range,
   ~65% lowmem coverage

### Runs (O_SYNC logged)
- kbm + G=0xc16412a4: crash at deref (+4831 renames)
- kbm + G=0xc1c4b2a4 @200MB: NO eviction (query=16 β€” pressure
  calibration varies with pinned spray; legal free, survived)
- kbm + G=0xc1c4b2a4 @400MB: eviction βœ“, +4053 renames, crash at deref
- Valid-range G record: 0/2. If events work with ~65% coverage:
  P(2 misses) β‰ˆ 12%. Question still open but narrower than ever.

### The question, final form
Regions take the victim slot 3/3; events 0/13. Same cache (proven at
source + disasm level), same process context, same pinned cpus, same
post-kill timing, thousands of allocations with exclusive head start.
Mechanism unknown. Remaining suspects: allocation-rate/frequency
correlation with partial-list rotation (region ioctls ~1ms apart vs
event renames ~100Β΅s apart β€” opposite directions?), or SLUB freelist
ordering details under slub_max_order=0 that favor... unclear.

### Session-8 TODO
1. Instrument-grade experiment: TWO event payload variants with
   DIFFERENT N/P values alternating (two name sets) β€” if the nodename
   ever changes, the LAST winner is identified; sweep G in
   [0xc1200000..0xc2000000] with kbm spray, 3-4 reboots budget
2. If still 0/N: abandon events. Alternatives ranked:
   a. **pipe_buf arrays via F_SETPIPE_SZ(4096 β†’ 1 buf? no β€” 16 bufs =
      kcalloc(16, 28)=448β†’512 βœ—)** β€” dead
   b. audit fs/notify + fs for other name/data-carrying kmalloc-128
      objects (fanotify events? mq off; fanotify needs groups...)
   c. **seq_file buffers** (kmalloc(PAGE_SIZE) βœ—)
   d. sock filters (code-field collision at +0x38 βœ—)
   e. accept the region-type reclaim + chain a SECOND bug/technique
3. Re-examine WHY regions win β€” maybe instrument via multiple jit ids:
   N dangling slots, region-vs-event race per slot, oracle detects which
   spray took which slot β†’ statistical fingerprint of the mechanism

## SESSION 8 β€” ARBITRARY WRITE ACHIEVED ON DEVICE; dispatch mystery left

### THE MILESTONE
```
[+] uname.nodename="X?lhost" (was "localhost")  highmem-free β†’ surplus lands in ZONE_NORMAL (physmap)
2. First weapon attempts crashed: W1 target N+4 was a USER page β€”
   unlink runs in kworker ctx (no mm) β†’ fault. Fixed by baking the ring-0
   shellcode INTO the physmap pattern at page+0x600 (direct map RWX on
   arm32 non-LPAE) β€” kernel-resident code, no ret2usr needed
3. ctl_table offset bug: proc_handler is at entry+0x14, not +0x18 (the
   original scan had it right; my define was wrong) β€” was writing extra1
4. Over-pressure regression found & reverted: kid budget 20Γ—100MB +
   trailing 10s broke the reclaim; the working config is 2 kids/200MB +
   5s/+3200 trailing (benign hit 1/1 after revert)
5. **diag2: W2 β†’ &pid_max global (0xc1114d7c) β†’ read returns our value
   (-1055861411 = 0xc110d55d as int32) β€” write + readback PROVEN**

### The remaining mystery (one experiment from closed)
diag1 with the CORRECT handler address (0xc1113f3c): W1 fires (nodename
changes), W2 must have executed (next instruction) β€” yet pid_max reads
still return clean values β†’ the handler field we write is not the one
the inode dispatches through. diag3 (queued; needs a hit boot): W2 β†’
entry->data FIELD (0xc1113f2c) pointing at nodename β€” if the read then
shows nodename-bytes-as-int, our entry IS live and only the handler
offset is somehow wrong; if unaffected, the inode uses a shadow table
copy and we hunt the live one.

### Hit-rate reality
Per-boot coin flip (~25-40%), clustered; several safe-miss/crash boots
in a row is normal. Roughly 1 in 3-4 boots is a hit. Keep the working
config EXACTLY (2 kids, 5s trailing, 260-region kbm, G=0xc154b2a4).

### Session-9 TODO
1. Complete diag3 on a hit boot (roll until "W1 FIRED")
2. If entry live: re-check handler offset empirically (write
   proc_dostring's address as handler via... N must equal a useful
   value β€” use the unlink to write entry->data instead and pivot: e.g.,
   data=selinux_enforcing-adjacent...)
3. If shadow table: locate the live one β€” kallsyms has no data symbols;
   candidates: scan /proc/sys behavior, or find a second ctl_table
   region via the header list pattern in .data (0x20-stride entries
   with handler=proc_dointvec_minmax and maxlen=4 β€” enumerate ALL and
   diag-write each)
4. Alternative target class that avoids dispatch entirely: .data
   function pointers called from shell-reachable paths (audit needed)
5. The write primitive itself is DONE β€” any reliable kernel-address
   target now suffices for root

## SESSION 9 β€” nf-WEAPON: reclaim+unlink+G-hit PROVEN IN-WEAPON; only the hook walk remains

### The new trigger design (replaces the sysctl-handler path entirely)
User's idea translated to kernel memory: no SUID file (system is
dm-verity RO; primitive writes kernel RAM). Instead: **fake netfilter
hook**. This kernel has the Android-common backport of the NEW
nf_hook_entries API, but implemented as a LINKED LIST (verified by
disasm of nf_hook_slow + helper 0xc09897d4):
- `__ip_local_out(net, sk, skb)` loads the entries CELL from
  **[net+0x58c]**, stores into state+0x1c, calls nf_hook_slow
- walk: `entry = *cell`; while(entry){ if (state->[4] [0x20])
  call entry->[0xc](entry->[0x14], skb, state); entry = entry->[0]; }
  β€” i.e. **fn@entry+0x0c, priv@entry+0x14, priority@entry+0x20,
  next@entry+0x00**; state+4 = INT_MIN threshold (always passes)
- init_net = **0xc1104548** (CONFIG_NET_NS=n β†’ sock_net() inlines the
  constant; 6678 movw/movt refs, histogram champion; cross-confirmed by
  nf_hook_slow's own literal). TARGET: **[init_net+0x58c] = 0xc1104ad4**
- LOCAL_OUT hook runs in the SENDER's process context β†’ our hookfn's
  commit_creds(prepare_kernel_cred(0)) roots the process that sent the
  packet. Trigger = sendto(127.0.0.1:9) UDP.

### nf mode layout (poc/stage3.c, mode `nf 200`)
- kbm pattern pages (page-relative, single source of truth β€” the old
  weapon had THREE bugs now fixed: proc_handler@+0x14 not +0x18; W1
  target must be KERNEL mem (kworker ctx, no mm); baked code at
  page+0x600 vs entry G+0x600=page+0x8a4 mismatch):
  - +0x2ac/+0x2bc/+0x2c0/+0x2dc/+0x3a8: fake phy-alloc chain (unchanged)
  - +0x600: nf_code hookfn (marker store + prepare_kernel_cred +
    commit_creds + return NF_ACCEPT(1))
  - +0x700: fake entry {next=0, fn=PM_PAGE+0x600, priv=0, prio=0x100}
  - +0x740: cell β†’ PM_PAGE+0x700
- payload: N = PM_PAGE+0x740 (0xc154b740), P = 0xc1104ad4
  β†’ W1: *(cell+4)=P (lands in our page), W2: *(init_net+0x58c)=cell

### SELF-DIAGNOSING instrumentation (kbm_scan_for)
The kbm CPU mappings are kept; after the free we scan every sprayed
page for a known word:
- scan(HOOKS_PTR_ADDR) at page+0x744 β†’ proves reclaim + unlink + reveals
  which phys page backs the G guess
- scan(0x600d600d) at page+0x7f0 β†’ proves the hookfn EXECUTED
  (nf_code writes this marker as its 2nd action)

### THE RUN THAT MATTERS (2026-09-11, late session 9)
```
[+] W1 CONFIRMED: region 135 page+0x185000  -1 errno=1 uid=0 euid=0
[+] nf trigger done, uid=0 euid=0  [probe|uprobe|oc|chain|fc |notrig|selroot]`
- `selroot` is the working weapon. Key statics: `init_net=0xc1185040`,
  `HOOKS_PTR_ADDR=0xc11855cc`, `ENFORCING_ADDR=0xc1213ea8`,
  `ZERO_GADGET=0xc01d503c`, `commit_creds=0xc014993c`,
  `init_cred=0xc1114f54`.
- Post-exploit is direct syscalls (no `system()`); keep the kctx alive
  (`pause()`) to avoid teardown crash.

### Remaining (Stage 4/5)
- Persistence across reboot (verity / boot image / recovery), since the cell
  hijack + permissive SELinux are runtime-only and re-running the exploit needs
  the ~1/3 reclaim coin flip.
- `su` will need a non-nosuid home (`/system`) or a launcher that re-triggers.