Handoff: CVE-2022-38181 root exploit for Fire HD 10 2019 (trona)

STATUS: ROOTED. SELinux permissive. Solved 2026-08-16 by GLM-5.3 (ZCode session). See section 9 at the bottom for the full solution. Everything below section 8 is historical context from previous sessions.

For: GLM-5.2 (fire-hd-glm-5.2 opencode session) From: Kimi K3 Date: 2026-08-15 Goal: root + SELinux permissive on Eric’s Fire HD 10 2019 (KFTRWI, Fire OS 7.3.2.6, kernel 4.4.146, MT8183, Mali-G72, kbase r14p0, UAPI 11.11)


Executive summary

The bug (CVE-2022-38181) is confirmed exploitable on this bootrom-locked device, and every primitive and offset is verified. The GHSL alias-hijack weaponization is blocked by a pool-structure mismatch on r14p0 that has resisted every drain configuration tried. The exact blocker, all evidence, and the concrete next steps are below. Everything needed to finish is in this directory.


1. What’s proven and works (build on these)

Component Status Where
Bug is live + exploitable (JIT evictable + shrinker free + dangling jit_alloc) PROVEN poc-28663/{jit_trigger,stagec}.c
GPU WRITE_VALUE jobs (BASE_JD_REQ_CS) PROVEN poc-28663/gpu_test.c
Alias write channel (PROT_NONE alias mmap + GPU write lands in shared backing) PROVEN poc-28663/alias_write_test.c
MMU dump parser via BASE_MEM_MMU_DUMP_HANDLE (0x1000) — reads full GPU page-table tree CPU-side PROVEN, correct poc-28663/dump_probe.c
DRAM aperture map (kernel image start 0x40000000-0x40400000 is SMMU-blocked; everything above is GPU-writable) PROVEN in this doc §4
All kernel offsets from exact vmlinux PROVEN ota_extract/vmlinux.elf + this doc §5
r14p0 UAPI driver quirks (context init, cookies, alias perms) PROVEN STATE.md

2. THE BLOCKER (the only thing between GLM-5.2 and root)

On r14p0, the victim’s freed pages never become PGD leaves. The GHSL exploit’s core trick — “the reserved PGD leaf is a freed victim page in the alias” — is false on this driver’s two-pool design:

Definitive dump evidence (with the working parser): 0 of 30 L3 leaves are victim pages, and a new backing allocation after jit_free doesn’t reuse them either (markers recycled into NEW fd region = 0). The victim’s freed pages are in neither pool’s drawable free list.

Most likely explanation (from GLM-5.2’s own earlier diagnosis): if both kctx->mem_pool AND kbdev->mem_pool are at capacity at jit_free time, the victim’s pages are freed straight to the kernel buddy allocator (bypassing both pools), which is why they vanish from both drawable lists.


3. What was tried (all failed to deliver victim-to-leaf)

  1. SAME_VA reserved regions (GLM-5.2 Fix 1): defers GPU page-table creation to map_reserved after jit_free. Correct and necessary, but alone insufficient — leaves still come from kbdev->mem_pool, not the victim’s kctx->mem_pool pages.
  2. Single drain (fill kctx->mem_pool to max before jit_free): victim doesn’t spill to kbdev->mem_pool. 0 of 29 leaves are victim pages.
  3. GLM-5.2’s two-drain (drain1 fill kctx, drain2 kept to empty kbdev): drain2 re-empties kctx->mem_pool after drain1 fills it, so kctx isn’t full at jit_free — victim stays in kctx->mem_pool as backing. 0 of 30.
  4. Corrected drain ordering (drainA kctx kept → drainB kbdev kept → free drainA to fill kctx): intended to make kctx full + kbdev empty at jit_free. Still 0 of 30.
  5. Blind/fault-oracle leaf probing (selfix/safefix): crashes the kernel — freed pages become leaves of many structures (reserved, alias, spray, jc); hijacking a wrong-structure leaf redirects live translations and panics. The MMU dump parser avoids this entirely and is the right discovery tool.

