Skip to content
KitploitKITPLOIT
FerramentasBlog
Enviar
FerramentasBlog
Enviar

Ferramentas de Hacking, PenTest e Cibersegurança para o seu Arsenal de Segurança!

Kitploit é um diretório de ferramentas de hacking, cibersegurança e pentesting. Descubra as últimas atualizações de projetos para encontrar vulnerabilidades, analisar sistemas, automatizar testes e fortalecer sua segurança.

··Feeds·Contato·Privacidade·© 2026 Kitploit

Diretório de Ferramentas

Categorias

Ver todas as categorias
Loading categories
ghostlock-pfem10 — GhostLock (CVE-2026-43499) para OPPO Find X5 Pro (PFEM10) — engenharia reversa do watchdog OPlus e do detector de heap-spray | Kitploit
Ferramentas/GitHubGitHub/imeiplus/ghostlock-pfem10
Segurança AndroidEscalada de PrivilégiosForensia de MemóriaAnálise de VulnerabilidadesExploraçãoEngenharia ReversaSegurança MóvelDesenvolvimento de PayloadsExploração de Binários
GitHubimeiplus/ghostlock-pfem10

ghostlock-pfem10

GhostLock (CVE-2026-43499) para OPPO Find X5 Pro (PFEM10) — engenharia reversa do watchdog OPlus e do detector de heap-spray

há 2 diasAinda não revisado

Mais Populares

Ver todos →

Descubra as ferramentas mais usadas pela nossa comunidade.

Explore todas as ferramentas

Navegue pela nossa coleção de ferramentas

Ver todas as ferramentas →
Compartilhar
Ver Repositório

GhostLock — OPPO Find X5 Pro (PFEM10)

English · 中文

build

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.

Vulnerabilidade

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.

Dispositivo

Estado

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.

Offsets

task_struct

thread_info

CampoOffset

cred

Fluxo do 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:~
**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.

Sobre init_cred — uma dicotomia explícita

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

  • Proibido como alvo. Escrever o ponteiro 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.
  • Mantido como o único par consistente PROVADO. A cadeia 09-14 que alcançou ksud escreveu um endereço fixo (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%.

A primitiva de escrita, e seu efeito colateral

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

root@kitploit:~
`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 onde gid e egid foram lidos limpos, então não pode vir do efeito colateral de init_cred+8; aponta para o próprio campo group_info do cred falso. Veja evidence/notes.md §10.6.

★ Um pouso de campo único deixa a task divergente — e isso é um BUG_ON fatal

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

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

  1. É o oráculo de aterrissagem para 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).
  2. É uma segunda razão independente pela qual o portão de lavagem pode capturar duas páginas diferentes. A metade do uid sozinha não consegue: qualquer página com uid 0 lê 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.

★ Instrumento 2 — probe_state NÃO é um critério de aterrissagem

Ele 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_line imprime o rótulo também (Uid: 0 0 4294967176 0), então o $1 do awk é "Uid:" e os quatro valores de id são $2..$5: $2=uid $3=euid $4=suid $5=fsuid. O carimbo fica em cred+8, ou seja, gid (low32) e suid (hi32) — então é Gid: $2 e Uid: . 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.

Caminhos de detecção

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

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

Watchdog — oplus_security_guard.ko

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

  • O killer dispara na syscall durante a qual as credenciais mudaram — aquela cujo 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.
  • Portanto, uma mudança de credenciais é sobrevivível sem tocar no módulo: deixe outra tarefa realizar a escrita enquanto a vítima gira em espaço de usuário, ou encaminhe a mudança através de uma das 12 syscalls isentas. Veja 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.

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

Entradas 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 (146 era chamado setresuid; é setuid — setresuid é 147). Os números sempre estiveram certos; apenas os nomes estavam errados. Os nomes agora são resolvidos a partir de sys_call_table @ 0xffffffc00a13d8c0 na imagem do kernel deste dispositivo. Em particular sendmsg (211), munmap (215), getsockopt (209) e getpeername (205) não são isentas — bloquear uma thread em qualquer uma delas enquanto suas credenciais mudam é uma morte, não uma passagem. Regenere com 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 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

Build

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

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 builds a cada push (.github/workflows/build.yml, Ubuntu + NDK r28c, artefato exploit_guard).

Configuração```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:~
## 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

Evidência

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=reboot limpo" como "sem panic." Em QCOM um assert de watchdog do SoC é resetado através do bloco PMIC PON, então panic → 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óprio total_17_dump_0_pmic_17 deste repositório atribui todos os 17 reboots anormais a pmic, que é exatamente a forma normal do watchdog, não evidência de "não é o kernel". bootreason nã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):

  • Terceiro estado necessário. /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.
  • Teste nulo primeiro. Um 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

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

Related

Project
JoinChang/ghostlock-oneplusreference implementation; 5.10 compact waiter
NebuSec CyberMeowfiaoriginal GhostLock research

License

GPL-3.0 — see LICENSE.

Baixar ferramenta
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 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=rootfunciona — 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 carregadofunciona
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=""
CampoOffset
real_cred / cred0x778 / 0x780
syscallno em cache0xdf8
uid / euid / gid / egid em cache0xe00 / 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
cred
status_gid
real_cred
lt_cred_ids_agree()
alvooráculo de aterrissagem
task+0x778Uid: 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+0x780o próprio getuid() da vítima
global selinux_enforcinggetenforce
probe_state❌ não é um critério. No máximo uma dica sobre a cadeia; nunca evidência de que uma escrita aterrissou
$4
valores
notes.md
$3
euid
0
hi32(write_target)
stamp_ok()
sem nenhum erro em lugar algum
run_bootA.sh
#HookGatilhoAção
1oplus_root_check_post_handler, tracepoint sys_exitalgum id desceu, ou addr_limit == KERNEL_DSoplus_root_killed → printk + do_exit(SIGKILL); e oplus_root_check_succ → kevent_send_to_user
2oplus_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/nativetest64oplus_RWO_root_check → printk + kevent_send_to_user (sem do_exit)
3kretprobes de oplus_secure_hardenoptname de setsockopt ∈ {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
kretprobeHooksFiltro
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
estágiosegurança da retentativa
R5passo 5, task+0x778seguro — 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)³.
R6passo 6, task+0x780nã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.