
eBPFベースのLinuxルートキット検出器。マルチチャンネルクロスビュー分析(sched_switch、NMI、/proc)を使用して、DKOM、トレースポイント改ざん、プロセス隠蔽を検出し、ハードウェアレベルの整合性検証を実施します。
システムプロセスの整合性とクロスビュー分析
「歌うから、輝いて、SPiCa...」
SPiCaはRustで書かれたeBPFベースのLinuxルートキット検出器です。その名前は初音ミクの曲『SPiCa』と、それが参照する星(おとめ座の最も明るい星であるスピカ/アルファ・ヴァージニス)に由来します。肉眼では単一の星に見えるものも、実際には分光連星であり、互いに軌道を周回する二つの星は、そのスペクトルを測定しなければ区別できません。SPiCaは同じ原理をカーネル観測に適用します。複数の独立したチャネルが物理的に異なるメカニズムで同一のカーネル状態を測定し、一方を抑制したルートキットは他方によって露見します。
免責事項: このコードベースのかなりの部分は、GLMの支援を受けて生成またはリファクタリングされました。厳格なテストと反復的な設計が適用されていますが、実運用前にセキュリティとパフォーマンスについてコードをレビューしてください。
SPiCaはeBPF制約下の敵対者(CAP_BPFまたはCAP_SYS_ADMIN権限を持ち、特権eBPFプログラムをカーネルにロードする攻撃者)を打ち負かすように設計されています。この敵対者は、BPF検証器が厳しい制約を課すため、LKMルートキットよりも根本的に弱いです。
| 制約 | 敵対者への影響 |
|---|---|
| 制限付きループ(プログラムあたり約100万命令) | カーネルメモリ全体をスキャンできない |
| ポインタ安全性(直接参照には証明可能な有効かつ制限付きのターゲットが必要) | 任意のポインタを追跡できない |
| 制限付きヘルパー(プログラムタイプごとの許可リスト) | kmalloc、vmalloc、その他カーネル割り当て関数を呼び出せない |
任意のカーネル書き込み不可(bpf_probe_write_kernelは厳重に制限) | task_struct、tracepoint関数配列、またはBPFプログラムバイトコードを変更できない |
| マップアクセスはロード済み参照に限定 | 他のプログラムに属するマップにアクセスできない(IDで列挙されない限り — §7参照) |
ブート後にLSMゲートによってLKMがブロックされている場合、この制約された敵対者が現実的に残る脅威です。SPiCaの検知回避対策機構はこの脅威に合わせて調整されており、すべての防御は何をカバーし、何をカバーしないかについて正直です。
init_moduleを必要としない任意のカーネル書き込みを伴うメモリ破壊。SPiCaはより簡単なLKM経路をブロックすることで下限を引き上げますが、敵対者の上限を引き下げるものではありません。SPiCaは多層防御スタックにおける最終防衛層であり、その上のレイヤーの代替ではありません。
SPiCaはカーネルフックにアタッチされた4つのeBPFプログラムと、それらの出力をシステム自身のビュー(/proc)と相互相関させるユーザースペース検出エンジンを実行します。
| チャネル | メカニズム | 抑制方法 | 抑制のコスト |
|---|---|---|---|
/proc | procfsディレクトリ列挙 | DKOM(プロセスをタスクリストから切断) | 低 — よく知られたルートキット手法 |
sched_switch | スケジューラ上のeBPF tracepoint | プログラムをfuncs配列からデタッチ;コンテキストをフィルタリング | 中 — funcs配列へのカーネル書き込みが必要 |
| NMI | ハードウェアパフォーマンスカウンタ割り込み → eBPF perfイベント | IDT/NMIハンドラをフック | ほぼ確実なカーネルパニック — ハードウェア割り込み、マスク不可 |
主要なアーキテクチャ的性質:ルートキットは、3つのチャネルすべてを同時に抑制することはできません。抑制自体が検出可能または不安定になるからです。 NMIの抑制にはIDT(割り込み記述子テーブル)のパッチが必要であり、ほとんどのカーネルでパニックを引き起こします。これが「生きた爆弾」であり、攻撃者が完全な盲目状態に至る唯一の経路は、システムをクラッシュさせる可能性が高いものです。
sched sees it, /proc doesn't → DKOM NMI finds sched's heartbeat stopped → TAMPER sched goes globally silent, /proc != empty → SILENT
各検出クラスは差分判定(differential verdict)である。すなわち2つ以上のチャネル間の不一致に基づく。検出エンジンはレジストリ+/procスナップショット+チャネルタイムスタンプに対する純粋関数であり、I/Oも副作用もなく、完全にユニットテスト可能である。
### NMIの再設計:観測から完全性へ
元の設計では、NMIはCPUをサンプリングしてどのタスクが実行中かを報告する第二のプロセス観測チャネルであった。これは冗長だった。なぜならsched\_switchがすでにスケジューリングを観測しており、NMIは別のメカニズムで同じデータをサンプリングしていたからである。この冗長性により、CPUあたり毎秒約1000以上のリングバッファイベントのプロセスデータが消費され、その99.999%は「はい、スケジューラはスケジューラの仕事をしている」ことを確認するだけだった。
再設計されたアーキテクチャでは、**NMIはプロセス観測からトレースポイントの完全性検証へと目的を変更する。** どのプロセスがCPU上にいるかを報告するのではなく、代わりに共有の`.bss`ハートビートを読み取ることで`sched_switch`が実際に動作していることを検証する。これにより以下の効果が得られる。
1. NMIリングバッファトラフィックの約99%を削減(定常状態ではイベントがほぼゼロ)
2. トレースポイントのデタッチ、抑制、BTF/アタッチ障害を直接検出(本来のBTFバグ — [§11](#11-the-btf-bug-incident)参照)
3. ハードウェア割り込みから動作し、トレースポイントディスパッチパスの外にあるため、`bpf_override_return`、kprobeインターセプト、funcs配列操作の影響を受けない
4. `.bss`グローバルをBPFヘルパーではなく直接メモリアクセスで読み取るため、ヘルパー関数に対する`fmod_ret`の影響を受けない
---
## 3. sched\_switch観測チャネル
`sched_switch`トレースポイントにアタッチされたeBPFプログラムは、カーネルがCPUにプロセスをスケジュールするたびに起動する。伝統的な(BTF以外の)固定オフセット読み取りを使用して、トレースポイントの引数から直接、入力タスクのPIDとcommを読み取る。```
ctx.read_at::<u32>(56) → next_pid
ctx.read_at::<[u8;16]>(40) → next_comm
意図的にBTF/CO-RE不使用。 トレースポイントの引数レイアウトはカーネルバージョン間で安定している(トレースポイントABIの一部)。ハードコードされたオフセットを使用することで、BTFで解決される構造体ナビゲーションのカーネルバージョンによる脆弱性を回避している。これは意図的な設計判断であり、§11で文書化されている。
プログラムは呼び出しのたびに以下を実行する:
next_pid と next_comm を読み取るProcessInfo 構造体を BASE_KEY でXOR難読化するsc_sched リングバッファにサブミットするbpf_ktime_get_ns() を .bss グローバル変数 SCHED_HEARTBEAT に書き込む — NMI整合性チェッカーが監視するハートビートこのコンテキストでは、プログラムは意図的に bpf_get_current_pid_tgid() を使用しない。sched_switch 時点では、「current」は 出ていく タスクであり、入ってくるタスクではない。トレースポイント引数は正しい(入ってくる)プロセスIDを提供する。
検出エンジンは単一のモノトニック時間ベースを使用する:SPiCaのプロセス起動以降のナノ秒(run_detection() エントリ以降の Instant::now())。スケジュールイベントが到着すると、イベントハンドラは 呼び出し元が提供する now(プロセスローカルのナノ秒)を保存し、イベントに埋め込まれたeBPFの bpf_ktime_get_ns() 値は使用しない。これにより、カーネルブート時間とプロセスローカル時間が混在した場合に発生する時間ベースの不一致を回避する。このバグは以前のバージョンに存在し、すべての生存性述語を静かに失敗させていた。
NMIプログラムは、ハードウェア割り込みレベルで発火するハードウェアパフォーマンスカウンター(HW_CPU_CYCLES)にアタッチされる。NMIは cli で無効化できない。抑制するにはPMUの再プログラミングかIDTのフックが必要であり、どちらもノイズが多く不安定化を招く。
整合性チェックメカニズムは極めてシンプルである:``` .bss (shared within SPiCa's ELF object — all programs see the same memory): SCHED_HEARTBEAT ← written by sched_switch on every invocation NMI_LAST_HB ← NMI's record of the last heartbeat it observed NMI_FIRST_TICK ← first NMI invocation timestamp (grace period reference) NMI_LAST_EMIT ← throttle: last event emission timestamp
sched_switch runs → SCHED_HEARTBEAT = bpf_ktime_get_ns()
NMI fires (once per second, throttled): if SCHED_HEARTBEAT == NMI_LAST_HB: if past grace period (5 seconds since first NMI tick): → sched_switch stopped running → emit event_type=1 (TAMPER) else: → sched_switch is alive → emit event_type=0 (heartbeat OK) NMI_LAST_HB = SCHED_HEARTBEAT
### なぜこれがスコープ内のすべての攻撃を検出するのか
| 攻撃 | 発生内容 | NMI の観測結果 |
|---|---|---|
| funcs 配列から切り離されたトレースポイント | sched\_switch の実行が停止 | `SCHED_HEARTBEAT` が停止 → TAMPER |
| BTF バグ / アタッチ失敗(サイレント) | sched\_switch が決して実行されない | `SCHED_HEARTBEAT` が 0 のまま → TAMPER(猶予後) |
| スケジューラ関数への `bpf_override_return` | トレースポイントの発火を妨げた場合 | `SCHED_HEARTBEAT` が停止 → TAMPER |
| インプレースでのバイトコードパッチ | 任意のカーネル書き込み(LKM レベル)が必要 | eBPF の脅威モデルの範囲外 |
| リングバッファのコンシューマポインタが操作された | sched イベントがユーザースペースに到達しない | `SCHED_HEARTBEAT` はまだ進行する(プログラムは実行される)→ 誤った TAMPER は発生しない;ユーザースペースが `max(sched_last)` の古さを検出 → SILENT |
### なぜ特に `.bss` なのか
`.bss` のグローバル変数は、BPF プログラムの内部データセクションに格納され、ローダーが管理する内部配列マップによってバックアップされています。それらは:
- **個別にピン留め不可** — `/sys/fs/bpf/` に名前付きマップとして表示されない
- **`bpf_map_update_elem` フックによる傍受不可** — `.bss` への書き込みは直接メモリストアであり、マップ更新システムコールではありません。以前の `sc_canary` メカニズム(`.bss` のコピーと名前付きマップのコピーを比較して `bpf_map_update_elem` の傍受を検出する)は不要になりました。
- **同じ ELF オブジェクト内のプログラム間で共有** — sched\_switch と NMI は外部インターフェースなしで `.bss` を介して通信します
### BPF ベースの傍受に対する耐性
NMI 整合性チェッカーは、根本的な特性により BPF オーバーライド攻撃に対して構造的に耐性があります:`bpf_override_return` は関数の**呼び出し**をインターセプトしますが、NMI チェッカーは検証対象を*呼び出し*ません — `.bss` メモリを*直接読み取る*だけです。メモリ読み取りは関数呼び出しではないため、その戻り値をオーバーライドすることはできません。
さらに:
- `bpf_probe_read_kernel`(代替設計でカーネル構造体の読み取りに使用される)は、任意のアドレスを受け入れるフォールトセーフなヘルパーですが、SPiCa の `.bss` ハートビート設計はそれを必要としません。チェッカーは直接ロード命令によって `.bss` グローバル変数を読み取ります。
- NMI プログラムは NMI コンテキストで実行され、そこでは kprobe が構造的に信頼できません(カーネルがそれらを延期または抑制します)。チェッカーの実行に対する kprobe ベースの攻撃はハードウェアと戦うことになります。
### NMI イベントのセマンティクス
NMI リングバッファ (`sc_nmi`) は軽量イベントを運びます: