
GhostLock (CVE-2026-43499) für OPPO Find X5 Pro (PFEM10) — Reverse Engineering des OPlus-Watchdog- und Heap-Spray-Detektors
Englisch · 中文
GhostLock (CVE-2026-43499)-Port für das OPPO Find X5 Pro unter ColorOS 16. Erreicht einen Kindprozess mit uid=0 und ein geladenes kernelsu.ko; der Root-Prozess wird abgefangen.
CVE-2026-43499 — futex PI Use-after-free. remove_waiter() löscht current->pi_blocked_on, wenn current der Requester ist, auf dem -EDEADLK-Rollback-Pfad von rt_mutex_start_proxy_lock().
remove_waiter @ 0xffffffc0081ed254 — Zustand vor dem Fix.
Zu „Root-Prozess überlebt": Die Läufe in evidence/kill.log
erreichen uid=0 und laden kernelsu.ko, und in dem Lauf, der tatsächlich darauf
pollte, überlebte der KernelSU-Manager-Prozess 120 s, wobei kernelsu weiterhin
Live in /proc/modules war. In einem späteren Lauf hinterließ dieselbe Kette die
Android-Framework-Dienste unerreichbar (Can't find service: package/power/input/phone/wifi),
während das Modul noch Live war. Es wurde nie eine [ROOTCHECK-*]-Kernelzeile und
keine $$sys_call_number@@-Nutzlast erfasst, daher wird die Ursache für den Zustand
des späteren Laufs nicht zugeordnet. Siehe evidence/notes.md
§2.3, §2.4 und §7.
task_struct
thread_info
| Feld | 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
**Stage 2 muss der Wert von Stage 1 explizit übergeben werden.** Stages 1 und 2 sind zwei
unabhängige Prozesse, jeder mit seinem eigenen Spray, daher ist „schreibe die Cred-Seite in beide
Slots" eine Falle: naiv gelesen erzeugt es `(pageA, pageB)`, und weil
`commit_creds` **Zeiger** vergleicht, ist dieses Paar divergent, selbst wenn beide Schreibvorgänge
erfolgreich sind. Das ist nicht hypothetisch — genau das taten die Läufe 3 und 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 startet daher Stufe 2 mit V12_W7_VALUE=<der in Stufe 1 beobachtete Wert> und weigert sich, sie überhaupt zu starten, wenn dieser Wert nicht wiederhergestellt werden kann. HOLD muss Stufe 2 überleben, sonst wird die Seite von Stufe 1 freigegeben und neu zugewiesen, und „derselbe Wert" wird zu einem Dangling Pointer. Siehe die Gleicher-Wert-Regel.
Eine Seite pro Boot wird repariert. Stufe 3 nullt V+8. Bei zwei verschiedenen Seiten würde das Nullsetzen beider den gid/suid-Stempel (unten) löschen und eine Divergenz wie Übereinstimmung aussehen lassen, daher repariert der Runner nur die Seite, die tatsächlich installiert wurde, und stoppt, wenn die beiden Werte nicht übereinstimmen.
Die Cred-Seite wird von payload.c erstellt: alle acht ID-Felder null, alle fünf Capability-Sets voll, und user / user_ns / group_info zeigen auf root_user / init_user_ns / init_groups. Stufe 3 existiert, weil der Seiteneffekt des Schreibens immer cred+8 (gid/suid) des jeweiligen Creds überschreibt, das installiert wird.
init_cred — eine explizite DichotomieZwei Abschnitte hier widersprachen sich früher gegenseitig („niemals das globale init_cred" vs. „CONTROL=1 reproduziert Zelle 2", und Zelle 2 ist init_cred). Beide Aussagen gelten für unterschiedliche Rollen:
init_cred-Zeigers lässt den Seiteneffekt init_cred+8 global beschädigen — init_cred wird von jedem Kernel-Thread geteilt, und Uid: 0 0 4294967176 0 ist genau diese Beschädigung. Der Code verweigert diesen Pfad, es sei denn V12_ALLOW_INIT_CRED=1 wird absichtlich gesetzt.0xffffff802a7e0be0) in beide Slots, sodass real_cred == cred konstruktionsbedingt gilt — deshalb überlebte sie bis execve. CONTROL=1 reproduziert dies. Es ist eine Kontrolle, keine Konfiguration, auf der aufgebaut werden sollte.perf leak: PERF_TYPE_SOFTWARE / PERF_COUNT_SW_CPU_CLOCK, PERF_SAMPLE_REGS_INTR, exclude_user=1.
Akzeptiere [0xffffff8400000000, 0xffffff90000000), Stimmen ≥ 15%.
Die UAF wird über rb_erase_cached Case 1-left angetrieben. Das ergibt zwei Stores, nicht einen:```
*(write_target) = write_value // the store you aim
*(write_value + 0x08) = write_target // unavoidable side effect
`write_value` muss 8-Byte-ausgerichtet sein, wobei Bit 0 gelöscht ist — es ist entweder `0` oder eine gültige Kernel-Adresse. **Deshalb kann `g_boot_state` mit diesem Primitiv nicht gesetzt werden**: Das Byte, das zu `1` werden muss, hat sein niederwertigstes Bit durch die Ausrichtungsanforderung auf `0` gezwungen, und `write_value` ist dieselbe Größe wie die Adresse, an der der Seiteneffekt landet.
### Der Seiteneffekt schreibt in das, worauf `write_value` zeigt
`write_value` ist sowohl *der gespeicherte Wert* als auch *die Adresse, an die der Seiteneffekt schreibt* (bei `+8`). Richte es auf ein globales Kernel-Objekt und du korrumpierst dieses Objekt.
**W7 tat genau dies früher** — indem es `write_value` auf den `init_cred`-Alias richtete — und es ist im Readback sichtbar. Aus `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 war der init_cred-Alias und write_target war child_task+0x778.
init_cred+8 ist gid/suid, also speicherte der Seiteneffekt 0xffffff8800cdd178
dort: init_cred.gid = 0x00cdd178 und init_cred.suid = 0xffffff88 = 4294967176 — genau das 4. awk-Feld der Uid:-Zeile oben. Das Nullsetzen von
init_cred+8 reparierte es (out/t5_repair.txt: Uid: 0 0 4294967176 0 →
), was alles war, was „W7 stage 3" jemals war.
Dieser Pfad wird nun im Code verweigert. V12_W7_INIT_CRED=1 bricht mit einer
Erklärung ab, es sei denn V12_ALLOW_INIT_CRED=1 ist ebenfalls gesetzt, und die W2/W6/LTC-Pfade
fallen nicht mehr auf init_cred zurück, wenn die private cred-Seite fehlt — sie
brechen stattdessen ab. Der Standard, und der einzig vernünftige Pfad, ist die gesprayte cred-Seite.
Der Seiteneffekt selbst kann nicht vermieden werden: write_value muss der cred-
Zeiger sein, also wird cred+8 immer mit dem Schreibziel überschrieben. Nur seine
Position ist eine Wahl — und die Reparatur ist nun ein lokales Nullsetzen von
cred_page+8 (stage 3), kein Schreiben in ein globales Objekt.
Eine fehlerhafte
groups=-Ausgabe ist ein separates Symptom, nicht dieses. Es wurde in einem Lauf beobachtet, in demgidundegidsauber zurückgelesen wurden, also kann es nicht vominit_cred+8-Seiteneffekt stammen; es deutet auf das eigenegroup_info- Feld des gefälschten cred hin. Sieheevidence/notes.md§10.6.
BUG_ONDas Primitiv schreibt auf genau eine Adresse pro Durchlauf. task+0x778
(real_cred) und task+0x780 (cred) sind zwei separate Adressen, also hinterlässt jeder
gelandete 0x778-only- oder 0x780-only-Schreibvorgang die Task mit
cred != real_cred — ein Divergenzzustand.
Auf diesem Image ist dieser Zustand ein harter Panic, keine Warnung. commit_creds beginnt
mit 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()
und der Kernel wird mit **`CONFIG_PANIC_ON_OOPS=y`** gebaut (`CONFIG_PANIC_ON_OOPS_VALUE=1`).
`__put_cred @0xffffffc008185530` trägt dieselbe Familie von Assertions
(`usage != 0` → BUG; `cred == current->cred` / `current->real_cred` → BUG).
Die Divergenz ist also *latent* — sie tut nichts, solange das Opfer nur spinnt —
bis **irgendein** `commit_creds` auf dieser Task geschieht: `setresuid` / `setresgid` /
`setuid` / `setgid` / `capset`, oder **`execve` via `install_exec_creds`**.
> **⛔ Zurückgezogen (2026-09-18 spät): dies ist NICHT der Reboot-Mechanismus.**
>
> Eine frühere Revision dieses Abschnitts nannte die Divergenz „den führenden Mechanismus-
> kandidaten für die Reboots" und sagte, sie „erklärt die Shape-Aufteilung". Das tut sie nicht,
> und der Grund ist jetzt gemessen statt argumentiert:
>
> * `commit_creds` nimmt seine Task von **`current`** — `0x1867a0 mrs x20, sp_el0`.
> Seine Signatur ist `commit_creds(struct cred *new)`; es gibt kein Task-Argument.
> Eine Divergenz ist also nur dann von Bedeutung, wenn die Task, die sie **hält**, selbst
> `commit_creds` aufruft.
> * Die rebootenden Läufe waren alle `V12_NO_EXEC=1` (wörtlich angegeben bei
> `run3_0445.log:18`, `run9_0606.log:20`, `run10_0616.log:24`), sodass das Opfer
> nie `execve` ausführte und `commit_creds` überhaupt nie erreichte.
> * Lauf 10 hatte überhaupt keinen Poke (`grep -c poke` = 0).
> * `exit_creds` nullt **beide** Zeiger vor `put_cred`
> (`0x185cb8 str xzr,[x19,#0x778]`; `0x185d24 str xzr,[x19,#0x780]`), sodass das
> `_exit(0)` des Opfers die Divergenz **auslöscht**, statt auf sie zu treten.
>
> ⇒ In jenen Läufen war die Divergenz **inert**. Das `BUG_ON` ist real, aber es ist eine
> Landmine, die nicht hochgegangen ist. „Shape A rebootet nie" ist wieder eine
> Korrelation. Was die Landmine tatsächlich einschränkt, ist **Laundering**, weil
> `setresgid`/`setresuid` selbst `commit_creds` aufrufen.
>
> **Die Größe, die die Chains tatsächlich trennt, ist Zeiger-Gleichheit, und das
> bedeutet, die beiden Schüsse müssen EINEN Wert schreiben** — siehe den nächsten Unterabschnitt.
### ★★★ Die beiden Schüsse müssen den *selben* Wert schreiben — nicht bloß beide landen
`BUG_ON` vergleicht **Zeiger**. Zwei Seiten, die beide `uid 0` tragen, sind immer noch zwei
verschiedene Objekte. Die Captures machen den Unterschied konkret:
| chain | 0x778-Schuss | 0x780-Schuss | Zeiger |
|---|---|---|---|
| alt (`t5loop.sh MODE=CRED`) | `in[0]=0xffffff802a7e0be0` | `in[0]=0xffffff802a7e0be0` | **gleich** → ksud geladen, Manager 120 s am Leben |
| neu (`run_bootA.sh`) | `0xffffff88679bade0` (Lauf 9) | `0xffffff8785d6ade0` | **verschieden** → divergent selbst wenn beide gelandet sind |
`tools/t5loop.sh` wendet **ein** `$ENVV` auf **jeden** Offset an, sodass `MODE=CRED`
beide Schüsse *konstruktionsbedingt* identisch machte. `run_bootA.sh` feuerte Schritt 5 und
Schritt 6 jeweils mit leerem `$extra`, sodass jeder seine **eigene** Seite sprayte.
⇒ Die Anforderung ist **„beide Schüsse schreiben denselben Wert"**. `run_bootA.sh` erzwingt
dies jetzt (`SAME_VALUE=1`, der Standard): Schritt 6 verwendet den in Schritt 5 beobachteten
`write_value` wörtlich wieder und **weigert sich überhaupt zu feuern**, wenn er diesen
Wert nicht wiederherstellen kann — denn Feuern würde ein divergentes Paar erzeugen.
⚠ `HOLD` muss den zweiten Schuss überleben. Wenn das PIN-Kind des ersten Schusses zuerst stirbt,
wird die Seite freigegeben und neu alloziert, und „selber Wert" wird zu einem Dangling Pointer.
Der Standard `HOLD=20` ist **zu kurz**; verwende `HOLD=600`. Dies ist jetzt der *Standard*,
wann immer `SAME_VALUE=1` — die alte unbedingte 20 s bedeutete, dass die Standard-
konfiguration selbst die Falle war — und ein explizit kurzes `HOLD` mit
`SAME_VALUE=1` warnt jetzt laut, statt still einen Dangling Pointer zu erzeugen.
⚠ `CONTROL=1` änderte früher **nur Schritt 5**, sodass es
`(init_cred, frische Seite)` erzeugte — ein divergentes Paar — während diese Datei behauptete, es
reproduziere Zelle 2. Behoben: es setzt jetzt beide Schüsse auf `init_cred`. (Die Kosten von Zelle 2
bleiben bestehen: der Seiteneffekt korrumpiert `init_cred+8` global, was
`Uid: 0 0 4294967176 0` ist.)
**Konsequenzen für alles, was die Credential launderen will**
(`setresgid` + `setresuid`, um die gesprayte Seite gegen ein echtes `struct cred` zu tauschen):
- Der Mechanismus ist real und verifiziert — `commit_creds` schreibt `x21` in **beide**
`task+0x778` und `task+0x780` (`0x186998` / `0x1869a0`), sodass ein Aufruf die
Aufteilung dauerhaft repariert; `prepare_creds @0xffffffc008186070` ist
`kmem_cache_alloc(cred_jar)` + `memcpy(new, task->cred, 0xA8)` +
`security_prepare_creds(...)`, und 147/149 stehen beide auf der Ausnahmeliste des Guards.
- **Aber seine Vorbedingung ist das Gegenteil von „den 0x778-Schuss überspringen".** Das Launder
selbst ruft `commit_creds` auf, also darf es nur ausgelöst werden, wenn **beide** Zeiger
bereits denselben Wert halten.
- `V12_LAUNDER=1` ist an **zwei** Dinge geknüpft, und das erste ist keine Beobachtung:
1. **`V12_W7_SAME_VALUE=1`** — die *Provenienz*-Tatsache, dass beiden Schüssen
derselbe Wert gegeben wurde. Ohne Read-Primitive ist Zeiger-Identität unbeobachtbar, also
kann dies nicht durch eine bessere Userspace-Prüfung ersetzt werden; es muss deklariert werden.
2. `consistent=1` — die `0x780`-Sicht (`getuid()`) stimmt mit der `0x778`-Sicht
(`/proc/self/status` `Uid:`) überein. **Notwendig, aber für sich allein nicht ausreichend**:
zwei verschiedene Seiten, die beide `uid 0` tragen, lesen sich gleich, während die Zeiger
verschieden sind — was genau der Fall ist, den der Runner früher erzeugte. Gegeben (1) wird
es ausreichend: übereinstimmend + selber Wert ⇒ beide landeten auf derselben Seite.
Schlägt eine der Prüfungen fehl ⇒ verweigern, und die Vier-Fälle-Tabelle geht in die Evidenz ein.
Die LT-Report-Zeile gibt beide Sichten aus (`uid=` / `real_uid=` / `consistent=`) plus
`same_value_declared=`, sodass der Zustand gelesen und nicht abgeleitet wird.
### ★ Instrument 1 — der Seiteneffekt ist ein STAMP, der auf das Ziel gerichtet ist
`*(write_value + 8) = write_target`, und `cred+8` / `cred+0xc` sind `gid` / `suid`,
sodass ein 8-Byte-Store über beide landet:```
cred.gid = low32(write_target)
cred.suid = hi32(write_target)
Das ist eine Messung, kein Modell. out/t5_w7_778.txt enthält
write_target = 0xffffff8800cdd178 und Uid: 0 0 4294967176 0, wobei
4294967176 = 0xffffff88 = hi32(write_target); notes.md §11 hält die andere
Hälfte fest, init_cred.gid = 0x00cdd178 = low32(write_target).
Zwei Verwendungen:
task+0x778. /proc/<pid>/status liest
real_cred = task+0x778 — genau das gerade installierte Cred —, sodass der
Stempel direkt aus dem Userspace lesbar ist. Lies ihn vor Stufe 3: Die
Reparatur nullt cred+8 und löscht ihn (notes.md §11s t5_repair.txt liest
4294967176 vor einer erfolgreichen Reparatur und 0 danach).0, also melden zwei verschiedene Seiten beide
„konsistent". Aber low32(T+0x778) und low32(T+0x780) unterscheiden sich um
genau 8, sodass bei zwei Seiten getgid() (aus ) und (aus
) nicht übereinstimmen — und vergleicht gid
ebenso wie uid.⇒ V12_W7_SAME_VALUE ist das zweite Gate, nicht das einzige. Es ist weiterhin
wichtig: Der Stempel unterscheidet nur, wenn beide Seiteneffekte ausgelöst haben,
also schließt die Same-Value-Regel dieses verbleibende Loch. Und beachte, was ein
Gate ist — ein Detektor, kein Verhinderer. Es kann nur ablehnen; es lässt die
Task für den Rest des Boots divergent. Die Same-Value-Regel ist das, was das Paar
korrekt macht, was die alte Kette hatte und was nötig ist, um execve
überhaupt zu erreichen.
probe_state ist KEIN Landing-KriteriumEs lag in diesem Projekt dreimal falsch: W1 landete auf dem Global und meldete
R; das D von Lauf 12 zielte auf ein Global statt auf ein Cred; und das R
von Lauf 11 wurde in eine Tabelle geschrieben, als wäre es ein Landing
(run11_w778r1_miss.txt und w7_w7781.txt aus Lauf 7 sind Zeile für Zeile
isomorph — beide probe_state = R, probe_done = 0). Verwende ein
zielspezifisches Orakel:
run_bootA.sh verwendet jetzt den Stempel für Stufe 1 — was ROUNDS>1-Wiederholungen
auf task+0x778 sinnvoll macht, da eine fehlgeschlagene Runde lesbar statt
abgeleitet ist — und es wird Stufe 2 nicht auslösen, wenn Stufe 1 nicht gelandet ist.
⛔ Sage „awk-Feld", niemals „3. Feld".
uid_linegibt auch das Label aus (Uid: 0 0 4294967176 0), also ist$1von awk"Uid:"und die vier ID-Werte sind$2..$5:$2=uid$3=euid$4=suid$5=fsuid. Der Stempel sitzt beicred+8, d. h.gid(low32) undsuid(hi32) — also ist esGid:$2undUid:. Ihn „das dritte Feld" zu nennen (was zählt und wie §11 es formuliert) verleitet den Code dazu, zu lesen, was = auf dem Fake-Cred ist und niemals gleich sein kann. Dieser Off-by-one war hier vorhanden: gab „kein Stempel" für einen Schuss zurück, der gelandet war, sodass Stufe 2 nie auslöste und das Launder-Gate für immer ablehnte — , weil „kein Stempel" auch das normale Ergebnis eines echten Fehlschlags ist.
Ein Kriterium, das nie gegen eine bekannte positive Probe getestet wird, ist
kein Kriterium, sondern eine Vermutung — und diese Fehlerklasse (dieser
Off-by-one, probe_state, dmesg -w, der leere klog.host, das leere Readback)
zeigt sich immer als „nichts ist passiert", was ebenfalls ein legitimes
experimentelles Ergebnis ist. Daher ist die Prüfung jetzt doppelt abgesichert:
stamp_selftest() läuft im Preflight und beendet mit exit 9 bei Fehler,
wobei die gleichen Extraktionsfunktionen, die das Gate verwendet, gegen die
gemessenen Werte aus out/t5_w7_778.txt laufen (write_target = 0xffffff8800cdd178
→ Uid $4 = 4294967176, Gid $2 = 13488504) plus negative und
unlesbare Proben. Ein Selbsttest, der die Prüfung neu implementiert, beweist
nichts, daher ist die Feldextraktion in uid_suid_field / gid_gid_field
herausgezogen.tools/test_stamp_criterion.sh — dasselbe
als eigenständiger Regressionstest, der die echten Funktionen aus
extrahiert.stamp_ok() gibt drei Zustände zurück, weil „kann nicht lesen" nicht „kein
Stempel" ist (diese Vermischung ließ Lauf 13 wie „keine Änderung" aussehen):
0 = vorhanden, 1 = lesbar und kein Stempel, 2 = UNLESBAR. Und wenn es
1 zurückgibt, während probe_state = D, gibt der Runner ⛔ ORACLE
INCONSISTENT aus — „geh das Kriterium prüfen" — statt der Meldung „nicht
gelandet", was den Operator an eine völlig andere Stelle schickt (einen frischen
Boot oder eine Hit-Rate-Jagd).
Vollständige Herleitung: evidence/2026-09-18-divergence-is-latent.md
und evidence/2026-09-18-cred-launder-verification.md
(letzteres §2.3 ist in situ zurückgezogen). Der Selbstcheck zur Überlappung der
Write-Shape ist offline geschlossen: Die Shape-Wörter liegen im fd_set-Grid auf
dem Kernel-Stack, während der Seiteneffekt innerhalb der gesprayten Seite landet,
sodass sich die beiden in keiner der beiden Shapes überlappen können.
Drei unabhängige Melder. Keiner ist ein Fallback für einen anderen, und nur Pfad 1 kann die aufrufende Task töten.
Pfad 2 löst bei execve aus, nicht bei Credential-Änderung, und er ist ein
separater Codepfad von Pfad 1. Er meldet über kevent_send_to_user, also ist,
was als Nächstes passiert, die Entscheidung eines Userspace-Daemons, nicht des
Kernels.
Die Prüfung erfolgt auf dem Pfad des gerade exec'ten Images, daher reicht es
nicht, die Payload des Loaders per memfd zu laden: Wenn der Loader selbst aus
/data/local/tmp exec't wird, meldet bereits dieses erste execve. d_path()
auf einem memfd ist /memfd:…, also muss der Loader selbst durch einen memfd
exec't werden — V12_EXEC_MEMFD ist jetzt genau aus diesem Grund
standardmäßig aktiv. Das alte Verhalten ist in RUN 4 sichtbar:```
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
Greppbare Marker für Pfad 2:```
[ROOTCHECK-EXEC-INFO]:common %s result %s with "execve_report" / "execve_block"
%d,path@@%s kevent payload fragment
Da Pfad 2 und Pfad 3 nur über kevent berichten, schließt „kein [ROOTCHECK-*] im Kernel-Log" nicht aus, dass einer von ihnen ausgelöst wurde. Diese Schlussfolgerung erfordert den Userspace-Empfänger, den wir nicht lokalisiert haben.
oplus_security_guard.kosys_enter-Cache:```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`-Prüfung:```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)
Hinweis zur Kontrollfluss-Reihenfolge (wichtig für die Exploit-Reihenfolge). Die vier Vergleiche der absteigenden Kante verzweigen direkt zu 0x468, dem Dispatch der Syscall-Nummer — sie fallen nicht durch das addr_limit-Gate. 0x454–0x464 wird nur erreicht, wenn keine id abgestiegen ist. Der Dispatch wird also betreten, wenn entweder eine id abgestiegen ist oder addr_limit == KERNEL_DS; er ist nicht durch addr_limit gegated.
Konsequenzen:
sys_enter noch die alte uid gecacht hat. Wenn die Task bereits uid=0 ist, wenn ein Syscall eintritt (0x400 cbz), kehrt der Hook zurück und bleibt von da an blind.delivery/外部建议评审_2026-09-18.md.g_boot_state — 1 Byte .data..ro_after_init, gesetzt beim Modul-Init aus verified_bootstate via strstr. is_unlocked() = LDRB + RET.
Modul-VA-Schreibzugriffe schlagen fehl (CONFIG_STRICT_MODULE_RWX=y) — verwende den Physmap-Alias 0xffffff80….
Report-Payload: $$sys_call_number@@%d, $$set_id_flag@@%d, $$addr_limit@@%lx, $$enforce@@%d.
.rodata+0, Indizes 143–214Verbleibende 60 Einträge → Report + Kill.
Blockiere keinen Thread auf sendmsg (211), während sich seine Credentials ändern. Es ist nicht in der Tabelle, also wird der Thread reported und gekillt. Die einzigen Syscalls, in denen es sicher ist, zu diesem Zweck blockiert zu sein, sind die zwölf oben:
setregid, setgid, setreuid, setuid, setresuid, setresgid,
connect, getsockname, setsockopt, shutdown, readahead, brk.
Korrektur (2026-09-18). Eine frühere Revision dieser Tabelle beschriftete jeden Eintrag um eins niedriger als die echte arm64-Syscall-Nummer (
146wurdesetresuidgenannt; es istsetuid—setresuidist147). Die Nummern waren immer richtig; nur die Namen waren falsch. Namen werden jetzt aussys_call_table@0xffffffc00a13d8c0im Kernel-Image dieses Geräts aufgelöst. Insbesonderesendmsg(211),munmap(215),getsockopt(209) undgetpeername(205) sind nicht ausgenommen — einen Thread in einem dieser Syscalls zu blockieren, während sich seine Credentials ändern, ist ein Kill, kein Pass. Neu generieren mittools/gen_exempt_table.py.
oplus_secure_harden.ko`entry_handler_setxattr` → `oplus_heapspray_check(7)`, `entry_handler_cpuinfo` → `(6)`, beide bedingungslos.
`oplus_heapspray_check` — Zähler `heapspary_ip4` / `ip6` / `xttr` / `cpuinfo`, `ktime_get_real_ts64`-Delta, 7 Report-Stellen (`snprintf` + `printk` + `kevent_send_to_user`), abhängig von `verified_bootstate`.
### Umgehung
| Primitive | |
|---|---|
| `setsockopt` optname 46 `MCAST_JOIN_SOURCE_GROUP` | nicht gefiltert |
| `setsockopt` level `SOL_IPV6` (41) | nicht gefiltert, wenn der Filter `level` liest |
| `setxattr` | wird immer gezählt |
| `/proc/cpuinfo` | wird immer gezählt |
| `socket()` / `socketpair()` | nicht gehookt |
| `sendmsg`, `pipe`, `memfd`, `add_key`, `io_uring`, mmap | nicht gehookt |
## Konfiguration```
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 sind fest vorgegeben — sie erhalten die Reclaim-Stackframe-Geometrie (delta=0-Kalibrierung). Jede Änderung daran erfordert eine erneute Kalibrierung auf dem Gerät.```bash
export ANDROID_NDK_HOME=/path/to/android-ndk-r28c
make # → exploit_guard
./build.sh # same, with NDK auto-detection
Manuell:```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 bei jedem Push (.github/workflows/build.yml, Ubuntu + NDK r28c, Artefakt exploit_guard).
adb push exploit_guard /data/local/tmp/e adb shell chmod 755 /data/local/tmp/e adb shell /data/local/tmp/e
## Dateien```
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 — wörtliche adb shell-Transkripte von vier
Root-Läufen: die vollständige Zeitachse, der Moment, in dem die uid der Ziel-Task 0 wird, das
Laden von kernelsu.ko und der Zustand danach. Zuerst den Header-Block lesen: Er
listet auf, was die Datei nicht enthält und warum.
evidence/notes.md — die Kernel-Seite. Moduladressen und
welche /proc-Kanäle in welchem SELinux-Zustand funktionieren; die vollständige g_boot_state-
Herleitung einschließlich des strstr-Schlüssels; die korrigierte Exempt-Tabelle; das Capture-
Rezept, das die fehlende Kernel-Hälfte erzeugen würde; und eine Liste dessen, was noch
offen ist.
evidence/2026-09-18-bootA/ — der erste
Gerätelauf des aktuellen Designs, 13 Boots. Die Reboots sind geordnet
(bootreason=reboot) und es wurde noch nie eine Panic-Zeile erfasst — aber das ist
als Stichprobe von eins zu lesen, nicht von dreizehn. Von den vier Läufen, die rebooteten, speicherten zwei ein
leeres klog.host, einer speicherte das Log des nächsten Boots, und nur ein Fenster kann
plausibel seinen eigenen Reboot eingrenzen. Ebenso sind bei einem Lauf probe_state und Victim-
Readback beide leer (das Gerät war bereits weg), sodass er keine
Information darüber trägt, ob sein Write gelandet ist — ein leeres Feld ist keine „keine Änderung".
Das Verzeichnis dokumentiert außerdem den Methodikfehler, den es zu kennen gilt:
dmesg -w ist auf diesem Gerät ein No-op (toybox gibt einmal aus und beendet sich), sodass ein
früherer Lauf im Kernel-Log nur die Vor-Capture-Historie enthielt — „kein [ROOTCHECK-*]" war
kein Beweis für irgendetwas. evidence/notes.md §6 enthält das korrigierte
Poll-and-Stream-to-Host-Rezept.
evidence/2026-09-18-cred-launder-verification.md
— Verifikation des Credential-Laundering-Vorschlags gegen die eigene
Disassembly dieses Images (nicht gegen generischen 5.10-Quellcode): der commit_creds-Doppel-Store, die
prepare_creds-Allokation und das gemessene sizeof(struct cred) = 0xA8, die
oben genannte BUG_ON(cred != real_cred)-Divergenzgefahr, der geschlossene
Write-Shape-Overlap-Selbstcheck und das Evidenzabdeckungs-Audit der 13 Boots.
§2.3 ist in place widerrufen — die Divergenz ist latent, nicht der Reboot-
Mechanismus.
evidence/2026-09-18-divergence-is-latent.md
— die Verifikation der zweiten Runde. commit_creds nimmt seine Task von current
(0x1867a0 mrs x20, sp_el0), exit_creds nullt beide Pointer vor put_cred,
und die Roh-Captures zeigen, dass die alte Kette einen identischen Wert
(0xffffff802a7e0be0) in beide Slots schrieb, während die neue Kette zwei verschiedene
Seiten schrieb. Das Kriterium ist also Pointer-Gleichheit — „beide Schüsse schreiben denselben Wert",
nicht „beide Schüsse landen".
postreboot_forensics.sh — Reboot-Forensik, die
nicht vom Poller abhängt. Das Kriterium ist eine einzige Bedingung:
CONFIG_PSTORE_CONSOLE=y lässt panic() den Konsolen-Tail in ramoops bei
kmsg_dump(KMSG_DUMP_PANIC) schreiben — vor jedem Reset —, sodass es irrelevant ist, ob die Box danach
rebootet oder hängt. Zieht /sys/fs/pstore/, grept nach kernel BUG /
__put_cred / cred.c und gibt den Boot-Reason-String aus (History-Einträge
haben reboot,shell / bootloader / reboot,edl-Suffixe getragen, sodass der Reason
einen Akteur unterscheidet, wo die Epoche es nicht tut).
⚠ „Sauberes
bootreason=reboot" nicht als „keine Panic" lesen. Auf QCOM wird ein SoC- Watchdog-Assert über den PMIC-PON-Block zurückgesetzt, sodasspanic → panic_timeout=-1 → hang → watchdog → PMIC reset → clean bootreasoneine selbstkonsistente Kette ist, die auf der uns vorliegenden Evidenz nicht unterscheidbar von einem Hardware-Reset ist. Das eigenetotal_17_dump_0_pmic_17dieses Repos schreibt alle 17 abnormalen Rebootspmiczu, was genau die normale Form des Watchdogs ist, kein Beweis für „nicht der Kernel".bootreasongrenzt hier nichts ein; ramoops ist das einzige Kriterium.
Zwei Vorbedingungen, sonst ist das Urteil des Skripts ungültig (铁律 8 — eine No-Signal- Schlussfolgerung erfordert, dass der Kanal zuerst als erreichbar nachgewiesen wird):
/sys/fs/pstore/* ist root-only, also schlagen unter Enforcing
sowohl adb pull als auch cat fehl — und „kann nicht lesen" erzeugt die gleiche Ausgabe
wie „gelesen und es war leer". Ein Zwei-Zustands-Skript gibt „pstore ist LEER ⇒
Panic widerlegt" aus einem Kanal aus, den es nie geöffnet hat. Das Skript gibt daher
CHANNEL UNREACHABLE aus (ls fehlgeschlagen, oder alle bekannten Einträge konnten nicht gelesen werden,
statt nicht zu existieren) und meldet getenforce daneben.adb reboot, gefolgt von einem sofortigen Fetch. Wenn ein
bekanntermaßen guter Reboot nichts Lesbares liefert, ist der Kanal nicht bewiesen und jedes
spätere „leere pstore" ist kein Beweis. Die Reihenfolge ist wichtig: Das Gerät verschiebt
und unlöscht den Datensatz kurz nach dem Boot, also ist die Sequenz
reboot → Permissive holen (W1) → das Skript sofort ausführen.run_bootA.sh — Orchestrierung für diesen einen Boot, in der Reihenfolge,
die zählt (0x778 → 0x780 mit demselben Wert → lokale Reparatur des Cred,
das tatsächlich installiert wurde → bestätigen → erst dann poke). ADB=/SER=/
BIN_LOCAL= überschreibbar; SAME_VALUE=1 (Standard) erzwingt die Same-Value-Regel,
LAUNDER=1 aktiviert das gated Launder, HOLD=600 ist für die Same-Value-
Sequenz erforderlich.
Retries sind pro Stage aufgeteilt (R5/R6), weil die beiden Stages entgegengesetzte
Risikoprofile haben:
R5 ist standardmäßig ROUNDS, R6 standardmäßig 1.
Der empfohlene Launder-Lauf — der Standard-Sprayed-Page-Pfad, nicht
CONTROL=1:```bash
LAUNDER=1 R5=3 R6=1 HOLD=600 CHAINWAIT=6000 NODRAIN=1 WATCH=180 ./run_bootA.sh
`CONTROL=1` würde ebenfalls ein konsistentes Paar erzeugen, aber durch das Schreiben des `init_cred`-Zeigers, dessen Nebenwirkung `init_cred+8` **global** korrumpiert — und „das Framework stirbt" ist eines der Dinge, die überwacht werden, sodass das Übertragen eines geräteweiten Fehlers in den Hintergrund der Messung genau die Ablesung verfälscht, für die dieser Lauf existiert. Der Pfad über die gesprayte Seite kostet nur „beide Schüsse müssen treffen", wofür `R5=3` da ist. `CONTROL=1` bleibt das einzige *bewiesene* konsistente Paar und dient als Kontrolle, nicht als empfohlene Konfiguration.
**Welche Seite installiert wurde, wird aus der unbedingten Zeile ausgelesen.** `run_w7` gibt den Schreibwert auf zwei Zeilen aus, und nur eine davon ist unbedingt:```
L1793 W7[..] write value = private cred page 0x.. — spray path only
L1802 W7[..] write_value = 0x.. — after the if/else, ALL paths
Der Runner passte früher auf die erste Formulierung, sodass bei CONTROL=1 die Extraktion leer zurückkam, if [ -n "$CRED" ] die Reparatur übersprang und der eigene [ -n "$CRED" ]-Term der Gleichwertigkeits-Konjunktion ihn bei 0 hielt — das Launder-Gate hätte für immer verweigert. Ein stiller No-Op auf beiden Seiten, von einer Regex, die eine von zwei Ausgabestellen erkannte. Sowohl sie als auch die write_target-Extraktion (die Eingabe des Stamp-Kriteriums) laufen jetzt über wv_from/wt_from, die zusammen mit dem Kriterium selbst durch den Regressionstest abgedeckt werden.
Beide Streams starten vor dem, was sie messen. uid.stream läuft ab Stufe 1; cred.stream beginnt beim Poke, nicht nach dem Watch — der Poke entlässt das Kind in seine NO_EXEC-Report-Schleife, die 240 × 0,5 s = 120 s dauert und dann _exit(0) aufruft (exploit.c: "LT child NO-EXEC mode done (120s)"), sodass die alte Platzierung bei t+~135 s mit der Abtastung begann, nachdem das Kind bereits verschwunden war, genau in dem Fenster, für das das Instrument existiert. Der Runner weigert sich außerdem fortzufahren, wenn stamp_selftest() fehlschlägt, und gibt ORACLE INCONSISTENT aus statt "did not land", wenn Stamp und probe_state nicht übereinstimmen.
artifacts/guard_post_handler.s — die Kill-Chain mit ausgefüllten Relocations. adrp x9, #0 in den älteren Listings ist .data..ro_after_init; bl #0x4ac ist oplus_root_check_succ. Nach dem Abrufen der Vendor-Module von Ihrem eigenen Gerät mit tools/gen_guard_disasm.py neu generieren.
tools/kdis_ko.py — RELA über sh_info abgeglichen; bei diesen Builds liegen .text-Relocs in .rela.text.<func>, sodass namensbasierte Suche nichts zurückgibt.
| Project | |
|---|---|
| JoinChang/ghostlock-oneplus | Referenzimplementierung; 5.10 compact waiter |
| NebuSec CyberMeowfia | ursprüngliche GhostLock-Forschung |
GPL-3.0 — siehe LICENSE.
| Gerät | OPPO Find X5 Pro (PFEM10) |
| SoC | SM8450 / Adreno 730 |
| OS | ColorOS 16.0.3.520 (CN01) |
| Kernel | 5.10.236-android12-9-o-gaf2075ad2c06 |
| Bootloader | gesperrt, grün |
| VA_BITS | 39 — KIMAGE_TEXT_BASE = 0xffffffc008000000 |
| Phase |
|---|
Kompakter Waiter-Trigger (CMP_REQUEUE_PI → EDEADLK) | funktioniert |
task_struct-Leak (perf) | funktioniert |
PI-Schreibvorgang (8 Byte; Wert = 0 oder eine gültige Kernel-Adresse) | funktioniert |
task+0x778 oder task+0x780 allein → Uid=root | funktioniert — aber eine Landung auf einem einzelnen Feld lässt die Task divergieren, und das ist ein latentes hartes BUG_ON. Siehe die Divergenzgefahr |
| Beide Felder mit EINEM Wert beschrieben (ein konsistentes Paar) | ❌ nie mit einer gesprayten Seite erzeugt. Nur jemals mit dem globalen init_cred-Alias beobachtet (09-14, CONTROL=1). Der Runner erzwingt es jetzt (SAME_VALUE=1); nicht auf dem Gerät ausgeführt |
Credential-Laundering (setresgid + setresuid) | implementiert hinter V12_LAUNDER=1; nicht auf dem Gerät ausgeführt |
kernelsu.ko geladen | funktioniert |
| Root-Prozess überlebt | ⚠ nicht nachgewiesen — siehe unten |
| Reboot-Mechanismus | ❌ nicht nachgewiesen. Ein Kandidat (die Divergenz) ist jetzt ausgeschlossen; siehe unten |
probe_state als Landungskriterium | ❌ falsch — nicht verwenden. Drei Gegenbeispiele; siehe die Tabelle unten |
| pstore/ramoops-Panikkanal | ⚠ Instrument existiert; Kanal nie validiert (noch kein Nulltest) |
| „Das Opfer dreht sich im reinen Userspace" | ⚠ noch keine Messung — uid.stream zeichnet jetzt utime/stime/nvcsw auf, sodass es überprüft werden kann |
| pi-seitiger Dual-Write in einem Durchgang | ⚠ nicht nachgewiesen; pi.pc/pi.left sind in fdset_map.h fest auf 0 gesetzt |
Pfad A (UMH / modprobe_path) | STATIC_USERMODEHELPER_PATH="" |
| Feld | Offset |
|---|
real_cred / cred | 0x778 / 0x780 |
gecachte syscallno | 0xdf8 |
gecachte uid / euid / gid / egid | 0xe00 / 0xe08 / 0xe10 / 0xe18 |
flags0x0 |
addr_limit | 0x8 |
ttbr0 | 0x10 |
preempt_count | 0x18 |
| Feld | Offset | Feld | 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()| Ziel | Landing-Orakel |
|---|
task+0x778 | Uid: 4. awk-Feld = hi32(write_target) und Gid: 2. awk-Feld = low32(write_target) — der obige Stempel; vor Stufe 3 lesen |
task+0x780 | das eigene getuid() des Opfers |
global selinux_enforcing | getenforce |
probe_state | ❌ kein Kriterium. Höchstens ein Hinweis auf die Kette; niemals ein Beleg, dass ein Write gelandet ist |
$4notes.md$3euid0hi32(write_target)stamp_ok()run_bootA.sh| # | Hook | Auslöser | Aktion |
|---|
| 1 | oplus_root_check_post_handler, sys_exit-Tracepoint | irgendeine ID ist abgestiegen, oder addr_limit == KERNEL_DS | oplus_root_killed → printk + do_exit(SIGKILL); und oplus_root_check_succ → kevent_send_to_user |
| 2 | oplus_exe_block_ret_handler, sys_exit aber nur für execve (221) | d_path(mm->exe_file) beginnt mit /data, /data/local/tmp, /data/nativetest, /data/nativetest64 | oplus_RWO_root_check → printk + kevent_send_to_user (kein do_exit) |
| 3 | oplus_secure_harden kretprobes | setsockopt optname ∈ {41,42,48}, setxattr, /proc/cpuinfo, SELinux-Policy-Reload | 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 | Filter |
|---|
socket_kretprobe | ip_setsockopt | regs[1] ∈ {41, 42, 48} |
socket_ip6_kretprobe | do_ipv6_setsockopt | regs[1] ∈ {41, 42} |
cpuinfo_kretprobe | cpuinfo_open | — |
setxattr_kretprobe | setxattr | — |
sepolicy_reload_kretprobe | spolicy_reload | — |
| ldr w8, [x1, #8] ; regs[1] | ||
| cmp w8, #0x29 ; 41 IP_MSFILTER | ||
| b.eq #0xd58 | ||
| cmp w8, #0x30 ; 48 MCAST_MSFILTER | ||
| b.eq #0xd60 | ||
| cmp w8, #0x2a ; 42 MCAST_JOIN_GROUP | ||
| b.ne #0xd68 ; else → return, no call | ||
| bl oplus_heapspray_check |
| Stage | Retry-Sicherheit |
|---|
R5 | Schritt 5, task+0x778 | sicher — ein Fehlschlag installiert nichts, und das Stamp-Kriterium macht eine fehlgeschlagene Runde lesbar, sodass ein weiterer Schuss nur ein weiterer Versuch ist. R5=3 hebt die Trefferrate pro Schuss von ~p auf ~1−(1−p)³. |
R6 | Schritt 6, task+0x780 | nicht sicher und nicht nötig — es feuert nur nachdem Schritt 5 gelandet ist, sodass ein Retry auf eine bereits divergente Task schießt: eine weitere Chance, eine zweite, andere Seite zu landen, ohne Nutzen, da ein Landen das Paar vervollständigt. Bei 1 belassen. |