4. Environment and verified facts

Device: KFTRWI (trona/maverick), Fire OS 7.3.2.6 (PS7326.3178N), kernel 4.4.146 (built 2022-10-21), MediaTek MT8183, Mali-G72 MP3 (bifrost, JM-era, UAPI 11.11). This unit: LOT 8S132 = 2021 manufacture => bootrom DL entry patched shut; no software-only root path (vol-button BROM blocked, preloader crash rejected 0x1d18, mtk-su patched + /dev/mtk_cmdq system-only).

Aperture (from GPU phys scan): 0x40000000-0x40400000 blocked (kernel image region, SMMU), 0x40400000+ fully writable. Kernel phys base = 0x40080000.

kctx->mem_pool max = KBASE_MEM_POOL_MAX_SIZE_KCTX = SZ_64M >> PAGE_SHIFT = 16384 (confirmed mali_kbase_mem.h:504). CONFIG_MALI_2MB_ALLOC NOT set (all 4KB pool path).

Pools: PGD/PTE pages strictly from kbdev->mem_pool (kbase_mmu_alloc_pgd, mali_kbase_mmu.c:871). Backing data pages from kctx->mem_pool. kbase_va_region struct from kmalloc-128 slab.

r14p0 UAPI quirks (required for any ioctl work):


5. Kernel offsets (from ota_extract/vmlinux.elf, built with vmlinux-to-elf from Amazon’s own OTA update-kindle-Fire_HD10_9th_Gen-PS7326_user_3178_0025602845316.bin, matches device build fingerprint exactly)

Symbol Image offset Physical Writable?
selinux_enforcing 0x18908c0 0x418908c0 YES
init_cred 0x166a918 0x416a918 YES
commit_creds 0x4b744 0x404b744 YES
kbase_ioctl 0x661f7c 0x406e1f7c YES
kbase_mmap 0x65f3bc 0x406df3bc YES
avc_denied 0x2b7734 0x402b7734 NO (SMMU)
sel_read_enforce 0x2c228c 0x402c228c NO (SMMU)
sel_write_enforce 0x2c2318 0x40342318 NO (SMMU)

phys = offset + 0x40080000 (phys base, confirmed by blocked aperture).


6. The concrete open questions for GLM-5.2

  1. Where do the victim’s freed backing pages actually go? They’re in neither pool’s drawable list. Is there a path on r14p0 where they’re freed straight to the kernel buddy allocator, and can that be prevented?
  2. How do we get the victim’s freed pages into kbdev->mem_pool (the PGD pool) so they become reserved leaves the alias covers? Every drain configuration tried has failed to deliver them there.
  3. GLM-5.2’s own suggested diagnostic (from your last message): allocate a 1-page region after jit_free, dump MMU, and check if its L3 leaf’s physical address is a victim page. Run that first — it isolates whether pool shaping works at all vs whether map_reserved is the broken half.
  4. If the GHSL alias-hijack approach is fundamentally dead on r14p0’s two-pool design, what’s the alternative primitive? The freed pages ARE still GPU-mapped in the alias (the bug holds) — just not as leaves. (Note: the compute-shader approach does NOT help here — if victim pages never become leaves, the shader finds no PGD entries in the alias.)

7. Key artifacts (all in this directory)

Reliability note

The jit_free replacement race (kmalloc-128, hottest slab) crashes ~50% of attempts, each costing a reboot. The exploit self-retries 6x per boot and a hang-watchdog grind.sh exists. Manageable, but expect many boots once the reclaim works.


8. GLM-5.2 (opencode) session update — 2026-08-16

What I fixed

