
OPPO Find X5 Pro (PFEM10) 用 GhostLock (CVE-2026-43499) — OPlus watchdog & heap-spray detector reverse engineering
English · 中文
ColorOS 16 上の OPPO Find X5 Pro 向け GhostLock (CVE-2026-43499) ポート。uid=0 の子プロセスとロード済みの kernelsu.ko に到達する。root プロセスは傍受される。
CVE-2026-43499 — futex PI の use-after-free。remove_waiter() は、current が requeuer である場合に current->pi_blocked_on をクリアする。これは rt_mutex_start_proxy_lock() の -EDEADLK ロールバックパス上で発生する。
remove_waiter @ 0xffffffc0081ed254 — 修正前の形状。
「root プロセスの生存」について: evidence/kill.log の実行は uid=0 に到達し kernelsu.ko をロードし、実際にそれをポーリングした実行では KernelSU マネージャープロセスが 120 秒間生存し、/proc/modules 内で kernelsu は依然 Live であった。後の実行では、同じチェーンが Android フレームワークサービスを到達不能にした (Can't find service: package/power/input/phone/wifi) が、モジュールは依然 Live であった。[ROOTCHECK-*] カーネル行も $$sys_call_number@@ ペイロードもこれまで捕捉されていないため、後の実行の状態の原因は特定されていない。evidence/notes.md §2.3, §2.4, §7 を参照。
task_struct
thread_info
| フィールド | オフセット |
|---|---|
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
**ステージ2にはステージ1の値を明示的に渡さなければならない。** ステージ1とステージ2はそれぞれ独自のスプレーを持つ独立した2つのプロセスであるため、「credページを両方のスロットに書き込む」というのは罠である。素朴に読むと `(pageA, pageB)` が生成され、`commit_creds` は**ポインタ**を比較するため、両方の書き込みが成功したとしてもこのペアは不一致となる。これは仮定の話ではない — まさにこれが実行3と実行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 はそれゆえステージ2を V12_W7_VALUE=<ステージ1で観測された値> で発火させ、その値が復元できなければ一切発火させない。HOLD はステージ2より長生きしなければならない。さもなければステージ1のページが解放・再確保され、「同じ値」がダングリングポインタになる。詳細は同一値ルールを参照。
1回のブートにつき1ページが修復される。 ステージ3は V+8 をゼロにする。2つの異なるページがある場合、両方をゼロにすると gid/suid スタンプ(後述)が消去され、不一致が一致のように見えてしまうため、ランナーは実際にインストールされたページのみを修復し、2つの値が一致しなければ停止する。
cred ページは payload.c によって構築される。8つの id フィールドはすべてゼロ、5つのケイパビリティセットはすべてフル、そして user / user_ns / group_info は root_user / init_user_ns / init_groups を指す。ステージ3が存在するのは、書き込みの副作用がインストールする cred の cred+8(gid/suid)を常に破壊するためである。
init_cred について — 明示的な二分法ここには以前、互いに矛盾する2つの記述があった(「グローバルの init_cred は決して使わない」対「CONTROL=1 はセル2を再現する」、そしてセル2はまさに init_cred である)。両方の記述は異なる役割について真である:
init_cred ポインタを書き込むと、副作用が init_cred+8 をグローバルに破壊する — init_cred はすべてのカーネルスレッドで共有されており、Uid: 0 0 4294967176 0 はまさにその破壊である。コードは V12_ALLOW_INIT_CRED=1 が意図的に設定されていない限り、このパスを拒否する。0xffffff802a7e0be0)を両方のスロットに書き込んだため、構造上 real_cred == cred となった — それが execve まで生き残った理由である。CONTROL=1 はこれを再現する。これはコントロールであり、構築の土台とすべき設定ではない。perf リーク: PERF_TYPE_SOFTWARE / PERF_COUNT_SW_CPU_CLOCK、PERF_SAMPLE_REGS_INTR、exclude_user=1。
[0xffffff8400000000, 0xffffff90000000) を受け入れ、投票は ≥ 15%。
UAF は rb_erase_cached の Case 1-left を通じて駆動される。これにより2つのストアが発生する(1つではない):```
*(write_target) = write_value // the store you aim
*(write_value + 0x08) = write_target // unavoidable side effect
`write_value` はビット 0 がクリアされた 8 バイト境界に整列されている必要がある — それは `0` か、有効なカーネルアドレスのいずれかである。**これが `g_boot_state` をこのプリミティブで設定できない理由である**: `1` になる必要があるバイトは、整列要件によってその下位ビットが `0` に強制され、`write_value` は副作用が到達するアドレスと同じ量である。
### 副作用は `write_value` が指す先に書き込む
`write_value` は*格納される値*であり、同時に*副作用が書き込むアドレス*(`+8` の位置)でもある。これをグローバルカーネルオブジェクトに向けると、そのオブジェクトを破壊することになる。
**W7 はまさにこれをしていた** — `write_value` を `init_cred` エイリアスに向けて — そしてそれはリードバックで確認できる。`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 は init_cred のエイリアスであり、write_target は child_task+0x778 でした。
init_cred+8 は gid/suid であるため、副作用として 0xffffff8800cdd178 が
そこに格納されました: init_cred.gid = 0x00cdd178 および init_cred.suid = 0xffffff88 = 4294967176 — まさに上記の Uid: 行の4番目の awk フィールドです。init_cred+8 を
ゼロクリアすることで修復されました(out/t5_repair.txt: Uid: 0 0 4294967176 0 →
)。これが「W7 stage 3」のすべてでした。
このパスはコード内で拒否されるようになりました。 V12_W7_INIT_CRED=1 は
V12_ALLOW_INIT_CRED=1 も設定されていない限り、説明とともに中止します。また W2/W6/LTC パスは
プライベート cred ページが存在しない場合に init_cred へフォールバックしなくなりました — 代わりに
中止します。デフォルト、そして唯一まともなパスは、スプレーされた cred ページです。
副作用自体は回避できません: write_value は cred ポインタでなければならないため、
cred+8 は常に書き込みターゲットで上書きされます。選択できるのはその場所だけです — そして
修復は今や cred_page+8 のローカルなゼロクリア(stage 3)であり、グローバルオブジェクトへの
書き込みではありません。
ゴミのような
groups=の読み取り値は、これとは別の症状です。これはgidとegidがクリーンに読み戻された実行で見られたため、init_cred+8の副作用から 生じることはあり得ません。これは偽の cred 自身のgroup_infoフィールドを指しています。evidence/notes.md§10.6 を参照してください。
BUG_ON であるこのプリミティブは1パスにつきちょうど1つのアドレスに格納します。task+0x778
(real_cred)と task+0x780(cred)は別々のアドレスであるため、0x778 のみ、または
0x780 のみの書き込みが着地すると、タスクは cred != real_cred のままになります — 分岐状態です。
このイメージでは、その状態は警告ではなくハードパニックです。commit_creds は
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()
そしてカーネルは **`CONFIG_PANIC_ON_OOPS=y`**(`CONFIG_PANIC_ON_OOPS_VALUE=1`)でビルドされている。
`__put_cred @0xffffffc008185530` は同じ系統のアサーションを抱えている
(`usage != 0` → BUG、`cred == current->cred` / `current->real_cred` → BUG)。
したがってこの分岐は*潜在*している——被害側がただスピンしている間は何もせず——
そのタスク上で**何らかの** `commit_creds` が発生するまで待つ:`setresuid` / `setresgid` /
`setuid` / `setgid` / `capset`、あるいは **`install_exec_creds` 経由の `execve`**。
> **⛔ 撤回(2026-09-18 遅く):これは再起動のメカニズムではない。**
>
> この節の以前の版では、この分岐を「再起動の主要メカニズム候補」と呼び、
> 「形状の分裂を説明する」と述べていた。それは誤りであり、その理由は今や
> 議論ではなく計測による:
>
> * `commit_creds` はタスクを **`current`** から取得する——`0x1867a0 mrs x20, sp_el0`。
> そのシグネチャは `commit_creds(struct cred *new)` であり、タスク引数はない。
> したがって分岐が問題になるのは、それを**保持している**タスク自身が
> `commit_creds` を呼び出した場合だけである。
> * 再起動した実行はすべて `V12_NO_EXEC=1` だった(`run3_0445.log:18`、
> `run9_0606.log:20`、`run10_0616.log:24` にそのまま記載されている)ため、被害側は
> `execve` を一度も発行せず、`commit_creds` に到達することもなかった。
> * Run 10 には poke が一切なかった(`grep -c poke` = 0)。
> * `exit_creds` は `put_cred` の前に**両方**のポインタを null にする
> (`0x185cb8 str xzr,[x19,#0x778]`、`0x185d24 str xzr,[x19,#0x780]`)ため、
> 被害側の `_exit(0)` は分岐に引っかかるどころか、それを**消去**してしまう。
>
> ⇒ これらの実行では分岐は**不活性**だった。`BUG_ON` は実在するが、それは
> まだ起爆していない地雷である。「形状 A は決して再起動しない」は相関関係に
> 戻る。この地雷が実際に制約するのは**ロンダリング**である。なぜなら
> `setresgid`/`setresuid` は自ら `commit_creds` を呼び出すからである。
>
> **実際にチェーンを分ける量はポインタの等価性であり、それは二つのショットが
> 一つの値を書き込まなければならないことを意味する**——次の小節を参照。
### ★★★ 二つのショットは*同じ*値を書き込まなければならない——単に両方が着弾するだけでは不十分
`BUG_ON` は**ポインタ**を比較する。両方とも `uid 0` を持つ二つのページは、
依然として二つの異なるオブジェクトである。キャプチャがその区別を具体的にする:
| チェーン | 0x778 ショット | 0x780 ショット | ポインタ |
|---|---|---|---|
| 旧(`t5loop.sh MODE=CRED`) | `in[0]=0xffffff802a7e0be0` | `in[0]=0xffffff802a7e0be0` | **等しい** → ksud ロード、マネージャ 120 秒生存 |
| 新(`run_bootA.sh`) | `0xffffff88679bade0`(run 9) | `0xffffff8785d6ade0` | **異なる** → 両方着弾しても分岐 |
`tools/t5loop.sh` は**一つの** `$ENVV` を**すべての**オフセットに適用するため、
`MODE=CRED` は両方のショットを*構造上*同一にした。`run_bootA.sh` はステップ 5 と
ステップ 6 をそれぞれ空の `$extra` で発火させたため、それぞれが**独自の**ページを
スプレーした。
⇒ 要件は**「両方のショットが同じ値を書き込むこと」**である。`run_bootA.sh` は今や
それを強制する(`SAME_VALUE=1`、デフォルト):ステップ 6 はステップ 5 で観測された
`write_value` をそのまま再利用し、その値を復元できなければ**一切発火を拒否する**
——発火すれば分岐したペアを構築してしまうからである。
⚠ `HOLD` は二番目のショットより長生きしなければならない。最初のショットの PIN 子
プロセスが先に死ぬと、ページは解放され再割り当てされ、「同じ値」はダングリング
ポインタになる。デフォルトの `HOLD=20` は**短すぎる**;`HOLD=600` を使うこと。
これは今や `SAME_VALUE=1` のときは常に*デフォルト*である——以前の無条件の 20 秒は、
デフォルト設定自体が罠であったことを意味していた——そして `SAME_VALUE=1` を伴う
明示的な短い `HOLD` は、黙ってダングリングポインタを生み出す代わりに、今や
大声で警告する。
⚠ `CONTROL=1` は以前**ステップ 5 のみ**を変更していたため、
`(init_cred, fresh page)`——分岐したペア——を生成していたが、このファイルは
セル 2 を再現すると主張していた。修正済み:今や両方のショットを `init_cred` に
設定する。(セル 2 のコストは変わらない:副作用が `init_cred+8` をグローバルに
破壊し、それが `Uid: 0 0 4294967176 0` である。)
**クレデンシャルをロンダリングしたいものすべてへの帰結**
(`setresgid` + `setresuid`、スプレーされたページを本物の `struct cred` と
交換するため):
- メカニズムは実在し検証済みである——`commit_creds` は `x21` を
`task+0x778` と `task+0x780` の**両方**に書き込む(`0x186998` / `0x1869a0`)ため、
一回の呼び出しで分裂を恒久的に修復する;`prepare_creds @0xffffffc008186070` は
`kmem_cache_alloc(cred_jar)` + `memcpy(new, task->cred, 0xA8)` +
`security_prepare_creds(...)` であり、147/149 はどちらもガードの免除リストに
載っている。
- **しかしその前提条件は「0x778 ショットをスキップする」の反対である。** ロンダリング
自体が `commit_creds` を呼び出すため、**両方**のポインタがすでに同じ値を保持して
いるときにのみ発行してよい。
- `V12_LAUNDER=1` は**二つ**の事柄でゲートされ、最初のものは観測ではない:
1. **`V12_W7_SAME_VALUE=1`**——両方のショットに同じ値が与えられたという
*来歴*の事実。読み取りプリミティブがなければポインタ同一性は観測不能である
ため、これはより良いユーザ空間チェックで置き換えることはできない;宣言
しなければならない。
2. `consistent=1`——`0x780` ビュー(`getuid()`)が `0x778` ビュー
(`/proc/self/status` の `Uid:`)と一致すること。**必要だがそれ単独では
十分ではない**:両方とも `uid 0` を持つ二つの異なるページは、ポインタが
異なっていても等しく読める——まさにランナーが製造するのに使われていた
ケースである。(1) が与えられれば、これは十分になる:一致 + 同じ値 ⇒
両方が同じページに着弾した。どちらかのチェックが失敗 ⇒ 拒否し、四つの
ケースの表が証拠に入る。LT レポート行は両方のビュー(`uid=` / `real_uid=` /
`consistent=`)に加えて `same_value_declared=` を出力し、状態が推測される
のではなく読まれるようにする。
### ★ 計器 1——副作用はターゲットを狙った STAMP である
`*(write_value + 8) = write_target` であり、`cred+8` / `cred+0xc` は `gid` / `suid`
であるため、一回の 8 バイトストアが両方にまたがって着弾する:```
cred.gid = low32(write_target)
cred.suid = hi32(write_target)
これは測定値であり、モデルではない。out/t5_w7_778.txt には
write_target = 0xffffff8800cdd178 と Uid: 0 0 4294967176 0 があり、
ここで 4294967176 = 0xffffff88 = hi32(write_target) である。notes.md §11 には
もう半分、init_cred.gid = 0x00cdd178 = low32(write_target) が記録されている。
2つの用途:
task+0x778 の着地オラクルである。 /proc/<pid>/status は
real_cred = task+0x778 を読む — まさに今インストールされた cred である — したがって
スタンプはユーザ空間から直接読める。ステージ3の前に読むこと: 修復は
cred+8 をゼロにして消去する(notes.md §11 の t5_repair.txt は
修復成功前に 4294967176 を、成功後に 0 を読む)。0 を読むので、2つの
異なるページがどちらも「一致」を報告する。しかし low32(T+0x778) と
low32(T+0x780) はちょうど8だけ異なるので、2つのページでは getgid()(cred から)と
( から)が食い違う — そして
は uid と同様に gid も比較する。⇒ V12_W7_SAME_VALUE は2つ目のゲートであり、唯一のものではない。それでも重要である:
スタンプは両方の副作用が発火した場合にのみ識別するので、same-value ルールは
その残存する穴を塞ぐ。そしてゲートとは何かに注意せよ — 検出器であり、防止器ではない。
それは拒否することしかできず、ブートの残りの間タスクを発散させたままにする。
same-value ルールこそがこのペアを正しくするものであり、それが古いチェーンに
あったものであり、execve に到達するために必要なものである。
probe_state は着地基準ではないこのプロジェクトでは3回間違っている: W1 はグローバルに着地して R を報告した。
run 12 の D は cred ではなくグローバルを狙っていた。そして run 11 の R は
着地であるかのようにテーブルに書き込まれた
(run11_w778r1_miss.txt と run 7 の w7_w7781.txt は行単位で同型である — どちらも
probe_state = R、probe_done = 0)。ターゲットごとのオラクルを使うこと:
run_bootA.sh は今やステージ1にスタンプを使う — これが ROUNDS>1 の
task+0x778 でのリトライを意味あるものにする。失敗したラウンドは推測ではなく
読めるからである — そしてステージ1が着地しない限りステージ2を発火させない。
⛔ 「awk フィールド」と言うこと、「3番目のフィールド」とは決して言わない。
uid_lineはラベルも出力する (Uid: 0 0 4294967176 0)ので、awk の$1は"Uid:"であり、4つの id 値は$2..$5である:$2=uid$3=euid$4=suid$5=fsuid。スタンプはcred+8、すなわちgid(low32)とsuid(hi32)にある — したがってGid:の$2とUid:の である。これを「3番目のフィールド」と呼ぶこと(これはを数えており、 §11 がそう表現している)は、コードに を読ませる誘いとなり、それは 偽の cred では = であり、 に決して等しくならない。 この off-by-one がここに存在した: は着地したショットに対して「no stamp」を返したので、 ステージ2は決して発火せず、launder ゲートは永遠に拒否した — 、 なぜなら「no stamp」は本物のミスの通常の結果でもあるからである。
既知の陽性サンプルに対して決してテストされない基準は、基準ではなく推測である
— そしてこの種の失敗(この off-by-one、probe_state、dmesg -w、空の klog.host、
空白の読み戻し)は常に*「何も起こらなかった」*として現れ、これもまた正当な実験結果である。
したがってチェックは今や二重に防御されている:
stamp_selftest() はプリフライトで実行され、失敗時に exit 9 する。ゲートが使う
同じ抽出関数を、out/t5_w7_778.txt の測定値
(write_target = 0xffffff8800cdd178 → Uid $4 =
4294967176、Gid $2 = 13488504)に対して、さらに陰性および読めないサンプルに対して駆動する。
チェックを再実装する自己テストは何も証明しないので、フィールド抽出は
uid_suid_field / gid_gid_field に因数分解されている。tools/test_stamp_criterion.sh — 同じものを
スタンドアロンの回帰テストとして、run_bootA.sh から実際の関数を抽出して行う。stamp_ok() は3つの状態を返す。なぜなら「読めない」は「no stamp」ではないからである
(その混同が run 13 を「変化なし」に見せた): 0 = 存在、1 = 読めるがスタンプなし、
2 = 読めない。そして probe_state = D のときに 1 を返すと、ランナーは
「着地しなかった」メッセージの代わりに ⛔ ORACLE INCONSISTENT — 「基準を確認せよ」 —
を出力する。これはオペレータをまったく別の場所(新規ブート、またはヒット率の探索)へ
送る。
完全な導出: evidence/2026-09-18-divergence-is-latent.md
および evidence/2026-09-18-cred-launder-verification.md
(後者の §2.3 はその場で撤回されている)。write-shape の重複自己チェックは
オフラインで閉じられている: shape ワードはカーネルスタック上の fd_set グリッドに存在し、
副作用はスプレーされたページ内に着地するので、どちらの shape でも両者は重複し得ない。
3つの独立したレポーター。どれも他方のフォールバックではなく、パス1のみが 呼び出し元タスクを kill できる。
パス2は credential 変更ではなく execve で発火し、パス1とは別のコード
パスである。 これは kevent_send_to_user を通じて報告するので、次に何が起こるかは
カーネルではなくユーザ空間デーモンの決定である。
チェックはexec されるイメージのパスに対して行われるので、ローダーのペイロードを
memfd ロードするだけでは不十分である: ローダー自体が /data/local/tmp から exec されるなら、
その最初の execve がすでに報告する。memfd 上の d_path() は
/memfd:… なので、ローダー自体が memfd を通じて exec されなければならない —
まさにこの理由で V12_EXEC_MEMFD は今やデフォルトでオンである。古い挙動は 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
パス2のgrep可能なマーカー:```
[ROOTCHECK-EXEC-INFO]:common %s result %s with "execve_report" / "execve_block"
%d,path@@%s kevent payload fragment
path 2 と path 3 は kevent 経由でのみ報告するため、「カーネルログに [ROOTCHECK-*] が存在しない」ことは、どちらかが発火したことを排除しない。 この推論にはユーザー空間のレシーバーが必要だが、それはまだ特定できていない。
oplus_security_guard.kosys_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` チェック:```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)
制御フローの注記(エクスプロイトの順序に関係する)。 4つの降順エッジ
比較は**0x468へ直接分岐する**。これはシステムコール番号のディスパッチであり、
addr_limitゲートをフォールスルーすることはない。0x454–0x464は
どのidも降下しなかった場合にのみ到達する。したがってディスパッチは、
何らかのidが降下したか、addr_limit == KERNEL_DSであるかのいずれかで
入られる。addr_limitによってゲートされることはない。
帰結:
sys_enterがまだ古いuidをキャッシュしていたものである。システムコール
進入時にタスクがすでにuid=0である場合(0x400 cbz)、フックはリターンし、
それ以降は盲目のままとなる。delivery/外部建议评审_2026-09-18.mdを参照。g_boot_state — 1バイトの.data..ro_after_initで、モジュール初期化時に
verified_bootstateからstrstr経由で設定される。is_unlocked() = LDRB + RET。
モジュールVA書き込みはフォールトする(CONFIG_STRICT_MODULE_RWX=y) — physmap
エイリアス0xffffff80…を使用すること。
レポートペイロード: $$sys_call_number@@%d、$$set_id_flag@@%d、$$addr_limit@@%lx、$$enforce@@%d。
.rodata+0、インデックス143–214残りの60エントリ → レポート + キル。
認証情報が変更されている間にスレッドをsendmsg (211)でブロックしてはならない。
これはテーブルに含まれていないため、スレッドはレポートされキルされる。その目的で
ブロックされていても安全なシステムコールは上記の12個のみである:
setregid、setgid、setreuid、setuid、setresuid、setresgid、
connect、getsockname、setsockopt、shutdown、readahead、brk。
訂正 (2026-09-18)。 このテーブルの以前のリビジョンでは、すべてのエントリが 実際のarm64システムコール番号より1つ低くラベル付けされていた(
146はsetresuidと呼ばれていたが、実際はsetuidである —setresuidは147)。 番号は常に正しかった。誤っていたのは名前だけである。名前は現在、このデバイスの カーネルイメージ内のsys_call_table@0xffffffc00a13d8c0から解決されている。 特にsendmsg(211)、munmap(215)、getsockopt(209)、getpeername(205)は 免除ではない — 認証情報が変更されている間にこれらのいずれかでスレッドを ブロックすることは、パスではなくキルである。tools/gen_exempt_table.pyで 再生成すること。
oplus_secure_harden.ko`entry_handler_setxattr` → `oplus_heapspray_check(7)`、`entry_handler_cpuinfo` → `(6)`、どちらも無条件。
`oplus_heapspray_check` — カウンタ `heapspary_ip4` / `ip6` / `xttr` / `cpuinfo`、`ktime_get_real_ts64` デルタ、7つのレポートサイト(`snprintf` + `printk` + `kevent_send_to_user`)、`verified_bootstate` でゲート。
### 回避
| プリミティブ | |
|---|---|
| `setsockopt` optname 46 `MCAST_JOIN_SOURCE_GROUP` | フィルタされていない |
| `setsockopt` level `SOL_IPV6` (41) | フィルタが `level` を読む場合はフィルタされていない |
| `setxattr` | 常にカウントされる |
| `/proc/cpuinfo` | 常にカウントされる |
| `socket()` / `socketpair()` | フックされていない |
| `sendmsg`、`pipe`、`memfd`、`add_key`、`io_uring`、mmap | フックされていない |
## 設定```
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 は 固定 — これらはリクレームのスタックフレーム形状(delta=0 キャリブレーション)を維持する。これらのいずれかを変更する場合は、デバイス上で再キャリブレーションが必要となる。```bash
export ANDROID_NDK_HOME=/path/to/android-ndk-r28c
make # → exploit_guard
./build.sh # same, with NDK auto-detection
手動:```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 はプッシュごとにビルドされます(.github/workflows/build.yml、Ubuntu + NDK r28c、成果物 exploit_guard)。
adb push exploit_guard /data/local/tmp/e adb shell chmod 755 /data/local/tmp/e adb shell /data/local/tmp/e
## ファイル```
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 — 4回のroot実行のadb shellトランスクリプトそのまま: 完全なタイムライン、対象タスクのuidが0になる瞬間、kernelsu.koのロード、そしてその後の状態。まずヘッダーブロックを読むこと: このファイルが含まないものとその理由が列挙されている。
evidence/notes.md — カーネル側。モジュールアドレスと、どのSELinux状態でどの/procチャネルが機能するか; strstrキーを含む完全なg_boot_stateの導出; 修正された除外テーブル; 欠落しているカーネル側の半分を生成するキャプチャレシピ; そしてまだ未解決の項目のリスト。
evidence/2026-09-18-bootA/ — 現行設計の最初の実機実行、13回のブート。再起動は整然としており(bootreason=reboot)、パニック行は一度もキャプチャされていない — しかしそれは13回ではなく1回のサンプルとして読むこと。 再起動した4回の実行のうち、2回は空のklog.hostを保存し、1回は次のブートのログを保存し、自身の再起動を妥当に挟み込めるウィンドウは1つだけである。同様に、ある実行のprobe_stateとvictimの読み戻しは両方とも空であり(デバイスがすでに消失していた)、そのため書き込みが成功したかどうかについて何の情報も持たない — 空欄は「変化なし」ではない。このディレクトリは知っておく価値のある方法論的誤りも記録している: このデバイスではdmesg -wはno-opである(toyboxは一度ダンプして終了する)ため、以前の実行のカーネルログにはキャプチャ前の履歴しか含まれていなかった — 「[ROOTCHECK-*]がない」ことは何の証拠でもなかった。evidence/notes.md §6に修正されたpoll-and-stream-to-hostレシピがある。
evidence/2026-09-18-cred-launder-verification.md
— このイメージ自身の逆アセンブリに対するcredential-laundering提案の検証(一般的な5.10ソースではない): commit_credsの二重ストア、prepare_credsのアロケーションと実測されたsizeof(struct cred) = 0xA8、上記のBUG_ON(cred != real_cred)ダイバージェンスハザード、閉じたwrite-shapeオーバーラップ自己チェック、そして13回のブートの証拠カバレッジ監査。§2.3はその場で撤回されている — ダイバージェンスは潜在的なものであり、再起動メカニズムではない。
evidence/2026-09-18-divergence-is-latent.md
— 第2ラウンドの検証。commit_credsはcurrentからタスクを取得し(0x1867a0 mrs x20, sp_el0)、exit_credsはput_credの前に両方のポインタをnullにし、生のキャプチャは古いチェーンが両方のスロットに同一の値(0xffffff802a7e0be0)を書き込み、新しいチェーンは2つの異なるページを書き込んだことを示している。したがって基準はポインタの等価性である — 「両方のショットが同じ値を書き込む」ことであり、「両方のショットが着弾する」ことではない。
postreboot_forensics.sh — ポーラーに依存しない再起動フォレンジック。基準は単一の条件である: CONFIG_PSTORE_CONSOLE=yはpanic()にkmsg_dump(KMSG_DUMP_PANIC)でコンソールの末尾をramoopsに書き込ませる — リセットの前に — したがってその後にボックスが再起動するかハングするかは無関係である。/sys/fs/pstore/を取得し、kernel BUG / __put_cred / cred.cをgrepし、boot-reasonの文字列を出力する(履歴エントリにはreboot,shell / bootloader / reboot,edlのサフィックスが付いているため、reasonはepochが区別しないアクターを区別する)。
⚠ 「クリーンな
bootreason=reboot」を「パニックなし」と読んではならない。 QCOMではSoCウォッチドッグアサートはPMIC PONブロックを介してリセットされるため、panic → panic_timeout=-1 → hang → watchdog → PMIC reset → clean bootreasonは自己整合的なチェーンであり、我々が保持する証拠上ではハードウェアリセットと区別できない。このリポジトリ自身のtotal_17_dump_0_pmic_17は17回の異常再起動すべてをpmicに帰属させており、これはまさにウォッチドッグの通常の形であり、「カーネルではない」ことの証拠ではない。ここでbootreasonは何も絞り込まない; ramoopsが唯一の基準である。
2つの前提条件、さもなければスクリプトの判定は無効である(鉄律8 — ノーシグナルの結論には、まずチャネルが到達可能であることが証明されている必要がある):
/sys/fs/pstore/*はroot専用であるため、Enforcing下ではadb pullもcatも失敗する — そして「読めない」は「読んで空だった」と同じ出力を生成する。二状態のスクリプトは、一度も開いていないチャネルから「pstore is EMPTY ⇒ panic disproven」と出力する。したがってスクリプトは**CHANNEL UNREACHABLE**を出力し(lsが失敗した、または既知のエントリすべてが存在しないのではなく読み取りに失敗した)、getenforceを併記する。adb rebootに続く即時のフェッチ。既知の良好な再起動が何も読めないものを生むなら、チャネルは証明されておらず、その後のすべての「空のpstore」は証拠ではない。順序が重要: デバイスはブート直後にレコードを移動してunlinkするため、順序は
reboot → get Permissive (W1) → スクリプトを即座に実行である。run_bootA.sh — その1回のブートのオーケストレーション、重要な順序で(0x778 → 0x780 同じ値で → 実際にインストールされたcredのローカル修復 → 確認 → その後にのみpoke)。ADB=/SER=/BIN_LOCAL=は上書き可能; SAME_VALUE=1(デフォルト)はsame-valueルールを強制し、LAUNDER=1はゲート付きlaunderを有効にし、HOLD=600はsame-valueシーケンスに必要である。
リトライはステージごとに分割されている(R5/R6)、なぜなら2つのステージは逆のリスクプロファイルを持つからである:
R5はデフォルトでROUNDS、R6は1である。
推奨されるlaunder実行 — デフォルトのsprayed-pageパスであり、CONTROL=1ではない:```bash
LAUNDER=1 R5=3 R6=1 HOLD=600 CHAINWAIT=6000 NODRAIN=1 WATCH=180 ./run_bootA.sh
`CONTROL=1` も一貫したペアを生成するが、`init_cred` ポインタを書き込むことで、その副作用が `init_cred+8` を **グローバルに** 破壊する — そして「フレームワークが死ぬ」ことは監視対象の一つであるため、デバイス全体の障害を測定のバックグラウンドに持ち込むと、この実行が存在するまさにその読み取りを濁してしまう。スプレーされたページのパスは「両方のショットが着弾しなければならない」というコストのみを伴い、それが `R5=3` の目的である。`CONTROL=1` は唯一の *証明された* 一貫したペアとして、また対照として残るが、推奨構成としてではない。
**どのページがインストールされたかは、無条件行から読み取られる。** `run_w7` は書き込み値を2行に出力し、そのうちの1行だけが無条件である:```
L1793 W7[..] write value = private cred page 0x.. — spray path only
L1802 W7[..] write_value = 0x.. — after the if/else, ALL paths
ランナーは最初の文言にマッチしていたため、CONTROL=1 では抽出が空で返り、if [ -n "$CRED" ] が修復をスキップし、同値結合自身の [ -n "$CRED" ] 項がそれを 0 に保っていた — launder ゲートは永遠に拒否し続けたはずだ。2 つの出力サイトのうち 1 つしか認識しない正規表現による、両方の点でのサイレントな no-op。これと write_target 抽出(スタンプ基準の 入力)は現在どちらも wv_from/wt_from を経由しており、基準自体とともに回帰テストで検証されている。
両方のストリームは、測定対象より前に開始する。 uid.stream はステージ 1 から実行され、cred.stream は watch の後ではなく poke の時点 で開始する — poke は子を NO_EXEC レポートループに解放し、それは 240 × 0.5 s = 120 s の後に _exit(0) となる(exploit.c: "LT child NO-EXEC mode done (120s)")。そのため t+~135 s という以前の配置では、子がすでに消えた後にサンプリングを開始しており、まさにこの計測器が存在する目的のウィンドウだった。またランナーは stamp_selftest() が失敗した場合に続行を拒否し、スタンプと probe_state が不一致の場合は "did not land" ではなく ORACLE INCONSISTENT を出力する。
artifacts/guard_post_handler.s — リロケーションを埋めた kill チェーン。古いリスティングの adrp x9, #0 は .data..ro_after_init であり、bl #0x4ac は oplus_root_check_succ である。自分のデバイスからベンダーモジュールを取得した後、tools/gen_guard_disasm.py で再生成すること。
tools/kdis_ko.py — RELA は sh_info でマッチされる。これらのビルドでは .text リロケーションは .rela.text.<func> に存在するため、名前ベースの検索では何も返らない。
| Project | |
|---|---|
| JoinChang/ghostlock-oneplus | reference implementation; 5.10 compact waiter |
| NebuSec CyberMeowfia | original GhostLock research |
GPL-3.0 — see LICENSE.
| デバイス | OPPO Find X5 Pro (PFEM10) |
| SoC | SM8450 / Adreno 730 |
| OS | ColorOS 16.0.3.520 (CN01) |
| カーネル | 5.10.236-android12-9-o-gaf2075ad2c06 |
| ブートローダー | locked, green |
| VA_BITS | 39 — KIMAGE_TEXT_BASE = 0xffffffc008000000 |
| ステージ |
|---|
Compact waiter トリガー (CMP_REQUEUE_PI → EDEADLK) | 動作する |
task_struct リーク (perf) | 動作する |
PI 書き込み (8 バイト; 値 = 0 または有効なカーネルアドレス) | 動作する |
task+0x778 または task+0x780 単独 → Uid=root | 動作する — ただし単一フィールドの着地ではタスクが分岐状態のまま残り、それが潜在的なハード BUG_ON となる。分岐の危険性を参照 |
| 両方のフィールドを単一の値で書き込み (一貫したペア) | ❌ スプレーページでは決して生成されなかった。 グローバルな init_cred エイリアス (09-14, CONTROL=1) でのみ観測された。ランナーは現在これを強制する (SAME_VALUE=1); デバイス上では未実行 |
資格情報のランドリング (setresgid + setresuid) | V12_LAUNDER=1 の背後で実装済み; デバイス上では未実行 |
kernelsu.ko ロード済み | 動作する |
| root プロセスの生存 | ⚠ 未確立 — 以下を参照 |
| 再起動メカニズム | ❌ 未確立。 候補の一つ (分岐) は現在除外されている; 以下を参照 |
着地基準としての probe_state | ❌ 誤り — 使用しないこと。 反例が三つある; 以下の表を参照 |
| pstore/ramoops パニックチャネル | ⚠ 計測器は存在する; チャネルは未検証 (ヌルテストはまだない) |
| 「被害者は純粋なユーザースペースでスピンする」 | ⚠ まだ読み取りなし — uid.stream が utime/stime/nvcsw を記録するようになったので確認可能 |
| pi 側のシングルパス二重書き込み | ⚠ 未確立; pi.pc/pi.left は fdset_map.h でハードコードされた 0 |
パス A (UMH / modprobe_path) | STATIC_USERMODEHELPER_PATH="" |
| フィールド | オフセット |
|---|
real_cred / cred | 0x778 / 0x780 |
キャッシュされた syscallno | 0xdf8 |
キャッシュされた uid / euid / gid / egid | 0xe00 / 0xe08 / 0xe10 / 0xe18 |
flags0x0 |
addr_limit | 0x8 |
ttbr0 | 0x10 |
preempt_count | 0x18 |
| フィールド | オフセット | フィールド | オフセット |
|---|
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 0status_gidreal_credlt_cred_ids_agree()| target | landing oracle |
|---|
task+0x778 | Uid: の4番目の awk フィールド = hi32(write_target) かつ Gid: の2番目の awk フィールド = low32(write_target) — 上記のスタンプ; ステージ3の前に読む |
task+0x780 | 被害者自身の getuid() |
グローバル selinux_enforcing | getenforce |
probe_state | ❌ 基準ではない。 せいぜいチェーンに関するヒント; 書き込みが着地した証拠には決してならない |
$4notes.md$3euid0hi32(write_target)stamp_ok()| # | フック | トリガー | アクション |
|---|
| 1 | oplus_root_check_post_handler、sys_exit tracepoint | 何らかの id が降下した、または addr_limit == KERNEL_DS | oplus_root_killed → printk + do_exit(SIGKILL); および oplus_root_check_succ → kevent_send_to_user |
| 2 | oplus_exe_block_ret_handler、sys_exit だが execve (221) のみ | d_path(mm->exe_file) が /data、/data/local/tmp、/data/nativetest、/data/nativetest64 で始まる | oplus_RWO_root_check → printk + kevent_send_to_user(do_exit なし) |
| 3 | oplus_secure_harden kretprobes | setsockopt optname ∈ {41,42,48}、setxattr、/proc/cpuinfo、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 | フック | フィルタ |
|---|
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 |
| ステージ | リトライの安全性 |
|---|
R5 | ステップ5、task+0x778 | 安全 — ミスは何もインストールせず、スタンプ基準が失敗したラウンドを読み取り可能にするため、別のショットは単なる別の試行である。R5=3は1ショットあたりのヒット率を |
R6 | ステップ6、task+0x780 | 安全ではなく、必要でもない — これはステップ5が着弾した後にのみ発火するため、リトライはすでにダイバージェントなタスクを撃つことになる: 2つ目の異なるページを着弾させる別のチャンスであり、1回の着弾でペアが完成するため上振れはない。1のままにしておくこと。 |