
GhostLock (CVE-2026-43499) para OPPO Find X5 Pro (PFEM10) — engenharia reversa do watchdog OPlus e do detector de heap-spray
English · 中文
Port do GhostLock (CVE-2026-43499) para o OPPO Find X5 Pro no ColorOS 16. Alcança um processo filho com uid=0 e um kernelsu.ko carregado; o processo root é interceptado.
CVE-2026-43499 — use-after-free no futex PI. remove_waiter() limpa current->pi_blocked_on quando current é o requeuer, no caminho de rollback -EDEADLK de rt_mutex_start_proxy_lock().
remove_waiter @ 0xffffffc0081ed254 — forma pré-correção.
Sobre "processo root sobrevive": as execuções em evidence/kill.log
alcançam uid=0 e carregam kernelsu.ko, e na execução que de fato fez polling
o processo do gerenciador KernelSU sobreviveu 120 s com kernelsu ainda Live em
/proc/modules. Em uma execução posterior, a mesma cadeia deixou os serviços do framework Android
inacessíveis (Can't find service: package/power/input/phone/wifi)
enquanto o módulo ainda estava Live. Nenhuma linha de kernel [ROOTCHECK-*] e nenhum
payload $$sys_call_number@@ jamais foi capturado, então a causa do estado da
execução posterior não é atribuída. Ver 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
**O Estágio 2 deve receber explicitamente o valor do estágio 1.** Os Estágios 1 e 2 são dois
processos independentes, cada um com seu próprio spray, então "escrever a página de credenciais em ambos
os slots" é uma armadilha: lida ingenuamente, produz `(pageA, pageB)`, e porque
`commit_creds` compara **ponteiros**, esse par é divergente mesmo quando ambas as escritas
ocorrem. Isso não é hipotético — é exatamente o que as execuções 3 e 9 fizeram:```
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 portanto dispara o estágio 2 com V12_W7_VALUE=<valor observado pelo estágio 1> e recusa-se a dispará-lo de todo se esse valor não puder ser recuperado.
HOLD deve sobreviver ao estágio 2, ou a página do estágio 1 é liberada e realocada e "o
mesmo valor" torna-se um ponteiro pendente. Veja
a regra do mesmo valor.
Uma página por boot é reparada. O estágio 3 zera V+8. Com duas páginas
diferentes, zerar ambas apagaria o carimbo gid/suid (abaixo) e faria uma divergência
parecer concordância, então o runner repara apenas a página que foi realmente
instalada e para se os dois valores discordarem.
A página de cred é construída por payload.c: todos os oito campos de id zerados, todos os cinco
conjuntos de capacidades completos, e user / user_ns / group_info apontando para
root_user / init_user_ns / init_groups. O estágio 3 existe porque o efeito colateral da escrita sempre
sobrescreve cred+8 (gid/suid) de qualquer cred que ele instale.
init_cred — uma dicotomia explícitaDuas seções aqui costumavam se contradizer ("nunca o init_cred global"
vs "CONTROL=1 reproduz a célula 2", e a célula 2 é init_cred). Ambas as afirmações
são verdadeiras para papéis diferentes:
init_cred faz o efeito colateral
corromper init_cred+8 globalmente — init_cred é compartilhado por toda thread
do kernel, e Uid: 0 0 4294967176 0 é precisamente essa corrupção. O código
recusa esse caminho a menos que V12_ALLOW_INIT_CRED=1 seja definido deliberadamente.0xffffff802a7e0be0) em ambos os slots, então
real_cred == cred por construção — é por isso que sobreviveu até execve.
CONTROL=1 o reproduz. É um controle, não uma configuração para se basear.vazamento de perf: PERF_TYPE_SOFTWARE / PERF_COUNT_SW_CPU_CLOCK, PERF_SAMPLE_REGS_INTR, exclude_user=1.
Aceitar [0xffffff8400000000, 0xffffff90000000), votos ≥ 15%.
O UAF é conduzido através de rb_erase_cached Caso 1-esquerda. Isso fornece dois
armazenamentos, não um:```
*(write_target) = write_value // the store you aim
*(write_value + 0x08) = write_target // unavoidable side effect
`write_value` deve estar alinhado a 8 bytes com o bit 0 limpo — é `0` ou um
endereço de kernel válido. **É por isso que `g_boot_state` não pode ser definido com esta
primitiva**: o byte que precisa se tornar `1` tem seu bit baixo forçado a `0` pelo
requisito de alinhamento, e `write_value` é a mesma quantidade que o
endereço onde o efeito colateral recai.
### O efeito colateral escreve naquilo que `write_value` aponta
`write_value` é tanto *o valor armazenado* quanto *o endereço para onde o efeito colateral
escreve* (em `+8`). Aponte-o para um objeto global do kernel e você corrompe esse
objeto.
**W7 costumava fazer exatamente isso** — mirando `write_value` no alias `init_cred` —
e isso é visível na leitura de retorno. 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 o alias init_cred e write_target era child_task+0x778.
init_cred+8 é gid/suid, então o efeito colateral armazenou 0xffffff8800cdd178
ali: init_cred.gid = 0x00cdd178 e init_cred.suid = 0xffffff88 = 4294967176 — precisamente o 4º campo awk da linha Uid: acima. Zerar
init_cred+8 corrigiu isso (out/t5_repair.txt: Uid: 0 0 4294967176 0 →
), que é tudo o que "W7 stage 3" sempre foi.
Este caminho agora é recusado no código. V12_W7_INIT_CRED=1 aborta com uma
explicação a menos que V12_ALLOW_INIT_CRED=1 também esteja definido, e os caminhos W2/W6/LTC
não recorrem mais a init_cred quando a página de cred privada está ausente — eles
abortam em vez disso. O padrão, e o único caminho sensato, é a página de cred pulverizada.
O efeito colateral em si não pode ser evitado: write_value deve ser o ponteiro
de cred, então cred+8 é sempre sobrescrito com o alvo de escrita. Apenas sua
localização é uma escolha — e o reparo agora é um zero local de
cred_page+8 (stage 3), não uma escrita em um objeto global.
Uma leitura de
groups=com lixo é um sintoma separado, não este. Foi visto em uma execução ondegideegidforam lidos limpos, então não pode vir do efeito colateral deinit_cred+8; aponta para o próprio campogroup_infodo cred falso. Vejaevidence/notes.md§10.6.
BUG_ON fatalA primitiva armazena em exatamente um endereço por passagem. task+0x778
(real_cred) e task+0x780 (cred) são dois endereços separados, então qualquer
escrita pousada apenas em 0x778 ou apenas em 0x780 deixa a task com
cred != real_cred — um estado de divergência.
Nesta imagem esse estado é um panic fatal, não um aviso. commit_creds abre
com 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 o kernel é compilado com **`CONFIG_PANIC_ON_OOPS=y`** (`CONFIG_PANIC_ON_OOPS_VALUE=1`).
`__put_cred @0xffffffc008185530` carrega a mesma família de asserções
(`usage != 0` → BUG; `cred == current->cred` / `current->real_cred` → BUG).
Portanto a divergência é *latente* — não faz nada enquanto a vítima apenas gira —
até que **qualquer** `commit_creds` aconteça nessa task: `setresuid` / `setresgid` /
`setuid` / `setgid` / `capset`, ou **`execve` via `install_exec_creds`**.
> **⛔ Retratado (2026-09-18 tarde): este NÃO é o mecanismo de reboot.**
>
> Uma revisão anterior desta secção chamava à divergência "o principal mecanismo
> candidato para os reboots" e dizia que "explica a divisão de formas". Não
> explica, e a razão é agora medida em vez de argumentada:
>
> * `commit_creds` obtém a sua task a partir de **`current`** — `0x1867a0 mrs x20, sp_el0`.
> A sua assinatura é `commit_creds(struct cred *new)`; não há argumento de task.
> Portanto uma divergência só importa se a task que a **detém** chamar
> `commit_creds` ela própria.
> * As execuções que reiniciavam eram todas `V12_NO_EXEC=1` (declarado textualmente em
> `run3_0445.log:18`, `run9_0606.log:20`, `run10_0616.log:24`), pelo que a vítima
> nunca emitiu `execve` e nunca chegou a `commit_creds`.
> * A execução 10 não teve poke algum (`grep -c poke` = 0).
> * `exit_creds` anula **ambos** os ponteiros antes de `put_cred`
> (`0x185cb8 str xzr,[x19,#0x778]`; `0x185d24 str xzr,[x19,#0x780]`), pelo que o
> `_exit(0)` da vítima **apaga** a divergência em vez de tropeçar nela.
>
> ⇒ Nessas execuções a divergência era **inerte**. O `BUG_ON` é real, mas é uma
> mina terrestre que não explodiu. "A forma A nunca reinicia" volta a ser uma
> correlação. O que a mina terrestre realmente restringe é o **branqueamento**, porque
> `setresgid`/`setresuid` chamam `commit_creds` eles próprios.
>
> **A quantidade que realmente separa as cadeias é a igualdade de ponteiros, e isso
> significa que os dois disparos têm de escrever UM valor** — ver a subsecção seguinte.
### ★★★ Os dois disparos têm de escrever o *mesmo* valor — não apenas ambos acertar
`BUG_ON` compara **ponteiros**. Duas páginas que ambas carregam `uid 0` continuam a ser dois
objetos diferentes. As capturas tornam a distinção concreta:
| cadeia | disparo 0x778 | disparo 0x780 | ponteiros |
|---|---|---|---|
| antiga (`t5loop.sh MODE=CRED`) | `in[0]=0xffffff802a7e0be0` | `in[0]=0xffffff802a7e0be0` | **iguais** → ksud carregado, manager vivo 120 s |
| nova (`run_bootA.sh`) | `0xffffff88679bade0` (execução 9) | `0xffffff8785d6ade0` | **diferentes** → divergente mesmo com ambos acertados |
`tools/t5loop.sh` aplica **um** `$ENVV` a **todos** os offsets, pelo que `MODE=CRED`
tornou ambos os disparos idênticos *por construção*. `run_bootA.sh` disparou o passo 5 e
o passo 6 cada um com um `$extra` vazio, pelo que cada um pulverizou a sua **própria** página.
⇒ O requisito é **"ambos os disparos escrevem o mesmo valor"**. `run_bootA.sh` agora
impõe isso (`SAME_VALUE=1`, o predefinido): o passo 6 reutiliza o `write_value` observado
do passo 5 textualmente, e **recusa-se a disparar de todo** se não conseguir recuperar esse
valor — porque disparar construiria um par divergente.
⚠ `HOLD` tem de sobreviver ao segundo disparo. Se o processo filho PIN do primeiro disparo morrer primeiro,
a página é libertada e realocada e "mesmo valor" torna-se um ponteiro pendente.
O `HOLD=20` predefinido é **demasiado curto**; use `HOLD=600`. Isto é agora o *predefinido*
sempre que `SAME_VALUE=1` — os antigos 20 s incondicionais significavam que a configuração
predefinida era ela própria a armadilha — e um `HOLD` curto explícito com
`SAME_VALUE=1` agora avisa ruidosamente em vez de produzir silenciosamente um ponteiro pendente.
⚠ `CONTROL=1` costumava alterar **apenas o passo 5**, pelo que produzia
`(init_cred, página nova)` — um par divergente — enquanto este ficheiro afirmava que
reproduzia a célula 2. Corrigido: agora define ambos os disparos para `init_cred`. (O custo da célula 2
mantém-se: o efeito secundário corrompe `init_cred+8` globalmente, que é o que
`Uid: 0 0 4294967176 0` é.)
**Consequências para qualquer coisa que queira branquear a credencial**
(`setresgid` + `setresuid`, para trocar a página pulverizada por um `struct cred` real):
- O mecanismo é real e verificado — `commit_creds` escreve `x21` em **ambos**
`task+0x778` e `task+0x780` (`0x186998` / `0x1869a0`), pelo que uma chamada repara a
divisão permanentemente; `prepare_creds @0xffffffc008186070` é
`kmem_cache_alloc(cred_jar)` + `memcpy(new, task->cred, 0xA8)` +
`security_prepare_creds(...)`, e 147/149 estão ambos na lista de isenções do guard.
- **Mas a sua pré-condição é o oposto de "saltar o disparo 0x778".** O branqueamento
em si chama `commit_creds`, pelo que só deve ser emitido quando **ambos** os ponteiros
já detêm o mesmo valor.
- `V12_LAUNDER=1` está condicionado a **duas** coisas, e a primeira não é uma observação:
1. **`V12_W7_SAME_VALUE=1`** — o facto de *proveniência* de que ambos os disparos receberam
o mesmo valor. Sem uma primitiva de leitura, a identidade de ponteiros é inobservável, pelo que
isto não pode ser substituído por uma verificação de userspace melhor; tem de ser declarado.
2. `consistent=1` — a vista `0x780` (`getuid()`) concordar com a vista `0x778`
(`/proc/self/status` `Uid:`). **Necessário mas não suficiente por si só**:
duas páginas distintas que ambas carregam `uid 0` leem-se iguais enquanto os ponteiros diferem
— que é exatamente o caso que o runner costumava fabricar. Dado (1), torna-se
suficiente: concordar + mesmo valor ⇒ ambos acertaram na mesma página.
Qualquer das verificações a falhar ⇒ recusar, e a tabela de quatro casos vai para a evidência.
A linha do relatório LT imprime ambas as vistas (`uid=` / `real_uid=` / `consistent=`) mais
`same_value_declared=` para que o estado seja lido, não inferido.
### ★ Instrumento 1 — o efeito secundário é um STAMP apontado ao alvo
`*(write_value + 8) = write_target`, e `cred+8` / `cred+0xc` são `gid` / `suid`,
pelo que um único store de 8 bytes acerta em ambos:```
cred.gid = low32(write_target)
cred.suid = hi32(write_target)
Isso é uma medição, não um modelo. out/t5_w7_778.txt tem
write_target = 0xffffff8800cdd178 e Uid: 0 0 4294967176 0, onde
4294967176 = 0xffffff88 = hi32(write_target); notes.md §11 registra a outra
metade, init_cred.gid = 0x00cdd178 = low32(write_target).
Dois usos:
task+0x778. /proc/<pid>/status lê
real_cred = task+0x778 — exatamente o cred recém-instalado — então o carimbo é
diretamente legível do espaço de usuário. Leia-o antes do estágio 3: o reparo
zera cred+8 e o apaga (t5_repair.txt do notes.md §11 lê
4294967176 antes de um reparo bem-sucedido e 0 depois).0, então duas
páginas distintas ambas reportam "consistente". Mas low32(T+0x778) e
low32(T+0x780) diferem em exatamente 8, então com duas páginas getgid() (de
) e (de ) discordam — e
compara gid assim como uid.⇒ V12_W7_SAME_VALUE é o segundo portão, não o único. Ele ainda importa:
o carimbo só discrimina se ambos os efeitos colaterais dispararam, então a regra do mesmo valor
fecha esse buraco residual. E note o que um portão é — um detector, não um
preventor. Ele só pode recusar; deixa a tarefa divergente pelo resto do
boot. A regra do mesmo valor é o que torna o par correto, que é o que a
cadeia antiga tinha e o que é necessário para alcançar execve.
probe_state NÃO é um critério de aterrissagemEle esteve errado três vezes neste projeto: W1 aterrissou no global e
reportou R; o D do run 12 foi apontado para um global em vez de para um cred; e o R do run
11 foi escrito numa tabela como se fosse uma aterrissagem
(run11_w778r1_miss.txt e o w7_w7781.txt do run 7 são linha por linha
isomórficos — ambos probe_state = R, probe_done = 0). Use um oráculo por alvo:
run_bootA.sh agora usa o carimbo para o estágio 1 — que é o que torna as
retentativas de ROUNDS>1 em task+0x778 significativas, já que uma rodada falha é legível em vez de
inferida — e não disparará o estágio 2 a menos que o estágio 1 tenha aterrissado.
⛔ Diga "campo do awk", nunca "3º campo".
uid_lineimprime o rótulo também (Uid: 0 0 4294967176 0), então o$1do awk é"Uid:"e os quatro valores de id são$2..$5:$2=uid$3=euid$4=suid$5=fsuid. O carimbo fica emcred+8, ou seja,gid(low32) esuid(hi32) — então éGid:$2eUid:. Chamá-lo de "o terceiro campo" (que conta , e é como o §11 o descreve) convida o código a ler , que é = no cred falso e nunca pode ser igual a . Esse off-by-one estava presente aqui: retornou "sem carimbo" para um disparo que aterrissou, então o estágio 2 nunca disparou e o portão de lavagem recusou para sempre — , porque "sem carimbo" também é o resultado normal de um erro genuíno.
Um critério que nunca é testado contra uma amostra positiva conhecida não é um
critério, é um palpite — e essa classe de falha (esse off-by-one,
probe_state, dmesg -w, o klog.host vazio, a leitura em branco) sempre
se apresenta como "nada aconteceu", que também é um desfecho experimental legítimo.
Então a verificação agora é defendida duas vezes:
stamp_selftest() roda no preflight e faz exit 9 em caso de falha, acionando as
mesmas funções de extração que o portão usa contra os valores medidos de
out/t5_w7_778.txt (write_target = 0xffffff8800cdd178 → Uid $4 =
4294967176, Gid $2 = 13488504) mais amostras negativas e ilegíveis.
Um autoteste que reimplementa a verificação não prova nada, então a extração de campos
é fatorada em uid_suid_field / gid_gid_field.tools/test_stamp_criterion.sh — a
mesma coisa como um teste de regressão autônomo, extraindo as funções reais
de .stamp_ok() retorna três estados, porque "não é possível ler" não é "sem carimbo"
(essa confusão é o que fez o run 13 parecer "sem mudança"): 0 = presente,
1 = legível e sem carimbo, 2 = ILEGÍVEL. E quando retorna 1 enquanto
probe_state = D, o runner imprime ⛔ ORACLE INCONSISTENT — "vá verificar o
critério" — em vez da mensagem "não aterrissou", que envia o operador para um
lugar completamente diferente (um boot novo, ou uma caçada à taxa de acerto).
Derivação completa: evidence/2026-09-18-divergence-is-latent.md
e evidence/2026-09-18-cred-launder-verification.md
(o §2.3 deste último é retratado no local). A autoverificação de sobreposição de forma de escrita é
fechada offline: as palavras de forma vivem na grade fd_set na pilha do kernel enquanto
o efeito colateral aterrissa dentro da página pulverizada, então os dois não podem se sobrepor em nenhuma
das formas.
Três repórteres independentes. Nenhum é fallback de outro, e apenas o caminho 1 pode matar a tarefa chamadora.
O caminho 2 dispara em execve, não em mudança de credencial, e é um caminho de código
separado do caminho 1. Ele reporta através de kevent_send_to_user, então o que acontece
a seguir é decisão de um daemon de espaço de usuário, não do kernel.
A verificação é sobre o caminho da imagem sendo exec'd, então não basta
carregar via memfd o payload do loader: se o próprio loader for exec'd de
/data/local/tmp, esse primeiro execve já reporta. d_path() num memfd é
/memfd:…, então o próprio loader deve ser exec'd através de um memfd —
V12_EXEC_MEMFD agora está ativado por padrão exatamente por esse motivo. O comportamento antigo
é visível no 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 pesquisáveis para o caminho 2:```
[ROOTCHECK-EXEC-INFO]:common %s result %s with "execve_report" / "execve_block"
%d,path@@%s kevent payload fragment
Porque o caminho 2 e o caminho 3 reportam apenas através do kevent, "nenhum [ROOTCHECK-*] no log do kernel" não exclui que qualquer um deles tenha disparado. Essa inferência precisa do receptor em espaço de usuário, que não localizamos.
oplus_security_guard.koCache 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 fluxo de controle (importante para a ordenação do exploit). As quatro comparações de aresta descendente ramificam diretamente para 0x468, o despacho do número de syscall — elas não caem através do portão addr_limit. 0x454–0x464 só é alcançado quando nenhum id desceu. Portanto, o despacho é acessado quando ou algum id desceu ou addr_limit == KERNEL_DS; ele não é controlado por addr_limit.
Consequências:
sys_enter ainda tinha em cache o uid antigo. Se a tarefa já está uid=0 quando uma syscall entra (0x400 cbz), o hook retorna e permanece cego a partir de então.delivery/外部建议评审_2026-09-18.md.g_boot_state — 1 byte .data..ro_after_init, definido na inicialização do módulo a partir de verified_bootstate via strstr. is_unlocked() = LDRB + RET.
Escritas em VA do módulo causam falha (CONFIG_STRICT_MODULE_RWX=y) — use o alias do physmap 0xffffff80….
Payload do relatório: $$sys_call_number@@%d, $$set_id_flag@@%d, $$addr_limit@@%lx, $$enforce@@%d.
.rodata+0, índices 143–214Entradas restantes 60 → relatar + matar.
Não bloqueie uma thread em sendmsg (211) enquanto suas credenciais mudam. Ela não está na tabela, então a thread é relatada e morta. As únicas syscalls que são seguras para se bloquear com esse propósito são as doze acima:
setregid, setgid, setreuid, setuid, setresuid, setresgid,
connect, getsockname, setsockopt, shutdown, readahead, brk.
Correção (2026-09-18). Uma revisão anterior desta tabela rotulava cada entrada um a menos que o número real de syscall do arm64 (
146era chamadosetresuid; ésetuid—setresuidé147). Os números sempre estiveram certos; apenas os nomes estavam errados. Os nomes agora são resolvidos a partir desys_call_table@0xffffffc00a13d8c0na imagem do kernel deste dispositivo. Em particularsendmsg(211),munmap(215),getsockopt(209) egetpeername(205) não são isentas — bloquear uma thread em qualquer uma delas enquanto suas credenciais mudam é uma morte, não uma passagem. Regenere comtools/gen_exempt_table.py.
oplus_secure_harden.ko`entry_handler_setxattr` → `oplus_heapspray_check(7)`, `entry_handler_cpuinfo` → `(6)`, ambos incondicionais.
`oplus_heapspray_check` — contadores `heapspary_ip4` / `ip6` / `xttr` / `cpuinfo`, delta de `ktime_get_real_ts64`, 7 locais de relatório (`snprintf` + `printk` + `kevent_send_to_user`), condicionados a `verified_bootstate`.
### Evasão
| Primitiva | |
|---|---|
| `setsockopt` optname 46 `MCAST_JOIN_SOURCE_GROUP` | não filtrado |
| `setsockopt` nível `SOL_IPV6` (41) | não filtrado se o filtro lê `level` |
| `setxattr` | sempre contabilizado |
| `/proc/cpuinfo` | sempre contabilizado |
| `socket()` / `socketpair()` | não interceptado |
| `sendmsg`, `pipe`, `memfd`, `add_key`, `io_uring`, mmap | não interceptado |
## 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 são fixos — eles mantêm a geometria do stack-frame de reclaim (calibração delta=0). Alterar qualquer um deles exige recalibração no 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 builds a cada push (.github/workflows/build.yml, Ubuntu + NDK r28c, artefato exploit_guard).
adb push exploit_guard /data/local/tmp/e adb shell chmod 755 /data/local/tmp/e adb shell /data/local/tmp/e
## Arquivos```
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 — transcrições verbatim de adb shell de quatro
execuções como root: a linha do tempo completa, o momento em que o uid da tarefa alvo se torna 0, o
carregamento do kernelsu.ko e o estado posterior. Leia primeiro o bloco de cabeçalho: ele
lista o que o arquivo não contém e por quê.
evidence/notes.md — o lado do kernel. Endereços de módulos e
quais canais /proc funcionam em cada estado do SELinux; a derivação completa de g_boot_state
incluindo a chave strstr; a tabela de isenções corrigida; a receita de captura
que produziria a metade ausente do kernel; e uma lista do que ainda está
em aberto.
evidence/2026-09-18-bootA/ — a primeira
execução no dispositivo do design atual, 13 boots. Os reboots são ordenados
(bootreason=reboot) e nenhuma linha de panic jamais foi capturada — mas leia isso
como uma amostra de um, não de treze. Das quatro execuções que reiniciaram, duas salvaram um
klog.host vazio, uma salvou o log do boot seguinte, e apenas uma janela pode
plausivelmente delimitar seu próprio reboot. Da mesma forma, o probe_state e a leitura da vítima de uma execução estão ambos vazios (o dispositivo já havia sumido), então ela não carrega
informação sobre se sua escrita chegou a ocorrer — um campo em branco não é "nenhuma mudança".
O diretório também documenta o erro de metodologia que vale a pena conhecer:
dmesg -w é um no-op neste dispositivo (o toybox despeja uma vez e sai), então o
log do kernel de uma execução anterior continha apenas o histórico pré-captura — "nenhum [ROOTCHECK-*]" não
era evidência de nada. evidence/notes.md §6 traz a receita corrigida de
poll-and-stream-to-host.
evidence/2026-09-18-cred-launder-verification.md
— verificação da proposta de lavagem de credenciais contra o próprio
disassembly desta imagem (não o código-fonte genérico do 5.10): o duplo armazenamento de commit_creds, a
alocação de prepare_creds e o sizeof(struct cred) = 0xA8 medido, o
risco de divergência BUG_ON(cred != real_cred) acima, a autoverificação fechada de
sobreposição de forma de escrita, e a auditoria de cobertura de evidências dos 13 boots.
§2.3 está retratada no local — a divergência é latente, não o mecanismo
de reboot.
evidence/2026-09-18-divergence-is-latent.md
— a verificação da rodada 2. commit_creds obtém sua tarefa de current
(0x1867a0 mrs x20, sp_el0), exit_creds anula ambos os ponteiros antes de put_cred,
e as capturas brutas mostram que a cadeia antiga escreveu um valor idêntico
(0xffffff802a7e0be0) em ambos os slots, enquanto a nova cadeia escreveu duas páginas
diferentes. Portanto, o critério é a igualdade de ponteiros — "ambos os disparos escrevem o mesmo valor",
não "ambos os disparos chegam".
postreboot_forensics.sh — forense de reboot que
não depende do poller. O critério é uma única condição:
CONFIG_PSTORE_CONSOLE=y faz panic() escrever a cauda do console no ramoops em
kmsg_dump(KMSG_DUMP_PANIC) — antes de qualquer reset — então se a máquina depois
reinicia ou trava é irrelevante. Puxa /sys/fs/pstore/, faz grep por kernel BUG /
__put_cred / cred.c, e imprime a string do motivo do boot (entradas do histórico
trouxeram sufixos reboot,shell / bootloader / reboot,edl, então o motivo
distingue um ator onde a época não distingue).
⚠ Não leia "
bootreason=rebootlimpo" como "sem panic." Em QCOM um assert de watchdog do SoC é resetado através do bloco PMIC PON, entãopanic → panic_timeout=-1 → hang → watchdog → PMIC reset → bootreason limpoé uma cadeia autoconsistente que é indistinguível de um reset de hardware com as evidências que temos. O própriototal_17_dump_0_pmic_17deste repositório atribui todos os 17 reboots anormais apmic, que é exatamente a forma normal do watchdog, não evidência de "não é o kernel".bootreasonnão restringe nada aqui; ramoops é o único critério.
Duas precondições, ou o veredito do script é nulo (铁律 8 — uma conclusão sem sinal exige que o canal seja provado alcançável primeiro):
/sys/fs/pstore/* é somente root, então sob Enforcing
tanto adb pull quanto cat falham — e "não consegue ler" produz a mesma saída
que "leu e estava vazio". Um script de dois estados imprime "pstore está VAZIO ⇒
panic refutado" a partir de um canal que nunca abriu. O script, portanto, emite
CHANNEL UNREACHABLE (ls falhou, ou todas as entradas conhecidas falharam ao ler
em vez de não existirem) e reporta getenforce junto.adb reboot limpo seguido de uma busca imediata. Se um
reboot sabidamente bom não produz nada legível, o canal não está provado e todo
"pstore vazio" posterior não é evidência. A ordem importa: o dispositivo move
e desvincula o registro logo após o boot, então a sequência é
reboot → obter Permissive (W1) → executar o script imediatamente.run_bootA.sh — orquestração para aquele único boot, na ordem
que importa (0x778 → 0x780 com o mesmo valor → reparo local da cred
que foi de fato instalada → confirmar → só então cutucar). ADB=/SER=/
BIN_LOCAL= sobrescrevíveis; SAME_VALUE=1 (padrão) impõe a regra do mesmo valor,
LAUNDER=1 habilita a lavagem com portão, HOLD=600 é necessário para a sequência
de mesmo valor.
As retentativas são divididas por estágio (R5/R6), porque os dois estágios têm perfis
de risco opostos:
R5 tem como padrão ROUNDS, R6 tem como padrão 1.
A execução de lavagem recomendada — o caminho padrão de página pulverizada, não
CONTROL=1:```bash
LAUNDER=1 R5=3 R6=1 HOLD=600 CHAINWAIT=6000 NODRAIN=1 WATCH=180 ./run_bootA.sh
`CONTROL=1` também produziria um par consistente, mas ao escrever o ponteiro `init_cred`, cujo efeito colateral corrompe `init_cred+8` **globalmente** — e "o framework morre" é uma das coisas sendo observadas, então carregar uma falha de todo o dispositivo para o fundo da medição turva exatamente a leitura que esta execução existe para fazer. O caminho da página pulverizada custa apenas "ambos os disparos devem acertar", que é para o que `R5=3` serve. `CONTROL=1` permanece como o único par *comprovadamente* consistente e como um controle, não como a configuração recomendada.
**Qual página foi instalada é lido da linha incondicional.** `run_w7` imprime o valor de escrita em duas linhas, e apenas uma delas é incondicional:```
L1793 W7[..] write value = private cred page 0x.. — spray path only
L1802 W7[..] write_value = 0x.. — after the if/else, ALL paths
O runner costumava corresponder à primeira redação, então com CONTROL=1 a extração voltava vazia, if [ -n "$CRED" ] pulava o reparo, e o próprio termo [ -n "$CRED" ] da conjunção de mesmo valor o mantinha em 0 — o portão de lavagem teria recusado para sempre. Um no-op silencioso em ambos os casos, de uma regex que reconhecia um de dois locais de impressão. Tanto ela quanto a extração de write_target (a entrada do critério de carimbo) agora passam por wv_from/wt_from, que são exercitados pelo teste de regressão junto com o próprio critério.
Ambos os fluxos começam antes da coisa que medem. uid.stream roda a partir do estágio 1; cred.stream começa no poke, não depois do watch — o poke libera o filho em seu loop de relatório NO_EXEC, que é 240 × 0,5 s = 120 s e então _exit(0) (exploit.c: "LT child NO-EXEC mode done (120s)"), então o posicionamento antigo em t+~135 s começava a amostrar depois que o filho já tinha ido embora, exatamente na janela para a qual o instrumento existe. O runner também se recusa a prosseguir se stamp_selftest() falhar, e imprime ORACLE INCONSISTENT em vez de "did not land" quando o carimbo e probe_state discordam.
artifacts/guard_post_handler.s — a cadeia de kill com as relocações preenchidas. adrp x9, #0 nas listagens mais antigas é .data..ro_after_init; bl #0x4ac é oplus_root_check_succ. Regenere com tools/gen_guard_disasm.py depois de extrair os módulos do vendor do seu próprio dispositivo.
tools/kdis_ko.py — RELA correspondido por sh_info; nessas builds as relocações de .text ficam em .rela.text.<func>, então a busca por nome não retorna nada.
| Project | |
|---|---|
| JoinChang/ghostlock-oneplus | reference implementation; 5.10 compact waiter |
| NebuSec CyberMeowfia | original GhostLock research |
GPL-3.0 — see 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 do waiter compacto (CMP_REQUEUE_PI → EDEADLK) | funciona |
Vazamento de task_struct (perf) | funciona |
Escrita PI (8 bytes; valor = 0 ou um endereço de kernel válido) | funciona |
task+0x778 ou task+0x780 sozinho → Uid=root | funciona — mas um pouso de campo único deixa a task divergente, e isso é um BUG_ON rígido latente. Ver o risco de divergência |
| Ambos os campos escritos com UM valor (um par consistente) | ❌ nunca produzido com uma página pulverizada. Só foi observado com o alias global init_cred (09-14, CONTROL=1). O runner agora impõe isso (SAME_VALUE=1); não executado no dispositivo |
Lavagem de credenciais (setresgid + setresuid) | implementado atrás de V12_LAUNDER=1; não executado no dispositivo |
kernelsu.ko carregado | funciona |
| Processo root sobrevive | ⚠ não estabelecido — ver abaixo |
| Mecanismo de reboot | ❌ não estabelecido. Um candidato (a divergência) agora está excluído; ver abaixo |
probe_state como critério de pouso | ❌ errado — não usar. Três contraexemplos; ver a tabela abaixo |
| Canal de pânico pstore/ramoops | ⚠ instrumento existe; canal nunca validado (ainda sem teste nulo) |
| "A vítima gira em userspace puro" | ⚠ ainda sem leitura — uid.stream agora registra utime/stime/nvcsw para que possa ser verificado |
| escrita dupla de passagem única do lado pi | ⚠ não estabelecido; pi.pc/pi.left estão fixados em 0 em fdset_map.h |
Caminho A (UMH / modprobe_path) | STATIC_USERMODEHELPER_PATH="" |
| Campo | Offset |
|---|
real_cred / cred | 0x778 / 0x780 |
syscallno em cache | 0xdf8 |
uid / euid / gid / egid em 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()| alvo | oráculo de aterrissagem |
|---|
task+0x778 | Uid: 4º campo do awk = hi32(write_target) e Gid: 2º campo do awk = low32(write_target) — o carimbo acima; leia antes do estágio 3 |
task+0x780 | o próprio getuid() da vítima |
global selinux_enforcing | getenforce |
probe_state | ❌ não é um critério. No máximo uma dica sobre a cadeia; nunca evidência de que uma escrita aterrissou |
$4notes.md$3euid0hi32(write_target)stamp_ok()run_bootA.sh| # | Hook | Gatilho | Ação |
|---|
| 1 | oplus_root_check_post_handler, tracepoint sys_exit | algum id desceu, ou 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 mas apenas para execve (221) | d_path(mm->exe_file) começa com /data, /data/local/tmp, /data/nativetest, /data/nativetest64 | oplus_RWO_root_check → printk + kevent_send_to_user (sem do_exit) |
| 3 | kretprobes de oplus_secure_harden | optname de setsockopt ∈ {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 | Hooks | 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 |
| estágio | segurança da retentativa |
|---|
R5 | passo 5, task+0x778 | seguro — um erro não instala nada, e o critério de carimbo torna uma rodada falha legível, então outro disparo é apenas outra tentativa. R5=3 leva a taxa de acerto por disparo de ~p para ~1−(1−p)³. |
R6 | passo 6, task+0x780 | não é seguro, e não é necessário — ele só dispara depois que o passo 5 chegou, então uma retentativa atira em uma tarefa já divergente: outra chance de acertar uma segunda página, diferente, sem benefício, já que um acerto completa o par. Mantenha em 1. |