
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 — 修正前の形状。
| デバイス | 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="" |
「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
| フィールド | オフセット |
|---|---|
real_cred / cred | 0x778 / 0x780 |
キャッシュされた syscallno | 0xdf8 |
キャッシュされた uid / euid / gid / egid | 0xe00 / 0xe08 / 0xe10 / 0xe18 |
thread_info
| フィールド | オフセット |
|---|---|
flags | 0x0 |
addr_limit | 0x8 |
ttbr0 | 0x10 |
preempt_count | 0x18 |
cred
| フィールド | オフセット | フィールド | オフセット |
|---|---|---|---|
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 |
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 →
Uid: 0 0 0 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 のままになります — 分岐状態です。