Skip to content

Split View: eBPF Verifier는 멈춘 곳만 알려준다 — 거부 235건을 재현해 측정한 진단 격차

|

eBPF Verifier는 멈춘 곳만 알려준다 — 거부 235건을 재현해 측정한 진단 격차

들어가며 — 에러는 멈춘 곳을 가리키고, 버그는 다른 곳에 있다

eBPF 프로그램을 한 번이라도 진지하게 작성해 본 사람은 이 경험을 압니다. 패킷 포인터를 분명히 바운드 체크했는데, 한참 뒤의 로드 명령에서 R5 invalid mem access 'scalar'가 뜹니다. 에러가 가리키는 줄에는 의심할 만한 게 아무것도 없습니다. 그래서 어떻게 하냐면 — 고칠 만한 데를 아무 데나 고쳐 보고, 다시 로드해 보고, 또 튕기면 또 고쳐 봅니다. eBPF 개발이 시행착오의 반복이 되는 지점이 여기입니다.

eBPF 기초 편에서 정리했듯, verifier의 존재 이유는 명확합니다. 커널 안에서 임의의 코드를 돌리려면 누군가 그게 안전하다는 걸 증명해야 하고, verifier가 그 증명을 대신 해 줍니다. 문제는 증명에 실패했을 때 verifier가 실패를 설명하는 방식입니다.

2026년 7월 2일 arXiv에 올라온 Characterizing and Bridging the Diagnostic Gap in eBPF Verifier Rejections(Zheng 등, arXiv:2607.02748)는 이 답답함을 처음으로 제대로 측정했습니다. 저자들은 UC Santa Cruz, Virginia Tech, Telecom Paris, University of Washington, University of Connecticut, 그리고 eunomia-bpf 소속입니다.

논문의 한 문장이 전체를 요약합니다 — 에러는 검증이 멈춘 곳을 보고할 뿐, 프로그램이 verifier가 요구한 증명을 잃은 곳을 보고하지 않는다.

진단 격차란 무엇인가 — 증명을 잃은 지점 vs 검증이 멈춘 지점

이 구분이 이 글 전체의 핵심이라 조금 천천히 가겠습니다.

verifier는 명령어 하나하나를 따라가며 각 값에 대해 "이 값은 안전하게 쓸 수 있다"는 증명을 쌓습니다. 예컨대 패킷 포인터라면 "이 포인터는 바운드 안에 머문다"는 증명입니다. 어떤 명령에서 그 증명이 없는 채로 값을 쓰려고 하면, 거기서 검증이 멈추고 프로그램이 거부됩니다.

그런데 증명이 없어진 시점과 증명이 필요해진 시점은 대개 다릅니다. 논문이 드는 실행 예시가 선명합니다. 어떤 중간 상태에서 R5는 pkt(off=34,r=42), 즉 바운드 정보를 가진 패킷 포인터입니다. 그런데 명령 37 직전의 상태에서 같은 R5가 스칼라 값 40으로 표현됩니다. 그리고 명령 37이 R5를 역참조합니다.

  • 증명을 잃은 지점: 패킷 포인터가 스칼라로 바뀐 어딘가 (논문 용어로 loss point)
  • 검증이 멈춘 지점: 명령 37 (에러가 알려주는 유일한 곳)

에러는 후자만 알려줍니다. 개발자가 고쳐야 하는 곳은 전자입니다. 논문의 표현대로, 수정은 잃어버린 증명을 복원해야 하는데 에러는 그 증명을 필요로 한 연산만 이름 붙여 줍니다. 그래서 개발자는 트레이스로부터 "어떤 증명이 필요했고, 그게 어디서 사라졌는지"를 손으로 재구성해야 합니다.

데이터셋을 어떻게 만들었나 — 936건에서 235건으로

논문의 실증 파트가 정직한 이유는 방법론을 숨기지 않기 때문입니다.

저자들은 네 군데에서 후보 936건을 모았습니다 — Stack Overflow 질문, GitHub 이슈, GitHub 수정 커밋, 커널 셀프테스트. 그리고 각 후보를 하나의 고정 툴체인에서 다시 빌드했습니다. 커널 6.15.11, clang 18, 로그 레벨 2입니다. (6.15는 2025년 5월에 나온 메인라인 계열의 stable 지점 릴리스입니다.)

936건 중 실제로 이 빌드에서 거부된 것은 235건이었고, 이 235건이 bpfix-empirical이라는 데이터셋이 됩니다. 나머지가 탈락한 이유도 명시돼 있습니다 — 저자들의 툴체인에서는 거부되지 않거나, 특정 환경을 필요로 하거나, 다시 빌드할 소스가 없어서입니다.

각 사례는 문제의 소스와 개발자 본인이 실제로 적용한 수정을 함께 갖고 있습니다. 이게 중요합니다. "무엇이 진짜 원인이었나"의 정답을 저자들이 추측한 게 아니라, 원 보고서에 남은 실제 수정이 알려 주기 때문입니다.

발견 1 — 거부의 19%는 소스가 멀쩡하다

235건을 "수정이 어디에 착지했는가"로 분류한 결과가 첫 번째 놀라움입니다.

  • 191건(81%): 수정이 프로그램 소스를 바꿨다. 즉 진짜 프로그램 버그.
  • 44건(19%): 소스는 옳은데 거부됐다. 수정은 다른 층에서 이뤄졌다 — 컴파일러 18건, 환경 14건, verifier 자체 12건.

