
Local privilege escalation exploit targeting CVE-2026-43499 for the Xiaomi 17T Pro (warhol) on Android 16 with MediaTek MT6993 SoC.
CVE-2026-43499 (IonStack) local privilege escalation, ported to the Xiaomi 17T Pro
(warhol) — MediaTek MT6993, Android 16.
GKI 6.12 / android16 only. The waiter overlay, the
pselectstack layout and every struct offset are pinned to that branch, so nothing here transfers to 6.6, 6.1 or 5.10 — those need a base built for them.generate_target.pyrefuses any other banner rather than emit a header that would fault on device.
Status: working. Verified on device 2026-07-26 from a clean boot, under both preloader variants —
uid=0(root) context=u:r:kernel:s0, SELinux permissive,suinstalled. Root is not persistent: re-run theLD_PRELOADline after each boot. The pselect race is not 100%: a failed run can panic the phone, and a retry after the reboot is normal. SeeWARHOL_PORT.mdfor the full ledger.
This device needs the kernel-MTE fix. Stock upstream popsicle cannot root it — it spins in
kernel page retry N/12forever. See Kernel MTE.
Builds come in two preloader flavours, PRELOADER=retail (default) and PRELOADER=eng.
They select different target directories so neither can overwrite the other's addresses —
see Preloader variant.
Facts below were read out of the retail full-OTA package — metadata, the A/B payload
manifest, and the boot/vendor_boot/dtbo images extracted from payload.bin.
The kernel matters more than the SoC here: warhol sits on the same GKI branch as
upstream popsicle (android16-5, 4K pages), one patchlevel apart — 6.12.38 vs
popsicle's verified 6.12.23. Struct offsets still have to be regenerated per build; only
the exploit shape carries over. A different OS3.0.x build needs its own target.h.
The exploit chain is version-locked to the GKI branch, not to the SoC. The rt_mutex
waiter layout, the pselect stack overlay and the pipe_buffer struct offsets all track the
kernel, so a 6.12/android16 base beats a same-vendor base on an older branch.
x-spy/CVE-2026-43499-popsicle is the only public implementation verified on the
6.12/android16 GKI line (Xiaomi 17 / Pro / Ultra, kernel 6.12.23-android16-5), so
source/ is taken from it and kept as close to upstream as possible — two lines aside
(see Kernel MTE, which this device needs to work at all).
Most of the MediaTek delta is confined to build-time target generation, in two pieces:
p0_phys_offset and p0_kernel_phys_load out of a Qualcomm xbl_config
partition, which warhol does not have. generate_target.py --dtb reads the DRAM base
from the /memory node of the vendor_boot FDT instead, rounding it down the same way
arm64_memblock_init does. The kernel's physical load address is read out of the
preloader's mb_kernel reservation with --preloader; the delta comes out 0 for this
device, and lk refuses to boot a kernel placed anywhere else.boot.img rather than as a bare
arm64 Image, so the generator decompresses it before analysis.WARHOL_PORT.md §2 has the full derivation.
MediaTek lk does not load the kernel to a compile-time constant — it looks up a DRAM
reservation named mb_kernel and asserts it landed exactly on it. That reservation is made
by the preloader, which is why the preloader build the phone is running is a build
input here at all: it is the one thing outside boot.img that can move
P0_KERNEL_PHYS_LOAD, and a wrong value there means a wrong linear-map alias and a dead
phone rather than a failed exploit.
Hence two variants, kept as separate target directories:
make preload # PRELOADER=retail -> out/preload-<device>.so
make PRELOADER=eng preload # -> out/preload-<device>-eng.so
make both # both of the above, and print their sha256
Both artifacts are kept side by side under distinct names. On this firmware they come out byte-identical — that is the result of the analysis below, not a shortcut around it, so the two are still built and named separately.
Read the two preloaders' memory-layout tables yourself with:
python3 tools/preloader_memlayout.py <preloader.bin> --diff <preloader_eng.bin>
For this firmware the two tables are identical, mb_kernel.start = 0x80000000 in both,
so the eng target.h comes out byte-identical to the retail one and both builds produce the
same preload.so.
What actually reaches the exploit is the difference
P0_KERNEL_PHYS_LOAD − P0_PHYS_OFFSET, and its two terms do not rest on equally hard
evidence:
P0_KERNEL_PHYS_LOAD — measured out of the eng image, as mb_kernel.start above.P0_PHYS_OFFSET — was argued rather than measured, since it is memstart_addr,
which the kernel takes from the /memory node lk emits at runtime from what the preloader
reports after DRAM init, not from the static FDT the generator reads. The argument was
that the two builds share the whole DRAM path — same dramc/emi/mblock/memory_layout
sources, no memory-layout code among the eng-only strings — and that the eng preloader's
one extra reservation, a 3 MiB dynamic security_fe_rsv with mapping=1, can neither
displace a fixed-address entry nor carve a hole at the bottom of DRAM. The device run
settled it: the linear-map aliases landed under eng, so the term is right.Full derivation in targets/warhol-OS3.0.304.0.WPSJPXM-eng/NOTES.md.
Notes from the device run:
ro.boot.verifiedbootstate=green and flash.locked=1 unchanged. That was an open
question until it was tried; it is decided by the efuse'd root key hash, and this build is
signed with the same keys as retail.%s META DIS is a
retail-only string, while eng carries a whole META implementation retail does not
(Enable fastmeta., FAST META GPIO: %d, META_COM PORT: %d, read_meta_proinfo, …).
It booted straight to Android here, but if an eng-flashed phone ever lands somewhere else,
that is the likely cause and no target.h value is implicated.mb_kernel, the two targets diverge — keeping them as separate
directories is what stops that from being silent.This kernel runs KASAN_HW_TAGS — /proc/cmdline carries kasan.stack_ring_size=524288
— so slab pointers carry an allocation tag in bits 59:56. Upstream popsicle assumes untagged
pointers and therefore cannot root this device at all: it loops in
[-] kernel page retry N/12 mode=1 indefinitely, with nothing in the log saying why.
Two lines in source/ fix it, and both are in warhol-mte-fix.patch:
Only the checks untag. The pointers themselves stay tagged, because the tag is the correct one for the memory they point at — which is exactly what a kernel dereference of them needs.
Do not read arm64.memtag.bootctl for this: that property is the userspace MTE control
and says nothing about whether the kernel is tagging its own allocations.
This repository is a port. Credit for the exploit chain belongs upstream.
source/ exploit core (upstream popsicle + the kernel-MTE fix; see warhol-mte-fix.patch)
src/ main.c slide.c fops.c pipe.c util.c preload.c su_daemon.c + kernelsnitch/
targets/ per-build generated target.h, one dir per fingerprint x preloader variant
warhol-OS3.0.304.0.WPSJPXM/ PRELOADER=retail
warhol-OS3.0.304.0.WPSJPXM-eng/ PRELOADER=eng
tools/
payload_dump.py extract partitions from an A/B payload.bin (verifies size+sha256)
kernel_banner.py read the Linux banner out of a boot.img
bootinfo.py dump boot / vendor_boot header fields
generate_target.py target.h generator; upstream's, MediaTek-only, plus kernel decompression
preloader_memlayout.py dump/diff a MediaTek preloader's static DRAM reservation table
lz4legacy.py LZ4 legacy-frame decompressor (kernel images)
out/ build artifacts (gitignored)
Nothing builds until a target.h exists — the Makefile fails loudly rather than emitting a
binary against the wrong kernel.
# 1. extract boot.img straight out of the OTA zip (payload.bin is stored uncompressed,
# so --base seeks into the zip; no need to unpack 7.6 GB first)
python3 tools/payload_dump.py <ota.zip> --base 5081 -p boot,vendor_boot,dtbo -o <dir>
# 2. confirm the kernel banner — every offset downstream is pinned to it
python3 tools/kernel_banner.py <dir>/boot.img
python3 tools/bootinfo.py <dir>/boot.img
# 3. generate the target header. --preloader reads the kernel's physical load address
# out of the preloader's mb_kernel reservation; pass the preloader the phone runs.
python3 tools/generate_target.py \
--boot <dir>/boot.img --dtb <dir>/vendor_boot.img --preloader <preloader.bin> \
-o targets/warhol-OS3.0.304.0.WPSJPXM/target.h
# 4. build
make preload # DEVICE=warhol-OS3.0.304.0.WPSJPXM, PRELOADER=retail by default
Without a preloader image to hand, --kernel-phys-delta 0 is the older form and gives the
same header for this firmware — but it is an assertion rather than a reading, so prefer
--preloader. Passing both makes the generator cross-check them and fail on a mismatch.
--base 5081 is the local-header offset of payload.bin for this particular OTA; read it
from ota-property-files in META-INF/com/android/metadata for any other package.
Requires an Android NDK (NDK_ROOT / ANDROID_NDK_HOME) and llvm-objdump.
# <device> is the target name: <fingerprint> for PRELOADER=retail, <fingerprint>-eng for eng
adb push out/preload-<device>.so /data/local/tmp/preload.so
adb shell chmod 0644 /data/local/tmp/preload.so
adb shell "LD_PRELOAD=/data/local/tmp/preload.so /system/bin/true"
adb shell "/data/local/tmp/su -c id"
cred and the SELinux state are patched in memory, so this has to be repeated after every
boot. A bootloader unlock is not required.
For research on hardware you own. Running this can bootloop or brick a device and will void the warranty. No warranty of any kind.
Upstream projects are licensed by their respective authors; this port carries their terms.
| Item | Value | Source |
|---|
| Codename | warhol | pre-device=warhol |
| Build fingerprint | Xiaomi/warhol_global/warhol:16/BP2A.250605.031.A3/OS3.0.304.0.WPSJPXM:user/release-keys | post-build |
| Incremental | OS3.0.304.0.WPSJPXM (JP global) | post-build-incremental |
| Android | 16, SDK 36 | post-sdk-level=36 |
| Security patch | 2026-05-01 | post-security-patch-level |
| OTA type | A/B (payload.bin, CrAU, 39 partitions) | ota-type=AB |
| SoC | MediaTek MT6993 (Dimensity 9500) | mediatek,mt6993-* compatibles in the vendor_boot DTB; cmdline bootopt=64S3,32N2,64N2 |
| Kernel | 6.12.38-android16-5-g1d46253471dd-ab15048002-4k, clang 19.0.1 | banner read from the extracted boot.img |
| boot image | header v4, kernel 18,898,125 B, LZ4-legacy compressed, no ramdisk | tools/bootinfo.py |
PRELOADER= | Preloader on the phone | Target directory | Status |
|---|
retail (default) | the shipping preloader_<device>.bin, byte-identical to the fastboot ROM's preloader_raw.img | targets/warhol-OS3.0.304.0.WPSJPXM/ | ✅ verified on device 2026-07-26 |
eng | the engineering build, preloader_<device>_eng.bin | targets/warhol-OS3.0.304.0.WPSJPXM-eng/ | ✅ verified on device 2026-07-26 |
| File | Change | Why |
|---|
src/util.c | kernelsnitch_setup(..., mte_enabled=1) | with 0, the mm_struct search only tries untagged candidates and can never reach a tagged pointer — a structural miss, not bad luck |
src/util.c | is_kernel_ptr() / is_direct_ptr() untag before the range check | a tagged pointer read out of kernel memory is otherwise rejected as not-in-linear-map (direct-entry-fatal reason=bad-task-or-cpu) |
src/kernelsnitch/kernelsnitch.h | tag sweep < 15 → < 16 | tag 0xf is the untagged/match-all pointer, so the sweep now also covers a kernel without MTE — mte_enabled=1 is safe either way |
| Repository | Role here |
|---|
| https://github.com/MobiusM/CVE-2026-43499 | Original CVE-2026-43499 PoC / crash trigger |
| https://github.com/x-spy/CVE-2026-43499-popsicle | Base of this port. Xiaomi 17 Pro Max (popsicle), kernel 6.12.23-android16-5; source/ and tools/generate_target.py come from here |
| https://github.com/Kananosa/CVE-2026-43499-For-Xiaomi-17T-chagall | Closest sibling device (Xiaomi 17T, chagall, also MediaTek). popsicle-derived, ships a prebuilt preload.so — its compiled-in constants confirmed the physical load offset |
| https://github.com/MiCode/Xiaomi_Kernel_OpenSource | Xiaomi kernel sources, for cross-checking struct layout |