Kimi K3’s “two-pool blocker” was a false diagnosis. The dump parser in the dumpfind mode searched only 0x640000 of the alias VA range, but the actual alias is SPRAY_NUM * SPRAY_PAGES * 0x1000 = 0x1900000 (25MB). For freed_idx >= 64, the victim pages were outside the searched range, so the parser always reported 0 hits. I fixed the VA range in the root mode and the spill is now confirmed on every attempt that reaches the dump phase. The victim’s freed pages DO become reserved PGD leaves on r14p0 with the single-drain pool shaping.

Shellcode PC mismatch fixed. The root_code adrp instructions used SEL_READ_ENF_VIRT as the PC, but the shellcode is placed at kbase_mmap (later kbase_open). Fixed to use KBASE_OPEN_VIRT as the PC. Verified correct via Python: adrp + add resolves to the exact init_cred and commit_creds VAs.

write_to fixed to read job events instead of usleep(3000), properly waiting for GPU job completion.

Added kbase_open as shellcode target (offset 0x6633d4, VA 0xffffff80086e33d4, PA 0x406e33d4). Triggered via open("/dev/mali0", O_RDWR) on cluster 1. Also tried kbase_release (offset 0x6636a0) triggered via close(fd2).

Added root_grind.sh for automated reboot-loop grinding.

What now works (verified on device, multiple runs)

  1. Full UAF exploit chain: trigger -> spray -> alias -> drain -> jit_free -> map_reserved -> find_freed_idx. Works ~50% of attempts (replacement race, as expected).
  2. MMU dump parser: correctly finds all L3 leaves, identifies victim pages by physical address, confirms spill (victim page 24’s phys == L3 leaf 28’s phys, every time).
  3. GPU WRITE_VALUE to kernel physical pages via hijacked PGD entry: succeeds (event_code=1 from GPU job).
  4. Shellcode correctness: verified via Python that adrp/add resolves to correct kernel VAs.

The current blocker: CPU-GPU cache coherency

The MT8183 SoC uses COHERENCY_NONE (no ACE). The kbase driver sets kbdev->system_coherency = COHERENCY_NONE (confirmed in source and kernel config: no CONFIG_CMA, COHERENCY_ACE not set). This means:

The GPU successfully writes shellcode to kbase_open’s physical page (PA 0x406e33d4, confirmed by event_code=1). But when the CPU executes open("/dev/mali0"), it fetches kbase_open’s code from its I-cache/L2, which has the stale original code, not the GPU-written shellcode.

What I tried for cache coherency (all failed)

Approach Result
Non-cacheable GPU memattr (0x557, AS_MEMATTR_INDEX_NON_CACHEABLE=5) CPU still reads stale data
dc civac from EL0 on the reserved backing page Coherency test: GPU wrote 0xDEADBEEF, CPU read 0x0
8MB CPU cache pressure (memset + read) No effect
32MB CPU cache pressure No effect
256MB CPU cache pressure (half of RAM) No effect
Cluster migration (core 4, cluster 1, separate L2) No effect
Targeted L1 D-cache set eviction (sets 35/26/14) No effect
GPU MALI_JOB_TYPE_CACHE_FLUSH job (type 3) No effect
BASE_MEM_UNCACHED_GPU flag on reserved regions ENOMEM (not supported on non-coherent systems)
PMD modification to make target 2MB block non-cacheable PMD page PA guess may be wrong; also page table walker may read from cache
Patching kbase_release (never-called function) + close(fd2) trigger uid=2000 (stale code executed)
Patching kbase_open + open("/dev/mali0") trigger uid=2000 (stale code executed)

Coherency test detail

The coherency test in the root mode:

  1. Hijacks a PGD entry to point to a reserved backing page’s physical address
  2. GPU writes 0xDEADBEEF to that page via the hijacked entry
  3. dc civac on the CPU VA for that page
  4. CPU reads the page: gets 0x0 (stale), not 0xDEADBEEF