논문이 드는 예가 인상적입니다. 명백히 안전한 컨텍스트 필드 읽기가 거부되는데, 이유는 -O0이 그 읽기를 스칼라로 낮춰 버려서(lowering) verifier가 포인터 출처를 못 보기 때문입니다. 수정은 소스를 건드리지 않는 컴파일러 플래그입니다. 논문 Figure 2의 코드는 이렇게 단순합니다.

// 올바른 소스. -O0이 필드 읽기를 스칼라로 낮춘다
static int add_one(int x) { return x + 1; }
int prog(struct xdp_md *ctx) { return add_one(ctx->rx_queue_index) & 1; }
// verifier: R2 invalid mem access 'scalar'

이건 실무적으로 꽤 중요한 이야기입니다. verifier가 거부했다고 해서 당신 코드가 틀렸다는 뜻이 아닙니다. 다섯 번 중 한 번쯤은 코드가 옳고, 컴파일러가 증명을 가렸거나 환경이 안 맞거나 verifier가 아직 그 패턴을 이해하지 못하는 것입니다. 다만 에러 메시지는 이 둘을 구분해 주지 않습니다.

발견 2 — 원인 12종, 그중 10종이 eBPF 고유

191건의 진짜 버그는 12가지 근본 원인으로 나뉩니다. 여기서 "근본 원인"은 verifier가 실패한 검사가 아니라 개발자가 소스 수준에서 저지른 실수를 뜻합니다.

근본 원인 범주사례 수
클램프되지 않은 스칼라를 오프셋이나 길이로 사용24
손상되었거나 오래된 dynptr 객체23
모든 경로에 바운드가 없는 패킷 접근22
널 검사 누락19
포인터 타입 또는 출처(provenance) 불일치16
검증되지 않은 주소의 역참조16
인덱스가 객체 용량을 초과15
컨텍스트 또는 계약(contract) 오용15
짝이 맞지 않는 리소스 참조15
인터럽트 플래그를 순서대로 복원하지 않음11
프로브 시그니처가 ABI와 불일치9
크기 초과이거나 초기화되지 않은 스택 버퍼6
합계191

논문의 지적은 이 12종 중 10종이 eBPF 고유라는 것입니다. 패킷 바운드를 증명하는 일이나 dynptr을 그 수명과 짝지어 다루는 일은 일반적인 C 프로그래밍 지식으로는 나오지 않습니다. 그래서 수정하려면 일반 프로그래밍 실력이 아니라 도메인 지식이 필요합니다. 널 검사 누락 정도가 예외적으로 일반적인 축에 듭니다.

이건 왜 eBPF 학습 곡선이 그렇게 가파른지에 대한 정량적 설명이기도 합니다. 문제의 대부분이 "C를 잘 짜면 되는" 종류가 아닙니다.

발견 3 — 에러 문자열 하나가 원인 9가지를 가리킨다

가장 아픈 수치가 여기 있습니다.

첫째, errno는 거의 아무 정보도 주지 않습니다. 전체 거부의 47%가 EINVAL 하나만 돌려줍니다. 절반 가까이가 "뭔가 잘못됨"이라는 뜻의 에러 코드로 뭉뚱그려지는 셈입니다.

둘째, 에러 문자열도 너무 거칩니다. 저자들은 각 에러 문자열에서 레지스터 번호와 오프셋을 마스킹해 정규화한 뒤 235건을 묶어 봤습니다. 서로 다른 문자열 167개가 템플릿 82개로 줄어듭니다. 그리고 82개 템플릿 중 15개가 각각 둘 이상의 근본 원인에 대응합니다.

가장 심한 것이 아래 표의 첫 줄입니다.

터미널 메시지 템플릿사례 수대응하는 원인 범주 수
R# invalid mem access 'scalar'289
invalid access to packet265
invalid access to map value184
R# !read_ok134

invalid mem access 'scalar' 하나가 28건에 걸쳐 서로 다른 근본 원인 9가지를 가립니다. 이 메시지를 보고 원인을 좁힐 수 있다고 믿는 건 통계적으로 근거가 없습니다. 논문의 Takeaway를 그대로 옮기면 — 터미널 에러는 수정을 안내하기에 너무 거칠다.

기존 도구들이 이 격차를 못 메우는 이유

이미 도구가 있지 않냐고 물을 수 있습니다. 논문은 두 가지를 짚습니다.

PrettyVerifier(Rizza 등, 2025 IEEE CSR)는 정규표현식으로 터미널 에러를 소스 줄과 힌트에 매핑합니다. BPF Verifier Visualizer(bpfvv)는 명령어별 상태를 인터랙티브하게 렌더링하는 프런트엔드입니다. bpfvv 저장소 자신도 스스로를 원시적인 디버거 UI라고 설명합니다 — 런타임 상태가 아니라 로그를 해석하는.

논문의 평가는 이렇습니다 — 둘 다 개발자가 에러를 읽는 걸 도와주지만, 증명을 어디서 잃었는지 짚어 주지는 않는다.

그리고 왜 이게 어려운지에 대한 설명이 날카롭습니다. Rust 같은 언어의 에러 설명 도구는 검사기가 만들어 둔 구조물(AST, 타이핑 제약)을 따라 걸을 수 있습니다. 그런데 eBPF verifier는 그런 아티팩트를 노출하지 않습니다. 남는 건 로그뿐입니다.

bpfix — 로그에서 증명의 생애를 복원한다

그래서 저자들이 만든 것이 bpfix입니다. 핵심 통찰은 이겁니다 — 로그 레벨 2에서 verifier는 명령어마다 추상 상태를 전부 찍습니다. 즉 잃어버린 증명은 이미 로그 안에 살아 있습니다. 아무도 그걸 읽어 주지 않을 뿐입니다.

