
Android-LPE-Exploit für CVE-2026-43499 gegen das OPPO PMG110 (Kernel 6.6). Nutzt futex PI UAF aus, um Root-Rechte zu erlangen und über LD_PRELOAD einen su-Daemon zu installieren.
CVE-2026-43499 (futex PI rt_mutex_waiter use-after-free) lokale Privilegieneskalation, portiert auf das OPPO PMG110 / K15 Pro+ — MediaTek MT6991, ColorOS 16.
Eine Datei übertragen, ausgeführt über 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
Bei Erfolg bleibt ein persistentes su zurück:
adb shell /data/local/tmp/su -c id # uid=0(root)
Auf dem Gerät verifiziert (2026-07-27): uid=0 in etwa 35 Sekunden bei einem reinen Lauf ohne Umgebungsüberschreibungen, und su antwortet anschließend aus einer gewöhnlichen unprivilegierten 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
Der Exploit und die su-Installation sind beide auf diesem Gerät verifiziert. Was diese gerootete Shell anschließend zurücklas, bestätigt außerdem P0_KERNEL_PHYS_LOAD, die Symbol-Offsets und KS_MTE_TAGGED=0 unabhängig vom Exploit — siehe targets/pmg110-16.0.9.400/NOTES.md.
Es führt Write 1 (SELinux permissive) und Write 2 (cred → init_cred) aus, bringt einen Kindprozess auf uid=0 und installiert von dort den eingebetteten su-Daemon.
su ist kein zweites Artefakt: su_daemon.c wird als eigenständiges aarch64-PIE gebaut und per .incbin in das .rodata der Bibliothek eingebettet, sodass es in preload.so mitfährt und zur Laufzeit wieder herausgeschrieben wird. Der Weg von warhol-root, unverändert.ksud, kein KernelSUsu wird an drei Orten installiert, weil einer davon derjenige ist, den du tatsächlich erreichst:
Der Daemon lauscht auf /data/local/tmp/temp_su.sock und protokolliert nach /data/local/tmp/su_daemon.log. Root bleibt über einen Neustart hinweg nicht bestehen — führe die LD_PRELOAD-Zeile nach jedem Boot erneut aus.
Für die KernelSU-Installation verwende stattdessen den /data/local/tmp/a/e-Weg in ghostlock-oneplus.
Alles außer dem Exploit-Kern stammt von warhol-root, übernommen statt neu erfunden:
targets/<device>/, zur Build-Zeit nach source/src/ gestagt, sodass ein Wechsel von DEVICE niemals die Header des vorherigen Geräts zurücklassen kannsource/Makefile (NDK, falls vorhanden, sonst Host-clang gegen den NDK-Sysroot) sowie die zweistufige Embed-Regel, die vor dem Linken der .so das build/embed/su_daemon_aarch64_pie erzeugtsu_daemon.c und su_blob.S sind byte-identisch mit denen von warhol-root, und su_install.c ist dessen preload.c-InstallerDer Exploit-Kern stammt nicht von warhol-root. warhol-root ist popsicle, das an GKI 6.12 / android16 gebunden ist und dessen generate_target.py jedes andere Banner ablehnt. PMG110 läuft auf 6.6 / android15, daher ist der Kern hier der ghostlock-6.6-Baum — selbst ein Abkömmling desselben Codes (kernelsnitch/utils.h und timeutils.h sind zwischen den beiden Repos byte-identisch), weiterentwickelt.
Jede Zeile des eigentlichen Exploits — Write 1, Write 2, KernelSnitch, der pselect-Weg — ist in beiden Bäumen derselbe Code.
Das ist der eine strukturelle Unterschied, und er ist dadurch erzwungen, dass die beiden Bäume auf unterschiedliche Weise Root erlangen.
warhol-root rootet den Exploit-Prozess selbst und ruft install_embedded_su() daher direkt aus run_direct_root() auf. Hier tauscht Write 2 den cred-Zeiger eines geforkten Kindes, und der Parent bleibt der unprivilegierte Aufrufer, sodass das Kind in child_main() der einzige Kontext ist, der die Installation durchführen kann — dort läuft sie.
Beide Bäume tragen denselben schwachen install_embedded_su()-Stub in util.c, der ENOSYS zurückgibt; die starke Definition bereitzustellen, ist das, was den Weg aktiviert. Das ist wissenswert, denn ein Build, der su_install.c irgendwie weglässt, linkt und läuft weiterhin — er meldet nur su=0/38 und installiert nichts.
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
Die Toolchain wird von selbst gefunden: zuerst ANDROID_NDK_HOME / ANDROID_NDK_ROOT, dann die üblichen NDK-Installationsorte für Linux und macOS, und falls all das fehlschlägt, Host-clang gegen den NDK-Sysroot. Setze ANDROID_NDK_HOME nur, um die Suche zu überschreiben. make info gibt aus, was gewählt wurde.
Der Build besteht aus zwei Stufen, was der Teil ist, den man kennen sollte:
su_daemon.c → build/embed/su_daemon_aarch64_pie, ein eigenständiges aarch64-PIEsu_blob.S bettet diese Binärdatei per .incbin in .rodata ein, und das Ganze wird zu der einen preload.so gelinktEin make clean und ein Rebuild sind also der einzige Weg, das eingebettete su zu ändern — su_daemon.c allein zu bearbeiten genügt, die Abhängigkeit ist deklariert, aber der Blob ist ein Build-Artefakt und wird nicht versioniert.
targets/<device>/{target.h,device_offsets.h} werden bei jedem Build erneut nach source/src/ gestagt, sodass ein veralteter Header eines anderen Geräts nicht stillschweigend übernommen werden kann.
out/*.so wird nicht versioniert (gleiche Konvention wie warhol-root) — klonen und make.
Die .so wird mit -fvisibility=hidden gebaut und exportiert null Symbole. Eine LD_PRELOAD-Bibliothek hat bei der Symbolauflösung für den gesamten Prozess Vorrang, sodass alles, was sie exportiert, ein gleichnamiges Symbol in der Host-Binary oder in libc überdecken könnte. Dieses Flag steuert nur die C-Codegenerierung, daher markiert su_blob.S seine beiden Symbole von Hand als .hidden — ohne diese Zeilen wären die Blob-Grenzen das Einzige, was die Bibliothek noch exportierte.
Keine davon war beim verifizierten Lauf nötig.
[*] 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 bedeutet, dass der Exploit erfolgreich war; alles danach ist die Installation. Beides wird absichtlich getrennt gemeldet, ebenso das Urteil:
Die mittlere ist die Unterscheidung, die sich lohnt: Sie besagt, dass die Offsets in target.h für diesen Build stimmen und das Problem irgendwo in der Installation liegt, was eine völlig andere Sache zum Debuggen ist. su=0/38 (ENOSYS) darin bedeutet konkret, dass der schwache Stub gelinkt wurde.
mm_struct leak failed, gefolgt von prepare_kernel_page retry N/24, ist kein Fehler. Es ist Schleifenfortschritt, und der erfolgreiche Lauf zeigt es ebenfalls. Nichts ist fehlgeschlagen, bis 24 Versuche erschöpft sind und prepare_kernel_page timeout erscheint. Ebenso ist probing cfi ... expected=9 mit child uid = 2000 eine verfehlte Runde von zehn.
Beurteile einen Lauf nicht anhand eines abgeschnittenen Logs — dieser Fehler kostete hier eine ganze Runde Fehldiagnose.
Auch ein komplett fehlgeschlagener Lauf ist normal. Die pselect-Race ist nicht zu 100 %: Ein Lauf kann sie fünfmal hintereinander verlieren und in Write 1 failed enden, und der nächste Lauf gewinnt sie auf Anhieb mit ret=9. Auf diesem Gerät beobachtet. ret=4 expected=9 sieht so aus, wenn man die Race verliert, nicht wie ein falsches target.h — ein Fehlschlag ist kein Grund, die Offsets neu herzuleiten. Führe es erneut aus.
Nur für autorisierte Sicherheitsforschung und zu Bildungszwecken.
| Gerät | OPPO PMG110 / K15 Pro+ / OP61E5L1 |
| SoC | MediaTek MT6991 (Dimensity 9500s) |
| Kernel | 6.6.118-android15-8-g93e223c276e7-abogki500782043-4k (GKI, 4K-Seiten) |
| Build | ColorOS 16 / PMG110_16.0.9.400(CN01) — gleiche Kernel-Bytes wie 16.0.8.300 |
| Bug | CVE-2026-43499, nicht gepatcht in diesem Image (per Disassemblierung belegt, nicht durch die Version) |
| Pfad | Warum |
|---|
/apex/com.android.virt/bin/su | auf einem tmpfs, das über dieses Verzeichnis gemountet ist; auf dem PATH einer Root-Shell |
/data/local/tmp/su | aus einer einfachen adb shell ohne PATH-Spielereien erreichbar |
/apex/com.android.virt/bin/su im Mount-Namespace von adbd | über setns installiert, sodass eine neue adb shell es sieht |
| Datei | Beziehung |
|---|
util.c slide.c fops.c pipe.c root.c miniadb.c common.h offset.h kernelsnitch/* | von ghostlock, byte-identisch |
su_daemon.c su_blob.S | von warhol-root, byte-identisch (su_blob.S fügt zwei .hidden-Zeilen hinzu — siehe Build) |
su_install.c | warhol-roots preload.c-Installer, in eine eigene Datei verschoben, weil das preload.c dieses Baums bereits eine andere Aufgabe hat |
main.c | von ghostlock, plus der su-Aufruf im gerooteten Kind und die Ergebnisberichterstattung |
preload.c | nur hier — der Konstruktor und das Dual-Sink-Log |
offsets.h | nur die Struct-Definition; der Eintrag wird aus targets/<device>/device_offsets.h gestagt |
| Variable | Effekt |
|---|
GHOSTLOCK_LOG | Log-Ziel (Standard /data/local/tmp/.ghostlock.log); die Ausgabe geht an stdout und in die Datei |
GHOSTLOCK_KS_VERBOSE=1 | die Kollisionsadressen von KernelSnitch und die Sweep-Bereiche ausgeben |
GHOSTLOCK_KS_THRESHOLD=<n> | den Kollisions-Schwellenmultiplikator überschreiben |
GHOSTLOCK_MTE=1 | auch Kernel-Pointer-Tags durchsuchen (15x langsamer) |
GHOSTLOCK_PHYS_LOAD=0x... | die physische Ladeadresse des Kernels überschreiben |
PSELECT_SHIFT=<n> | den Stack-Overlay-Shift überschreiben (ersetzt, addiert nicht) |
| Urteil | Bedeutung |
|---|
EXPLOIT OK | Root, und su antwortet |
EXPLOIT OK, SU INSTALL FAILED | Write 1 und Write 2 sind gelandet; nur die Installation ist schiefgelaufen |
EXPLOIT FAILED | die Writes sind nicht gelandet |
ABORTED | der Lauf starb, bevor er melden konnte — lies die letzte [!]-Zeile |
| Pfad | Inhalt |
|---|
source/src/preload.c | Konstruktor: führt den Exploit aus, meldet, stoppt |
source/src/main.c | der Exploit selbst (Write 1 / Write 2) |
source/src/su_daemon.c | die su-Binärdatei — eigenständig als aarch64-PIE gebaut, nicht in die .so gelinkt |
source/src/su_blob.S | bettet dieses PIE per .incbin in das .rodata der .so ein |
source/src/su_install.c | schreibt den Blob zurück, startet den Daemon, prüft ihn |
source/src/target.h | Staging-Ziel (gitignoriert) |
targets/<device>/target.h | Kompilierzeit-Layout: Struct-Offsets, Physmap-Konstanten, Slab- und Futex-Formen |
targets/<device>/device_offsets.h | globale Symbol-Offsets aus kallsyms |
tools/extract_device.py | boot.img → Offsets, BTF-Struct-Felder, pselect-Overlay-Ergebnis |
tools/preloader_memlayout.py | MediaTek-Preloader → P0_KERNEL_PHYS_LOAD |
tools/qemu_verify.py | bootet den Kernel unter QEMU: misst das Stack-Overlay, prüft die Stabilität der Linearmap |
tools/device_probe.sh | Pre-Flight-Check aus einer unprivilegierten adb-Shell |