
GhostLock (CVE-2026-43499) para OPPO Find X5 Pro (PFEM10) — ingeniería inversa del watchdog de OPlus y del detector de heap-spray
English · 中文
GhostLock (CVE-2026-43499) port para el OPPO Find X5 Pro en ColorOS 16. Alcanza un proceso hijo con uid=0 y un kernelsu.ko cargado; el proceso root es interceptado.
CVE-2026-43499 — use-after-free de futex PI. remove_waiter() limpia current->pi_blocked_on cuando current es el requeuer, en la ruta de rollback -EDEADLK de rt_mutex_start_proxy_lock().
remove_waiter @ 0xffffffc0081ed254 — forma previa al fix.
Sobre "el proceso root sobrevive": las ejecuciones en evidence/kill.log
alcanzan uid=0 y cargan kernelsu.ko, y en la ejecución que realmente lo sondeó
el proceso del gestor de KernelSU sobrevivió 120 s con kernelsu aún Live en
/proc/modules. En una ejecución posterior la misma cadena dejó los servicios del framework de Android inalcanzables (Can't find service: package/power/input/phone/wifi)
mientras el módulo seguía Live. Nunca se ha capturado ninguna línea de kernel [ROOTCHECK-*] ni ningún payload $$sys_call_number@@, por lo que la causa del estado de la ejecución posterior no se atribuye. Ver evidence/notes.md
§2.3, §2.4 y §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
**La etapa 2 debe recibir explícitamente el valor de la etapa 1.** Las etapas 1 y 2 son dos
procesos independientes, cada uno con su propio spray, por lo que "escribir la página de credenciales en ambas
ranuras" es una trampa: leído ingenuamente produce `(pageA, pageB)`, y como
`commit_creds` compara **punteros**, ese par es divergente incluso cuando ambas escrituras
se realizan. Esto no es hipotético — es exactamente lo que hicieron las ejecuciones 3 y 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 por lo tanto dispara la etapa 2 con V12_W7_VALUE=<valor observado de la etapa 1> y se niega a dispararla por completo si ese valor no puede recuperarse. HOLD debe sobrevivir a la etapa 2, o la página de la etapa 1 se libera y se reasigna y "el mismo valor" se convierte en un puntero colgante. Véase la regla del mismo valor.
Se repara una página por arranque. La etapa 3 pone a cero V+8. Con dos páginas diferentes, poner a cero ambas borraría el sello gid/suid (abajo) y haría que una divergencia pareciera un acuerdo, por lo que el ejecutor repara solo la página que realmente se instaló y se detiene si los dos valores no coinciden.
La página de credenciales es construida por payload.c: los ocho campos de id a cero, los cinco conjuntos de capacidades completos, y user / user_ns / group_info apuntando a root_user / init_user_ns / init_groups. La etapa 3 existe porque el efecto secundario de la escritura siempre sobrescribe cred+8 (gid/suid) de cualquier credencial que instale.
init_cred — una dicotomía explícitaDos secciones aquí solían contradecirse entre sí ("nunca el init_cred global" vs "CONTROL=1 reproduce la celda 2", y la celda 2 es init_cred). Ambas afirmaciones son ciertas para roles diferentes:
init_cred hace que el efecto secundario corrompa init_cred+8 globalmente — init_cred es compartido por cada hilo del kernel, y Uid: 0 0 4294967176 0 es precisamente esa corrupción. El código rechaza esta ruta a menos que V12_ALLOW_INIT_CRED=1 se establezca deliberadamente.0xffffff802a7e0be0) en ambas ranuras, por lo que real_cred == cred por construcción — es por eso que sobrevivió hasta execve. CONTROL=1 lo reproduce. Es un control, no una configuración sobre la que construir.fuga de perf: PERF_TYPE_SOFTWARE / PERF_COUNT_SW_CPU_CLOCK, PERF_SAMPLE_REGS_INTR, exclude_user=1.
Aceptar [0xffffff8400000000, 0xffffff90000000), votos ≥ 15%.
El UAF se controla a través de rb_erase_cached Caso 1-izquierda. Eso da dos almacenamientos, no uno:```
*(write_target) = write_value // the store you aim
*(write_value + 0x08) = write_target // unavoidable side effect
`write_value` debe estar alineado a 8 bytes con el bit 0 en cero — es `0` o una
dirección de kernel válida. **Por esto `g_boot_state` no puede establecerse con esta
primitiva**: el byte que necesitas que se convierta en `1` tiene su bit bajo forzado a `0` por
el requisito de alineación, y `write_value` es la misma cantidad que la
dirección en la que recae el efecto secundario.
### El efecto secundario escribe en cualquier cosa a la que apunte `write_value`
`write_value` es tanto *el valor almacenado* como *la dirección a la que el efecto secundario
escribe* (en `+8`). Apúntalo a un objeto global del kernel y corromperás ese
objeto.
**W7 solía hacer exactamente esto** — apuntando `write_value` al alias `init_cred` —
y es visible en la lectura de vuelta. De `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 el alias de init_cred y write_target era child_task+0x778.
init_cred+8 es gid/suid, por lo que el efecto secundario almacenó 0xffffff8800cdd178
ahí: init_cred.gid = 0x00cdd178 y init_cred.suid = 0xffffff88 = 4294967176 — precisamente el cuarto campo de awk de la línea Uid: anterior. Poner a cero
init_cred+8 lo reparó (out/t5_repair.txt: Uid: 0 0 4294967176 0 →
), que es todo lo que "W7 stage 3" fue alguna vez.
Esta ruta ahora se rechaza en el código. V12_W7_INIT_CRED=1 aborta con una
explicación a menos que también se establezca V12_ALLOW_INIT_CRED=1, y las rutas W2/W6/LTC
ya no recurren a init_cred cuando falta la página de cred privada — en su lugar
abortan. La ruta predeterminada, y la única sensata, es la página de cred rociada.
El efecto secundario en sí no puede evitarse: write_value debe ser el puntero de cred,
por lo que cred+8 siempre se sobrescribe con el objetivo de escritura. Solo su
ubicación es una elección — y la reparación ahora es un cero local de
cred_page+8 (etapa 3), no una escritura en un objeto global.
Una lectura de
groups=basura es un síntoma separado, no este. Se observó en una ejecución dondegidyegidse leyeron limpiamente, por lo que no puede provenir del efecto secundario deinit_cred+8; apunta al propio campogroup_infodel cred falso. Véaseevidence/notes.md§10.6.
BUG_ON duroLa primitiva almacena en exactamente una dirección por pasada. task+0x778
(real_cred) y task+0x780 (cred) son dos direcciones separadas, por lo que cualquier
escritura aterrizada solo en 0x778 o solo en 0x780 deja la tarea con
cred != real_cred — un estado de divergencia.
En esta imagen ese estado es un pánico duro, no una advertencia. commit_creds comienza
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()
y el kernel está compilado con **`CONFIG_PANIC_ON_OOPS=y`** (`CONFIG_PANIC_ON_OOPS_VALUE=1`).
`__put_cred @0xffffffc008185530` lleva la misma familia de aserciones
(`usage != 0` → BUG; `cred == current->cred` / `current->real_cred` → BUG).
Así que la divergencia es *latente* — no hace nada mientras la víctima simplemente gira —
hasta que **cualquier** `commit_creds` ocurre en esa tarea: `setresuid` / `setresgid` /
`setuid` / `setgid` / `capset`, o **`execve` vía `install_exec_creds`**.
> **⛔ Retractado (2026-09-18 tarde): este NO es el mecanismo de reinicio.**
>
> Una revisión anterior de esta sección llamaba a la divergencia "el principal mecanismo
> candidato para los reinicios" y decía que "explica la división de formas". No lo hace,
> y la razón ahora está medida en lugar de argumentada:
>
> * `commit_creds` toma su tarea de **`current`** — `0x1867a0 mrs x20, sp_el0`.
> Su firma es `commit_creds(struct cred *new)`; no hay argumento de tarea.
> Así que una divergencia solo importa si la tarea que la **posee** llama a `commit_creds`
> ella misma.
> * Las ejecuciones que reiniciaban eran todas `V12_NO_EXEC=1` (indicado textualmente en
> `run3_0445.log:18`, `run9_0606.log:20`, `run10_0616.log:24`), así que la víctima
> nunca emitió `execve` y nunca alcanzó `commit_creds` en absoluto.
> * La ejecución 10 no tuvo ningún poke en absoluto (`grep -c poke` = 0).
> * `exit_creds` anula **ambos** punteros antes de `put_cred`
> (`0x185cb8 str xzr,[x19,#0x778]`; `0x185d24 str xzr,[x19,#0x780]`), así que el
> `_exit(0)` de la víctima **borra** la divergencia en lugar de tropezar con ella.
>
> ⇒ En esas ejecuciones la divergencia fue **inerte**. El `BUG_ON` es real, pero es una
> mina terrestre que no ha explotado. "La forma A nunca reinicia" vuelve a ser una
> correlación. Lo que la mina terrestre realmente restringe es el **blanqueo**, porque
> `setresgid`/`setresuid` llaman a `commit_creds` ellos mismos.
>
> **La cantidad que realmente separa las cadenas es la igualdad de punteros, y eso
> significa que los dos disparos deben escribir UN valor** — véase la siguiente subsección.
### ★★★ Los dos disparos deben escribir el *mismo* valor — no simplemente ambos acertar
`BUG_ON` compara **punteros**. Dos páginas que ambas llevan `uid 0` siguen siendo dos
objetos diferentes. Las capturas hacen la distinción concreta:
| cadena | disparo 0x778 | disparo 0x780 | punteros |
|---|---|---|---|
| antigua (`t5loop.sh MODE=CRED`) | `in[0]=0xffffff802a7e0be0` | `in[0]=0xffffff802a7e0be0` | **iguales** → ksud cargado, gestor vivo 120 s |
| nueva (`run_bootA.sh`) | `0xffffff88679bade0` (ejecución 9) | `0xffffff8785d6ade0` | **difieren** → divergente incluso con ambos acertados |
`tools/t5loop.sh` aplica **un** `$ENVV` a **cada** offset, así que `MODE=CRED`
hizo ambos disparos idénticos *por construcción*. `run_bootA.sh` disparó el paso 5 y
el paso 6 cada uno con un `$extra` vacío, así que cada uno roció su **propia** página.
⇒ El requisito es **"ambos disparos escriben el mismo valor"**. `run_bootA.sh` ahora
lo impone (`SAME_VALUE=1`, el predeterminado): el paso 6 reutiliza el `write_value`
observado del paso 5 textualmente, y **se niega a disparar en absoluto** si no puede
recuperar ese valor — porque disparar construiría un par divergente.
⚠ `HOLD` debe sobrevivir al segundo disparo. Si el proceso hijo PIN del primer disparo
muere primero, la página se libera y se reasigna y "mismo valor" se convierte en un
puntero colgante. El `HOLD=20` predeterminado es **demasiado corto**; use `HOLD=600`.
Esto ahora es el valor *predeterminado* siempre que `SAME_VALUE=1` — el antiguo 20 s
incondicional significaba que la configuración predeterminada era ella misma la
trampa — y un `HOLD` corto explícito con `SAME_VALUE=1` ahora advierte ruidosamente
en lugar de producir silenciosamente un puntero colgante.
⚠ `CONTROL=1` solía cambiar **solo el paso 5**, así que producía
`(init_cred, página nueva)` — un par divergente — mientras este archivo afirmaba que
reproducía la celda 2. Corregido: ahora establece ambos disparos a `init_cred`. (El
coste de la celda 2 se mantiene: el efecto secundario corrompe `init_cred+8`
globalmente, que es lo que es `Uid: 0 0 4294967176 0`.)
**Consecuencias para cualquier cosa que quiera blanquear la credencial**
(`setresgid` + `setresuid`, para intercambiar la página rociada por un `struct cred`
real):
- El mecanismo es real y está verificado — `commit_creds` escribe `x21` en **ambos**
`task+0x778` y `task+0x780` (`0x186998` / `0x1869a0`), así que una llamada repara la
división permanentemente; `prepare_creds @0xffffffc008186070` es
`kmem_cache_alloc(cred_jar)` + `memcpy(new, task->cred, 0xA8)` +
`security_prepare_creds(...)`, y 147/149 están ambos en la lista de exentos del guard.
- **Pero su precondición es lo opuesto a "saltarse el disparo 0x778".** El blanqueo
en sí llama a `commit_creds`, así que solo debe emitirse cuando **ambos** punteros
ya contienen el mismo valor.
- `V12_LAUNDER=1` está condicionado a **dos** cosas, y la primera no es una observación:
1. **`V12_W7_SAME_VALUE=1`** — el hecho de *procedencia* de que a ambos disparos se
les dio el mismo valor. Sin una primitiva de lectura, la identidad de punteros es
inobservable, así que esto no puede reemplazarse por una mejor comprobación en
espacio de usuario; debe declararse.
2. `consistent=1` — la vista de `0x780` (`getuid()`) coincidiendo con la vista de
`0x778` (`/proc/self/status` `Uid:`). **Necesario pero no suficiente por sí solo**:
dos páginas distintas que ambas llevan `uid 0` se leen iguales mientras los
punteros difieren — que es exactamente el caso que el runner solía fabricar.
Dado (1), se vuelve suficiente: coinciden + mismo valor ⇒ ambos aterrizaron en
la misma página.
Si cualquiera de las comprobaciones falla ⇒ negarse, y la tabla de cuatro casos va
a la evidencia. La línea del informe LT imprime ambas vistas (`uid=` / `real_uid=` /
`consistent=`) más `same_value_declared=` para que el estado se lea, no se infiera.
### ★ Instrumento 1 — el efecto secundario es un STAMP dirigido al objetivo
`*(write_value + 8) = write_target`, y `cred+8` / `cred+0xc` son `gid` / `suid`,
así que un almacén de 8 bytes aterriza sobre ambos:```
cred.gid = low32(write_target)
cred.suid = hi32(write_target)
Eso es una medición, no un modelo. out/t5_w7_778.txt tiene
write_target = 0xffffff8800cdd178 y Uid: 0 0 4294967176 0, donde
4294967176 = 0xffffff88 = hi32(write_target); notes.md §11 registra la otra
mitad, init_cred.gid = 0x00cdd178 = low32(write_target).
Dos usos:
task+0x778. /proc/<pid>/status lee
real_cred = task+0x778 — exactamente el cred recién instalado — así que el
sello es directamente legible desde el espacio de usuario. Léelo antes de
la etapa 3: la reparación pone a cero cred+8 y lo borra (t5_repair.txt de
notes.md §11 lee 4294967176 antes de una reparación exitosa y 0 después).0, así que dos páginas distintas reportan
ambas "consistente". Pero low32(T+0x778) y low32(T+0x780) difieren en
exactamente 8, así que con dos páginas (de ) y
(de ) discrepan — y compara gid además de uid.⇒ V12_W7_SAME_VALUE es la segunda compuerta, no la única. Sigue importando:
el sello solo discrimina si ambos efectos secundarios se dispararon, así que la
regla de mismo valor cierra ese agujero residual. Y nota qué es una compuerta —
un detector, no un preventor. Solo puede rechazar; deja la tarea divergente por el
resto del arranque. La regla de mismo valor es lo que hace que el par sea
correcto, que es lo que tenía la cadena antigua y lo que se necesita para
llegar a execve siquiera.
probe_state NO es un criterio de aterrizajeHa estado equivocado tres veces en este proyecto: W1 aterrizó en el global y
reportó R; la D de la ejecución 12 apuntaba a un global en lugar de a un
cred; y la R de la ejecución 11 se escribió en una tabla como si fuera un
aterrizaje (run11_w778r1_miss.txt y w7_w7781.txt de la ejecución 7 son
isomorfos línea por línea — ambos probe_state = R, probe_done = 0). Usa un
oráculo por objetivo:
run_bootA.sh ahora usa el sello para la etapa 1 — que es lo que hace que los
reintentos de ROUNDS>1 sobre task+0x778 sean significativos, ya que una ronda
fallida es legible en lugar de inferida — y no disparará la etapa 2 a menos que
la etapa 1 haya aterrizado.
⛔ Di "campo de awk", nunca "3er campo".
uid_lineimprime también la etiqueta (Uid: 0 0 4294967176 0), así que$1de awk es"Uid:"y los cuatro valores de id son$2..$5:$2=uid$3=euid$4=suid$5=fsuid. El sello está encred+8, es decirgid(low32) ysuid(hi32) — así que esGid:$2yUid:. Llamarlo "el tercer campo" (que cuenta , y es como lo redacta §11) invita al código a leer , que es = en el cred falso y nunca puede ser igual a . Ese off-by-one estaba presente aquí: devolvía "no stamp" para un disparo que aterrizó, así que la etapa 2 nunca se disparó y la compuerta de blanqueo rechazó para siempre — , porque "no stamp" es también el resultado normal de un fallo genuino.
Un criterio que nunca se prueba contra una muestra positiva conocida no es un
criterio, es una conjetura — y esta clase de fallo (este off-by-one,
probe_state, dmesg -w, el klog.host vacío, la lectura en blanco) siempre se
presenta como "no pasó nada", que también es un resultado experimental
legítimo. Así que la comprobación ahora está defendida dos veces:
stamp_selftest() se ejecuta en el preflight y hace exit 9 si falla,
ejecutando las mismas funciones de extracción que usa la compuerta contra los
valores medidos de out/t5_w7_778.txt (write_target = 0xffffff8800cdd178 →
Uid $4 = 4294967176, Gid $2 = 13488504) más muestras negativas e
ilegibles. Un autotest que reimplementa la comprobación no prueba nada, así que
la extracción de campos está factorizada en uid_suid_field / gid_gid_field.tools/test_stamp_criterion.sh — lo mismo
como test de regresión independiente, extrayendo las funciones reales de
.stamp_ok() devuelve tres estados, porque "no se puede leer" no es "no hay
sello" (esa confusión es lo que hizo que la ejecución 13 pareciera "sin cambio"):
0 = presente, 1 = legible y sin sello, 2 = ILEGIBLE. Y cuando devuelve
1 mientras probe_state = D, el runner imprime ⛔ ORACLE INCONSISTENT —
"ve a revisar el criterio" — en lugar del mensaje "no aterrizó", que envía al
operador a un lugar completamente distinto (un arranque nuevo, o una cacería de
tasa de aciertos).
Derivación completa: evidence/2026-09-18-divergence-is-latent.md
y evidence/2026-09-18-cred-launder-verification.md
(el §2.3 de este último queda retractado en el lugar). La autocomprobación de
solapamiento de forma de escritura se cierra offline: las palabras de forma viven
en la rejilla fd_set en la pila del kernel mientras el efecto secundario aterriza
dentro de la página rociada, así que las dos no pueden solaparse en ninguna de las
dos formas.
Tres reporteros independientes. Ninguno es un respaldo de otro, y solo la ruta 1 puede matar la tarea llamante.
La ruta 2 se dispara en execve, no en el cambio de credenciales, y es una
ruta de código separada de la ruta 1. Reporta a través de
kevent_send_to_user, así que lo que ocurre después es decisión de un demonio de
espacio de usuario, no del kernel.
La comprobación es sobre la ruta de la imagen que se está ejecutando, así que
no basta con cargar en memfd el payload del cargador: si el propio cargador se
ejecuta desde /data/local/tmp, ese primer execve ya reporta. d_path() sobre
un memfd es /memfd:…, así que el propio cargador debe ejecutarse a través de
un memfd — V12_EXEC_MEMFD ahora está activado por defecto exactamente por esta
razón. El comportamiento antiguo es visible en 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
Marcadores grepeables para la ruta 2:```
[ROOTCHECK-EXEC-INFO]:common %s result %s with "execve_report" / "execve_block"
%d,path@@%s kevent payload fragment
Porque la ruta 2 y la ruta 3 solo reportan a través de kevent, "ningún [ROOTCHECK-*] en el log del kernel" no excluye que alguna de ellas se haya disparado. Esa inferencia necesita el receptor en espacio de usuario, que no hemos localizado.
oplus_security_guard.koCaché de 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 sobre el flujo de control (importante para el orden de explotación). Las cuatro comparaciones de borde descendente saltan directamente a 0x468, el despacho del número de syscall — no caen a través de la puerta addr_limit. Solo se alcanza 0x454–0x464 cuando ningún id descendió. Por lo tanto, el despacho se entra cuando o bien algún id descendió o bien addr_limit == KERNEL_DS; no está condicionado por addr_limit.
Consecuencias:
sys_enter aún tenía cacheado el uid antiguo. Si la tarea ya es uid=0 cuando entra una syscall (0x400 cbz), el hook retorna y permanece ciego a partir de entonces.delivery/外部建议评审_2026-09-18.md.g_boot_state — 1 byte .data..ro_after_init, establecido en la inicialización del módulo desde verified_bootstate mediante strstr. is_unlocked() = LDRB + RET.
Las escrituras en VA del módulo provocan fallo (CONFIG_STRICT_MODULE_RWX=y) — usa el alias de physmap 0xffffff80….
Payload del informe: $$sys_call_number@@%d, $$set_id_flag@@%d, $$addr_limit@@%lx, $$enforce@@%d.
.rodata+0, índices 143–214Las 60 entradas restantes → informe + kill.
No bloquees un hilo en sendmsg (211) mientras cambian sus credenciales. No está en la tabla, por lo que el hilo es reportado y eliminado. Las únicas syscalls en las que es seguro quedar bloqueado con ese propósito son las doce anteriores:
setregid, setgid, setreuid, setuid, setresuid, setresgid,
connect, getsockname, setsockopt, shutdown, readahead, brk.
Corrección (2026-09-18). Una revisión anterior de esta tabla etiquetaba cada entrada una menos que el número real de syscall de arm64 (
146se llamabasetresuid; essetuid—setresuides147). Los números siempre estuvieron correctos; solo los nombres estaban mal. Los nombres ahora se resuelven desdesys_call_table@0xffffffc00a13d8c0en la imagen del kernel de este dispositivo. En particularsendmsg(211),munmap(215),getsockopt(209) ygetpeername(205) no están exentas — bloquear un hilo en cualquiera de esas mientras cambian sus credenciales es un kill, no un pass. Regenerar contools/gen_exempt_table.py.
oplus_secure_harden.ko`entry_handler_setxattr` → `oplus_heapspray_check(7)`, `entry_handler_cpuinfo` → `(6)`, ambos incondicionales.
`oplus_heapspray_check` — contadores `heapspary_ip4` / `ip6` / `xttr` / `cpuinfo`, delta de `ktime_get_real_ts64`, 7 sitios de reporte (`snprintf` + `printk` + `kevent_send_to_user`), condicionado a `verified_bootstate`.
### Evasión
| Primitiva | |
|---|---|
| `setsockopt` optname 46 `MCAST_JOIN_SOURCE_GROUP` | no filtrado |
| `setsockopt` level `SOL_IPV6` (41) | no filtrado si el filtro lee `level` |
| `setxattr` | siempre contado |
| `/proc/cpuinfo` | siempre contado |
| `socket()` / `socketpair()` | no enganchado |
| `sendmsg`, `pipe`, `memfd`, `add_key`, `io_uring`, mmap | no enganchado |
## 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 son fijos — mantienen la geometría del marco de pila de reclamación (calibración delta=0). Cambiar cualquiera de ellos requiere recalibrar en el dispositivo.```bash
export ANDROID_NDK_HOME=/path/to/android-ndk-r28c
make # → exploit_guard
./build.sh # same, with NDK auto-detection
Manual:```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 compila en cada push (.github/workflows/build.yml, Ubuntu + NDK r28c, artefacto exploit_guard).
adb push exploit_guard /data/local/tmp/e adb shell chmod 755 /data/local/tmp/e adb shell /data/local/tmp/e
## Archivos```
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 — transcripciones textuales de adb shell de cuatro
ejecuciones como root: la línea de tiempo completa, el momento en que el uid de la tarea objetivo se convierte en 0, la
carga de kernelsu.ko y el estado posterior. Lee primero el bloque de encabezado: enumera lo que el archivo no
contiene y por qué.
evidence/notes.md — el lado del kernel. Direcciones del módulo y
qué canales de /proc funcionan en cada estado de SELinux; la derivación completa de g_boot_state
incluida la clave strstr; la tabla de exenciones corregida; la receta de captura
que produciría la mitad del kernel faltante; y una lista de lo que aún está
abierto.
evidence/2026-09-18-bootA/ — la primera
ejecución en dispositivo del diseño actual, 13 arranques. Los reinicios son ordenados
(bootreason=reboot) y nunca se ha capturado una línea de pánico — pero léelo
como una muestra de uno, no de trece. De las cuatro ejecuciones que se reiniciaron, dos guardaron un
klog.host vacío, una guardó el registro del arranque siguiente y solo una ventana puede
plausiblemente delimitar su propio reinicio. Asimismo, el probe_state y la lectura de vuelta de la víctima de una ejecución están ambos vacíos (el dispositivo ya no estaba), por lo que no aporta
información sobre si su escritura se realizó — un campo en blanco no es "sin cambios".
El directorio también documenta el error de metodología que vale la pena conocer:
dmesg -w no hace nada en este dispositivo (toybox vuelca una vez y sale), por lo que el
registro del kernel de una ejecución anterior solo contenía historial previo a la captura — "sin [ROOTCHECK-*]" no
era evidencia de nada. evidence/notes.md §6 contiene la receta corregida de
sondeo y transmisión al host.
evidence/2026-09-18-cred-launder-verification.md
— verificación de la propuesta de blanqueo de credenciales contra el propio
desensamblado de esta imagen (no el código fuente genérico de 5.10): el doble almacenamiento de commit_creds, la
asignación de prepare_creds y el sizeof(struct cred) = 0xA8 medido, el
riesgo de divergencia BUG_ON(cred != real_cred) mencionado arriba, la autocomprobación cerrada de
solapamiento de forma de escritura y la auditoría de cobertura de evidencia de los 13 arranques.
§2.3 está retractado en el lugar — la divergencia es latente, no el mecanismo
de reinicio.
evidence/2026-09-18-divergence-is-latent.md
— la verificación de la ronda 2. commit_creds toma su tarea de current
(0x1867a0 mrs x20, sp_el0), exit_creds anula ambos punteros antes de put_cred,
y las capturas sin procesar muestran que la cadena antigua escribió un valor idéntico
(0xffffff802a7e0be0) en ambas ranuras mientras que la cadena nueva escribió dos páginas
diferentes. Así que el criterio es la igualdad de punteros — "ambos disparos escriben el mismo valor",
no "ambos disparos aciertan".
postreboot_forensics.sh — análisis forense de reinicio que
no depende del sondeador. El criterio es una única condición:
CONFIG_PSTORE_CONSOLE=y hace que panic() escriba la cola de la consola en ramoops en
kmsg_dump(KMSG_DUMP_PANIC) — antes de cualquier reinicio — por lo que si la máquina luego
se reinicia o se cuelga es irrelevante. Extrae /sys/fs/pstore/, busca kernel BUG /
__put_cred / cred.c y muestra la cadena del motivo de arranque (las entradas del historial
han llevado sufijos reboot,shell / bootloader / reboot,edl, por lo que el motivo
distingue a un actor donde la época no lo hace).
⚠ No interpretes "
bootreason=rebootlimpio" como "sin pánico". En QCOM una aserción del watchdog del SoC se reinicia a través del bloque PMIC PON, por lo quepanic → panic_timeout=-1 → hang → watchdog → PMIC reset → clean bootreasones una cadena autoconsistente que es indistinguible de un reinicio de hardware con la evidencia que tenemos. El propiototal_17_dump_0_pmic_17de este repositorio atribuye los 17 reinicios anómalos apmic, que es exactamente la forma normal del watchdog, no evidencia de "no es el kernel".bootreasonno acota nada aquí; ramoops es el único criterio.
Dos precondiciones, o el veredicto del script es nulo (铁律 8 — una conclusión sin señal requiere que el canal se demuestre accesible primero):
/sys/fs/pstore/* es solo para root, por lo que bajo Enforcing
tanto adb pull como cat fallan — y "no se puede leer" produce la misma salida
que "lo leí y estaba vacío". Un script de dos estados imprime "pstore está VACÍO ⇒
pánico refutado" desde un canal que nunca abrió. Por lo tanto, el script emite
CHANNEL UNREACHABLE (ls falló, o todas las entradas conocidas fallaron al leerse
en lugar de no existir) e informa getenforce junto con ello.adb reboot limpio seguido de una obtención inmediata. Si un
reinicio que se sabe bueno no produce nada legible, el canal no está probado y cada
"pstore vacío" posterior no es evidencia. El orden importa: el dispositivo mueve
y desvincula el registro poco después del arranque, por lo que la secuencia es
reiniciar → obtener Permissive (W1) → ejecutar el script inmediatamente.run_bootA.sh — orquestación para ese único arranque, en el orden
que importa (0x778 → 0x780 con el mismo valor → reparación local de la credencial
que realmente se instaló → confirmar → solo entonces sondear). ADB=/SER=/
BIN_LOCAL= se pueden sobrescribir; SAME_VALUE=1 (predeterminado) impone la regla del mismo valor,
LAUNDER=1 habilita el blanqueo condicionado, HOLD=600 es necesario para la secuencia
del mismo valor.
Los reintentos se dividen por etapa (R5/R6), porque las dos etapas tienen perfiles de
riesgo opuestos:
R5 toma por defecto ROUNDS, R6 toma 1.
La ejecución de blanqueo recomendada — la ruta predeterminada de página rociada, no
CONTROL=1:```bash
LAUNDER=1 R5=3 R6=1 HOLD=600 CHAINWAIT=6000 NODRAIN=1 WATCH=180 ./run_bootA.sh
`CONTROL=1` también produciría un par consistente, pero al escribir el puntero `init_cred`, cuyo efecto secundario corrompe `init_cred+8` **globalmente** — y "el framework muere" es una de las cosas que se están vigilando, por lo que arrastrar un fallo a nivel de dispositivo al fondo de la medición enturbia exactamente la lectura que esta ejecución existe para tomar. La ruta de página rociada solo cuesta "ambos disparos deben acertar", que es para lo que sirve `R5=3`. `CONTROL=1` se mantiene como el único par consistente *probado* y como control, no como la configuración recomendada.
**Qué página se instaló se lee de la línea incondicional.** `run_w7` imprime el valor de escritura en dos líneas, y solo una de ellas es incondicional:```
L1793 W7[..] write value = private cred page 0x.. — spray path only
L1802 W7[..] write_value = 0x.. — after the if/else, ALL paths
El runner solía coincidir con la primera redacción, así que con CONTROL=1 la extracción volvía vacía, if [ -n "$CRED" ] omitía la reparación, y el propio término [ -n "$CRED" ] de la conjunción de mismo valor lo mantenía en 0 — la puerta de blanqueo habría rechazado para siempre. Un no-op silencioso en ambos conteos, de una regex que reconocía uno de dos sitios de impresión. Tanto ella como la extracción de write_target (la entrada del criterio de estampado) ahora pasan por wv_from/wt_from, que son ejercitados por la prueba de regresión junto con el propio criterio.
Ambos flujos comienzan antes de la cosa que miden. uid.stream se ejecuta desde la etapa 1; cred.stream comienza en el poke, no después del watch — el poke libera al hijo en su bucle de informe NO_EXEC, que es 240 × 0.5 s = 120 s y luego _exit(0) (exploit.c: "LT child NO-EXEC mode done (120s)"), así que la antigua ubicación en t+~135 s comenzaba a muestrear después de que el hijo ya se había ido, exactamente en la ventana para la que existe el instrumento. El runner también se niega a proceder si stamp_selftest() falla, e imprime ORACLE INCONSISTENT en lugar de "did not land" cuando el estampado y probe_state discrepan.
artifacts/guard_post_handler.s — la cadena de kill con las reubicaciones completadas. adrp x9, #0 en los listados más antiguos es .data..ro_after_init; bl #0x4ac es oplus_root_check_succ. Regenerar con tools/gen_guard_disasm.py después de extraer los módulos del vendor desde tu propio dispositivo.
tools/kdis_ko.py — RELA emparejado por sh_info; en estas compilaciones las relocs de .text viven en .rela.text.<func>, así que la búsqueda basada en nombres no devuelve nada.
| Proyecto | |
|---|---|
| JoinChang/ghostlock-oneplus | implementación de referencia; waiter compacto 5.10 |
| NebuSec CyberMeowfia | investigación original de GhostLock |
GPL-3.0 — ver LICENSE.
| Dispositivo | OPPO Find X5 Pro (PFEM10) |
| SoC | SM8450 / Adreno 730 |
| SO | ColorOS 16.0.3.520 (CN01) |
| Kernel | 5.10.236-android12-9-o-gaf2075ad2c06 |
| Bootloader | bloqueado, verde |
| VA_BITS | 39 — KIMAGE_TEXT_BASE = 0xffffffc008000000 |
| Etapa |
|---|
Disparo de waiter compacto (CMP_REQUEUE_PI → EDEADLK) | funciona |
Fuga de task_struct (perf) | funciona |
Escritura PI (8 bytes; valor = 0 o una dirección de kernel válida) | funciona |
task+0x778 o task+0x780 por sí solos → Uid=root | funciona — pero un aterrizaje de un solo campo deja la tarea divergente, y eso es un BUG_ON duro latente. Ver el riesgo de divergencia |
| Ambos campos escritos con UN solo valor (un par consistente) | ❌ nunca producido con una página rociada. Solo observado con el alias global init_cred (09-14, CONTROL=1). El runner ahora lo impone (SAME_VALUE=1); no ejecutado en el dispositivo |
Blanqueo de credenciales (setresgid + setresuid) | implementado detrás de V12_LAUNDER=1; no ejecutado en el dispositivo |
kernelsu.ko cargado | funciona |
| El proceso root sobrevive | ⚠ no establecido — ver abajo |
| Mecanismo de reinicio | ❌ no establecido. Un candidato (la divergencia) ahora está excluido; ver abajo |
probe_state como criterio de aterrizaje | ❌ incorrecto — no usar. Tres contraejemplos; ver la tabla de abajo |
| Canal de pánico pstore/ramoops | ⚠ el instrumento existe; canal nunca validado (aún sin prueba nula) |
| "La víctima gira en espacio de usuario puro" | ⚠ aún sin lectura — uid.stream ahora registra utime/stime/nvcsw para poder comprobarlo |
| doble escritura de una sola pasada del lado pi | ⚠ no establecido; pi.pc/pi.left están codificados a 0 en fdset_map.h |
Ruta A (UMH / modprobe_path) | STATIC_USERMODEHELPER_PATH="" |
| Campo | Offset |
|---|
real_cred / cred | 0x778 / 0x780 |
syscallno en caché | 0xdf8 |
uid / euid / gid / egid en caché | 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 0getgid()credstatus_gidreal_credlt_cred_ids_agree()| objetivo | oráculo de aterrizaje |
|---|
task+0x778 | Uid: 4º campo de awk = hi32(write_target) y Gid: 2º campo de awk = low32(write_target) — el sello de arriba; leer antes de la etapa 3 |
task+0x780 | el propio getuid() de la víctima |
global selinux_enforcing | getenforce |
probe_state | ❌ no es un criterio. Como mucho una pista sobre la cadena; nunca evidencia de que una escritura aterrizó |
$4notes.md$3euid0hi32(write_target)stamp_ok()run_bootA.sh| # | Hook | Disparador | Acción |
|---|
| 1 | oplus_root_check_post_handler, tracepoint sys_exit | algún id descendió, o addr_limit == KERNEL_DS | oplus_root_killed → printk + do_exit(SIGKILL); y oplus_root_check_succ → kevent_send_to_user |
| 2 | oplus_exe_block_ret_handler, sys_exit pero solo para execve (221) | d_path(mm->exe_file) empieza por /data, /data/local/tmp, /data/nativetest, /data/nativetest64 | oplus_RWO_root_check → printk + kevent_send_to_user (sin do_exit) |
| 3 | kretprobes de oplus_secure_harden | setsockopt optname ∈ {41,42,48}, setxattr, /proc/cpuinfo, recarga de política 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 | Engancha | 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 |
| etapa | seguridad de reintento |
|---|
R5 | paso 5, task+0x778 | seguro — un fallo no instala nada, y el criterio de la marca hace que una ronda fallida sea legible, por lo que otro disparo es solo otro intento. R5=3 lleva la tasa de acierto por disparo de ~p a ~1−(1−p)³. |
R6 | paso 6, task+0x780 | no es seguro, y no es necesario — solo se dispara después de que el paso 5 haya acertado, por lo que un reintento dispara a una tarea ya divergente: otra oportunidad de acertar una segunda página diferente, sin beneficio, ya que un acierto completa el par. Déjalo en 1. |