bpfix는 Rust로 약 23,000줄이고, 유일한 필수 입력이 기존 verifier 로그입니다. 소스나 오브젝트 메타데이터는 선택 사항이고, 있으면 명령어에서 소스로의 매핑이 좋아집니다. 파이프라인은 세 단계입니다.

  1. 로그 분석 — 터미널 에러와 명령어별 추상 상태를 정규화된 증거 스트림으로 뽑는다.
  2. 증명 복원 — 터미널 에러를 증명 계열(proof family)에 매핑한다. 포인터 출처, 패킷 바운드, 스칼라 범위 같은 것들이다. 그리고 그 증명에 대한 증거가 언제 성립하고, 유지되고, 사라지는지를 추적한다.
  3. 진단 생성 — 스팬을 고르고, 증명을 다시 세우기 위한 지침을 만든다.

출력은 Rust 스타일 평문 진단입니다. 안정적인 에러 식별자, 필요한 증명, 관련 스팬과 증거, 관측 가능한 경우의 loss point, 그리고 help: 지침으로 구성됩니다. "관측 가능한 경우"라는 단서는 저자들이 직접 단 것입니다 — 언제나 잡아내는 게 아닙니다.

또 하나 실용적인 기능은 책임 층 분리입니다. bpfix는 거부를 source_bug(소스 층)와 lowering_artifact(컴파일러 층)로 라벨링합니다. 앞서 본 "19%는 소스가 멀쩡하다" 문제에 직접 대응하는 기능입니다. 다만 논문은 이걸 최선 노력 기반 귀속(best-effort attribution)이라고 명시합니다.

논문 Figure 5가 이 기능의 요점을 보여 줍니다. 두 거부가 똑같은 터미널 메시지 invalid mem access 'scalar'로 끝나는데, 하나는 소스 버그(Stack Overflow 56965789 — 패킷 베이스 없이 정수 오프셋을 포인터로 캐스팅)이고 다른 하나는 컴파일러 lowering 아티팩트(Stack Overflow 53136145 — 분기 병합이 패킷 포인터 타입을 가림)입니다. 수정이 착지해야 하는 층이 완전히 다른데 verifier 메시지는 동일합니다.

실제 사례 — aya 이슈 1002

논문이 든 구체적인 예 하나를 보겠습니다. 맵 객체 &globals의 주소를 __u64 *로 캐스팅해서 직접 읽고 쓰는 프로그램입니다.

// 거부되는 프로그램
__u64 *raw_map = (__u64 *)&globals;  // 맵 객체 포인터
__u64 v = *raw_map;
*raw_map = v + 1;                    // 여기서 거부

// verifier: only read from bpf_array is supported

verifier 메시지는 틀리지 않았습니다. 다만 증상만 말합니다 — 맵 객체를 통한 쓰기를 봤다는 것. 왜 안 되는지, 뭘 해야 하는지는 말하지 않습니다.

bpfix의 진단은 이렇습니다 — 맵 포인터가 일반 메모리처럼 접근되고 있다(source_bug), 필요한 증명은 맵 내용을 읽거나 쓰기 전에 적절한 맵 헬퍼로 맵-값 포인터를 유도하는 것이다, 다음 행동은 원소를 룩업한 뒤 그 포인터를 통해 쓰는 것이다.

그리고 실제 개발자의 수정이 정확히 그 진단을 따라갑니다.

// 개발자의 실제 수정
__u32 key = 0;
__u64 *v = bpf_map_lookup_elem(&globals, &key);
if (!v) return 0;
*v += 1;

LLM은 verifier 에러를 고칠 수 있나 — bpfix-bench

논문의 마지막 축은 요즘 사람들이 실제로 궁금해하는 질문입니다. 어차피 에러 로그를 LLM한테 던질 텐데, 그게 되긴 하나?

저자들은 bpfix-bench라는 벤치마크를 만들었습니다. 소스 수준 수리 과제 75개이고, 40개는 특정 verifier 증명을 중심으로 구성했으며 35개는 Cilium, xdp-tools, bpftrace 같은 오픈소스 프로젝트에서 최소화해 뽑았습니다.

채점 방식이 정직합니다. 각 과제는 bpfix와 독립적인 실행 가능한 테스트 스위트를 함께 싣습니다. 수정이 인정받으려면 (1) 실제 커널 verifier를 통과해 로드되고, (2) 기능 테스트를 통과하고, (3) 소스 시맨틱 검사를 통과해야 합니다. 즉 "그럴듯한 텍스트"로는 점수를 못 받습니다. verifier를 우회하려고 로직을 망가뜨리는 흔한 꼼수도 소스 시맨틱 검사에서 걸립니다.

모델은 Qwen3.6 27B(주 모델), GLM 5.2(호스팅), Qwen2.5 3B 세 가지이고 temperature 0입니다. 3B를 넣은 이유는 이득이 작은 모델에서도 살아남는지 보려는 것입니다. 결과는 아래와 같습니다(전부 75문제 기준, 저자 자체 측정).

모델원본 로그 1회bpfix 1회원본 로그 + 재시도 1회bpfix + 재시도 1회
GLM 5.228 (37.3%)38 (50.7%)47 (62.7%)52 (69.3%)
Qwen3.6 27B22 (29.3%)38 (50.7%)30 (40.0%)44 (58.7%)
Qwen2.5 3B0 (0%)8 (10.7%)0 (0%)10 (13.3%)

두 가지를 읽을 수 있습니다.

