
Exploit LPE Android pour CVE-2026-43499 ciblant l'OPPO PMG110 (kernel 6.6). Utilise une UAF futex PI pour obtenir les droits root et installer un démon su via LD_PRELOAD.
CVE-2026-43499 (futex PI, use-after-free de rt_mutex_waiter) escalade de privilèges locale, porté sur le OPPO PMG110 / K15 Pro+ — MediaTek MT6991, ColorOS 16.
Un seul fichier poussé, lancé via 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
En cas de succès, un su persistant est laissé en place :
adb shell /data/local/tmp/su -c id # uid=0(root)
Vérifié sur l'appareil (2026-07-27) : uid=0 en environ 35 secondes à partir d'une exécution brute, sans aucune variable d'environnement modifiée, et su répondant ensuite depuis un adb shell non privilégié ordinaire :
$ 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
L'exploit et l'installation de su sont tous deux vérifiés sur cet appareil. Ce que ce shell rooté a ensuite relu confirme également P0_KERNEL_PHYS_LOAD, les offsets de symboles et KS_MTE_TAGGED=0 indépendamment de l'exploit — voir targets/pmg110-16.0.9.400/NOTES.md.
Il exécute Write 1 (SELinux permissif) et Write 2 (cred → init_cred), amène un processus enfant à uid=0, et, à partir de là, installe le démon su embarqué.
su n'est pas un second artefact : su_daemon.c est compilé comme un PIE aarch64 autonome et incorporé via .incbin dans le .rodata de la bibliothèque, il voyage donc à l'intérieur de preload.so et est réécrit à l'exécution. La voie de warhol-root, inchangée.ksud, pas de KernelSUsu est installé à trois endroits, car l'un d'eux est celui que vous atteindrez réellement :
Le démon écoute sur /data/local/tmp/temp_su.sock et journalise dans /data/local/tmp/su_daemon.log. Root n'est pas persistant après un redémarrage — relancez la ligne LD_PRELOAD après chaque démarrage.
Pour l'installation de KernelSU, utilisez plutôt la voie /data/local/tmp/a/e dans ghostlock-oneplus.
Tout, sauf le cœur de l'exploit, provient de warhol-root, repris plutôt que réinventé :
targets/<device>/, mis en place dans source/src/ à la compilation, de sorte que changer de DEVICE ne puisse jamais laisser les en-têtes de l'appareil précédent derrièresource/Makefile (NDK s'il y en a un, clang hôte contre le sysroot NDK sinon) et la règle d'intégration en deux étapes qui produit build/embed/su_daemon_aarch64_pie avant de lier le .sosu_daemon.c et su_blob.S sont octet-pour-octet identiques à ceux de warhol-root, et su_install.c est son installateur preload.cLe cœur de l'exploit n'est pas celui de warhol-root. warhol-root est popsicle, qui est verrouillé sur GKI 6.12 / android16 et dont generate_target.py refuse toute autre bannière. PMG110 est en 6.6 / android15, donc le cœur ici est l'arborescence ghostlock 6.6 — elle-même descendante du même code (kernelsnitch/utils.h et timeutils.h sont octet-pour-octet identiques entre les deux dépôts), développée plus avant.
Chaque ligne de l'exploit proprement dit — Write 1, Write 2, KernelSnitch, la voie pselect — est le même code dans les deux arborescences.
C'est la seule différence structurelle, et elle est imposée par les deux arborescences qui obtiennent root de manières différentes.
warhol-root root le processus de l'exploit lui-même et appelle donc install_embedded_su() directement depuis run_direct_root(). Ici, Write 2 échange le pointeur cred d'un enfant forké et le parent reste l'appelant non privilégié ; l'enfant dans child_main() est donc le seul contexte capable d'effectuer l'installation — c'est là qu'elle s'exécute.
Les deux arborescences portent le même stub faible install_embedded_su() dans util.c, qui retourne ENOSYS ; fournir la définition forte est ce qui active la voie. C'est bon à savoir, car une compilation qui, pour une raison ou une autre, omet su_install.c se lie et s'exécute toujours — elle rapporte simplement su=0/38 et n'installe rien.
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
La chaîne d'outils est trouvée automatiquement : ANDROID_NDK_HOME / ANDROID_NDK_ROOT d'abord, puis les emplacements d'installation NDK habituels pour Linux et macOS et, si tout cela échoue, clang hôte ciblant le sysroot NDK. Ne définissez ANDROID_NDK_HOME que pour remplacer la recherche. make info affiche ce qu'elle a choisi.
La compilation se fait en deux étapes, ce qu'il vaut la peine de savoir :
su_daemon.c → build/embed/su_daemon_aarch64_pie, un PIE aarch64 autonomesu_blob.S intègre ce binaire via .incbin dans .rodata, et l'ensemble est lié dans l'unique preload.soDonc make clean et une recompilation sont le seul moyen de changer le su embarqué — modifier su_daemon.c seul suffit, la dépendance est déclarée, mais le blob est un artefact de compilation et n'est pas suivi.
targets/<device>/{target.h,device_offsets.h} sont remis en place dans source/src/ à chaque compilation, afin qu'un en-tête périmé d'un autre appareil ne puisse pas être pris en compte silencieusement.
out/*.so n'est pas suivi (même convention que warhol-root) — clonez et lancez make.
Le .so est compilé avec -fvisibility=hidden et exporte zéro symbole. Une bibliothèque LD_PRELOAD a priorité dans la résolution des symboles pour tout le processus ; tout ce qu'elle exporterait pourrait donc masquer un symbole du même nom dans le binaire hôte ou dans libc. Ce drapeau ne régit que la génération de code C ; c'est pourquoi su_blob.S marque ses deux symboles .hidden à la main — sans ces lignes, les bornes du blob seraient la seule chose que la bibliothèque exporterait encore.
Aucune de ces variables n'était nécessaire lors de l'exécution vérifiée.
[*] 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 signifie que l'exploit a réussi ; tout ce qui suit est l'installation. Les deux sont rapportés séparément à dessein, et le verdict aussi :
Celui du milieu est la distinction intéressante : il indique que les offsets dans target.h sont corrects pour cette compilation et que le problème se situe quelque part dans l'installation, ce qui est une chose complètement différente à déboguer. su=0/38 (ENOSYS) à l'intérieur signifie précisément que c'est le stub faible qui a été lié.
mm_struct leak failed suivi de prepare_kernel_page retry N/24 n'est pas un échec. C'est la progression de la boucle, et l'exécution réussie le montre aussi. Rien n'a échoué jusqu'à ce que les 24 tentatives soient épuisées et que prepare_kernel_page timeout apparaisse. De même, probing cfi ... expected=9 avec child uid = 2000 est un tour manquant, sur dix.
Ne jugez pas une exécution à partir d'un journal tronqué — cette erreur a coûté ici un cycle complet de diagnostic erroné.
Une exécution entièrement échouée est normale aussi. La course pselect n'est pas fiable à 100 % : une exécution peut la perdre cinq fois de suite et se terminer par Write 1 failed, puis la suivante la gagne du premier coup avec ret=9. Observé sur cet appareil. ret=4 expected=9 est ce à quoi ressemble une course perdue, pas un target.h erroné — un échec n'est pas une raison pour aller redériver les offsets. Relancez-la.
Uniquement à des fins de recherche en sécurité autorisée et d'éducation.
| Appareil | OPPO PMG110 / K15 Pro+ / OP61E5L1 |
| SoC | MediaTek MT6991 (Dimensity 9500s) |
| Noyau | 6.6.118-android15-8-g93e223c276e7-abogki500782043-4k (GKI, pages 4K) |
| Build | ColorOS 16 / PMG110_16.0.9.400(CN01) — mêmes octets de noyau que 16.0.8.300 |
| Bug | CVE-2026-43499, non corrigé dans cette image (prouvé par désassemblage, pas par la version) |
| Chemin | Pourquoi |
|---|
/apex/com.android.virt/bin/su | sur un tmpfs monté par-dessus ce répertoire ; sur PATH pour un shell root |
/data/local/tmp/su | atteignable depuis un simple adb shell, sans contournements de PATH |
/apex/com.android.virt/bin/su dans l'espace de noms de montage d'adbd | installé via setns, donc un nouveau adb shell le voit |
| Fichier | Relation |
|---|
util.c slide.c fops.c pipe.c root.c miniadb.c common.h offset.h kernelsnitch/* | de ghostlock, octet-pour-octet identique |
su_daemon.c su_blob.S | de warhol-root, octet-pour-octet identique (su_blob.S ajoute deux lignes .hidden — voir Compilation) |
su_install.c | l'installateur preload.c de warhol-root, déplacé dans son propre fichier car le preload.c de cette arborescence a déjà un autre rôle |
main.c | de ghostlock, plus l'appel su dans l'enfant rooté et le rapport des résultats |
preload.c | uniquement ici — le constructeur et le journal à double sortie |
offsets.h | uniquement la définition de structure ; l'entrée est mise en place depuis targets/<device>/device_offsets.h |
| Variable | Effet |
|---|
GHOSTLOCK_LOG | destination du journal (défaut /data/local/tmp/.ghostlock.log) ; la sortie va sur stdout et dans le fichier |
GHOSTLOCK_KS_VERBOSE=1 | affiche les adresses de collision et les plages de balayage de KernelSnitch |
GHOSTLOCK_KS_THRESHOLD=<n> | remplace le multiplicateur de seuil de collision |
GHOSTLOCK_MTE=1 | balaye également les tags des pointeurs du noyau (15x plus lent) |
GHOSTLOCK_PHYS_LOAD=0x... | remplace l'adresse de chargement physique du noyau |
PSELECT_SHIFT=<n> | remplace le décalage de la superposition de pile (remplace, n'ajoute pas) |
| Verdict | Signification |
|---|
EXPLOIT OK | root, et su répond |
EXPLOIT OK, SU INSTALL FAILED | Write 1 et Write 2 ont abouti ; seule l'installation a échoué |
EXPLOIT FAILED | les écritures n'ont pas abouti |
ABORTED | l'exécution s'est arrêtée avant de pouvoir faire son rapport — lisez la dernière ligne [!] |
| Chemin | Contenu |
|---|
source/src/preload.c | constructeur : exécute l'exploit, rapporte, s'arrête |
source/src/main.c | l'exploit lui-même (Write 1 / Write 2) |
source/src/su_daemon.c | le binaire su — compilé de manière autonome comme PIE aarch64, non lié dans le .so |
source/src/su_blob.S | intègre ce PIE via .incbin dans le .rodata du .so |
source/src/su_install.c | réécrit le blob, démarre le démon, le sonde |
source/src/target.h | destination de mise en place (ignoré par git) |
targets/<device>/target.h | disposition à la compilation : offsets de structures, constantes physmap, formes de slab et futex |
targets/<device>/device_offsets.h | offsets de symboles globaux depuis kallsyms |
tools/extract_device.py | boot.img → offsets, champs de structures BTF, résultat de la superposition pselect |
tools/preloader_memlayout.py | preloader MediaTek → P0_KERNEL_PHYS_LOAD |
tools/qemu_verify.py | démarre le noyau sous QEMU : mesure la superposition de pile, vérifie la stabilité du mapping linéaire |
tools/device_probe.sh | contrôle préalable depuis un adb shell non privilégié |