soralis0912/CVE-2026-43499-pmg110-root
GitHub: soralis0912/CVE-2026-43499-pmg110-root
针对 OPPO PMG110(MediaTek MT6991 / ColorOS 16)的本地提权 exploit,利用 CVE-2026-43499 futex PI 内核漏洞在设备上获取 root 权限并部署 su 守护进程。
Stars: 0 | Forks: 0
# pmg110-root
CVE-2026-43499 (futex PI `rt_mutex_waiter` use-after-free) 本地提权,
移植到 **OPPO PMG110 / K15 Pro+** — MediaTek MT6991,ColorOS 16。
仅推入一个文件,通过 `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
```
成功后,会留下一个持久化的 `su`:
```
adb shell /data/local/tmp/su -c id # uid=0(root)
```
**已在设备上验证** (2026-07-27):在无任何环境变量覆盖的纯运行状态下,
约 35 秒内获取 uid=0,随后在普通的非特权 `adb shell` 中
`su` 可正常响应:
```
$ 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
```
该 exploit 和 `su` 安装均已在此设备上验证通过。通过该已获取 root 权限的 shell
读取回的信息,也独立于 exploit 确认了 `P0_KERNEL_PHYS_LOAD`、符号偏移量
以及 `KS_MTE_TAGGED=0` — 详见
[`targets/pmg110-16.0.9.400/NOTES.md`](targets/pmg110-16.0.9.400/NOTES.md)。
| | |
|---|---|
| 设备 | OPPO PMG110 / K15 Pro+ / `OP61E5L1` |
| SoC | MediaTek MT6991 (Dimensity 9500s) |
| 内核 | `6.6.118-android15-8-g93e223c276e7-abogki500782043-4k` (GKI,4K 页面) |
| 版本 | ColorOS 16 / `PMG110_16.0.9.400(CN01)` — 内核字节与 `16.0.8.300` 相同 |
| 漏洞 | CVE-2026-43499,在此镜像中**未修复**(通过反汇编证明,而非版本号) |
## 能做什么,不能做什么
它执行 Write 1(将 SELinux 设置为 permissive)和 Write 2(将 `cred` 替换为 `init_cred`),使
子进程获取 uid=0,并由此安装内置的 `su` 守护进程 (daemon)。
- **依然只推入一个文件。** `su` 并非第二个独立产物:`su_daemon.c` 会
被构建为独立的 aarch64 PIE,并通过 `.incbin` 嵌入到库的
`.rodata` 中,因此它会随 `preload.so` 一起加载,并在运行
时写回。沿用 warhol-root 的路径,未作更改。
- 没有 root 脚本,没有 `ksud`,也没有 KernelSU
- 调用进程保持非特权状态 — 它通过*请求守护进程*来获取 root,
这与您之后在 shell 中所做的操作相同
- SELinux 被**保留为 permissive**,这与 warhol-root 的做法一致:守护进程必须
通过其 socket 为非特权客户端提供服务。重启设备以恢复为 enforcing 模式。
`su` 被安装在三个位置,因为其中必有一个是您实际能够访问到的:
| 路径 | 原因 |
|---|---|
| `/apex/com.android.virt/bin/su` | 位于挂载在该目录上的 tmpfs 中;在 root shell 的 `PATH` 中 |
| `/data/local/tmp/su` | 无需修改 `PATH` 即可从普通的 `adb shell` 访问 |
| *adbd 挂载命名空间中的* `/apex/com.android.virt/bin/su` | 通过 `setns` 安装,因此**新启动的** `adb shell` 能看到它 |
守护进程监听 `/data/local/tmp/temp_su.sock`,日志输出至
`/data/local/tmp/su_daemon.log`。重启后 root 权限不会保留 —
每次开机后需重新运行 `LD_PRELOAD` 命令。
如需安装 KernelSU,请改用
`ghostlock-oneplus` 中的 `/data/local/tmp/a/e` 路径。
## 与 warhol-root 的关系
除了 exploit 核心之外,所有内容均来自 warhol-root,是直接采用而非重新发明的:
- **布局** — 位于 `targets//` 下的设备专用头文件,在
构建时暂存到 `source/src/` 中,因此切换 `DEVICE` 不会残留
上一个设备的头文件
- **构建过程** — `source/Makefile` 的工具链选择(如果存在 NDK 则使用 NDK,
否则使用针对 NDK sysroot 的主机 clang)以及生成
`build/embed/su_daemon_aarch64_pie` 后再链接 `.so` 的两阶段嵌入
规则
- **su 路径** — `su_daemon.c` 和 `su_blob.S` 与
warhol-root 中的在字节级完全一致,且 `su_install.c` 即为其 `preload.c` 的安装程序
**exploit 核心并非来自 warhol-root。** warhol-root 即 popsicle,它
被绑定在 GKI 6.12 / android16 上,且其 `generate_target.py` 拒绝任何其他
banner。PMG110 是 6.6 / android15,因此这里的核心是 ghostlock 的 6.6 代码树 —
它本身也是同一套代码的后代(两个仓库中的 `kernelsnitch/utils.h` 和 `timeutils.h`
在字节级完全一致),并进行了进一步开发。
| 文件 | 关系 |
|---|---|
| `util.c` `slide.c` `fops.c` `pipe.c` `root.c` `miniadb.c` `common.h` `offset.h` `kernelsnitch/*` | ghostlock 专用,字节级相同 |
| `su_daemon.c` `su_blob.S` | warhol-root 专用,字节级相同(`su_blob.S` 增加了两行 `.hidden` — 见构建说明) |
| `su_install.c` | warhol-root 的 `preload.c` 安装程序,因为此代码树的 `preload.c` 已有其他用途,故移至单独的文件中 |
| `main.c` | ghostlock 专用,并在获取 root 的子进程中增加了 su 调用和结果报告 |
| `preload.c` | 仅此处独有 — 包含构造函数和双输出日志 |
| `offsets.h` | 仅包含结构体定义;入口由 `targets//device_offsets.h` 暂存提供 |
exploit 本身的每一行代码 — Write 1、Write 2、KernelSnitch、pselect
路径 — 在两棵代码树中是完全相同的代码。
### su 安装的调用位置
这是唯一的结构性差异,并且是由两棵代码树获取 root 权限的
不同形式所决定的。
warhol-root 直接赋予 **exploit 进程本身** root 权限,因此直接
从 `run_direct_root()` 调用 `install_embedded_su()`。在这里,Write 2 替换了
**派生子进程** 的 `cred` 指针,而父进程仍然是那个非特权的
调用者,因此 `child_main()` 中的子进程是唯一能够执行
安装操作的环境 — 这就是它的运行位置。
两棵代码树都在 `util.c` 中包含了一个相同的弱符号 `install_embedded_su()` stub,它
返回 `ENOSYS`;提供强符号定义才是启用该路径的关键。
了解这一点很有价值,因为如果某个构建过程不小心遗漏了 `su_install.c`,它仍然会
链接并运行 — 只是会报告 `su=0/38` 且什么也不会
安装。
## 构建说明
```
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
```
工具链会自动查找:优先查找 `ANDROID_NDK_HOME` / `ANDROID_NDK_ROOT`,
然后是 Linux 和 macOS 上常见的 NDK 安装位置,如果都
找不到,则使用针对 NDK sysroot 的主机 `clang`。仅在需要
覆盖搜索结果时设置 `ANDROID_NDK_HOME`。`make info` 会打印出它选择的工具链。
构建过程分为两个阶段,这是值得了解的关键部分:
1. `su_daemon.c` → `build/embed/su_daemon_aarch64_pie`,一个独立的 aarch64 PIE
2. `su_blob.S` 使用 `.incbin` 将该二进制文件嵌入到 `.rodata` 中,然后将整个内容链接
为一个单独的 `preload.so`
因此,`make clean` 并重新构建是更改嵌入式 `su` 的唯一方法 —
仅编辑 `su_daemon.c` 就足够了,依赖关系已经声明,但由于该 blob 是
构建产物,因此不受版本控制跟踪。
`targets//{target.h,device_offsets.h}` 会在每次构建时
重新暂存到 `source/src/` 中,因此不会发生隐式加载来自其他设备的旧头文件的情况。
`out/*.so` 不受版本控制(与 warhol-root 约定相同) — 克隆后执行 `make` 即可。
该 `.so` 在构建时使用了 `-fvisibility=hidden`,并导出了 **零** 个符号。
`LD_PRELOAD` 库会在整个进程中赢得符号查找,因此它导出的
任何内容都可能覆盖宿主二进制文件或 libc 中同名的符号。该
标志仅控制 C 代码生成,因此 `su_blob.S` 需要手动将其两个符号标记为 `.hidden` —
如果没有这两行,blob 的边界将成为该库中
唯一仍被导出的内容。
## 环境变量
| 变量 | 作用 |
|---|---|
| `GHOSTLOCK_LOG` | 日志目标(默认为 `/data/local/tmp/.ghostlock.log`);输出会同时发送到 stdout *和* 该文件 |
| `GHOSTLOCK_KS_VERBOSE=1` | 打印 KernelSnitch 的冲突地址和扫描范围 |
| `GHOSTLOCK_KS_THRESHOLD=` | 覆盖冲突阈值乘数 |
| `GHOSTLOCK_MTE=1` | 同时扫描内核指针标签(速度慢 15 倍) |
| `GHOSTLOCK_PHYS_LOAD=0x...` | 覆盖内核物理加载地址 |
| `PSELECT_SHIFT=` | 覆盖栈覆盖偏移量(**替换**,而非叠加) |
在已验证的运行中均未用到这些变量。
## 阅读日志
```
[*] 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` 表示 exploit 成功;其后的所有内容都是安装过程。
两者被刻意分开报告,判定结论也是如此:
| 判定 | 含义 |
|---|---|
| `EXPLOIT OK` | 已获取 root 权限,且 `su` 可响应 |
| `EXPLOIT OK, SU INSTALL FAILED` | Write 1 和 Write 2 已成功写入;仅安装过程出错 |
| `EXPLOIT FAILED` | 写入未成功 |
| `ABORTED` | 运行在能够报告结果前即终止 — 阅读最后一行 `[!]` |
中间的那一项是需要掌握的重要区别:它表明 `target.h` 中的偏移量
对于此构建是正确的,问题出在安装过程的
某个地方,这完全是两码事,需要不同的调试方法。其中出现的 `su=0/38` (`ENOSYS`)
具体意味着链接到的是那个弱符号 stub。
**`mm_struct leak failed` 紧接着 `prepare_kernel_page retry N/24` 并非
失败。** 这是循环进度,且成功的运行中也会显示它。在尝试满
24 次并出现 `prepare_kernel_page timeout` 之前,都不算作
失败。
同样,`probing cfi ... expected=9` 伴随 `child uid = 2000` 只是十次尝试中
错失了一轮而已。
不要根据截断的日志来评判运行结果 — 这个错误曾在此处导致了
一整轮的误诊。
**出现完全失败的运行也是正常的。** `pselect` 竞争并非 100% 成功:一次运行可能
连续输掉五次竞争并最终以 `Write 1 failed` 结束,而下一次运行
可能在第一次尝试时就以 `ret=9` 成功。这是在此设备上观察到的情况。`ret=4 expected=9` 是
输掉竞争的表现,而不是 `target.h` 错误 — 一次失败并不足以
成为去重新推导偏移量的理由。直接重新运行即可。
## 文件列表
| 路径 | 内容 |
|---|---|
| `source/src/preload.c` | 构造函数:运行 exploit,报告结果,停止 |
| `source/src/main.c` | exploit 本身(Write 1 / Write 2) |
| `source/src/su_daemon.c` | `su` 二进制文件 — 独立构建为 aarch64 PIE,未链接到 `.so` 中 |
| `source/src/su_blob.S` | 使用 `.incbin` 将该 PIE 嵌入 `.so` 的 `.rodata` 中 |
| `source/src/su_install.c` | 将 blob 写回,启动守护进程,并进行探测 |
| `source/src/target.h` | 暂存目标(已被 gitignore 忽略) |
| `targets//target.h` | 编译时布局:结构体偏移量、physmap 常量、slab 和 futex 结构 |
`targets//device_offsets.h` | 来自 kallsyms 的全局符号偏移量 |
| `tools/extract_device.py` | `boot.img` → 偏移量、BTF 结构体字段、pselect 覆盖结果 |
| `tools/preloader_memlayout.py` | MediaTek preloader → `P0_KERNEL_PHYS_LOAD` |
| `tools/qemu_verify.py` | 在 QEMU 下启动内核:测量栈覆盖情况,检查 linear-map 稳定性 |
| `tools/device_probe.sh` | 从非特权 adb shell 进行的预检 |
## 许可证
仅用于授权的安全研究和教育目的。
标签:Android, DSL, LD_PRELOAD, Mediatek, Web报告查看器, 内核漏洞, 客户端加密, 本地提权, 逆向工具