첫째, 원본 verifier 로그만 주면 현재 모델들은 잘 못 고칩니다. 원샷 성공률이 0~37%입니다. 논문 표현으로는, 3B에서 27B까지 세 모델에 걸쳐 verifier 거부는 유능한 모델에게도 여전히 어렵습니다.

둘째, 로그를 bpfix 진단으로 바꾸면 개선됩니다. 논문이 초록에서 주장하는 폭은 11~21%p입니다. 원샷 기준으로 보면 Qwen3.6이 21.4%p로 가장 크고, Qwen2.5 3B가 10.7%p, GLM 5.2가 13.4%p입니다. 다만 GLM 5.2의 재시도 모드 이득은 6.6%p로 훨씬 작다는 점은 표에서 직접 확인해야 합니다 — 이 초록의 범위는 원샷 기준으로 읽는 게 맞습니다.

실패가 어느 단계에서 사라지는지도 저자들이 분해해 놨는데, 이게 오히려 더 설득력 있습니다. Qwen3.6 27B에서 verifier 로드 실패가 19건에서 10건으로, 소스 시맨틱 실패가 22건에서 16건으로 줄었습니다. GLM 5.2도 같은 방향입니다(10 → 5, 25 → 22). 컴파일 실패는 양쪽 모두 낮게 유지됐습니다.

작은 모델 이야기는 따로 볼 만합니다. Qwen2.5 3B는 원본 로그로 75문제 중 하나도 못 고쳤습니다. 원샷 후보 62건이 verifier 로드에서 실패했고, 프롬프트 3건은 아예 컨텍스트 윈도를 넘겨 프로그램을 반환하지 못했습니다. 더 짧은 bpfix 진단을 주면 모델 호출 실패는 사라지고 로드 실패는 39건으로 줄지만, 컴파일 실패는 7건에서 14건으로 늘어납니다. 저자들의 해석이 정직합니다 — bpfix는 작은 모델에게도 더 의미 있는 수리를 시도할 만큼의 verifier 관련 정보를 주지만, 평범한 코드 생성 오류는 그대로 남는다.

이 수치를 어디까지 믿을 것인가

여기서부터는 논문이 스스로 말한 한계와, 제가 읽으며 붙인 유보입니다. 이 글의 숫자 대부분이 저자 자체 측정이라는 점을 먼저 분명히 해 둡니다. bpfix도, 벤치마크도 같은 팀이 만들었습니다.

선택 효과가 큽니다. 936건 중 235건만 남았습니다. 남은 이유는 저자들의 고정 툴체인에서 재현됐기 때문이고, 탈락한 701건 중 상당수는 환경 의존적이거나 소스가 없는 것들입니다. 그리고 애초에 데이터 출처가 Stack Overflow와 GitHub 이슈입니다 — 사람들이 질문을 올릴 만큼 헷갈렸던 거부들이 과대표집됩니다. "진단이 불충분하다"는 결론을 내기에 유리한 쪽으로 표본이 기울어 있을 수 있습니다. 물론 커널 셀프테스트와 수정 커밋이 섞여 있어 순수 편향은 아니고, 실무에서 사람을 실제로 막아 세우는 게 그 헷갈리는 거부라는 반론도 성립합니다.

툴체인이 하나로 고정돼 있습니다. 커널 6.15.11, clang 18입니다. verifier는 릴리스마다 바뀌고, 특히 에러 메시지와 pruning 동작은 계속 손질됩니다. 6.15에서 측정한 47%가 최신 커널에서 그대로일 거라고 가정하면 안 됩니다.

모델 선택이 제한적입니다. Qwen3.6 27B, GLM 5.2, Qwen2.5 3B입니다. 프런티어급 모델은 평가에 없습니다. 그러니 "LLM은 verifier 에러를 못 고친다"가 아니라 "이 세 모델은 원본 로그로 0~37%를 고쳤다"가 정확한 진술입니다.

그리고 bpfix를 써도 천장이 낮습니다. 최고 성적이 원샷 50.7%, 재시도까지 붙여 69.3%입니다. 진단이 좋아지면 수리가 나아진다는 방향성은 세 모델에서 일관되게 나왔지만, "이제 LLM에게 맡기면 된다" 수준과는 거리가 있습니다.

도구 성숙도도 감안해야 합니다. bpfix 저장소는 MIT 라이선스로 공개돼 있고 v0.1.0이 2026년 7월 11일에 나왔습니다. 닷새 된 0.1.0입니다. README 자신이 스스로를 커널 패치도, verifier 대체물도, 자동 소스 수리 도구도 아니라고 못 박습니다. 진단 설명 도구입니다. 프로덕션 파이프라인에 넣기 전에 그 정도 기대치로 접근하는 게 맞습니다.

한편, verifier 자체도 바뀌고 있다

이 논문이 "로그를 더 잘 읽자"는 접근이라면, 반대편에는 "verifier가 애초에 덜 거부하게 만들자"는 흐름이 있습니다.

LWN의 BPF loop verification with scalar evolution(Daroc Alden, 2026년 6월 9일)이 다룬 Eduard Zingerman의 작업이 그 예입니다. 2026년 LSFMM+BPF 서밋에서 발표된 진행 중인 작업으로, 목표는 Zingerman의 말 그대로 verifier가 전형적인 for/while 루프를 루프를 돌지 않고 단일 패스로 처리하게 하는 것입니다.

현재 verifier는 루프를 만나면 종료 조건에 도달할 때까지 반복마다 평가하는데, 이 과정에서 더 나은 구현이라면 걸리지 않았을 명령어 수 제한에 잘못 걸릴 수 있습니다. 스칼라 진화(scalar evolution)는 루프 안에서 변수가 가질 수 있는 값의 범위를 계산해 이걸 피하려는 기법입니다.

