Skip to content
KitploitKITPLOIT
OutilsBlog
Soumettre
OutilsBlog
Soumettre

Outils de Hacking, PenTest et Cybersécurité pour votre Arsenal de Sécurité !

Kitploit est un répertoire d'outils de hacking, de cybersécurité et de pentesting. Découvrez les dernières mises à jour des projets pour trouver des vulnérabilités, analyser des systèmes, automatiser les tests et renforcer votre sécurité.

··Flux·Contact·Confidentialité·© 2026 Kitploit

Répertoire d'outils

Catégories

Voir toutes les catégories
Loading categories
ghostlock-pfem10 — GhostLock (CVE-2026-43499) pour OPPO Find X5 Pro (PFEM10) — rétro-ingénierie du watchdog OPlus et du détecteur de heap-spray | Kitploit
Outils/GitHubGitHub/imeiplus/ghostlock-pfem10
Sécurité AndroidEscalade de PrivilègesCriminalistique MémoireAnalyse des VulnérabilitésExploitationRétro-ingénierieSécurité MobileDéveloppement de Charges UtilesExploitation de Binaires
GitHubimeiplus/ghostlock-pfem10

ghostlock-pfem10

GhostLock (CVE-2026-43499) pour OPPO Find X5 Pro (PFEM10) — rétro-ingénierie du watchdog OPlus et du détecteur de heap-spray

il y a 2 joursPas encore vérifié

Populaires

Voir tout →

Découvrez les outils les plus utilisés par notre communauté.

Explorer tous les outils

Parcourez notre collection d'outils

Voir tous les outils →
Partager
Voir le dépôt

GhostLock — OPPO Find X5 Pro (PFEM10)

English · 中文

build

Portage de GhostLock (CVE-2026-43499) pour l'OPPO Find X5 Pro sous ColorOS 16. Atteint un processus enfant avec uid=0 et un kernelsu.ko chargé ; le processus root est intercepté.

Vulnérabilité

CVE-2026-43499 — use-after-free de futex PI. remove_waiter() efface current->pi_blocked_on lorsque current est le requeueur, sur le chemin de rollback -EDEADLK de rt_mutex_start_proxy_lock().

remove_waiter @ 0xffffffc0081ed254 — forme pré-correctif.

Appareil

État