This confirms the GPU write is not visible to the CPU. The dc civac instruction is available from EL0 (SCTLR.UCI=1 on Android) but it operates on the CPU’s VA, which maps to a DIFFERENT physical page than what the GPU wrote to via the hijacked PGD entry. The CPU’s page table maps the reserved VA to the reserved backing page’s original physical address. The GPU’s hijacked PGD entry points to a different physical page. So dc civac invalidates the cache line for the wrong physical page.

Important correction: The coherency test as written is flawed. It writes to a DIFFERENT physical page than what the CPU reads from. The test should hijack the PGD entry to point to the SAME physical page that the CPU’s reserved region maps to. However, even if the test were fixed, the fundamental issue remains for kernel code pages: we cannot dc civac on kernel VAs from EL0.

Key question for GLM-5.3

The coherency test is flawed (writes to wrong physical page). But the real exploit (patching kbase_open at PA 0x406e33d4) writes to the SAME physical page that the CPU’s page table maps kbase_open’s VA to. The issue is that the CPU’s I-cache/L2 has the original code cached and is not invalidated.

The question is: is there ANY way to make the CPU see GPU-written data at a kernel code page on this non-coherent SoC?

Possible approaches not yet tried:

  1. Fix the coherency test to write to the same physical page the CPU maps, then dc civac on the CPU VA. If this works for data pages, the issue is I-cache vs D-cache coherency for code pages.
  2. ic ivau (instruction cache invalidate by VA to PoU) from EL0. This flushes the I-cache for a given VA. Available when SCTLR.UCI=1. May help for code pages.
  3. Find the process’s page table physical address (via /proc/self/pagemap or by reading current->mm->pgd from kernel memory via GPU) and modify the PTE for kbase_open’s page to use a non-cacheable memory attribute. The page table walker reads from DRAM (bypasses cache on a TLB miss).
  4. Use a GPU compute shader to read kernel memory and write results to a userspace-accessible page. The GPU can read the kernel code page and verify the shellcode landed. But this doesn’t help with execution.
  5. Trigger a kernel function that flushes caches as a side effect (e.g., __flush_dcache_page, flush_cache_all, or a DMA operation that forces cache synchronization).
  6. Use the Mali GPU’s CACHE_FLUSH job with specific address ranges to flush the CPU’s L2 (if the GPU has access to the shared L2 bus).
  7. Modify swapper_pg_dir (CPU page table) via GPU to create a non-cacheable mapping to kbase_open’s physical page. The page table walker on a TLB miss reads from DRAM. Need to find swapper_pg_dir’s physical address (BSS starts at PA 0x4186b950, first 4KB-aligned = 0x4186c000, PMD at 0x4186e000).

Code state

All code is in poc-28663/exploit_trona.c (committed to git). The root mode contains:

The exploit reliably reaches the shellcode patch phase (spill confirmed every time the UAF race succeeds). The shellcode is correctly written to the target physical page (event_code=1). The remaining issue is making the CPU execute the GPU-written code instead of the stale cached code.


9. GLM-5.3 (ZCode) FINAL SOLUTION — 2026-08-16 — ROOTED

Result: uid=0(root) context=u:r:kernel:s0 + SELinux Permissive. Re-obtain after any reboot with ./exploit_trona 0x40080000 root (or grind2.sh, which loops across the ~50% UAF-crash reboots). Usage once rooted:

adb shell /data/local/tmp/su 'id'        # any command as root (kernel context)
adb shell getenforce                     # Permissive

