Share
## https://sploitus.com/exploit?id=4751E71C-BFFD-599D-A771-739E97A2E41D
# pmg110-root

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`:

```sh
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:

```sh
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`](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) |

## What it does, and what it does not

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.

- **still one file pushed.** `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.
- no root script, no `ksud`, no KernelSU
- the calling process stays unprivileged โ€” it gets root by *asking the daemon*,
  which is the same thing you will do from the shell afterwards
- SELinux is **left permissive**, as warhol-root leaves it: the daemon has to
  serve unprivileged clients over its socket. Reboot to restore enforcing.

`su` 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.

## Relationship to warhol-root

Everything except the exploit core is warhol-root's, taken rather than
reinvented:

- the **layout** โ€” per-device headers under `targets//`, staged into
  `source/src/` at build time, so switching `DEVICE` can never leave the
  previous device's headers behind
- the **build** โ€” `source/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 `.so`
- the **su route** โ€” `su_daemon.c` and `su_blob.S` are byte-identical to
  warhol-root's, and `su_install.c` is its `preload.c` installer

**The 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_offsets.h` |

Every line of the exploit proper โ€” Write 1, Write 2, KernelSnitch, the pselect
route โ€” is the same code in both trees.

### Where the su install is called from

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.

## Build

```sh
make                      # = make preload -> out/preload-.so
make DEVICE=        # use targets//
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:

1. `su_daemon.c` โ†’ `build/embed/su_daemon_aarch64_pie`, a standalone aarch64 PIE
2. `su_blob.S` `.incbin`s that binary into `.rodata`, and the whole thing links
   into the one `preload.so`

So `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//{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.

## Environment variables

| 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=` | 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=` | override the stack overlay shift (**replaces**, does not add) |

None of these were needed on the verified run.

## Reading the log

```
[*] 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:

| 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 |

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.

## Files

| 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` | `.incbin`s 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//target.h` | compile-time layout: struct offsets, physmap constants, slab and futex shapes |
| `targets//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 |

## License

For authorized security research and educational purposes only.