다만 이건 아직 프로토타입이고 머지되지 않았습니다. 스택에 스필/복원되는 레지스터를 다루지 못하고, 모든 루프 변수를 균일하게 분석하지도 못합니다. Zingerman은 스택 조작, 부호 있는 정수 연산, 더 복잡한 루프를 지원한 뒤에 커널 제출을 제안할 계획이라고 밝혔습니다. 로드 시간에 대해서는 엄밀한 측정을 하지 않았다고 본인이 말했습니다. (Starovoitov는 과거 유사한 verifier 변경들이 처음엔 우려를 샀지만 결국 로드 시간이 빨라지고 메모리 사용이 줄었다고 언급했습니다.)

두 흐름이 같은 문제의 양면입니다 — 하나는 거부를 줄이고, 하나는 거부됐을 때 이해 가능하게 만듭니다. 그리고 후자가 사라질 일은 없습니다. verifier가 아무리 똑똑해져도 거부는 남고, 남는 거부는 설명돼야 합니다.

그래서 실무자는 무엇을 할 것인가

이 논문에서 도구를 도입하지 않아도 가져갈 수 있는 것들이 있습니다.

로그 레벨 2를 기본으로 쓰십시오. bpfix가 존재할 수 있는 이유 자체가 "필요한 정보는 이미 로그에 있다"는 것입니다. 레벨 2에서 verifier는 명령어마다 추상 상태를 찍습니다. 그걸 안 켜고 있다면 진단의 원재료를 버리고 있는 겁니다.

터미널 에러 줄을 믿지 마십시오. 그건 검증이 멈춘 곳입니다. 버그는 증명이 사라진 곳에 있습니다. 로그를 거슬러 올라가며 문제의 레지스터가 언제 마지막으로 제대로 된 타입이었는지를 찾는 게 실제로 해야 하는 일입니다. pkt(off=..,r=..)scalar로 바뀐 지점 같은 것 말이죠.

거부를 무조건 자기 탓으로 돌리지 마십시오. 다섯 중 하나는 소스가 멀쩡합니다. 특히 -O0이나 특이한 최적화 레벨로 빌드하고 있다면 컴파일러 lowering을 의심할 이유가 통계적으로 있습니다.

에러 문자열로 검색해서 나온 답을 그대로 믿지 마십시오. invalid mem access 'scalar'는 원인 9가지를 가립니다. Stack Overflow에서 같은 메시지에 달린 답이 당신 경우에 맞을 확률은 생각보다 낮습니다.

LLM에게 던질 거면 로그만 던지지 마십시오. 이 논문이 보여 준 건 진단의 질이 수리 성공률을 좌우한다는 것입니다. 어떤 증명이 필요했고 어디서 사라졌는지에 대한 맥락을 사람이 먼저 좁혀 주면 결과가 달라집니다.

마치며

이 논문이 가치 있는 이유는 새로운 사실을 알려줘서가 아닙니다. eBPF 개발자들이 이미 몸으로 알던 걸 숫자로 만들었기 때문입니다. verifier 에러가 도움이 안 된다는 건 모두가 알았지만, 47%가 EINVAL이고 하나의 에러 문자열이 원인 9가지를 가린다는 건 아무도 몰랐습니다.

그리고 진단이 이렇게 어려운 근본 이유가 구조적이라는 점이 흥미롭습니다. Rust 컴파일러는 자기가 만든 타이핑 제약을 따라 걸으며 에러를 설명할 수 있습니다. eBPF verifier는 그런 아티팩트를 남기지 않습니다. 로그만 남깁니다. bpfix가 하는 일은 결국 그 로그를 아티팩트로 되돌리는 역공학입니다. 근사한 해법이지만, 애초에 verifier가 증명 구조를 내보내 줬다면 필요 없었을 일이기도 합니다. 장기적으로 이 격차가 진짜로 닫히는 길은 커널 쪽에 있을 겁니다.

당장은 bpfix가 닷새 된 0.1.0이고, 개선 폭은 저자 자체 측정이고, 툴체인 하나에서 잰 수치입니다. 그러니 이 글의 결론은 "bpfix를 쓰세요"가 아닙니다. verifier가 멈춘 지점과 당신 버그가 있는 지점은 다르다 — 이걸 명시적으로 알고 로그를 읽기 시작하는 것만으로도, 다음번 invalid mem access 'scalar' 앞에서 보내는 시간이 줄어들 겁니다.

참고 자료

The eBPF Verifier Only Tells You Where It Stopped — Measuring the Diagnostic Gap Across 235 Reproduced Rejections

Introduction — The Error Points to Where It Stopped; the Bug Is Somewhere Else

Anyone who has written an eBPF program in earnest even once knows this feeling. You clearly bounds-checked the packet pointer, and yet a load instruction much later throws R5 invalid mem access 'scalar'. The line the error points to has nothing suspicious about it. So what do you do — you edit whatever looks editable, reload, and if it bounces again, edit something else. This is the point where eBPF development turns into a loop of trial and error.

As covered in eBPF Fundamentals — Programs, Maps, and the World of the Verifier, the verifier's reason for existing is clear. To run arbitrary code inside the kernel, someone has to prove it is safe, and the verifier does that proof on your behalf. The problem is how the verifier explains the failure when the proof fails.