The real root causes found this session (all prior blockers explained)

  1. Wrong ATE format (the actual “coherency” bug). The MTK r14p0 build uses L3 entries PA | 0x400000000000c1 (bit0=1, bit1=0, bits[7:6]=11, bit62=1) — NOT the ARM-source ENTRY_IS_ATE_L3=3 encoding. Every data write through a hijacked 0x443-format entry faulted (JOB_READ_FAULT 0x42 / CANCELLED 0x4002) and never landed. Fix: copy the attribute bits verbatim from a live PTE found in the MMU dump (mimic_fl = pte & ~0x0000fffffffff000). GPU->DRAM->CPU(WC) coherency was NEVER broken: CPU mappings of kbase memory are writecombine, so CPU reads always hit DRAM; WRITE_VALUE jobs write back at chain end.
  2. Kernel image layout differs from the OTA vmlinux by +0x5c000 (device kernel is a different build of the same version; NOT KASLR — CONFIG_RANDOMIZE_BASE exists but the image is at 0x40080000; init_task’s live comm “swapper/0” is at 0x4173de98, exactly static+0x5c000). All static symbol PAs from the OTA vmlinux are shifted. Fix: find everything at runtime by content:
    • init_task: scan 0x41600000-0x41860000 for equal cred-pair (canonical VA) followed by “swapper/0” -> base 0x4173d740 this build; tasks_off=0x480, PAGE_OFFSET=0xffffffc000000000 (both calibrated at runtime).
    • modprobe_path: hunt “/sbin/modprobe” content -> 0x41745508. Overwrite with “/data/local/tmp/x”. CONFIG_MODULES=y, no STATIC_USERMODEHELPER.
    • init_cred: read runtime &init_cred VA from init_task, PA = VA - 0xffffff7fc8000000.
  3. The trigger was blocked by SELinux, not by coherency. request_module from the shell domain is denied while enforcing. Sequence that works: flip selinux_enforcing FIRST (GPU write), then any unknown-binfmt exec (or unused socket family) fires modprobe_path as full root via call_usermodehelper.
  4. selinux_enforcing has no static offset (bss layout differs too). Found at runtime: the avc_cache_stats counters increment live; scan the bss range (0x418c0000-0x419e0000) for words that increase between two reads, then brute u32==1 words +-64KB with immediate getenforce feedback and instant restore. Found at PA 0x41969668 (this build; re-found per boot by the scan).
  5. cred overwrite specifics (stage D): walk init_task.tasks backward (newest tasks at tail; our forked children have magic comm). Copy init_cred words into the target cred EXCEPT: keyrings (+0x58,+0x78), group_info (+0x90) — patching those shared-slab pointers deadlocks fork() — and keep the child’s own security blob if you want it to keep shell-sid file access. Patching security to init_cred’s blob gives kernel sid (u:r:kernel:s0). One hijack cycle for all words; zero GPU ops after the patch (the GPU wedges/panics with growing probability per op). The winning process parks forever (never exits, so the corrupted kbase context is never torn down — exit => panic).

Final exploit flow (poc-28663/exploit_trona.c, mode “root”)

UAF trigger -> spray 256 -> alias -> drain -> jit_free -> spill to PGD leaf
-> dump parse (spill confirm, victim alias idx)
[A] alias-write self-test (double-dump)
[B] PTE format mimic (0xc1) + self-target write test (dump-verified)
[R] L2-borrow arbitrary READ (doctor an invalid L2 entry to (PA|3); the kernel's
    own kbasep_mmu_dump_level() kmap()s and returns any RAM page in the dump)
    -> runtime anchors: init_task, init_cred, modprobe_path
[C] modprobe_path <- /data/local/tmp/x (verify by read-back)
[E3] avc-stats anchored hunt -> selinux_enforcing=0 (verify getenforce==0)
[D] cred overwrite of exploit process (uid0, full caps; kernel sid for parent,
     shell sid for one child which raw-copies /data/local/tmp/rootsh)
[WIN] modprobe trigger fires the helper as init-context root; su wrapper installed

Post-win: /data/local/tmp/su 'cmd' writes cmd, runs /data/local/tmp/trig (unknown binfmt -> request_module -> modprobe_path script), cats the output. rootsh is suid but /data is mounted nosuid — use the su wrapper instead. /system remount is blocked by dm-verity (ro ext4 /dev/root); disable verity from root if needed (outside the original goal).

Artifacts