
GhostLock (CVE-2026-43499) pour OPPO Find X5 Pro (PFEM10) — rétro-ingénierie du watchdog OPlus et du détecteur de heap-spray
English · 中文
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é.
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.
À 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.
task_struct
thread_info
| Champ | Décalage |
|---|---|
cred
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
**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.
init_cred — une dichotomie expliciteDeux 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 :
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.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 %.
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
`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ùgidetegidse relisaient proprement, donc il ne peut pas provenir de l'effet de bordinit_cred+8; il pointe vers le champgroup_infopropre au faux cred. Voirevidence/notes.md§10.6.
BUG_ON durLa 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()
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 :
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).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.
probe_state n'est PAS un critère d'atterrissageIl 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_lineimprime aussi le label (Uid: 0 0 4294967176 0), donc$1d'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-à-diregid(low32) etsuid(hi32) — donc c'estGid:$2etUid:. 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.
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
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é.
oplus_security_guard.koCache 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
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 :
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.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.
.rodata+0, indices 143–214Les 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'estsetuid—setresuidest147). Les numéros étaient toujours corrects ; seuls les noms étaient faux. Les noms sont désormais résolus depuissys_call_table@0xffffffc00a13d8c0dans l'image du noyau de cet appareil. En particuliersendmsg(211),munmap(215),getsockopt(209) etgetpeername(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 avectools/gen_exempt_table.py.
oplus_secure_harden.ko`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
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
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).
adb push exploit_guard /data/local/tmp/e adb shell chmod 755 /data/local/tmp/e adb shell /data/local/tmp/e
## 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
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=rebootpropre » comme « pas de panic ». Sur QCOM, un assert de watchdog SoC est réinitialisé via le bloc PMIC PON, doncpanic → panic_timeout=-1 → hang → watchdog → PMIC reset → clean bootreasonest une chaîne cohérente qui est indiscernable d'un reset matériel sur les preuves dont nous disposons. Le propretotal_17_dump_0_pmic_17de 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 ».bootreasonne 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) :
/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.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
`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.
| Project | |
|---|---|
| JoinChang/ghostlock-oneplus | implémentation de référence ; compact waiter 5.10 |
| NebuSec CyberMeowfia | recherche originale GhostLock |
GPL-3.0 — voir LICENSE.
| Appareil | OPPO Find X5 Pro (PFEM10) |
| SoC | SM8450 / Adreno 730 |
| OS | ColorOS 16.0.3.520 (CN01) |
| Noyau | 5.10.236-android12-9-o-gaf2075ad2c06 |
| Bootloader | verrouillé, vert |
| VA_BITS | 39 — 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=root | fonctionne — 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="" |
| Champ | Décalage |
|---|
real_cred / cred | 0x778 / 0x780 |
syscallno mis en cache | 0xdf8 |
uid / euid / gid / egid mis en cache | 0xe00 / 0xe08 / 0xe10 / 0xe18 |
flags0x0 |
addr_limit | 0x8 |
ttbr0 | 0x10 |
preempt_count | 0x18 |
| Champ | Décalage | Champ | Décalage |
|---|
uid | 0x4 | cap_inheritable | 0x28 |
gid | 0x8 | cap_permitted | 0x30 |
suid | 0xc | cap_effective | 0x38 |
sgid | 0x10 | cap_bset | 0x40 |
euid | 0x14 | cap_ambient | 0x48 |
egid | 0x18 | ||
fsuid / fsgid | 0x1c / 0x20 |
Uid: 0 0 0 0getgid()credstatus_gidreal_credlt_cred_ids_agree()| cible | oracle d'atterrissage |
|---|
task+0x778 | Uid: 4e champ awk = hi32(write_target) et Gid: 2e champ awk = low32(write_target) — l'empreinte ci-dessus ; à lire avant l'étape 3 |
task+0x780 | le getuid() de la victime elle-même |
global selinux_enforcing | getenforce |
probe_state | ❌ pas un critère. Au mieux un indice sur la chaîne ; jamais une preuve qu'une écriture a atterri |
$4notes.md$3euid0hi32(write_target)stamp_ok()run_bootA.sh| # | Hook | Déclencheur | Action |
|---|
| 1 | oplus_root_check_post_handler, tracepoint sys_exit | un id a descendu, ou addr_limit == KERNEL_DS | oplus_root_killed → printk + do_exit(SIGKILL) ; et oplus_root_check_succ → kevent_send_to_user |
| 2 | oplus_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/nativetest64 | oplus_RWO_root_check → printk + kevent_send_to_user (pas de do_exit) |
| 3 | kretprobes oplus_secure_harden | setsockopt optname ∈ {41,42,48}, setxattr, /proc/cpuinfo, rechargement de la politique SELinux | oplus_heapspray_check → kevent_send_to_user |
143 setregid | 144 setgid | 145 setreuid | 146 setuid |
|---|
147 setresuid | 149 setresgid | 203 connect | 204 getsockname |
208 setsockopt | 210 shutdown | 213 readahead | 214 brk |
| kretprobe | Hooks | Filtre |
|---|
socket_kretprobe | ip_setsockopt | regs[1] ∈ {41, 42, 48} |
socket_ip6_kretprobe | do_ipv6_setsockopt | regs[1] ∈ {41, 42} |
cpuinfo_kretprobe | cpuinfo_open | — |
setxattr_kretprobe | setxattr | — |
sepolicy_reload_kretprobe | spolicy_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 |
| étape | sécurité des tentatives |
|---|
R5 | étape 5, task+0x778 | sû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+0x780 | pas 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. |