Characterizing and Bridging the Diagnostic Gap in eBPF Verifier Rejections (Zheng et al., arXiv:2607.02748), posted to arXiv on July 2, 2026, is the first paper to properly measure that frustration. The authors are affiliated with UC Santa Cruz, Virginia Tech, Telecom Paris, University of Washington, University of Connecticut, and eunomia-bpf.

One sentence from the paper sums up the whole thing — the error reports only where verification stopped, not where the program lost the proof the verifier required.

What Is the Diagnostic Gap — Loss Point vs. Stop Point

This distinction is the core of the whole post, so let's go through it slowly.

The verifier walks instruction by instruction, building up a proof for each value that says "this value can be used safely." For a packet pointer, for instance, that proof is "this pointer stays within bounds." The moment an instruction tries to use a value without that proof in hand, verification stops right there and the program is rejected.

But the point where the proof disappears and the point where the proof becomes needed are usually different. The paper's execution example makes this vivid. At some intermediate state, R5 is pkt(off=34,r=42) — a packet pointer carrying bounds information. But in the state right before instruction 37, that same R5 is represented as the scalar value 40. And instruction 37 dereferences R5.

  • Where the proof was lost: somewhere the packet pointer turned into a scalar (what the paper calls the loss point)
  • Where verification stopped: instruction 37 (the only place the error names)

The error tells you only the latter. The place the developer needs to fix is the former. In the paper's words, the fix has to restore the lost proof, yet the error only names the operation that needed that proof. So the developer has to manually reconstruct, from the trace, which proof was needed and where it vanished.

Building the Dataset — From 936 Candidates to 235 Rejections

The paper's empirical section is honest because it doesn't hide its methodology.

The authors gathered 936 candidates from four sources — Stack Overflow questions, GitHub issues, GitHub fix commits, and kernel selftests. Each candidate was then rebuilt on one fixed toolchain: kernel 6.15.11, clang 18, log level 2. (6.15 is a mainline-series stable point release that shipped in May 2025.)

Of the 936, only 235 were actually rejected under this build, and those 235 became the bpfix-empirical dataset. The reasons the rest fell out are stated too — they weren't rejected under the authors' toolchain, they required a specific environment, or there was no source left to rebuild.

Each case comes paired with the problem source and the fix the original developer actually applied. This matters: the answer to "what was the real root cause" isn't the authors' guess — it comes from the actual fix left in the original report.

Finding 1 — 19% of Rejections Have Perfectly Fine Source

The first surprise comes from classifying the 235 cases by "where the fix landed."

  • 191 cases (81%): the fix changed the program source — a genuine program bug.
  • 44 cases (19%): the source was correct but got rejected anyway. The fix landed on a different layer — the compiler in 18, the environment in 14, the verifier itself in 12.

The paper's example here is striking. A plainly safe context-field read gets rejected, because -O0 lowers that read into a scalar, so the verifier can no longer see the pointer's provenance. The fix is a compiler flag that never touches the source. The code in the paper's Figure 2 is this simple.

// Correct source. -O0 lowers the field read to a scalar
static int add_one(int x) { return x + 1; }
int prog(struct xdp_md *ctx) { return add_one(ctx->rx_queue_index) & 1; }
// verifier: R2 invalid mem access 'scalar'

This matters quite a bit in practice. A verifier rejection does not mean your code is wrong. Roughly one time in five, the code is fine, and either the compiler obscured the proof, the environment doesn't match, or the verifier just doesn't understand that pattern yet. The error message, though, does not distinguish between the two.

Finding 2 — 12 Root-Cause Categories, 10 of Them eBPF-Specific

The 191 genuine bugs break down into 12 root causes. Here, "root cause" does not mean the check the verifier failed on — it means the mistake the developer made at the source level.

Root-cause categoryCases
Unclamped scalar used as an offset or length24
Corrupted or stale dynptr object23
Packet access without bounds on every path22
Missing null check19
Pointer type or provenance mismatch16
Dereference of an unverified address16
Index exceeds object capacity15
Context or contract misuse15
Mismatched resource reference15
Interrupt flags not restored in order11
Probe signature mismatched with ABI9
Oversized or uninitialized stack buffer6
Total191

The paper's point is that 10 of these 12 are eBPF-specific. Proving packet bounds, or handling a dynptr correctly paired with its lifetime, is not something general C programming knowledge gets you to. So fixing them takes domain knowledge, not general programming skill. Missing null checks are the exception — about the only category that's generically familiar.

This is also a quantitative explanation for why the eBPF learning curve is so steep. Most of these problems are not the kind you solve by just writing better C.

Finding 3 — One Error String, Nine Root Causes

This is where the most painful number shows up.

First, errno tells you almost nothing. 47% of all rejections return nothing but EINVAL. Nearly half get lumped into an error code that just means "something went wrong."

Second, the error strings themselves are too coarse. The authors masked out register numbers and offsets from each error string, normalized them, and grouped the 235 cases. 167 distinct strings collapse into 82 templates. And of those 82 templates, 15 each correspond to more than one root cause.

The worst offender is the first row of the table below.

Terminal message templateCasesRoot-cause categories it maps to
R# invalid mem access 'scalar'289
invalid access to packet265
invalid access to map value184
R# !read_ok134

invalid mem access 'scalar' alone hides 9 distinct root causes across 28 cases. Believing you can narrow down the cause from this message has no statistical basis. To quote the paper's takeaway directly — terminal errors are too coarse to guide a fix.

Why Existing Tools Don't Close the Gap

You might ask whether tools already exist for this. The paper points to two.

PrettyVerifier (Rizza et al., 2025 IEEE CSR) uses regular expressions to map terminal errors to source lines and hints. BPF Verifier Visualizer (bpfvv) is a front end that interactively renders per-instruction state. The bpfvv repository itself describes it as a primitive debugger UI — one that interprets the log, not runtime state.