À propos de « le processus root survit » : les exécutions dans evidence/kill.log atteignent uid=0 et chargent kernelsu.ko, et dans l'exécution qui l'a réellement sondé, le processus du gestionnaire KernelSU a survécu 120 s avec kernelsu toujours Live dans /proc/modules. Dans une exécution ultérieure, la même chaîne a laissé les services du framework Android injoignables (Can't find service: package/power/input/phone/wifi) alors que le module était toujours Live. Aucune ligne noyau [ROOTCHECK-*] et aucune charge utile $$sys_call_number@@ n'a jamais été capturée, donc la cause de l'état de l'exécution ultérieure n'est pas attribuée. Voir evidence/notes.md §2.3, §2.4 et §7.

Décalages

task_struct

thread_info

ChampDécalage

cred

Flux d'exploitation```

LT perf leak target task_struct → file W7 stage 1 task+0x778 = V (real_cred → private sprayed page; V observed) W7 stage 2 task+0x780 = V (cred) ★ V12_W7_VALUE=V — THE SAME VALUE, not a new page W7 stage 3 V+8 = 0 (LOCAL repair of the page that was installed, ZERO shape) LT child fexecve(memfd of loader) — no execve of a /data path loader ksud late-load → kernelsu ... Live

root@kitploit:~
**L'étape 2 doit recevoir explicitement la valeur de l'étape 1.** Les étapes 1 et 2 sont deux
processus indépendants, chacun avec son propre spray, donc « écrire la page de creds dans les deux
slots » est un piège : lu naïvement, cela produit `(pageA, pageB)`, et comme
`commit_creds` compare des **pointeurs**, cette paire est divergente même lorsque les deux écritures
aboutissent. Ce n'est pas hypothétique — c'est exactement ce que font les exécutions 3 et 9 :```
run 9   0x778 shot  write value = 0xffffff88679bade0
        0x780 shot  write value = 0xffffff8785d6ade0     <- a different page
run 3   0x778 shot  write value = 0xffffff8787b5ade0
        0x780 shot  write value = 0xffffff881bad2de0     <- a different page

run_bootA.sh déclenche donc l'étape 2 avec V12_W7_VALUE=<valeur observée à l'étape 1> et refuse de la déclencher du tout si cette valeur ne peut pas être récupérée. HOLD doit survivre à l'étape 2, sinon la page de l'étape 1 est libérée et réallouée et « la même valeur » devient un pointeur suspendu. Voir la règle de la même valeur.

Une page par démarrage est réparée. L'étape 3 met à zéro V+8. Avec deux pages différentes, mettre à zéro les deux effacerait l'estampille gid/suid (ci-dessous) et ferait passer une divergence pour un accord, donc le runner ne répare que la page qui a été effectivement installée et s'arrête si les deux valeurs divergent.

La page cred est construite par payload.c : les huit champs id à zéro, les cinq ensembles de capacités complets, et user / user_ns / group_info pointant vers root_user / init_user_ns / init_groups. L'étape 3 existe parce que l'effet de bord de l'écriture écrase toujours cred+8 (gid/suid) du cred qu'il installe.

Sur init_cred — une dichotomie explicite

Deux sections ici se contredisaient auparavant (« jamais le global init_cred » vs « CONTROL=1 reproduit la cellule 2 », et la cellule 2 est init_cred). Les deux affirmations sont vraies pour des rôles différents :

  • Interdit comme cible. Écrire le pointeur init_cred fait que l'effet de bord corrompt init_cred+8 globalement — init_cred est partagé par chaque thread du noyau, et Uid: 0 0 4294967176 0 est précisément cette corruption. Le code refuse ce chemin sauf si V12_ALLOW_INIT_CRED=1 est défini délibérément.
  • Conservé comme la seule paire PROUVÉE cohérente. La chaîne du 09-14 qui a atteint ksud écrivait une adresse fixe (0xffffff802a7e0be0) dans les deux slots, donc real_cred == cred par construction — c'est pourquoi elle a survécu jusqu'à execve. CONTROL=1 la reproduit. C'est un contrôle, pas une configuration sur laquelle bâtir.

fuite perf : PERF_TYPE_SOFTWARE / PERF_COUNT_SW_CPU_CLOCK, PERF_SAMPLE_REGS_INTR, exclude_user=1. Accepter [0xffffff8400000000, 0xffffff90000000), votes ≥ 15 %.

La primitive d'écriture, et son effet de bord

L'UAF est piloté via rb_erase_cached Case 1-left. Cela donne deux écritures, pas une :``` *(write_target) = write_value // the store you aim *(write_value + 0x08) = write_target // unavoidable side effect

root@kitploit:~
`write_value` doit être aligné sur 8 octets avec le bit 0 à zéro — il vaut soit `0`, soit une adresse noyau valide. **C'est pourquoi `g_boot_state` ne peut pas être défini avec cette primitive** : l'octet que vous devez faire devenir `1` a son bit de poids faible forcé à `0` par l'exigence d'alignement, et `write_value` est la même quantité que l'adresse à laquelle l'effet de bord aboutit.

### L'effet de bord écrit dans ce que `write_value` pointe

`write_value` est à la fois *la valeur stockée* et *l'adresse à laquelle l'effet de bord écrit* (à `+8`). Pointez-le vers un objet noyau global et vous corrompez cet objet.

**W7 faisait exactement cela** — visant `write_value` vers l'alias `init_cred` — et c'est visible dans la relecture. Depuis `out/t5_w7_778.txt` :```
shape shift=0 wps=5: in[0]=0xffffff802a7e0be0 (write_value) in[2]=0xffffff8800cdd178 (write_target)
W7[W7] write_target= 0xffffff8800cdd178
Uid:	0	0	4294967176	0

write_value était l'alias init_cred et write_target était child_task+0x778. init_cred+8 est gid/suid, donc l'effet de bord y a stocké 0xffffff8800cdd178 : init_cred.gid = 0x00cdd178 et init_cred.suid = 0xffffff88 = 4294967176 — précisément le 4e champ awk de la ligne Uid: ci-dessus. La mise à zéro de init_cred+8 l'a réparé (out/t5_repair.txt : Uid: 0 0 4294967176 0 → ), ce qui est tout ce que « W7 stage 3 » a jamais été.

Ce chemin est désormais refusé dans le code. V12_W7_INIT_CRED=1 abandonne avec une explication à moins que V12_ALLOW_INIT_CRED=1 ne soit également défini, et les chemins W2/W6/LTC ne retombent plus sur init_cred lorsque la page cred privée est absente — ils abandonnent à la place. Le défaut, et le seul chemin raisonnable, est la page cred pulvérisée.

L'effet de bord lui-même ne peut pas être évité : write_value doit être le pointeur cred, donc cred+8 est toujours écrasé avec la cible d'écriture. Seul son emplacement est un choix — et la réparation est maintenant une mise à zéro locale de cred_page+8 (stage 3), et non une écriture dans un objet global.

Une lecture groups= erronée est un symptôme distinct, pas celui-ci. Il a été observé dans une exécution où gid et egid se relisaient proprement, donc il ne peut pas provenir de l'effet de bord init_cred+8 ; il pointe vers le champ group_info propre au faux cred. Voir evidence/notes.md §10.6.

★ Un atterrissage sur un seul champ laisse la tâche divergente — et c'est un BUG_ON dur

La primitive écrit à exactement une adresse par passe. task+0x778 (real_cred) et task+0x780 (cred) sont deux adresses distinctes, donc toute écriture atterrissant sur 0x778 seul ou 0x780 seul laisse la tâche avec cred != real_cred — un état de divergence.

Sur cette image, cet état est un panic dur, pas un avertissement. commit_creds s'ouvre avec BUG_ON(task->cred != task->real_cred) :``` commit_creds @0xffffffc008186784 0x1867a4 ldr x19, [x20, #0x778] ; old = task->real_cred 0x1867a8 ldr x8, [x20, #0x780] ; task->cred 0x1867ac cmp x8, x19 0x1867b0 b.ne #0xffffffc008186b68 0x186b68 brk #0x800 ; == BUG()

root@kitploit:~
et le noyau est compilé avec **`CONFIG_PANIC_ON_OOPS=y`** (`CONFIG_PANIC_ON_OOPS_VALUE=1`).
`__put_cred @0xffffffc008185530` porte la même famille d'assertions
(`usage != 0` → BUG ; `cred == current->cred` / `current->real_cred` → BUG).

La divergence est donc *latente* — elle ne fait rien tant que la victime se contente de tourner —
jusqu'à ce que **n'importe quel** `commit_creds` se produise sur cette tâche : `setresuid` / `setresgid` /
`setuid` / `setgid` / `capset`, ou **`execve` via `install_exec_creds`**.

> **⛔ Rétracté (2026-09-18 tard) : ce n'est PAS le mécanisme de redémarrage.**
>
> Une révision antérieure de cette section qualifiait la divergence de « principal mécanisme
> candidat pour les redémarrages » et affirmait qu'elle « explique la répartition des formes ». Ce n'est pas le cas,
> et la raison est désormais mesurée plutôt qu'argumentée :
>
> * `commit_creds` prend sa tâche depuis **`current`** — `0x1867a0 mrs x20, sp_el0`.
>   Sa signature est `commit_creds(struct cred *new)` ; il n'y a pas d'argument de tâche.
>   Une divergence n'a donc d'importance que si la tâche qui la **détient** appelle `commit_creds`
>   elle-même.
> * Les exécutions qui redémarraient étaient toutes en `V12_NO_EXEC=1` (indiqué textuellement à
>   `run3_0445.log:18`, `run9_0606.log:20`, `run10_0616.log:24`), donc la victime
>   n'a jamais émis d'`execve` et n'a jamais atteint `commit_creds` du tout.
> * L'exécution 10 n'a eu aucun poke (`grep -c poke` = 0).
> * `exit_creds` met à zéro **les deux** pointeurs avant `put_cred`
>   (`0x185cb8 str xzr,[x19,#0x778]` ; `0x185d24 str xzr,[x19,#0x780]`), donc le
>   `_exit(0)` de la victime **efface** la divergence au lieu de déclencher l'assertion.
>
> ⇒ Dans ces exécutions, la divergence était **inerte**. Le `BUG_ON` est réel, mais c'est une
> mine qui n'a pas explosé. « La forme A ne redémarre jamais » redevient une
> corrélation. Ce que la mine contraint réellement, c'est le **blanchiment**, parce que
> `setresgid`/`setresuid` appellent `commit_creds` eux-mêmes.
>
> **La quantité qui sépare réellement les chaînes est l'égalité des pointeurs, et cela
> signifie que les deux tirs doivent écrire UNE seule valeur** — voir la sous-section suivante.

### ★★★ Les deux tirs doivent écrire la *même* valeur — pas simplement tous deux aboutir

`BUG_ON` compare des **pointeurs**. Deux pages qui portent toutes deux `uid 0` restent deux
objets différents. Les captures rendent la distinction concrète :

| chaîne | tir 0x778 | tir 0x780 | pointeurs |
|---|---|---|---|
| ancienne (`t5loop.sh MODE=CRED`) | `in[0]=0xffffff802a7e0be0` | `in[0]=0xffffff802a7e0be0` | **égaux** → ksud chargé, manager vivant 120 s |
| nouvelle (`run_bootA.sh`) | `0xffffff88679bade0` (exécution 9) | `0xffffff8785d6ade0` | **différents** → divergence même avec les deux aboutis |

`tools/t5loop.sh` applique **un seul** `$ENVV` à **chaque** offset, donc `MODE=CRED`
rendait les deux tirs identiques *par construction*. `run_bootA.sh` déclenchait l'étape 5 et
l'étape 6 chacune avec un `$extra` vide, donc chacune pulvérisait sa **propre** page.

⇒ L'exigence est **« les deux tirs écrivent la même valeur »**. `run_bootA.sh` l'impose
désormais (`SAME_VALUE=1`, la valeur par défaut) : l'étape 6 réutilise verbatim le
`write_value` observé à l'étape 5, et **refuse de tirer du tout** s'il ne peut pas récupérer cette
valeur — car tirer construirait une paire divergente.

⚠ `HOLD` doit survivre au second tir. Si le processus enfant PIN du premier tir meurt en premier,
la page est libérée et réallouée et « même valeur » devient un pointeur pendant.
Le `HOLD=20` par défaut est **trop court** ; utilisez `HOLD=600`. C'est désormais la valeur *par défaut*
chaque fois que `SAME_VALUE=1` — l'ancien 20 s inconditionnel signifiait que la configuration
par défaut était elle-même le piège — et un `HOLD` court explicite avec
`SAME_VALUE=1` avertit désormais bruyamment au lieu de produire silencieusement un pointeur pendant.

⚠ `CONTROL=1` ne modifiait auparavant **que l'étape 5**, produisant ainsi
`(init_cred, page fraîche)` — une paire divergente — alors que ce fichier prétendait qu'il
reproduisait la cellule 2. Corrigé : il règle désormais les deux tirs sur `init_cred`. (Le coût de la cellule 2
demeure : l'effet de bord corrompt `init_cred+8` globalement, ce qui est ce que
`Uid: 0 0 4294967176 0` représente.)

**Conséquences pour tout ce qui veut blanchir le credential**
(`setresgid` + `setresuid`, pour échanger la page pulvérisée contre un vrai `struct cred`) :

- Le mécanisme est réel et vérifié — `commit_creds` écrit `x21` dans **les deux**
  `task+0x778` et `task+0x780` (`0x186998` / `0x1869a0`), donc un seul appel répare la
  séparation définitivement ; `prepare_creds @0xffffffc008186070` est
  `kmem_cache_alloc(cred_jar)` + `memcpy(new, task->cred, 0xA8)` +
  `security_prepare_creds(...)`, et 147/149 figurent tous deux sur la liste d'exemption du garde.
- **Mais sa précondition est l'opposé de « sauter le tir 0x778 ».** Le blanchiment
  lui-même appelle `commit_creds`, il ne doit donc être émis que lorsque **les deux** pointeurs
  détiennent déjà la même valeur.
- `V12_LAUNDER=1` est conditionné par **deux** choses, et la première n'est pas une observation :
  1. **`V12_W7_SAME_VALUE=1`** — le fait de *provenance* que les deux tirs ont reçu
     la même valeur. Sans primitive de lecture, l'identité des pointeurs est inobservable, donc
     cela ne peut pas être remplacé par une meilleure vérification en espace utilisateur ; cela doit être déclaré.
  2. `consistent=1` — la vue `0x780` (`getuid()`) concordant avec la vue `0x778`
     (`/proc/self/status` `Uid:`). **Nécessaire mais pas suffisant à lui seul** :
     deux pages distinctes portant toutes deux `uid 0` se lisent égales alors que les pointeurs diffèrent
     — ce qui est exactement le cas que le runner fabriquait auparavant. Étant donné (1), cela
     devient suffisant : concordance + même valeur ⇒ les deux ont abouti sur la même page.
  Si l'une des vérifications échoue ⇒ refus, et le tableau à quatre cas entre dans les preuves.
  La ligne de rapport LT affiche les deux vues (`uid=` / `real_uid=` / `consistent=`) plus
  `same_value_declared=` afin que l'état soit lu, et non déduit.

### ★ Instrument 1 — l'effet de bord est un TAMPON visant la cible

`*(write_value + 8) = write_target`, et `cred+8` / `cred+0xc` sont `gid` / `suid`,
donc un seul store de 8 octets s'étend sur les deux :```
cred.gid  = low32(write_target)
cred.suid = hi32(write_target)

C'est une mesure, pas un modèle. out/t5_w7_778.txt contient write_target = 0xffffff8800cdd178 et Uid: 0 0 4294967176 0, où 4294967176 = 0xffffff88 = hi32(write_target) ; notes.md §11 consigne l'autre moitié, init_cred.gid = 0x00cdd178 = low32(write_target).

Deux usages :

  1. C'est l'oracle d'atterrissage pour task+0x778. /proc/<pid>/status lit real_cred = task+0x778 — exactement le cred qui vient d'être installé — donc l'empreinte est directement lisible depuis l'espace utilisateur. Lisez-la avant l'étape 3 : la réparation met à zéro cred+8 et l'efface (t5_repair.txt de notes.md §11 lit 4294967176 avant une réparation réussie et 0 après).
  2. C'est une seconde raison indépendante pour laquelle la barrière de blanchiment peut attraper deux pages différentes. La moitié uid seule ne le peut pas : toute page avec uid 0 lit 0, donc deux pages distinctes rapportent toutes deux « cohérent ». Mais low32(T+0x778) et low32(T+0x780) diffèrent exactement de 8, donc avec deux pages (depuis ) et (depuis ) sont en désaccord — et compare le gid aussi bien que l'uid.

⇒ V12_W7_SAME_VALUE est la seconde barrière, pas la seule. Elle compte quand même : l'empreinte ne discrimine que si les deux effets de bord se sont déclenchés, donc la règle de même-valeur ferme ce trou résiduel. Et notez ce qu'est une barrière — un détecteur, pas un empêcheur. Elle ne peut que refuser ; elle laisse la tâche divergente pour le reste du boot. La règle de même-valeur est ce qui rend la paire correcte, ce que l'ancienne chaîne avait et ce qui est nécessaire pour atteindre execve du tout.

★ Instrument 2 — probe_state n'est PAS un critère d'atterrissage

Il s'est trompé trois fois dans ce projet : W1 a atterri sur le global et a rapporté R ; le D du run 12 visait un global plutôt qu'un cred ; et le R du run 11 a été écrit dans un tableau comme s'il s'agissait d'un atterrissage (run11_w778r1_miss.txt et w7_w7781.txt du run 7 sont isomorphes ligne pour ligne — tous deux probe_state = R, probe_done = 0). Utilisez un oracle par cible :

run_bootA.sh utilise désormais l'empreinte pour l'étape 1 — ce qui rend les retentatives ROUNDS>1 sur task+0x778 significatives, puisqu'un round échoué est lisible au lieu d'être inféré — et il ne déclenchera pas l'étape 2 à moins que l'étape 1 ait atterri.

⛔ Dites « champ awk », jamais « 3e champ ». uid_line imprime aussi le label (Uid: 0 0 4294967176 0), donc $1 d'awk est "Uid:" et les quatre valeurs d'id sont $2..$5 : $2=uid $3=euid $4=suid $5=fsuid. L'empreinte se situe à cred+8, c'est-à-dire gid (low32) et suid (hi32) — donc c'est Gid: $2 et Uid: . L'appeler « le troisième champ » (ce qui compte les , et c'est ainsi que §11 le formule) invite le code à lire , qui est = sur le faux cred et ne peut jamais égaler . Ce décalage d'un était présent ici : a renvoyé « pas d'empreinte » pour un tir qui a atterri, donc l'étape 2 ne s'est jamais déclenchée et la barrière de blanchiment a refusé pour toujours — , car « pas d'empreinte » est aussi le résultat normal d'un véritable raté.

Un critère qui n'est jamais testé contre un échantillon connu-positif n'est pas un critère, c'est une supposition — et cette classe de défaillance (ce décalage d'un, probe_state, dmesg -w, le klog.host vide, la relecture blanche) se présente toujours comme « rien ne s'est passé », ce qui est aussi un résultat expérimental légitime. Donc la vérification est désormais défendue deux fois :

  • stamp_selftest() s'exécute en préflight et fait exit 9 en cas d'échec, en pilotant les mêmes fonctions d'extraction que celles utilisées par la barrière contre les valeurs mesurées de out/t5_w7_778.txt (write_target = 0xffffff8800cdd178 → Uid $4 = 4294967176, Gid $2 = 13488504) plus des échantillons négatifs et illisibles. Un auto-test qui réimplémente la vérification ne prouve rien, donc l'extraction des champs est factorisée dans uid_suid_field / gid_gid_field.
  • tools/test_stamp_criterion.sh — la même chose en tant que test de régression autonome, extrayant les vraies fonctions de .

stamp_ok() renvoie trois états, car « ne peut pas lire » n'est pas « pas d'empreinte » (cette confusion est ce qui a fait ressembler le run 13 à « aucun changement ») : 0 = présent, 1 = lisible et pas d'empreinte, 2 = ILLISIBLE. Et quand il renvoie 1 alors que probe_state = D, le runner imprime ⛔ ORACLE INCONSISTENT — « allez vérifier le critère » — au lieu du message « n'a pas atterri », qui envoie l'opérateur à un endroit complètement différent (un nouveau boot, ou une chasse au taux de réussite).

Dérivation complète : evidence/2026-09-18-divergence-is-latent.md et evidence/2026-09-18-cred-launder-verification.md (le §2.3 de ce dernier est rétracté sur place). L'auto-vérification du chevauchement de forme d'écriture est close hors ligne : les mots de forme vivent dans la grille fd_set sur la pile du noyau tandis que l'effet de bord atterrit à l'intérieur de la page pulvérisée, donc les deux ne peuvent pas se chevaucher dans l'une ou l'autre forme.

Chemins de détection

Trois rapporteurs indépendants. Aucun n'est un repli pour un autre, et seul le chemin 1 peut tuer la tâche appelante.

Le chemin 2 se déclenche sur execve, pas sur un changement de credential, et c'est un chemin de code distinct du chemin 1. Il rapporte via kevent_send_to_user, donc ce qui se passe ensuite est la décision d'un démon en espace utilisateur, pas celle du noyau.

La vérification porte sur le chemin de l'image en cours d'exec, donc il ne suffit pas de charger en memfd la charge utile du loader : si le loader lui-même est exec depuis /data/local/tmp, ce premier execve rapporte déjà. d_path() sur un memfd est /memfd:…, donc le loader doit lui-même être exec via un memfd — V12_EXEC_MEMFD est désormais activé par défaut pour exactement cette raison. L'ancien comportement est visible dans RUN 4 :``` LT child exec /data/local/tmp/glx12 (4 args) <- execve of a /data path while uid=0 LT child memfd loaded 5014624 bytes (fd=5) <- memfd only protected the second image

root@kitploit:~
Marqueurs greppables pour le chemin 2 :```
[ROOTCHECK-EXEC-INFO]:common %s result %s      with  "execve_report" / "execve_block"
%d,path@@%s                                    kevent payload fragment

Parce que le chemin 2 et le chemin 3 ne rapportent que via kevent, « aucune occurrence de [ROOTCHECK-*] dans le journal du noyau » n'exclut pas que l'un ou l'autre se soit déclenché. Cette inférence nécessite le récepteur en espace utilisateur, que nous n'avons pas localisé.

Watchdog — oplus_security_guard.ko

Cache sys_enter :```asm ldrsw x8, [x1, #0x118] ; regs->syscallno mrs x9, sp_el0 ; current ldr x10, [x9, #0x780] ; cred str x8, [x9, #0xdf8] ldr w8, [x10, #4] → str x8, [x9, #0xe00] ; uid ldr w8, [x10, #0x14] → str x8, [x9, #0xe08] ; euid ldr w8, [x10, #8] → str x8, [x9, #0xe10] ; gid ldr w8, [x10, #0x18] → str x8, [x9, #0xe18] ; egid

root@kitploit:~
Vérification de `sys_exit` :```asm
ldr   x0,  [x8, #0xe00]         ; cached uid
cbz   x0, #0x48c                ; cached uid == 0 → return
adrp  x9, #0 ; ldrb w9, [x9]    ; g_boot_state
tbnz  w9, #0, #0x48c            ; is_unlocked → return
ldr   x9,  [x8, #0x780]         ; cred
ldr   w3,  [x8, #0xdf8]         ; cached syscallno
cmp   x0, w10 ; b.hi #0x468     ; uid descending → kill path
                                ; euid / gid / egid, same shape
ldr   x9,  [x8, #8]             ; addr_limit
cmp   x9,  #0x8000000001
b.lo  #0x48c                    ; addr_limit != KERNEL_DS → return
sub   w9,  w3, #0x8f            ; syscallno - 143
cmp   w9,  #0x47
b.hi  #0x4a0                    ; outside 143..214 → kill
ldrsw x12, [x10, x9, lsl #2]    ; jmp table @ .rodata+0
br    x11
0x48c: ret
0x4a0: bl oplus_root_check_succ ; printk + kevent_send_to_user
       bl oplus_root_killed     ; printk + do_exit(SIGKILL)

Note sur le flux de contrôle (importante pour l'ordonnancement de l'exploit). Les quatre comparaisons d'arêtes descendantes branchent directement vers 0x468, la répartition par numéro de syscall — elles ne passent pas par la barrière addr_limit. 0x454–0x464 n'est atteint que lorsqu'aucun id n'a descendu. Ainsi la répartition est entrée quand soit un id a descendu soit addr_limit == KERNEL_DS ; elle n'est pas conditionnée par addr_limit.

Conséquences :

  • Le tueur se déclenche sur le syscall pendant lequel les identifiants ont changé — celui dont le sys_enter avait encore mis en cache l'ancien uid. Si la tâche est déjà uid=0 lorsqu'un syscall entre (0x400 cbz), le hook retourne et reste aveugle à partir de ce moment.
  • Par conséquent, un changement d'identifiants est survivable sans toucher au module : laissez une autre tâche effectuer l'écriture pendant que la victime tourne en espace utilisateur, ou faites passer le changement par l'un des 12 syscalls exemptés. Voir delivery/外部建议评审_2026-09-18.md.

g_boot_state — 1 octet .data..ro_after_init, défini à l'init du module depuis verified_bootstate via strstr. is_unlocked() = LDRB + RET.

Les écritures en VA du module déclenchent une faute (CONFIG_STRICT_MODULE_RWX=y) — utilisez l'alias physmap 0xffffff80….

Charge utile du rapport : $$sys_call_number@@%d, $$set_id_flag@@%d, $$addr_limit@@%lx, $$enforce@@%d.

Syscalls exemptés — .rodata+0, indices 143–214

Les 60 entrées restantes → rapport + kill.

Ne bloquez pas un thread sur sendmsg (211) pendant que ses identifiants changent. Il n'est pas dans la table, donc le thread est signalé et tué. Les seuls syscalls dans lesquels il est sûr d'être bloqué à cette fin sont les douze ci-dessus : setregid, setgid, setreuid, setuid, setresuid, setresgid, connect, getsockname, setsockopt, shutdown, readahead, brk.

Correction (2026-09-18). Une révision antérieure de cette table étiquetait chaque entrée un cran plus bas que le véritable numéro de syscall arm64 (146 était appelé setresuid ; c'est setuid — setresuid est 147). Les numéros étaient toujours corrects ; seuls les noms étaient faux. Les noms sont désormais résolus depuis sys_call_table @ 0xffffffc00a13d8c0 dans l'image du noyau de cet appareil. En particulier sendmsg (211), munmap (215), getsockopt (209) et getpeername (205) ne sont pas exemptés — bloquer un thread dans l'un de ceux-ci pendant que ses identifiants changent est un kill, pas un pass. Régénérez avec tools/gen_exempt_table.py.

Détecteur de Heap-Spray — oplus_secure_harden.ko

root@kitploit:~
`entry_handler_setxattr` → `oplus_heapspray_check(7)`, `entry_handler_cpuinfo` → `(6)`, tous deux inconditionnels.

`oplus_heapspray_check` — compteurs `heapspary_ip4` / `ip6` / `xttr` / `cpuinfo`, delta `ktime_get_real_ts64`, 7 sites de rapport (`snprintf` + `printk` + `kevent_send_to_user`), conditionnés par `verified_bootstate`.

### Contournement

| Primitive | |
|---|---|
| `setsockopt` optname 46 `MCAST_JOIN_SOURCE_GROUP` | non filtré |
| `setsockopt` niveau `SOL_IPV6` (41) | non filtré si le filtre lit `level` |
| `setxattr` | toujours compté |
| `/proc/cpuinfo` | toujours compté |
| `socket()` / `socketpair()` | non hooké |
| `sendmsg`, `pipe`, `memfd`, `add_key`, `io_uring`, mmap | non hooké |

## Config```
CONFIG_CFI_CLANG=y
CONFIG_PTR_AUTH=y
CONFIG_SHADOW_CALL_STACK=y
CONFIG_STRICT_MODULE_RWX=y
CONFIG_STATIC_USERMODEHELPER=y
CONFIG_STATIC_USERMODEHELPER_PATH=""
CONFIG_SET_FS=y
CONFIG_RANDOMIZE_BASE=y
CONFIG_RANDOMIZE_MODULE_REGION_FULL=n
CONFIG_UNMAP_KERNEL_AT_EL0=y
CONFIG_ARM64_VA_BITS=39
CONFIG_ARM64_SW_TTBR0_PAN=y
CONFIG_SLAB_FREELIST_RANDOM=y
CONFIG_SLAB_FREELIST_HARDENED=y
CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y
CONFIG_RANDOM_KMALLOC_CACHES=n
CONFIG_USER_NS=n
CONFIG_NF_TABLES=n
CONFIG_SYSVIPC=n
CONFIG_ANDROID_BINDER_IPC=y
CONFIG_KASAN=y

perf_event_paranoid = -1

Compilation

NDK r28c. -O1 / API 26 / -D__ARM=1 sont fixes — ils préservent la géométrie de la pile d'appel de reclaim (calibration delta=0). Modifier l'un d'entre eux nécessite une recalibration sur l'appareil.```bash export ANDROID_NDK_HOME=/path/to/android-ndk-r28c make # → exploit_guard ./build.sh # same, with NDK auto-detection

root@kitploit:~
Manuel :```bash
"$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang" \
  -D__ARM=1 -O1 -Wall -Wextra -pthread -Isrc/core -Isrc/devices/pfem10 \
  -o exploit_guard src/core/exploit.c

CI builds on every push (.github/workflows/build.yml, Ubuntu + NDK r28c, artifact exploit_guard).

Configuration```bash

adb push exploit_guard /data/local/tmp/e adb shell chmod 755 /data/local/tmp/e adb shell /data/local/tmp/e

root@kitploit:~
## Fichiers```
src/core/                 exploit.c  payload.c  payload.h  fdset_map.h
src/lib/                  KernelSnitch — kernelsnitch.h  futex_hash.h  timeutils.h  utils.h
src/devices/pfem10/       pfem10_target.h
model/                    model.c — host-side rtmutex chain-walk model
tools/                    kdis.py  kdis_ko.py  kdis_ko_reloc.py  gen_guard_disasm.py
                          gen_exempt_table.py  mod_layout.py  sct_dump.py
                          find_task_off.py  slide_resolve.py
                          test_stamp_criterion.sh   regression test for the 0x778
                                                    landing criterion (run it after
                                                    touching uid_line/gid_line)
artifacts/                guard_post_handler.s   kill chain, relocations resolved
                          guard_relocs.txt       raw .text relocation dump
                          guard_disasm.txt       guard + heap-spray detector
                          guard_exempt_table.txt 72-slot jump table, real names
evidence/                 kill.log  notes.md — device captures and their limits
Makefile  build.sh        exploit build (-O1, API 26, NDK r28c)
run.sh                    device-side run orchestration (retry across reboots)
.github/workflows/        build.yml — cloud build + artifact

Preuves

evidence/kill.log — transcriptions verbatim de adb shell de quatre exécutions root : la chronologie complète, le moment où l'uid de la tâche cible devient 0, le chargement de kernelsu.ko, et l'état après. Lisez d'abord le bloc d'en-tête : il liste ce que le fichier ne contient pas et pourquoi.

evidence/notes.md — le côté noyau. Adresses des modules et quels canaux /proc fonctionnent dans quel état SELinux ; la dérivation complète de g_boot_state incluant la clé strstr ; la table d'exemption corrigée ; la recette de capture qui produirait la moitié noyau manquante ; et une liste de ce qui reste ouvert.

evidence/2026-09-18-bootA/ — la première exécution sur appareil de la conception actuelle, 13 démarrages. Les redémarrages sont ordonnés (bootreason=reboot) et aucune ligne de panic n'a jamais été capturée — mais lisez cela comme un échantillon de un, pas de treize. Sur les quatre exécutions qui ont redémarré, deux ont enregistré un klog.host vide, une a enregistré le journal du démarrage suivant, et une seule fenêtre peut plausiblement encadrer son propre redémarrage. De même, le probe_state et la relecture de la victime d'une exécution sont tous deux vides (l'appareil avait déjà disparu), donc elle ne porte aucune information sur le fait que son écriture ait abouti — un champ vide n'est pas « aucun changement ». Le répertoire documente aussi l'erreur de méthodologie qu'il vaut la peine de connaître : dmesg -w est un no-op sur cet appareil (toybox vide une fois puis quitte), donc le journal noyau d'une exécution antérieure ne contenait que l'historique d'avant capture — « pas de [ROOTCHECK-*] » n'était la preuve de rien. evidence/notes.md §6 contient la recette corrigée de poll-and-stream-to-host.

evidence/2026-09-18-cred-launder-verification.md — vérification de la proposition de blanchiment d'identifiants par rapport au désassemblage de cette image elle-même (pas au code source 5.10 générique) : le double stockage de commit_creds, l'allocation prepare_creds et la mesure sizeof(struct cred) = 0xA8, le risque de divergence BUG_ON(cred != real_cred) ci-dessus, l'auto-vérification de chevauchement de forme d'écriture fermée, et l'audit de couverture des preuves des 13 démarrages. §2.3 est rétracté sur place — la divergence est latente, pas le mécanisme de redémarrage.

evidence/2026-09-18-divergence-is-latent.md — la vérification du round 2. commit_creds prend sa tâche depuis current (0x1867a0 mrs x20, sp_el0), exit_creds met à zéro les deux pointeurs avant put_cred, et les captures brutes montrent que l'ancienne chaîne écrivait une valeur identique (0xffffff802a7e0be0) dans les deux emplacements tandis que la nouvelle chaîne écrivait deux pages différentes. Le critère est donc l'égalité des pointeurs — « les deux tirs écrivent la même valeur », pas « les deux tirs aboutissent ».

postreboot_forensics.sh — forensique de redémarrage qui ne dépend pas du poller. Le critère est une condition unique : CONFIG_PSTORE_CONSOLE=y fait que panic() écrit la fin de la console dans ramoops à kmsg_dump(KMSG_DUMP_PANIC) — avant tout reset — donc que la machine redémarre ensuite ou se bloque n'a pas d'importance. Récupère /sys/fs/pstore/, filtre pour kernel BUG / __put_cred / cred.c, et affiche la chaîne de raison de démarrage (les entrées d'historique ont porté des suffixes reboot,shell / bootloader / reboot,edl, donc la raison distingue un acteur là où l'époque ne le fait pas).

⚠ Ne lisez pas « bootreason=reboot propre » comme « pas de panic ». Sur QCOM, un assert de watchdog SoC est réinitialisé via le bloc PMIC PON, donc panic → panic_timeout=-1 → hang → watchdog → PMIC reset → clean bootreason est une chaîne cohérente qui est indiscernable d'un reset matériel sur les preuves dont nous disposons. Le propre total_17_dump_0_pmic_17 de ce dépôt attribue les 17 redémarrages anormaux à pmic, ce qui est exactement la forme normale du watchdog, pas la preuve de « pas le noyau ». bootreason ne restreint rien ici ; ramoops est le seul critère.

Deux préconditions, sinon le verdict du script est nul (铁律 8 — une conclusion sans signal exige que le canal soit d'abord prouvé accessible) :

  • Troisième état requis. /sys/fs/pstore/* est réservé à root, donc sous Enforcing, adb pull et cat échouent tous deux — et « ne peut pas lire » produit la même sortie que « l'a lu et c'était vide ». Un script à deux états affiche « pstore est VIDE ⇒ panic réfuté » depuis un canal qu'il n'a jamais ouvert. Le script émet donc CHANNEL UNREACHABLE (ls a échoué, ou toutes les entrées connues ont échoué à lire plutôt que de ne pas exister) et rapporte getenforce en parallèle.
  • Test nul d'abord. Un adb reboot propre suivi d'une récupération immédiate. Si un redémarrage connu comme valide ne produit rien de lisible, le canal n'est pas prouvé et tout « pstore vide » ultérieur n'est pas une preuve. L'ordre compte : l'appareil déplace et supprime l'enregistrement peu après le démarrage, donc la séquence est reboot → obtenir Permissive (W1) → exécuter le script immédiatement.

run_bootA.sh — orchestration pour ce seul démarrage, dans l'ordre qui compte (0x778 → 0x780 avec la même valeur → réparation locale du cred qui a été réellement installé → confirmer → seulement ensuite poke). ADB=/SER=/BIN_LOCAL= surchargeables ; SAME_VALUE=1 (par défaut) impose la règle de même valeur, LAUNDER=1 active le blanchiment conditionné, HOLD=600 est requis pour la séquence de même valeur.

Les tentatives sont réparties par étape (R5/R6), car les deux étapes ont des profils de risque opposés :

R5 vaut par défaut ROUNDS, R6 vaut 1.

L'exécution de blanchiment recommandée — le chemin par défaut de page pulvérisée, pas CONTROL=1 :```bash LAUNDER=1 R5=3 R6=1 HOLD=600 CHAINWAIT=6000 NODRAIN=1 WATCH=180 ./run_bootA.sh

root@kitploit:~
`CONTROL=1` produirait également une paire cohérente, mais en écrivant le pointeur `init_cred`, dont l'effet de bord corrompt `init_cred+8` **globalement** — et « le framework meurt » fait partie des choses surveillées, donc transporter une faute à l'échelle du dispositif dans l'arrière-plan de la mesure brouille exactement la lecture que cette exécution existe pour prendre. Le chemin de la page pulvérisée ne coûte que « les deux tirs doivent toucher », ce à quoi sert `R5=3`. `CONTROL=1` reste la seule paire cohérente *prouvée* et sert de contrôle, non de configuration recommandée.

**Quelle page a été installée se lit à partir de la ligne inconditionnelle.** `run_w7` imprime la valeur d'écriture sur deux lignes, et une seule d'entre elles est inconditionnelle :```
L1793  W7[..] write value = private cred page 0x..   — spray path only
L1802  W7[..] write_value = 0x..                     — after the if/else, ALL paths

Le runner utilisait la première formulation, donc avec CONTROL=1 l'extraction revenait vide, if [ -n "$CRED" ] sautait la réparation, et le terme [ -n "$CRED" ] propre à la conjonction de même valeur le maintenait à 0 — la porte de blanchiment aurait refusé indéfiniment. Un no-op silencieux sur les deux plans, à cause d'une regex qui ne reconnaissait qu'un des deux sites d'impression. Elle et l'extraction write_target (l'entrée du critère de stamp) passent désormais par wv_from/wt_from, qui sont exercés par le test de régression aux côtés du critère lui-même.

Les deux flux démarrent avant la chose qu'ils mesurent. uid.stream tourne depuis l'étape 1 ; cred.stream démarre au poke, pas après le watch — le poke libère l'enfant dans sa boucle de rapport NO_EXEC, qui dure 240 × 0,5 s = 120 s puis _exit(0) (exploit.c : "LT child NO-EXEC mode done (120s)"), donc l'ancien placement à t+~135 s commençait l'échantillonnage après que l'enfant avait déjà disparu, exactement dans la fenêtre pour laquelle l'instrument existe. Le runner refuse également de continuer si stamp_selftest() échoue, et affiche ORACLE INCONSISTENT plutôt que "did not land" lorsque le stamp et probe_state sont en désaccord.

artifacts/guard_post_handler.s — la chaîne de kill avec les relocations renseignées. adrp x9, #0 dans les listings plus anciens est .data..ro_after_init ; bl #0x4ac est oplus_root_check_succ. Régénérez avec tools/gen_guard_disasm.py après avoir récupéré les modules du vendeur depuis votre propre appareil.

tools/kdis_ko.py — RELA mis en correspondance par sh_info ; sur ces builds les relocs .text résident dans .rela.text.<func>, donc la recherche par nom ne retourne rien.

Related

Project
JoinChang/ghostlock-oneplusimplémentation de référence ; compact waiter 5.10
NebuSec CyberMeowfiarecherche originale GhostLock

License

GPL-3.0 — voir LICENSE.

Télécharger l’outil
AppareilOPPO Find X5 Pro (PFEM10)
SoCSM8450 / Adreno 730
OSColorOS 16.0.3.520 (CN01)
Noyau5.10.236-android12-9-o-gaf2075ad2c06
Bootloaderverrouillé, vert
VA_BITS39 — KIMAGE_TEXT_BASE = 0xffffffc008000000
Étape
Déclenchement du waiter compact (CMP_REQUEUE_PI → EDEADLK)fonctionne
Fuite de task_struct (perf)fonctionne
Écriture PI (8 octets ; valeur = 0 ou une adresse noyau valide)fonctionne
task+0x778 ou task+0x780 seul → Uid=rootfonctionne — mais un atterrissage sur un seul champ laisse la tâche divergente, et c'est un BUG_ON dur latent. Voir le risque de divergence
Les deux champs écrits avec UNE seule valeur (une paire cohérente)❌ jamais produit avec une page pulvérisée. Observé uniquement avec l'alias global init_cred (09-14, CONTROL=1). Le runner l'impose désormais (SAME_VALUE=1) ; non exécuté sur l'appareil
Blanchiment des identifiants (setresgid + setresuid)implémenté derrière V12_LAUNDER=1 ; non exécuté sur l'appareil
kernelsu.ko chargéfonctionne
Le processus root survit⚠ non établi — voir ci-dessous
Mécanisme de redémarrage❌ non établi. Un candidat (la divergence) est désormais exclu ; voir ci-dessous
probe_state comme critère d'atterrissage❌ incorrect — ne pas utiliser. Trois contre-exemples ; voir le tableau ci-dessous
Canal de panique pstore/ramoops⚠ l'instrument existe ; canal jamais validé (pas encore de test nul)
« La victime tourne en pur espace utilisateur »⚠ aucune lecture pour l'instant — uid.stream enregistre désormais utime/stime/nvcsw pour pouvoir le vérifier
double écriture en une passe côté pi⚠ non établi ; pi.pc/pi.left sont codés en dur à 0 dans fdset_map.h
Chemin A (UMH / modprobe_path)STATIC_USERMODEHELPER_PATH=""
ChampDécalage
real_cred / cred0x778 / 0x780
syscallno mis en cache0xdf8
uid / euid / gid / egid mis en cache0xe00 / 0xe08 / 0xe10 / 0xe18
flags
0x0
addr_limit0x8
ttbr00x10
preempt_count0x18
ChampDécalageChampDécalage
uid0x4cap_inheritable0x28
gid0x8cap_permitted0x30
suid0xccap_effective0x38
sgid0x10cap_bset0x40
euid0x14cap_ambient0x48
egid0x18
fsuid / fsgid0x1c / 0x20
Uid: 0 0 0 0
getgid()
cred
status_gid
real_cred
lt_cred_ids_agree()
cibleoracle d'atterrissage
task+0x778Uid: 4e champ awk = hi32(write_target) et Gid: 2e champ awk = low32(write_target) — l'empreinte ci-dessus ; à lire avant l'étape 3
task+0x780le getuid() de la victime elle-même
global selinux_enforcinggetenforce
probe_state❌ pas un critère. Au mieux un indice sur la chaîne ; jamais une preuve qu'une écriture a atterri
$4
valeurs
notes.md
$3
euid
0
hi32(write_target)
stamp_ok()
sans aucune erreur nulle part
run_bootA.sh
#HookDéclencheurAction
1oplus_root_check_post_handler, tracepoint sys_exitun id a descendu, ou addr_limit == KERNEL_DSoplus_root_killed → printk + do_exit(SIGKILL) ; et oplus_root_check_succ → kevent_send_to_user
2oplus_exe_block_ret_handler, sys_exit mais uniquement pour execve (221)d_path(mm->exe_file) commence par /data, /data/local/tmp, /data/nativetest, /data/nativetest64oplus_RWO_root_check → printk + kevent_send_to_user (pas de do_exit)
3kretprobes oplus_secure_hardensetsockopt optname ∈ {41,42,48}, setxattr, /proc/cpuinfo, rechargement de la politique SELinuxoplus_heapspray_check → kevent_send_to_user
143 setregid144 setgid145 setreuid146 setuid
147 setresuid149 setresgid203 connect204 getsockname
208 setsockopt210 shutdown213 readahead214 brk
kretprobeHooksFiltre
socket_kretprobeip_setsockoptregs[1] ∈ {41, 42, 48}
socket_ip6_kretprobedo_ipv6_setsockoptregs[1] ∈ {41, 42}
cpuinfo_kretprobecpuinfo_open—
setxattr_kretprobesetxattr—
sepolicy_reload_kretprobespolicy_reload—
ldr w8, [x1, #8] ; regs[1]
cmp w8, #0x29 ; 41 IP_MSFILTER
b.eq #0xd58
cmp w8, #0x30 ; 48 MCAST_MSFILTER
b.eq #0xd60
cmp w8, #0x2a ; 42 MCAST_JOIN_GROUP
b.ne #0xd68 ; else → return, no call
bl oplus_heapspray_check
étapesécurité des tentatives
R5étape 5, task+0x778sûr — un raté n'installe rien, et le critère d'estampille rend un tour échoué lisible, donc un autre tir n'est qu'une autre tentative. R5=3 fait passer le taux de réussite par tir de ~p à ~1−(1−p)³.
R6étape 6, task+0x780pas sûr, et pas nécessaire — il ne se déclenche qu'après que l'étape 5 a abouti, donc une nouvelle tentative tire sur une tâche déjà divergente : une autre chance d'atterrir sur une seconde page différente, sans avantage, puisqu'un atterrissage complète la paire. Gardez-le à 1.