
GhostLock (CVE-2026-43499) per OPPO Find X5 Pro (PFEM10) — reverse engineering del watchdog OPlus e del rilevatore di heap-spray
English · 中文
Port di GhostLock (CVE-2026-43499) per OPPO Find X5 Pro su ColorOS 16. Raggiunge un processo figlio con uid=0 e un kernelsu.ko caricato; il processo root viene intercettato.
CVE-2026-43499 — futex PI use-after-free. remove_waiter() azzera current->pi_blocked_on quando current è il requeuer, sul percorso di rollback -EDEADLK di rt_mutex_start_proxy_lock().
remove_waiter @ 0xffffffc0081ed254 — forma pre-fix.
Su "il processo root sopravvive": le esecuzioni in evidence/kill.log
raggiungono uid=0 e caricano kernelsu.ko, e nell'esecuzione che effettivamente lo ha interrogato
il processo del manager KernelSU è sopravvissuto 120 s con kernelsu ancora Live in
/proc/modules. In un'esecuzione successiva la stessa catena ha reso irraggiungibili i servizi
del framework Android (Can't find service: package/power/input/phone/wifi)
mentre il modulo era ancora Live. Nessuna riga kernel [ROOTCHECK-*] e nessun
payload $$sys_call_number@@ è mai stato catturato, quindi la causa dello stato
dell'esecuzione successiva non è attribuita. Vedi evidence/notes.md
§2.3, §2.4 e §7.
task_struct
thread_info
| Campo | Offset |
|---|---|
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
**Lo stage 2 deve ricevere esplicitamente il valore dello stage 1.** Gli stage 1 e 2 sono due processi indipendenti, ciascuno con il proprio spray, quindi "scrivi la pagina delle credenziali in entrambi gli slot" è una trappola: letto ingenuamente produce `(pageA, pageB)`, e poiché `commit_creds` confronta i **puntatori**, quella coppia è divergente anche quando entrambe le scritture vanno a buon fine. Questo non è ipotetico — è esattamente ciò che hanno fatto i run 3 e 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 attiva quindi lo stage 2 con V12_W7_VALUE=<valore osservato dallo stage 1> e rifiuta di attivarlo del tutto se quel valore non può essere recuperato.
HOLD deve sopravvivere allo stage 2, altrimenti la pagina dello stage 1 viene liberata e riallocata e "lo stesso valore" diventa un puntatore pendente. Vedi
la regola dello stesso valore.
Una pagina per boot viene riparata. Lo stage 3 azzera V+8. Con due pagine diverse, azzerare entrambe cancellerebbe il timbro gid/suid (sotto) e farebbe sembrare una divergenza un accordo, quindi il runner ripara solo la pagina che è stata effettivamente installata e si ferma se i due valori non concordano.
La pagina cred è costruita da payload.c: tutti gli otto campi id a zero, tutti e cinque i set di capability pieni, e user / user_ns / group_info puntati a
root_user / init_user_ns / init_groups. Lo stage 3 esiste perché l'effetto collaterale della scrittura sovrascrive sempre cred+8 (gid/suid) di qualunque cred venga installato.
init_cred — una dicotomia esplicitaDue sezioni qui si contraddicevano a vicenda ("mai il globale init_cred"
contro "CONTROL=1 riproduce la cella 2", e la cella 2 è init_cred). Entrambe le affermazioni
sono vere per ruoli diversi:
init_cred fa sì che l'effetto collaterale
corrompa init_cred+8 globalmente — init_cred è condiviso da ogni thread
del kernel, e Uid: 0 0 4294967176 0 è precisamente quella corruzione. Il codice
rifiuta questo percorso a meno che V12_ALLOW_INIT_CRED=1 non sia impostato deliberatamente.0xffffff802a7e0be0) in entrambi gli slot, quindi
real_cred == cred per costruzione — ecco perché è sopravvissuta fino a execve.
CONTROL=1 la riproduce. È un controllo, non una configurazione su cui costruire.perf leak: PERF_TYPE_SOFTWARE / PERF_COUNT_SW_CPU_CLOCK, PERF_SAMPLE_REGS_INTR, exclude_user=1.
Accetta [0xffffff8400000000, 0xffffff90000000), voti ≥ 15%.
La UAF è pilotata attraverso rb_erase_cached Case 1-left. Questo fornisce due
store, non uno:```
*(write_target) = write_value // the store you aim
*(write_value + 0x08) = write_target // unavoidable side effect
`write_value` deve essere allineato a 8 byte con il bit 0 azzerato — è o `0` o un
indirizzo kernel valido. **Questo è il motivo per cui `g_boot_state` non può essere impostato con questa
primitiva**: il byte che deve diventare `1` ha il bit basso forzato a `0` dal
requisito di allineamento, e `write_value` è la stessa quantità dell'
indirizzo su cui atterra l'effetto collaterale.
### L'effetto collaterale scrive in qualunque cosa `write_value` punti
`write_value` è sia *il valore memorizzato* sia *l'indirizzo su cui l'effetto collaterale
scrive* (a `+8`). Puntalo a un oggetto globale del kernel e corrompi quell'
oggetto.
**W7 faceva esattamente questo** — puntando `write_value` all'alias `init_cred` —
ed è visibile nella rilettura. Da `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 era l'alias di init_cred e write_target era child_task+0x778.
init_cred+8 è gid/suid, quindi l'effetto collaterale memorizzava 0xffffff8800cdd178
lì: init_cred.gid = 0x00cdd178 e init_cred.suid = 0xffffff88 = 4294967176 — precisamente il 4° campo awk della riga Uid: sopra. Azzerare
init_cred+8 lo riparava (out/t5_repair.txt: Uid: 0 0 4294967176 0 →
), che è tutto ciò che "W7 stage 3" sia mai stato.
Questo percorso è ora rifiutato nel codice. V12_W7_INIT_CRED=1 termina con un
messaggio esplicativo a meno che non sia impostato anche V12_ALLOW_INIT_CRED=1, e i percorsi W2/W6/LTC
non ricadono più su init_cred quando la pagina cred privata è mancante — terminano
invece. Il default, e l'unico percorso sensato, è la pagina cred spruzzata.
L'effetto collaterale stesso non può essere evitato: write_value deve essere il puntatore
cred, quindi cred+8 viene sempre sovrascritto con il write target. Solo la sua
posizione è una scelta — e la riparazione è ora un azzeramento locale di
cred_page+8 (stage 3), non una scrittura in un oggetto globale.
Un output
groups=spazzatura è un sintomo separato, non questo. È stato visto in un'esecuzione in cuigideegidvenivano riletti puliti, quindi non può derivare dall'effetto collaterale diinit_cred+8; punta al campogroup_infodella fake cred stessa. Vedievidence/notes.md§10.6.
BUG_ON fataleLa primitiva scrive su esattamente un indirizzo per passata. task+0x778
(real_cred) e task+0x780 (cred) sono due indirizzi separati, quindi qualsiasi
scrittura atterrata solo su 0x778 o solo su 0x780 lascia il task con
cred != real_cred — uno stato di divergenza.
Su questa immagine quello stato è un panic fatale, non un warning. commit_creds si apre
con 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()
e il kernel è compilato con **`CONFIG_PANIC_ON_OOPS=y`** (`CONFIG_PANIC_ON_OOPS_VALUE=1`).
`__put_cred @0xffffffc008185530` porta la stessa famiglia di asserzioni
(`usage != 0` → BUG; `cred == current->cred` / `current->real_cred` → BUG).
Quindi la divergenza è *latente* — non fa nulla finché la vittima si limita a girare a vuoto —
finché **qualsiasi** `commit_creds` avviene su quel task: `setresuid` / `setresgid` /
`setuid` / `setgid` / `capset`, oppure **`execve` tramite `install_exec_creds`**.
> **⛔ Ritrattato (2026-09-18 in tarda serata): questo NON è il meccanismo del riavvio.**
>
> Una revisione precedente di questa sezione chiamava la divergenza "il principale meccanismo
> candidato per i riavvii" e diceva che "spiega la divisione delle forme". Non è così,
> e la ragione ora è misurata anziché argomentata:
>
> * `commit_creds` prende il suo task da **`current`** — `0x1867a0 mrs x20, sp_el0`.
> La sua firma è `commit_creds(struct cred *new)`; non c'è alcun argomento task.
> Quindi una divergenza conta solo se il task che **la detiene** chiama `commit_creds`
> esso stesso.
> * Le esecuzioni che riavviavano erano tutte `V12_NO_EXEC=1` (dichiarato testualmente in
> `run3_0445.log:18`, `run9_0606.log:20`, `run10_0616.log:24`), quindi la vittima
> non ha mai emesso `execve` e non ha mai raggiunto `commit_creds`.
> * L'esecuzione 10 non aveva alcun poke di sorta (`grep -c poke` = 0).
> * `exit_creds` azzera **entrambi** i puntatori prima di `put_cred`
> (`0x185cb8 str xzr,[x19,#0x778]`; `0x185d24 str xzr,[x19,#0x780]`), quindi
> `_exit(0)` della vittima **cancella** la divergenza invece di inciamparvi.
>
> ⇒ In quelle esecuzioni la divergenza era **inerte**. Il `BUG_ON` è reale, ma è una
> mina inesplosa. "La forma A non riavvia mai" torna a essere una correlazione. Ciò che
> la mina vincola effettivamente è il **laundering**, perché
> `setresgid`/`setresuid` chiamano `commit_creds` essi stessi.
>
> **La quantità che separa effettivamente le catene è l'uguaglianza dei puntatori, e ciò
> significa che i due colpi devono scrivere UN solo valore** — vedi la sottosezione successiva.
### ★★★ I due colpi devono scrivere lo *stesso* valore — non semplicemente andare a segno entrambi
`BUG_ON` confronta **puntatori**. Due pagine che portano entrambe `uid 0` sono comunque due
oggetti diversi. Le catture rendono concreta la distinzione:
| catena | colpo 0x778 | colpo 0x780 | puntatori |
|---|---|---|---|
| vecchia (`t5loop.sh MODE=CRED`) | `in[0]=0xffffff802a7e0be0` | `in[0]=0xffffff802a7e0be0` | **uguali** → ksud caricato, manager vivo 120 s |
| nuova (`run_bootA.sh`) | `0xffffff88679bade0` (esecuzione 9) | `0xffffff8785d6ade0` | **diversi** → divergenti anche con entrambi a segno |
`tools/t5loop.sh` applica **un** `$ENVV` a **ogni** offset, quindi `MODE=CRED`
rendeva entrambi i colpi identici *per costruzione*. `run_bootA.sh` ha eseguito il passo 5 e
il passo 6 ciascuno con un `$extra` vuoto, quindi ciascuno ha spruzzato la **propria** pagina.
⇒ Il requisito è **"entrambi i colpi scrivono lo stesso valore"**. `run_bootA.sh` ora
lo impone (`SAME_VALUE=1`, il default): il passo 6 riutilizza verbatim il `write_value`
osservato dal passo 5, e **rifiuta di sparare del tutto** se non riesce a recuperare quel
valore — perché sparare costruirebbe una coppia divergente.
⚠ `HOLD` deve sopravvivere al secondo colpo. Se il processo figlio PIN del primo colpo muore per primo,
la pagina viene liberata e riallocata e "stesso valore" diventa un puntatore pendente.
Il default `HOLD=20` è **troppo corto**; usare `HOLD=600`. Questo è ora il *default*
ogniqualvolta `SAME_VALUE=1` — il vecchio 20 s incondizionato significava che la configurazione
predefinita era essa stessa la trappola — e un `HOLD` corto esplicito con
`SAME_VALUE=1` ora avvisa a gran voce invece di produrre silenziosamente un puntatore pendente.
⚠ `CONTROL=1` modificava **solo il passo 5**, quindi produceva
`(init_cred, pagina fresca)` — una coppia divergente — mentre questo file sosteneva che
riproducesse la cella 2. Corretto: ora imposta entrambi i colpi a `init_cred`. (Il costo della cella 2
rimane: l'effetto collaterale corrompe `init_cred+8` globalmente, che è ciò che
`Uid: 0 0 4294967176 0` rappresenta.)
**Conseguenze per qualsiasi cosa voglia fare laundering della credenziale**
(`setresgid` + `setresuid`, per scambiare la pagina spruzzata con un vero `struct cred`):
- Il meccanismo è reale e verificato — `commit_creds` scrive `x21` in **entrambi**
`task+0x778` e `task+0x780` (`0x186998` / `0x1869a0`), quindi una sola chiamata ripara la
divisione in modo permanente; `prepare_creds @0xffffffc008186070` è
`kmem_cache_alloc(cred_jar)` + `memcpy(new, task->cred, 0xA8)` +
`security_prepare_creds(...)`, e 147/149 sono entrambi nella lista di esenzione della guardia.
- **Ma la sua precondizione è l'opposto di "saltare il colpo 0x778".** Il laundering
stesso chiama `commit_creds`, quindi deve essere emesso solo quando **entrambi** i puntatori
detengono già lo stesso valore.
- `V12_LAUNDER=1` è vincolato a **due** cose, e la prima non è un'osservazione:
1. **`V12_W7_SAME_VALUE=1`** — il fatto di *provenienza* che a entrambi i colpi è stato dato
lo stesso valore. Senza una primitiva di lettura, l'identità dei puntatori è inosservabile, quindi
questo non può essere sostituito da un controllo userspace migliore; deve essere dichiarato.
2. `consistent=1` — la vista `0x780` (`getuid()`) concorde con la vista `0x778`
(`/proc/self/status` `Uid:`). **Necessario ma non sufficiente da solo**:
due pagine distinte che portano entrambe `uid 0` si leggono uguali mentre i puntatori differiscono
— che è esattamente il caso che il runner era solito fabbricare. Dato (1), diventa
sufficiente: concordanza + stesso valore ⇒ entrambi sono atterrati sulla stessa pagina.
Se uno dei due controlli fallisce ⇒ rifiuta, e la tabella dei quattro casi finisce nelle prove.
La riga del report LT stampa entrambe le viste (`uid=` / `real_uid=` / `consistent=`) più
`same_value_declared=` così lo stato viene letto, non inferito.
### ★ Strumento 1 — l'effetto collaterale è un TIMBRO mirato al bersaglio
`*(write_value + 8) = write_target`, e `cred+8` / `cred+0xc` sono `gid` / `suid`,
quindi un singolo store di 8 byte atterra su entrambi:```
cred.gid = low32(write_target)
cred.suid = hi32(write_target)
È una misurazione, non un modello. out/t5_w7_778.txt ha
write_target = 0xffffff8800cdd178 e Uid: 0 0 4294967176 0, dove
4294967176 = 0xffffff88 = hi32(write_target); notes.md §11 registra l'altra
metà, init_cred.gid = 0x00cdd178 = low32(write_target).
Due usi:
task+0x778. /proc/<pid>/status legge
real_cred = task+0x778 — esattamente il cred appena installato — quindi il
timbro è direttamente leggibile dallo userspace. Leggilo prima dello stage 3: la
riparazione azzera cred+8 e lo cancella (t5_repair.txt di notes.md §11 legge
4294967176 prima di una riparazione riuscita e 0 dopo).0,
quindi due pagine distinte riportano entrambe "coerente". Ma low32(T+0x778) e
low32(T+0x780) differiscono esattamente di 8, quindi con due pagine getgid() (da
) e (da ) sono in disaccordo — e
confronta anche il gid oltre all'uid.⇒ V12_W7_SAME_VALUE è il secondo gate, non l'unico. Conta comunque:
il timbro discrimina solo se entrambi gli effetti collaterali si sono attivati, quindi la regola
del valore uguale chiude quel buco residuo. E nota cos'è un gate — un
rilevatore, non un preventore. Può solo rifiutare; lascia il task divergente per il resto del
boot. La regola del valore uguale è ciò che rende la coppia corretta, che è ciò che
la vecchia catena aveva e ciò che serve per raggiungere execve.
probe_state NON è un criterio di atterraggioÈ stato sbagliato tre volte in questo progetto: W1 è atterrato sul globale e
ha riportato R; il D del run 12 era puntato a un globale invece che a un cred; e il
R del run 11 è stato scritto in una tabella come se fosse un atterraggio
(run11_w778r1_miss.txt e w7_w7781.txt del run 7 sono isomorfi riga per riga —
entrambi probe_state = R, probe_done = 0). Usa un oracolo per-target:
run_bootA.sh ora usa il timbro per lo stage 1 — che è ciò che rende significativi i
retry di ROUNDS>1 su task+0x778, dato che un round fallito è leggibile invece che
inferito — e non attiverà lo stage 2 a meno che lo stage 1 non sia atterrato.
⛔ Di' "campo awk", mai "3° campo".
uid_linestampa anche l'etichetta (Uid: 0 0 4294967176 0), quindi$1di awk è"Uid:"e i quattro valori id sono$2..$5:$2=uid$3=euid$4=suid$5=fsuid. Il timbro si trova acred+8, cioègid(low32) esuid(hi32) — quindi èGid:$2eUid:. Chiamarlo "il terzo campo" (che conta i , ed è come §11 lo formula) invita il codice a leggere , che è = sul fake cred e non può mai essere uguale a . Quell'off-by-one era presente qui: restituiva "no stamp" per uno shot che era atterrato, quindi lo stage 2 non si è mai attivato e il launder gate ha rifiutato per sempre — , perché "no stamp" è anche il risultato normale di una vera mancanza.
Un criterio che non viene mai testato contro un campione noto-positivo non è un
criterio, è un'ipotesi — e questa classe di fallimenti (questo off-by-one,
probe_state, dmesg -w, il klog.host vuoto, il readback vuoto) si presenta sempre come
"non è successo nulla", che è anche un esito sperimentale legittimo.
Quindi il controllo è ora difeso due volte:
stamp_selftest() viene eseguito in preflight e fa exit 9 in caso di fallimento, guidando le
stesse funzioni di estrazione che il gate usa contro i valori misurati da
out/t5_w7_778.txt (write_target = 0xffffff8800cdd178 → Uid $4 =
4294967176, Gid $2 = 13488504) più campioni negativi e non leggibili.
Un self-test che reimplementa il controllo non prova nulla, quindi l'estrazione dei campi
è fattorizzata in uid_suid_field / gid_gid_field.tools/test_stamp_criterion.sh — la
stessa cosa come test di regressione standalone, che estrae le funzioni reali da
.stamp_ok() restituisce tre stati, perché "non leggibile" non è "no stamp"
(quella confusione è ciò che ha fatto sembrare il run 13 "nessun cambiamento"): 0 = presente,
1 = leggibile e nessun timbro, 2 = NON LEGGIBILE. E quando restituisce 1 mentre
probe_state = D, il runner stampa ⛔ ORACLE INCONSISTENT — "vai a controllare il
criterio" — invece del messaggio "non è atterrato", che manda l'operatore in un
posto completamente diverso (un nuovo boot, o una caccia al tasso di successo).
Derivazione completa: evidence/2026-09-18-divergence-is-latent.md
e evidence/2026-09-18-cred-launder-verification.md
(il §2.3 di quest'ultimo è ritrattato sul posto). L'autocontrollo di sovrapposizione della forma di scrittura è
chiuso offline: le parole della forma vivono nella griglia fd_set sullo stack del kernel mentre
l'effetto collaterale atterra dentro la pagina spruzzata, quindi i due non possono sovrapporsi in nessuna
delle due forme.
Tre reporter indipendenti. Nessuno è un fallback per un altro, e solo il percorso 1 può uccidere il task chiamante.
Il percorso 2 si attiva su execve, non sul cambio di credenziali, ed è un percorso di codice
separato dal percorso 1. Riporta tramite kevent_send_to_user, quindi ciò che accade
dopo è una decisione di un daemon userspace, non del kernel.
Il controllo è sul percorso dell'immagine che viene exec'd, quindi non basta
caricare via memfd il payload del loader: se il loader stesso è exec'd da
/data/local/tmp, quel primo execve riporta già. d_path() su un memfd è
/memfd:…, quindi il loader stesso deve essere exec'd attraverso un memfd —
V12_EXEC_MEMFD è ora attivo di default esattamente per questa ragione. Il vecchio comportamento
è visibile nel 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
Marcatori greppabili per il percorso 2:```
[ROOTCHECK-EXEC-INFO]:common %s result %s with "execve_report" / "execve_block"
%d,path@@%s kevent payload fragment
Poiché il percorso 2 e il percorso 3 riportano solo tramite kevent, "nessun [ROOTCHECK-*] nel log del kernel" non esclude che uno dei due sia stato attivato. Questa inferenza richiede il ricevitore in userspace, che non abbiamo individuato.
oplus_security_guard.koCache di 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
`sys_exit` check:```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)
Nota sul flusso di controllo (rilevante per l'ordinamento dell'exploit). I quattro confronti sull'arco discendente saltano direttamente a 0x468, il dispatch del numero di syscall — non cadono attraverso il gate addr_limit. 0x454–0x464 viene raggiunto solo quando nessun id è disceso. Quindi il dispatch viene raggiunto quando o qualche id è disceso o addr_limit == KERNEL_DS; non è vincolato da addr_limit.
Conseguenze:
sys_enter aveva ancora in cache il vecchio uid. Se il task è già uid=0 quando una syscall entra (0x400 cbz), l'hook ritorna e resta cieco da quel momento in poi.delivery/外部建议评审_2026-09-18.md.g_boot_state — 1 byte .data..ro_after_init, impostato all'init del modulo da verified_bootstate tramite strstr. is_unlocked() = LDRB + RET.
Le scritture su VA del modulo causano fault (CONFIG_STRICT_MODULE_RWX=y) — usa l'alias physmap 0xffffff80….
Payload del report: $$sys_call_number@@%d, $$set_id_flag@@%d, $$addr_limit@@%lx, $$enforce@@%d.
.rodata+0, indici 143–214Le restanti 60 voci → report + kill.
Non bloccare un thread su sendmsg (211) mentre le sue credenziali cambiano. Non è nella tabella, quindi il thread viene segnalato e ucciso. Le uniche syscall in cui è sicuro essere bloccati per questo scopo sono le dodici sopra:
setregid, setgid, setreuid, setuid, setresuid, setresgid,
connect, getsockname, setsockopt, shutdown, readahead, brk.
Correzione (2026-09-18). Una revisione precedente di questa tabella etichettava ogni voce uno in meno rispetto al numero di syscall arm64 reale (
146era chiamatasetresuid; èsetuid—setresuidè147). I numeri erano sempre corretti; solo i nomi erano sbagliati. I nomi ora sono risolti dasys_call_table@0xffffffc00a13d8c0nell'immagine del kernel di questo dispositivo. In particolaresendmsg(211),munmap(215),getsockopt(209) egetpeername(205) non sono esenti — bloccare un thread in una di queste mentre le sue credenziali cambiano è un kill, non un pass. Rigenera contools/gen_exempt_table.py.
oplus_secure_harden.ko`entry_handler_setxattr` → `oplus_heapspray_check(7)`, `entry_handler_cpuinfo` → `(6)`, entrambi incondizionati.
`oplus_heapspray_check` — contatori `heapspary_ip4` / `ip6` / `xttr` / `cpuinfo`, delta `ktime_get_real_ts64`, 7 siti di report (`snprintf` + `printk` + `kevent_send_to_user`), vincolati a `verified_bootstate`.
### Elusione
| Primitive | |
|---|---|
| `setsockopt` optname 46 `MCAST_JOIN_SOURCE_GROUP` | non filtrato |
| `setsockopt` level `SOL_IPV6` (41) | non filtrato se il filtro legge `level` |
| `setxattr` | sempre contato |
| `/proc/cpuinfo` | sempre contato |
| `socket()` / `socketpair()` | non intercettato |
| `sendmsg`, `pipe`, `memfd`, `add_key`, `io_uring`, mmap | non intercettato |
## 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 sono fissi — mantengono la geometria dello stack-frame di reclaim (calibrazione delta=0). Modificare uno di questi richiede una nuova calibrazione sul dispositivo.```bash
export ANDROID_NDK_HOME=/path/to/android-ndk-r28c
make # → exploit_guard
./build.sh # same, with NDK auto-detection
Manuale:```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 build a ogni 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
## File```
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 — trascrizioni adb shell verbatim di quattro
esecuzioni root: la timeline completa, il momento in cui l'uid del task target diventa 0, il
caricamento di kernelsu.ko, e lo stato successivo. Leggi prima il blocco di intestazione: elenca ciò che il file non contiene e perché.
evidence/notes.md — il lato kernel. Indirizzi dei moduli e
quali canali /proc funzionano in quale stato SELinux; la derivazione completa di g_boot_state
inclusa la chiave strstr; la tabella exempt corretta; la ricetta di cattura che produrrebbe la metà kernel mancante; e una lista di ciò che è ancora
aperto.
evidence/2026-09-18-bootA/ — la prima
esecuzione sul dispositivo del design attuale, 13 boot. I riavvii sono ordinati
(bootreason=reboot) e nessuna riga di panic è mai stata catturata — ma leggi questo
come un campione di uno, non tredici. Delle quattro esecuzioni che si sono riavviate, due hanno salvato un
klog.host vuoto, una ha salvato il log del boot successivo, e solo una finestra può
plausibilmente delimitare il proprio riavvio. Allo stesso modo, il probe_state e la rilettura della vittima di un'esecuzione sono entrambi vuoti (il dispositivo era già andato), quindi non porta alcuna
informazione sul fatto che la sua scrittura sia atterrata — un campo vuoto non è "nessun cambiamento".
La directory documenta anche l'errore di metodologia che vale la pena conoscere:
dmesg -w è un no-op su questo dispositivo (toybox scarica una volta ed esce), quindi il
log kernel di un'esecuzione precedente conteneva solo la storia pre-cattura — "nessun [ROOTCHECK-*]" non era
prova di nulla. evidence/notes.md §6 contiene la ricetta corretta
poll-and-stream-to-host.
evidence/2026-09-18-cred-launder-verification.md
— verifica della proposta di credential-laundering contro il disassembly di questa stessa immagine (non il sorgente 5.10 generico): il doppio store di commit_creds, l'allocazione di
prepare_creds e il sizeof(struct cred) = 0xA8 misurato, il pericolo di divergenza
BUG_ON(cred != real_cred) sopra, l'auto-verifica chiusa della sovrapposizione write-shape, e l'audit di copertura delle prove dei 13 boot.
§2.3 è ritrattata sul posto — la divergenza è latente, non il meccanismo di riavvio.
evidence/2026-09-18-divergence-is-latent.md
— la verifica del round-2. commit_creds prende il suo task da current
(0x1867a0 mrs x20, sp_el0), exit_creds azzera entrambi i puntatori prima di put_cred,
e le catture grezze mostrano che la vecchia catena ha scritto un valore identico
(0xffffff802a7e0be0) in entrambi gli slot mentre la nuova catena ha scritto due pagine
diverse. Quindi il criterio è l'uguaglianza dei puntatori — "entrambi i colpi scrivono lo stesso valore",
non "entrambi i colpi atterrano".
postreboot_forensics.sh — forensics di riavvio che non
dipendono dal poller. Il criterio è una singola condizione:
CONFIG_PSTORE_CONSOLE=y fa sì che panic() scriva la coda della console in ramoops a
kmsg_dump(KMSG_DUMP_PANIC) — prima di qualsiasi reset — quindi se la macchina poi
si riavvia o si blocca è irrilevante. Estrae /sys/fs/pstore/, cerca kernel BUG /
__put_cred / cred.c, e stampa la stringa del boot-reason (le voci di cronologia hanno portato suffissi reboot,shell / bootloader / reboot,edl, quindi la ragione
distingue un attore dove l'epoca non lo fa).
⚠ Non leggere "clean
bootreason=reboot" come "nessun panic." Su QCOM un assert del watchdog SoC viene resettato attraverso il blocco PMIC PON, quindipanic → panic_timeout=-1 → hang → watchdog → PMIC reset → clean bootreasonè una catena autoconsistente che è indistinguibile da un reset hardware sulle prove che abbiamo. Iltotal_17_dump_0_pmic_17di questo stesso repo attribuisce tutti i 17 riavvii anomali apmic, che è esattamente la forma normale del watchdog, non prova di "non il kernel."bootreasonnon restringe nulla qui; ramoops è l'unico criterio.
Due precondizioni, o il verdetto dello script è nullo (铁律 8 — una conclusione no-signal richiede che il canale sia prima dimostrato raggiungibile):
/sys/fs/pstore/* è solo root, quindi sotto Enforcing
sia adb pull che cat falliscono — e "non può leggere" produce lo stesso output
di "letto ed era vuoto". Uno script a due stati stampa "pstore è VUOTO ⇒
panic smentito" da un canale che non ha mai aperto. Lo script quindi emette
CHANNEL UNREACHABLE (ls fallito, o tutte le voci note fallite nel leggere
piuttosto che nel non esistere) e riporta getenforce accanto.adb reboot pulito seguito da un fetch immediato. Se un
riavvio noto-buono non produce nulla di leggibile, il canale non è dimostrato e ogni
successivo "pstore vuoto" non è prova. L'ordine conta: il dispositivo sposta
e scollega il record poco dopo il boot, quindi la sequenza è
reboot → get Permissive (W1) → esegui lo script immediatamente.run_bootA.sh — orchestrazione per quel singolo boot, nell'ordine
che conta (0x778 → 0x780 con lo stesso valore → riparazione locale del cred
che è stato effettivamente installato → conferma → solo allora poke). ADB=/SER=/
BIN_LOCAL= sovrascrivibili; SAME_VALUE=1 (default) impone la regola dello stesso valore,
LAUNDER=1 abilita il launder gated, HOLD=600 è richiesto per la sequenza
stesso valore.
I retry sono divisi per stage (R5/R6), perché i due stage hanno profili di
rischio opposti:
R5 è di default ROUNDS, R6 a 1.
L'esecuzione launder raccomandata — il percorso predefinito della pagina spruzzata, non
CONTROL=1:```bash
LAUNDER=1 R5=3 R6=1 HOLD=600 CHAINWAIT=6000 NODRAIN=1 WATCH=180 ./run_bootA.sh
`CONTROL=1` produrrebbe anch'esso una coppia coerente, ma scrivendo il puntatore `init_cred`, il cui effetto collaterale corrompe `init_cred+8` **globalmente** — e "il framework muore" è una delle cose tenute sotto osservazione, quindi trasportare un guasto a livello di dispositivo nello sfondo della misurazione inquina esattamente la lettura che questa esecuzione esiste per effettuare. Il percorso della pagina spruzzata costa solo "entrambi i colpi devono andare a segno", ed è a questo che serve `R5=3`. `CONTROL=1` rimane l'unica coppia coerente *provata* e come controllo, non come configurazione raccomandata.
**Quale pagina sia stata installata si legge dalla riga incondizionata.** `run_w7` stampa il valore di scrittura su due righe, e solo una di esse è incondizionata:```
L1793 W7[..] write value = private cred page 0x.. — spray path only
L1802 W7[..] write_value = 0x.. — after the if/else, ALL paths
Il runner usato per corrispondere alla prima dicitura, quindi con CONTROL=1 l'estrazione tornava
vuota, if [ -n "$CRED" ] saltava la riparazione, e il termine [ -n "$CRED" ] della
congiunzione a valore identico lo manteneva a 0 — il gate di laundering avrebbe
rifiutato per sempre. Un no-op silenzioso su entrambi i fronti, da una regex che riconosceva
uno dei due siti di stampa. Sia essa che l'estrazione write_target (l'input del
criterio di stamp) ora passano attraverso wv_from/wt_from, che sono esercitati dal
test di regressione insieme al criterio stesso.
Entrambi gli stream iniziano prima della cosa che misurano. uid.stream parte dallo
stage 1; cred.stream inizia al poke, non dopo il watch — il poke
rilascia il figlio nel suo ciclo di report NO_EXEC, che è 240 × 0.5 s = 120 s e
poi _exit(0) (exploit.c: "LT child NO-EXEC mode done (120s)"), quindi il vecchio
posizionamento a t+~135 s iniziava il campionamento dopo che il figlio era già sparito, esattamente
nella finestra per cui lo strumento esiste. Il runner inoltre rifiuta di procedere
se stamp_selftest() fallisce, e stampa ORACLE INCONSISTENT invece di "did
not land" quando lo stamp e probe_state sono in disaccordo.
artifacts/guard_post_handler.s — la kill
chain con le rilocazioni compilate. adrp x9, #0 nei listati più vecchi è
.data..ro_after_init; bl #0x4ac è oplus_root_check_succ. Rigenera con
tools/gen_guard_disasm.py dopo aver estratto i moduli vendor dal tuo dispositivo.
tools/kdis_ko.py — RELA corrisposto tramite sh_info; su queste build le rilocazioni .text vivono in .rela.text.<func>, quindi la ricerca basata sul nome non restituisce nulla.
| Progetto | |
|---|---|
| JoinChang/ghostlock-oneplus | implementazione di riferimento; waiter compatto 5.10 |
| NebuSec CyberMeowfia | ricerca originale GhostLock |
GPL-3.0 — vedi LICENSE.
| Dispositivo | OPPO Find X5 Pro (PFEM10) |
| SoC | SM8450 / Adreno 730 |
| OS | ColorOS 16.0.3.520 (CN01) |
| Kernel | 5.10.236-android12-9-o-gaf2075ad2c06 |
| Bootloader | bloccato, verde |
| VA_BITS | 39 — KIMAGE_TEXT_BASE = 0xffffffc008000000 |
| Fase |
|---|
Trigger compact waiter (CMP_REQUEUE_PI → EDEADLK) | funziona |
Leak di task_struct (perf) | funziona |
Scrittura PI (8 byte; valore = 0 o un indirizzo kernel valido) | funziona |
task+0x778 oppure task+0x780 da solo → Uid=root | funziona — ma un atterraggio su un singolo campo lascia il task divergente, e questo è un latente hard BUG_ON. Vedi il rischio di divergenza |
| Entrambi i campi scritti con UN SOLO valore (una coppia coerente) | ❌ mai prodotto con una pagina sprayata. Osservato solo con l'alias globale init_cred (09-14, CONTROL=1). Il runner ora lo impone (SAME_VALUE=1); non eseguito sul dispositivo |
Laundering delle credenziali (setresgid + setresuid) | implementato dietro V12_LAUNDER=1; non eseguito sul dispositivo |
kernelsu.ko caricato | funziona |
| Il processo root sopravvive | ⚠ non stabilito — vedi sotto |
| Meccanismo di reboot | ❌ non stabilito. Un candidato (la divergenza) è ora escluso; vedi sotto |
probe_state come criterio di atterraggio | ❌ errato — non usare. Tre controesempi; vedi la tabella sotto |
| Canale di panic pstore/ramoops | ⚠ lo strumento esiste; canale mai validato (nessun null test ancora) |
| "La vittima gira in puro userspace" | ⚠ nessuna lettura ancora — uid.stream ora registra utime/stime/nvcsw così può essere verificato |
| doppia scrittura single-pass lato pi | ⚠ non stabilito; pi.pc/pi.left sono hard-coded a 0 in fdset_map.h |
Path A (UMH / modprobe_path) | STATIC_USERMODEHELPER_PATH="" |
| Campo | Offset |
|---|
real_cred / cred | 0x778 / 0x780 |
syscallno in cache | 0xdf8 |
uid / euid / gid / egid in cache | 0xe00 / 0xe08 / 0xe10 / 0xe18 |
flags0x0 |
addr_limit | 0x8 |
ttbr0 | 0x10 |
preempt_count | 0x18 |
| Campo | Offset | Campo | Offset |
|---|
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 0credstatus_gidreal_credlt_cred_ids_agree()| target | oracolo di atterraggio |
|---|
task+0x778 | Uid: 4° campo awk = hi32(write_target) e Gid: 2° campo awk = low32(write_target) — il timbro sopra; leggi prima dello stage 3 |
task+0x780 | il getuid() della vittima stessa |
globale selinux_enforcing | getenforce |
probe_state | ❌ non è un criterio. Al massimo un indizio sulla catena; mai prova che una scrittura sia atterrata |
$4notes.md$3euid0hi32(write_target)stamp_ok()run_bootA.sh| # | Hook | Trigger | Azione |
|---|
| 1 | oplus_root_check_post_handler, tracepoint sys_exit | qualche id è sceso, o addr_limit == KERNEL_DS | oplus_root_killed → printk + do_exit(SIGKILL); e oplus_root_check_succ → kevent_send_to_user |
| 2 | oplus_exe_block_ret_handler, sys_exit ma solo per execve (221) | d_path(mm->exe_file) inizia con /data, /data/local/tmp, /data/nativetest, /data/nativetest64 | oplus_RWO_root_check → printk + kevent_send_to_user (nessun do_exit) |
| 3 | kretprobes oplus_secure_harden | setsockopt optname ∈ {41,42,48}, setxattr, /proc/cpuinfo, ricarica della policy 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 | Hook | Filtro |
|---|
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 |
| stage | sicurezza del retry |
|---|
R5 | step 5, task+0x778 | sicuro — un miss non installa nulla, e il criterio dello stamp rende un round fallito leggibile, quindi un altro colpo è solo un altro tentativo. R5=3 porta il tasso di successo per colpo da ~p a ~1−(1−p)³. |
R6 | step 6, task+0x780 | non sicuro, e non necessario — si attiva solo dopo che lo step 5 è atterrato, quindi un retry spara a un task già divergente: un'altra possibilità di far atterrare una seconda pagina diversa, senza vantaggi, dato che un atterraggio completa la coppia. Mantienilo a 1. |