The paper's verdict is this — both help the developer read the error, but neither points to where the proof was lost.

And the explanation for why this is hard is sharp. Error-explanation tools for languages like Rust can walk the structures the checker already built — the AST, the typing constraints. But the eBPF verifier exposes no such artifact. All that's left is the log.

bpfix — Reconstructing the Proof's Lifetime from the Log

So the authors built bpfix. The core insight is this — at log level 2, the verifier dumps the full abstract state for every instruction. The lost proof is already alive inside the log. No one is reading it, that's all.

bpfix is about 23,000 lines of Rust, and its only required input is the existing verifier log. Source or object metadata is optional — supplying it just improves the instruction-to-source mapping. The pipeline has three stages.

  1. Log parsing — extract the terminal error and the per-instruction abstract state into a normalized evidence stream.
  2. Proof reconstruction — map the terminal error to a proof family — things like pointer provenance, packet bounds, scalar range — and track when the evidence for that proof holds, is maintained, and disappears.
  3. Diagnostic generation — pick spans and produce instructions for re-establishing the proof.

The output is a plain-text, Rust-style diagnostic. It consists of a stable error identifier, the required proof, the relevant spans and evidence, the loss point when it is observable, and a help: instruction. The "when it is observable" caveat is the authors' own — it doesn't always catch it.

Another practical feature is layer-of-blame separation. bpfix labels a rejection as source_bug (the source layer) or lowering_artifact (the compiler layer) — a feature that directly addresses the "19% have a clean source" problem seen earlier. The paper, though, explicitly calls this best-effort attribution.

The paper's Figure 5 shows this point exactly. Two rejections both end in the identical terminal message invalid mem access 'scalar', yet one is a source bug (Stack Overflow 56965789 — casting an integer offset to a pointer without a packet base) and the other is a compiler lowering artifact (Stack Overflow 53136145 — a branch merge obscures the packet pointer type). The layer where the fix must land is completely different, but the verifier message is identical.

A Real Case — aya Issue 1002

Let's look at one concrete example the paper gives — a program that casts the address of the map object &globals to __u64 * and reads and writes it directly.

// Rejected program
__u64 *raw_map = (__u64 *)&globals;  // map object pointer
__u64 v = *raw_map;
*raw_map = v + 1;                    // rejected here

// verifier: only read from bpf_array is supported

The verifier message isn't wrong. It just states the symptom — it saw a write through the map object. It doesn't say why that's not allowed, or what to do instead.

bpfix's diagnostic reads like this — the map pointer is being accessed like ordinary memory (source_bug); the proof needed is deriving a map-value pointer through the appropriate map helper before reading or writing the map contents; the next action is to look up the element and then write through that pointer.

And the developer's actual fix follows that diagnosis exactly.

// Developer's actual fix
__u32 key = 0;
__u64 *v = bpf_map_lookup_elem(&globals, &key);
if (!v) return 0;
*v += 1;

Can LLMs Fix Verifier Errors — bpfix-bench

The paper's final axis is the question people actually care about these days. You're going to throw the error log at an LLM anyway — does that even work?

The authors built a benchmark called bpfix-bench: 75 source-level repair tasks, of which 40 were constructed around specific verifier proofs and 35 were minimized and pulled from open-source projects like Cilium, xdp-tools, and bpftrace.

The scoring method is honest. Each task ships with an executable test suite that is independent of bpfix. For a fix to be credited, it must (1) load by passing the real kernel verifier, (2) pass the functional tests, and (3) pass a source-semantics check. Plausible-looking text alone earns no score, and the common trick of breaking the logic just to dodge the verifier gets caught by the source-semantics check.

The models are Qwen3.6 27B (primary), GLM 5.2 (hosted), and Qwen2.5 3B, all at temperature 0. The 3B model is included to see whether the gain survives on a smaller model. The results are below (all out of 75 problems, author-measured).

ModelRaw log, single shotbpfix, single shotRaw log + 1 retrybpfix + 1 retry
GLM 5.228 (37.3%)38 (50.7%)47 (62.7%)52 (69.3%)
Qwen3.6 27B22 (29.3%)38 (50.7%)30 (40.0%)44 (58.7%)
Qwen2.5 3B0 (0%)8 (10.7%)0 (0%)10 (13.3%)

Two things stand out.

First, current models don't do well when given only the raw verifier log. Single-shot success ranges from 0% to 37%. In the paper's words, across three models from 3B to 27B, verifier rejections remain hard even for capable models.

Second, swapping the log for a bpfix diagnostic improves things. The range the paper's abstract claims is 11–21 points. On a single-shot basis, Qwen3.6 sees the largest gain at 21.4 points, Qwen2.5 3B gains 10.7 points, and GLM 5.2 gains 13.4 points. Note, though — and this you have to check against the table yourself — GLM 5.2's gain in retry mode is much smaller, at 6.6 points. The abstract's range is correctly read as the single-shot figures.

The authors also broke down at which stage failures disappear, and this is actually more convincing. For Qwen3.6 27B, verifier-load failures dropped from 19 to 10, and source-semantics failures dropped from 22 to 16. GLM 5.2 moves the same direction (10 → 5, 25 → 22). Compile failures stayed low on both sides.

