
Android LPE exploit for CVE-2026-43499 targeting OPPO PMG110 (kernel 6.6). Uses futex PI UAF to gain root and install a su daemon via LD_PRELOAD.
CVE-2026-43499 (futex PI rt_mutex_waiter use-after-free) local privilege
escalation, ported to the OPPO PMG110 / K15 Pro+ — MediaTek MT6991, ColorOS 16.
One file pushed, run over LD_PRELOAD:
adb push out/preload-pmg110-16.0.9.400.so /data/local/tmp/preload.so
adb shell chmod 644 /data/local/tmp/preload.so
adb shell LD_PRELOAD=/data/local/tmp/preload.so /system/bin/true
On success a persistent su is left behind:
adb shell /data/local/tmp/su -c id # uid=0(root)
Verified on device (2026-07-27): uid=0 in about 35 seconds from a bare run
with no environment overrides, and su answering afterwards from an ordinary
unprivileged adb shell:
$ adb shell "/data/local/tmp/su -c 'echo 0 > /proc/sys/kernel/kptr_restrict'"
$ adb shell "/data/local/tmp/su -c 'grep -w init_task /proc/kallsyms'"
ffffffe89033e780 D init_task
The exploit and the su install are both verified on this device. What that
rooted shell then read back also confirms P0_KERNEL_PHYS_LOAD, the symbol
offsets and KS_MTE_TAGGED=0 independently of the exploit — see
targets/pmg110-16.0.9.400/NOTES.md.
| Device | OPPO PMG110 / K15 Pro+ / OP61E5L1 |
| SoC | MediaTek MT6991 (Dimensity 9500s) |
| Kernel | 6.6.118-android15-8-g93e223c276e7-abogki500782043-4k (GKI, 4K pages) |
| Build | ColorOS 16 / PMG110_16.0.9.400(CN01) — same kernel bytes as 16.0.8.300 |
| Bug | CVE-2026-43499, unfixed in this image (shown by disassembly, not by version) |
It runs Write 1 (SELinux permissive) and Write 2 (cred → init_cred), gets a
child process to uid=0, and from there installs the embedded su daemon.
su is not a second artifact: su_daemon.c is
built as a standalone aarch64 PIE and .incbin'd into the library's
.rodata, so it rides inside preload.so and is written back out at run
time. warhol-root's route, unchanged.ksud, no KernelSUsu is installed in three places, because one of them is the one you will
actually reach:
| Path | Why |
|---|---|
/apex/com.android.virt/bin/su | on a tmpfs mounted over that directory; on PATH for a root shell |
/data/local/tmp/su | reachable from a plain adb shell with no PATH games |
/apex/com.android.virt/bin/su in adbd's mount namespace | installed via setns, so a new adb shell sees it |
The daemon listens on /data/local/tmp/temp_su.sock and logs to
/data/local/tmp/su_daemon.log. Root is not persistent across reboot — re-run
the LD_PRELOAD line after each boot.
For KernelSU installation, use the /data/local/tmp/a/e route in
ghostlock-oneplus instead.
Everything except the exploit core is warhol-root's, taken rather than reinvented:
targets/<device>/, staged into
source/src/ at build time, so switching DEVICE can never leave the
previous device's headers behindsource/Makefile's toolchain selection (NDK if there is one,
host clang against the NDK sysroot if there is not) and the two-stage embed
rule that produces build/embed/su_daemon_aarch64_pie before linking the .sosu_daemon.c and su_blob.S are byte-identical to
warhol-root's, and su_install.c is its preload.c installerThe exploit core is not warhol-root's. warhol-root is popsicle, which is
pinned to GKI 6.12 / android16 and whose generate_target.py refuses any other
banner. PMG110 is 6.6 / android15, so the core here is the ghostlock 6.6 tree —
itself a descendant of the same code (kernelsnitch/utils.h and timeutils.h
are byte-identical between the two repos), further developed.
| File | Relationship |
|---|---|
util.c slide.c fops.c pipe.c root.c miniadb.c common.h offset.h kernelsnitch/* | ghostlock's, byte-identical |
su_daemon.c su_blob.S | warhol-root's, byte-identical (su_blob.S adds two .hidden lines — see Build) |
su_install.c | warhol-root's preload.c installer, moved to its own file because this tree's preload.c already has a different job |
main.c | ghostlock's, plus the su call in the rooted child and the result reporting |
preload.c | only here — the constructor and the dual-sink log |
offsets.h | struct definition only; the entry is staged from targets/<device>/device_offsets.h |
Every line of the exploit proper — Write 1, Write 2, KernelSnitch, the pselect route — is the same code in both trees.
This is the one structural difference, and it is forced by the two trees getting root in different shapes.
warhol-root roots the exploit process itself and so calls
install_embedded_su() straight from run_direct_root(). Here Write 2 swaps the
cred pointer of a forked child and the parent stays the unprivileged
caller, so the child in child_main() is the only context that can do the
install — that is where it runs.
Both trees carry the same weak install_embedded_su() stub in util.c that
returns ENOSYS; providing the strong definition is what turns the route on.
That is worth knowing because a build that somehow drops su_install.c still
links and still runs — it just reports su=0/38 and installs nothing.
make # = make preload -> out/preload-<DEVICE>.so
make DEVICE=<name> # use targets/<name>/
make devices # list available DEVICE values
make info # show the selected target and the resolved toolchain
The toolchain is found on its own: ANDROID_NDK_HOME / ANDROID_NDK_ROOT
first, then the usual NDK install locations for Linux and macOS, and failing all
of those, host clang targeting the NDK sysroot. Set ANDROID_NDK_HOME only to
override the search. make info prints what it picked.
The build is two stages, which is the part worth knowing:
su_daemon.c → build/embed/su_daemon_aarch64_pie, a standalone aarch64 PIEsu_blob.S .incbins that binary into .rodata, and the whole thing links
into the one preload.soSo make clean and a rebuild are the only way to change the embedded su —
editing su_daemon.c alone is enough, the dependency is declared, but the blob
is a build artifact and is not tracked.
targets/<device>/{target.h,device_offsets.h} are re-staged into source/src/
on every build, so a stale header from another device cannot be picked up
silently.
out/*.so is not tracked (same convention as warhol-root) — clone and make.
The .so is built -fvisibility=hidden and exports zero symbols. An
LD_PRELOAD library wins symbol lookup for the entire process, so anything it
exported could shadow a same-named symbol in the host binary or in libc. That
flag governs C codegen only, so su_blob.S marks its two symbols .hidden by
hand — without those lines the blob bounds would be the only thing the library
still exported.