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:
- The victim’s 25 freed backing pages go to
kctx->mem_pool(per-context backing pool) viakbase_jit_free -> kbase_mem_shrink -> kbase_free_phy_pages_helper(reclaimed=false). They become backing pages the alias maps. - The reserved regions’ PGD leaves come from
kbdev->mem_pool(separate device-wide PGD pool) viakbase_mmu_alloc_pgd. They are different physical pages, not in the alias.
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)
- SAME_VA reserved regions (GLM-5.2 Fix 1): defers GPU page-table creation to
map_reservedafterjit_free. Correct and necessary, but alone insufficient — leaves still come fromkbdev->mem_pool, not the victim’skctx->mem_poolpages. - Single drain (fill
kctx->mem_poolto max beforejit_free): victim doesn’t spill tokbdev->mem_pool.0 of 29leaves are victim pages. - GLM-5.2’s two-drain (drain1 fill kctx, drain2 kept to empty kbdev): drain2 re-empties
kctx->mem_poolafter drain1 fills it, sokctxisn’t full atjit_free— victim stays inkctx->mem_poolas backing.0 of 30. - Corrected drain ordering (drainA kctx kept → drainB kbdev kept → free drainA to fill kctx): intended to make kctx full + kbdev empty at
jit_free. Still0 of 30. - 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):
- Context init:
VERSION_CHECK(any version) →SET_FLAGS(create_flags=0)(flips setup_complete AND clears SUBMIT_DISABLED) →mmap(BASE_MEM_MAP_TRACKING_HANDLE)to bindkctx->mm. MEM_ALLOCreturns “cookie” addresses; real VA established by mmap. nr 5 = 32-byte (extent) struct. nr 21 alias = 32 bytes. nr 23 flags_change = 24.- Aliases: CPU perms masked off in
kbase_mem_alias(onlyPROT_NONEmmap accepted). GPU mappings install at the PROT_NONE mmap. -
The MMU dump handle: mmap(NULL, size, PROT_READ, MAP_SHARED, fd, 0x1000)→ buffer = config[3] + recursive table dump (m_pgd = physlevel header + 512 entries per page) + 0xFF end marker. Tree walk: follow (entry & 3) == 3entries (table pointers useentry_set_pte, PTE type).
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
- 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?
- 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. - 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 whethermap_reservedis the broken half. - 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)
poc-28663/exploit_trona.c— the full exploit (trigger, spray, alias, drain, jit_free, map_reserved, dumpfind, selfix/safefix/win modes, corrected drain sequence)poc-28663/dump_probe.c— working standalone MMU dump parser (correct tree walk; use this as the discovery tool)poc-28663/mali_trona.h— r14p0 UAPI header (verified against the kernel binary)ota_extract/vmlinux.elf— exact kernel with kallsyms (all offsets computed from it)kbase-src/— r14p0 kbase source (LCM-MTK mirror, matches the build path embedded in the kernel)ghsl/SecurityExploits/Android/Mali/CVE_2022_38181/— GHSL reference exploitSTATE.md— full engineering logREVIEW.md— GLM-5.2’s earlier code review (Issue 1 confirmed correct)
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)
- Full UAF exploit chain: trigger -> spray -> alias -> drain -> jit_free -> map_reserved -> find_freed_idx. Works ~50% of attempts (replacement race, as expected).
- 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).
- GPU WRITE_VALUE to kernel physical pages via hijacked PGD entry: succeeds (event_code=1 from GPU job).
- 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:
- GPU writes go to DRAM through the GPU’s own L2 cache.
- CPU reads from its own L1/L2 cache, which is NOT invalidated by GPU writes.
- There is no hardware cache snooping between GPU and CPU.
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:
- Hijacks a PGD entry to point to a reserved backing page’s physical address
- GPU writes 0xDEADBEEF to that page via the hijacked entry
dc civacon the CPU VA for that page- 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:
- Fix the coherency test to write to the same physical page the CPU maps, then
dc civacon the CPU VA. If this works for data pages, the issue is I-cache vs D-cache coherency for code pages. 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.- Find the process’s page table physical address (via
/proc/self/pagemapor by readingcurrent->mm->pgdfrom kernel memory via GPU) and modify the PTE forkbase_open’s page to use a non-cacheable memory attribute. The page table walker reads from DRAM (bypasses cache on a TLB miss). - 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.
- 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). - Use the Mali GPU’s
CACHE_FLUSHjob with specific address ranges to flush the CPU’s L2 (if the GPU has access to the shared L2 bus). - Modify
swapper_pg_dir(CPU page table) via GPU to create a non-cacheable mapping tokbase_open’s physical page. The page table walker on a TLB miss reads from DRAM. Need to findswapper_pg_dir’s physical address (BSS starts at PA0x4186b950, first 4KB-aligned =0x4186c000, PMD at0x4186e000).
Code state
All code is in poc-28663/exploit_trona.c (committed to git). The root mode contains:
- Fixed MMU dump parser (correct alias VA range)
- Coherency test (flawed - writes to wrong physical page)
kbase_openshellcode patch +open()trigger- GPU L2 cache flush job
- Targeted L1/L2 eviction
dc civactest- Cluster migration
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)
- 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-sourceENTRY_IS_ATE_L3=3encoding. Every data write through a hijacked0x443-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. - 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.
- 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.
- 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).
- 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
poc-28663/exploit_trona.c— complete exploit (all stages instrumented)grind2.sh— nohup-detached grind across UAF-crash reboots (win detection)win_final.log— the winning run’s log- Device files: /data/local/tmp/{exploit_trona,su,x,trig,pwned2}