The small-model story is worth looking at separately. With the raw log, Qwen2.5 3B fixed none of the 75 problems. Of the single-shot candidates, 62 failed at verifier load, and 3 prompts blew past the context window entirely and returned no program at all. Given the shorter bpfix diagnostic, the model-call failures disappear and load failures drop to 39, but compile failures rise from 7 to 14. The authors' own interpretation is honest — bpfix gives even a small model enough verifier-related information to attempt a more meaningful fix, but ordinary code-generation mistakes remain.

How Far Should You Trust These Numbers

From here on, these are the limitations the paper states itself, plus reservations I'm attaching as I read it. Let me be clear up front that most of the numbers in this post are author-measured. bpfix and the benchmark were both built by the same team.

The selection effect is significant. Only 235 of the 936 candidates survived. They survived because they reproduced under the authors' fixed toolchain, and a good share of the 701 that fell out were environment-dependent or had no source to rebuild. And the data sources are Stack Overflow and GitHub issues to begin with — rejections confusing enough that someone bothered to post a question are overrepresented. The sample could be tilted toward a conclusion favorable to "diagnostics are inadequate." That said, kernel selftests and fix commits are mixed in too, so it isn't pure bias, and there's a valid counter-argument that confusing rejections like these are exactly what actually stops people in practice.

The toolchain is pinned to a single version — kernel 6.15.11, clang 18. The verifier changes with every release, and error messages and pruning behavior in particular keep getting tweaked. You should not assume the 47% measured on 6.15 holds on the latest kernel.

The model selection is limited — Qwen3.6 27B, GLM 5.2, and Qwen2.5 3B. No frontier-tier model is in the evaluation. So the accurate statement is not "LLMs can't fix verifier errors" but "these three models fixed 0–37% given the raw log."

And even with bpfix, the ceiling stays low. The best score is 50.7% single-shot, 69.3% with a retry added. The direction — better diagnostics mean better repairs — held consistently across all three models, but it's still a long way from "you can just hand this to an LLM now."

Tool maturity has to be factored in too. The bpfix repository is open under the MIT license, and v0.1.0 shipped on July 11, 2026 — a five-day-old 0.1.0. The README itself explicitly states it is not a kernel patch, not a verifier replacement, and not an automatic source-repair tool. It's a diagnostic-explanation tool. That's the right level of expectation to bring before putting it into a production pipeline.

Meanwhile, the Verifier Itself Is Changing

If this paper's approach is "read the log better," the other side of the coin is the movement to "make the verifier reject less in the first place."

Eduard Zingerman's work, covered by LWN's BPF loop verification with scalar evolution (Daroc Alden, June 9, 2026), is one example. It's work in progress presented at the 2026 LSFMM+BPF Summit, and the goal, in Zingerman's own words, is to make the verifier handle a typical for/while loop in a single pass instead of iterating through the loop.

Today, when the verifier meets a loop, it evaluates every iteration until the termination condition is reached, and in the process it can spuriously hit the instruction-count limit — a limit a better implementation wouldn't have tripped. Scalar evolution is a technique meant to avoid this by computing the range of values a loop variable can take.

But this is still a prototype and unmerged. It cannot yet handle registers spilled to and restored from the stack, and it doesn't analyze every loop variable uniformly. Zingerman said he plans to propose it for kernel submission after adding support for stack manipulation, signed integer arithmetic, and more complex loops. He himself said he has not done a rigorous measurement of load time. (Starovoitov noted that similar verifier changes in the past raised concerns at first but ultimately ended up making load times faster and reducing memory use.)

The two currents are two sides of the same problem — one reduces rejections, the other makes a rejection understandable when it happens. And the latter is never going away. However smart the verifier gets, some rejections will remain, and the ones that remain will need explaining.

So What Should Practitioners Do

There are things you can take from this paper even without adopting any tool.

Make log level 2 your default. The very reason bpfix can exist is that "the information you need is already in the log." At level 2, the verifier dumps the abstract state for every instruction. If you're not turning that on, you're throwing away the raw material for diagnosis.

Don't trust the terminal error line. That's where verification stopped. The bug is where the proof disappeared. What you actually have to do is walk the log backward and find the last point the offending register had the right type — the point where pkt(off=..,r=..) turned into scalar, for instance.

Don't automatically blame yourself for a rejection. One in five have a perfectly clean source. If you're building with -O0 or an unusual optimization level in particular, there's statistical grounds to suspect compiler lowering.

Don't take an answer you found by searching the error string at face value. invalid mem access 'scalar' hides 9 root causes. The odds that a Stack Overflow answer attached to the same message applies to your case are lower than you'd think.

If you're handing this to an LLM, don't hand it just the log. What this paper showed is that diagnostic quality governs repair success. Narrowing the context yourself first — what proof was needed, where it disappeared — changes the outcome.

Closing

This paper is valuable not because it tells us something new, but because it put numbers to something eBPF developers already knew in their gut. Everyone knew verifier errors weren't helpful, but no one knew that 47% is EINVAL, or that one error string hides 9 root causes.

And it's interesting that the underlying reason diagnosis is this hard is structural. The Rust compiler can explain an error by walking the typing constraints it built itself. The eBPF verifier leaves behind no such artifact. Only a log. What bpfix ultimately does is reverse-engineer that log back into an artifact. It's a neat solution, but also one that wouldn't be necessary if the verifier had exposed its proof structure in the first place. Over the long run, the real path to closing this gap probably runs through the kernel side.

For now, bpfix is a five-day-old 0.1.0, the improvement figures are author-measured, and they're measured on a single toolchain. So this post's conclusion isn't "go use bpfix." It's this — where the verifier stopped and where your bug actually is are two different places. Just starting to read the log knowing that explicitly should cut down the time you spend in front of the next invalid mem access 'scalar'.

References