Why 12 eBPF Partial-Init Variants That Looked Equivalent Produced Different Verification Results
목차
local struct를 stack에 올려 두고 일부 필드만 채운 뒤 읽게 만드는 eBPF 코드를 만들었다. 처음에는 이런 코드는 verifier가 당연히 reject할 것이라고 생각했다. 사람 눈에는 쓰지 않은 바이트가 분명히 남아 있었고, eBPF verifier가 stack에 대해 write-before-read 규칙을 강하게 적용한다는 것도 이미 알려져 있었기 때문이다.
그런데 실제 실험은 처음 예상과 조금 다르게 흘렀다. tracepoint 프로그램을 root 경로에서 로드했을 때는 partial-init read가 기대보다 훨씬 관대하게 통과했다. verifier 코드 경로를 따라가 보고 나서야 그 결과가 allow_uninit_stack와 CAP_PERFMON 경로에 묶여 있다는 사실을 확인할 수 있었다. 그래서 본선 실험은 socket + verifier_runner_noperfmon + -O2 문맥으로 다시 고정했다. 그제야 내가 처음 기대했던 partial-init, branch join, read range 차이가 안정적으로 재현됐다.
이 글의 질문은 세 가지다.
- verifier는 stack object의 초기화 여부를 어떤 단위로 보는가.
- 분기 후 join 지점에서 initializedness 정보는 어떻게 남고 어떻게 사라지는가.
- 실제로 읽는 범위와 코드 모양이 왜 verification 결과를 바꾸는가.
핵심은 partial init이 무조건 나쁘다는 말이 아니다. 사람이 보기에는 거의 같은 의미의 코드라도 verifier는 초기화된 바이트 범위와 경로별 상태를 기준으로 프로그램을 본다. 그래서 source level에서 비슷해 보여도 판정은 달라질 수 있다.
배경
verifier는 C 소스를 직접 이해하지 않는다. 컴파일된 BPF instruction의 CFG를 검사하고, 가능한 경로를 따라가며 레지스터 타입과 stack 상태를 추적한다. stack도 마찬가지다. 어떤 offset에 실제 store가 생겼는지, 어떤 바이트가 initialized 상태인지, 분기 후 상태를 어떻게 합칠 수 있는지가 판정의 중심이 된다.
이번 글에서 계속 보는 것도 결국 네 가지다.
- 어떤 stack offset에 실제 write가 생겼는가.
- 어떤 바이트 범위가 initialized 상태인가.
- 분기 후 join에서 어떤 정보가 남는가.
- 실제 read 범위가 initialized 범위를 넘는가.
이 관점을 놓치면 코드를 계속 C 의미로만 해석하게 된다. 반대로 이 관점을 붙잡으면 왜 verifier가 그렇게 판단했는지 설명이 된다.
예비 실험에서 통제 변수를 다시 잡은 이유
본선 실험에 들어가기 전에 예비 실험이 한 번 빗나갔다. 처음에는 tracepoint 프로그램으로 partial-init 케이스 A-F를 만들었다. 그런데 root 경로에서 로드하자 B-E 같은 partial-init read 케이스가 예상과 달리 통과했다. helper를 바꿔도, noinline 소비 함수로 direct stack read를 남겨도 결과는 크게 달라지지 않았다.
원인은 verifier의 allow_uninit_stack 경로였다. kernel/bpf/verifier.c에서 verifier env를 만들 때 아래 값이 채워진다.
env->allow_uninit_stack = bpf_allow_uninit_stack(env->prog->aux->token);
include/linux/bpf.h를 보면 이 값은 사실상 CAP_PERFMON에 묶여 있다.
static inline bool bpf_allow_uninit_stack(const struct bpf_token *token)
{
return bpf_token_capable(token, CAP_PERFMON);
}
그리고 stack read 검사 쪽에는 아래 조건이 있다.
if (type == STACK_INVALID && env->allow_uninit_stack)
continue;
즉, 읽는 바이트가 STACK_INVALID여도 allow_uninit_stack가 켜져 있으면 바로 에러로 보지 않는다. 내가 처음 본 의외의 통과는 verifier bug가 아니라 privilege-dependent behavior였다.
이 예비 실험은 본문 중심 사례로 쓰기보다, 왜 본선 기준선을 다시 잡아야 했는지 설명하는 절로 두는 편이 맞다. 실제 본문 표와 해설은 모두 socket + verifier_runner_noperfmon + -O2 문맥을 기준으로 읽어야 한다.
본선 실험 설계
본선 실험은 하나의 baseline과 12개의 partial-init family로 구성했다. 기준 문맥은 아래와 같다.
- 커널 버전:
Linux 7.0.0-rc6-00020-g9147566d8016 - clang/LLVM 버전:
22.1.3 - bpftool 버전:
bpftool v7.7.0,libbpf v1.7(host 기준) - 프로그램 타입:
socket - loader:
verifier_runner_noperfmon - optimization level:
-O2
공통으로 쓴 구조체는 아래와 같다.
struct partial_init_event {
__u64 pid_tgid;
__u64 ts;
__u32 cpu;
__u32 tag;
};
읽는 방식은 두 가지로 고정했다.
static __noinline int partial_init_consume_full(struct partial_init_event *event)
{
volatile struct partial_init_event *src = event;
struct partial_init_event copy;
copy.pid_tgid = src->pid_tgid;
copy.ts = src->ts;
copy.cpu = src->cpu;
copy.tag = src->tag;
return (__u32)copy.pid_tgid + (__u32)copy.ts + copy.cpu + copy.tag;
}
static __noinline int partial_init_consume_first(struct partial_init_event *event)
{
volatile struct partial_init_event *src = event;
__u64 pid_tgid;
pid_tgid = src->pid_tgid;
return (__u32)pid_tgid;
}
consume_full()은 구조체 전체를 읽고, consume_first()는 첫 필드만 읽는다. 이 대비를 통해 partial-init 자체가 문제인지, 아니면 실제 read 범위가 문제인지 분리해서 볼 수 있었다.
변형군
이번 글에서는 아래 12개를 변형군으로 둔다.
| case | 코드 형태 | 관찰 포인트 |
|---|---|---|
| A | zero-init + full read | baseline |
| B | field 하나만 init + full read | 전체 read range와 uninit byte |
| C | field 둘만 init + full read | 뒤쪽 필드 미초기화 |
| D | 분기 한쪽만 init + full read | branch join |
| E | 분기 A/B가 서로 다른 필드 init + full read | 경로별 initializedness 병합 |
| F | field 하나만 init + narrow read | read range 축소 효과 |
| G | partial init 후 임시 변수에 복사 | temp copy 영향 |
| H | inline 함수 안에서 partial init | inline 영향 |
| I | early return 구조 | CFG rewrite |
| J | nested if 구조 | nested join |
| K | zero-init 없이 field-by-field 전체 초기화 | full init이면 충분한가 |
| L | memset 후 일부 필드 덮어쓰기 | zeroed remainder 효과 |
결과 요약 표
실제 결과를 표로 먼저 정리하면 아래와 같다.
| case | 코드 형태 | 기대 | 실제 | 로그 핵심 | 1차 판단 |
|---|---|---|---|---|---|
| A | zero-init + full read | pass | pass | processed 18 insns | baseline |
| B | field 하나만 init + full read | fail 예상 | fail | invalid read from stack off -16+0 size 8 | partial-init full read |
| C | field 둘만 init + full read | fail 예상 | fail | invalid read from stack off -8+0 size 4 | 뒤쪽 필드 미초기화 |
| D | 분기 한쪽만 init + full read | fail 예상 | fail | invalid read from stack off -16+0 size 8 | branch join 영향 |
| E | 분기별 다른 필드 init + full read | fail 예상 | fail | invalid read from stack off -16+0 size 8 | 경로별 initializedness 병합 실패 |
| F | partial init + narrow read | pass 예상 | pass | processed 8 insns | read range 영향 |
| G | 임시 변수 복사 후 전달 | fail 예상 | fail | invalid read from stack off -16+0 size 8 | temp copy로 숨겨지지 않음 |
| H | inline 함수 초기화 | fail 예상 | fail | invalid read from stack off -16+0 size 8 | inline이어도 본질 동일 |
| I | early return | fail 예상 | fail | invalid read from stack off -16+0 size 8 | CFG rewrite에도 reject |
| J | nested if | fail 예상 | fail | invalid read from stack off -16+0 size 8 | nested join에도 reject |
| K | field-by-field 전체 초기화 | pass 예상 | pass | processed 18 insns | zero-init 없이도 full init이면 통과 |
| L | memset 후 덮어쓰기 | pass 예상 | pass | processed 17 insns | zeroed remainder가 충분 |
이 표가 이번 글의 중심이다. 12개를 전부 길게 해설할 필요는 없다. 대표로는 B, E, F 세 케이스만 깊게 보면 충분하다.
Case B: 일부 필드만 초기화하고 전체를 읽는 경우
Case B는 가장 단순한 partial-init 실패다. pid_tgid만 채운 뒤 partial_init_consume_full()이 pid_tgid, ts, cpu, tag를 순서대로 읽는다. 그래서 “초기화된 바이트 범위를 넘는 read”가 무엇인지 가장 빠르게 보여 준다.
왜 직관과 어긋났나
필드 하나만 초기화했으니 당연히 실패할 것 같았다. 실제로 사람 눈에는 ts, cpu, tag가 전혀 초기화되지 않은 상태다. 그런데 이 케이스의 핵심은 단순히 “안 썼다”가 아니라, 실제로 어디까지 읽었는가에 있다.
verifier 로그
7: (79) r2 = *(u64 *)(r1 +0) ; frame1: R1=fp[0]-24 R2=0x1111111111111111
; copy.ts = src->ts; @ partial_init_defs.h:35
8: (79) r0 = *(u64 *)(r1 +8)
invalid read from stack off -16+0 size 8
첫 8바이트 pid_tgid는 읽을 수 있었지만, 다음 8바이트 ts를 읽는 순간 바로 invalid read from stack가 발생한다.
핵심 instruction
0: (18) r1 = 0x1111111111111111
2: (7b) *(u64 *)(r10 -24) = r1
3: (bf) r1 = r10
4: (07) r1 += -24
5: (85) call pc+1
7: (79) r2 = *(u64 *)(r1 +0)
8: (79) r0 = *(u64 *)(r1 +8)
fp-24에 한 번만 store가 발생했고, 그 뒤 소비 함수가 r1 +0, r1 +8, r1 +16, r1 +20을 순서대로 읽는다. write 범위와 read 범위가 정확히 어긋난다.
해석
이 케이스에서는 pid_tgid가 놓인 첫 8바이트만 initialized이다. 그 다음 8바이트 ts, 그 뒤 4바이트 cpu, 마지막 4바이트 tag는 아직 증명되지 않았다. partial_init_consume_full()은 구조체 전체를 읽는 모양으로 컴파일됐으므로 verifier는 전체 read 범위가 initialized라는 증명을 요구한다. 그 증명이 없어서 reject된다.
결론
Case B는 “partial init이 문제”라기보다 “실제 read 범위가 initialized 범위를 넘는다”는 점을 가장 단순하게 보여 준다.
Case E: 분기 A/B에서 서로 다른 필드를 초기화한 경우
Case E는 branch join을 설명하기에 가장 좋은 케이스다. 한 경로에서는 pid_tgid만 채우고, 다른 경로에서는 ts만 채운다. 사람 눈에는 “둘 중 하나는 채워졌다” 정도로 보일 수 있지만, full read를 하려면 그 정도로는 부족하다.
왜 직관과 어긋났나
한 경로에서는 pid_tgid, 다른 경로에서는 ts를 채우니, 얼핏 보면 “어쨌든 struct 일부는 채워졌다”고 느끼기 쉽다. 하지만 verifier는 사람의 의도를 읽지 않는다. join 이후에는 모든 경로에서 공통으로 보장되는 사실만 안전하게 남긴다.
verifier 로그
14: (79) r2 = *(u64 *)(r1 +0) ; frame1: R1=fp[0]-24 R2=0x1111111111111111
; copy.ts = src->ts; @ partial_init_defs.h:35
15: (79) r0 = *(u64 *)(r1 +8)
invalid read from stack off -16+0 size 8
full read 시점에서 r1 +8의 ts read가 막힌다. 어느 한 경로에서 ts를 채웠다는 사실이 전체 경로에 대한 증명이 되지 못한다는 뜻이다.
핵심 instruction
0: (61) r1 = *(u32 *)(r1 +0)
1: (54) w1 &= 1
2: (16) if w1 == 0x0 goto pc+4
3: (18) r1 = 0x1111111111111111
5: (7b) *(u64 *)(r10 -24) = r1
6: (05) goto pc+3
10: (bf) r1 = r10
11: (07) r1 += -24
12: (85) call pc+1
15: (79) r0 = *(u64 *)(r1 +8)
분기 전에는 skb->len & 1을 검사하고, 참이면 fp-24에 pid_tgid를 쓰고, 거짓이면 fp-16에 ts를 쓴다. 그 뒤 공통 경로에서 partial_init_consume_full()이 전체 필드를 읽는다.
해석
branch A에서는 pid_tgid만 initialized, branch B에서는 ts만 initialized다. join 이후 verifier가 안전하게 유지할 수 있는 사실은 “구조체 전체가 initialized”가 아니라, 훨씬 더 약한 공통 사실뿐이다. 그래서 full read 시점에 reject된다.
결론
Case E는 branch join 이후 initializedness가 어떻게 병합되는지 설명하는 핵심 사례다. “한 경로에서만 true인 사실은 join 이후 전체 경로에 대한 증명이 아니다”라는 점을 가장 선명하게 보여 준다.
Case F: 일부 필드만 초기화했지만 좁게 읽는 경우
Case F는 반대편 기준점이다. pid_tgid만 채우고 partial_init_consume_first()로 그 필드만 읽는다. 이 경우는 정상적으로 통과한다.
왜 중요한가
Case B와 거의 같은 모양인데 결과만 다르다. 그래서 읽는 범위가 핵심이라는 점을 설명하기에 가장 좋은 대비군이다.
verifier 로그
7: frame1: R1=fp[0]-24 R10=fp0
; pid_tgid = src->pid_tgid; @ partial_init_defs.h:47
7: (79) r0 = *(u64 *)(r1 +0) ; frame1: R0=0x1111111111111111 R1=fp[0]-24
8: (95) exit
이번에는 r1 +0만 읽고 바로 종료한다. reject가 아니라 정상 종료로 끝난다.
핵심 instruction
0: (18) r1 = 0x1111111111111111
2: (7b) *(u64 *)(r10 -24) = r1
3: (bf) r1 = r10
4: (07) r1 += -24
5: (85) call pc+1
7: (79) r0 = *(u64 *)(r1 +0)
8: (95) exit
store는 여전히 fp-24 한 번뿐이지만, read도 r1 +0 한 번뿐이다. initialized 범위와 read 범위가 정확히 일치한다.
해석
구조체 전체는 partial-init 상태지만, verifier가 실제로 확인해야 하는 read 범위는 pid_tgid 8바이트뿐이다. 그 범위는 이미 initialized이므로 통과한다.
결론
Case F는 글의 핵심 논지를 가장 짧게 요약한다. partial-init 자체가 금지된 것이 아니라, verifier가 보는 read 범위가 initialized 범위를 넘는지가 문제다.
분기 후 initializedness 정보는 어떻게 합쳐지는가
이 절은 별도 소절로 두는 편이 좋다. 심화 글은 결국 여기서 갈린다. 한쪽 경로만 채운 필드가 join 이후 어떤 운명을 맞는지를 설명하지 못하면, 케이스를 여러 개 보여 줘도 단순 사례 모음에 머물기 쉽다.
정리하면 아래 세 문장으로 요약할 수 있다.
- verifier는 경로별 상태를 따로 추적한다.
- join 지점에서는 모든 경로에서 공통으로 증명되는 사실만 안전하게 남길 수 있다.
- 따라서 일부 경로에서만 초기화된 바이트는 join 이후 initialized라고 보기 어렵다.
Case D와 E는 이 원리를 보여 주는 쌍이다. 특히 E는 한 경로에서 pid_tgid, 다른 경로에서 ts를 채워도 join 이후 전체 read를 보장하지 못한다는 점을 가장 선명하게 드러낸다.
실제 read range와 stack object 모델
많이 막히는 지점은 여기다. 필드 하나만 쓸 것 같은데 왜 전체 초기화가 필요하냐는 질문이다. 이번 본선 실험은 helper size가 아니라 direct stack read를 사용했지만, 핵심은 같다. verifier가 실제로 읽는다고 보는 범위가 initialized 범위를 넘으면 reject된다.
그래서 이번 실험은 아래 대비로 읽는 편이 가장 좋다.
B: partial-init + full read -> failF: partial-init + narrow read -> pass
즉, partial init 문제는 단순히 필드를 몇 개 썼느냐의 문제가 아니라, stack object의 어느 바이트 범위가 실제 read set에 포함되느냐의 문제로 봐야 한다.
이것이 verifier bug가 아닌 이유
처음에는 verifier가 이상하게 굴었다고 느꼈다. 그런데 코드를 따라가 보니, 이번 결과는 오히려 verifier가 자기 모델을 일관되게 적용한 사례에 가까웠다.
정리하면 이렇다.
- verifier는 C 의미를 추론하지 않는다.
- verifier는 instruction과 경로별 상태만 본다.
- full read와 narrow read는 다른 사건으로 취급된다.
- branch join 이후에는 공통으로 증명되는 initializedness만 남는다.
- capability context에 따라 stack read 판정 경로도 달라질 수 있다.
물론 진단성은 아쉬울 수 있다. 에러 메시지가 맞더라도, 왜 그 지점에서 그런 상태가 되었는지 바로 읽히지 않는 경우가 많다. 하지만 그건 verifier의 판단이 틀렸다는 말과는 다르다.
실무 규칙 5개
이번 실험을 지나고 나니 실무 규칙도 비교적 단순하게 정리됐다.
- stack object를 읽게 만들 때는 실제 read 범위를 먼저 확인한다.
- partial init 상태에서 full read를 하면 어떤 바이트가 비는지 offset 기준으로 본다.
- 분기별 초기화는 join 이후 공통 사실만 남는다고 가정한다.
- source code가 비슷해 보여도 instruction shape를 직접 확인한다.
- verifier 로그만 보지 말고 verifier 코드 경로까지 같이 확인한다.
남은 리스크와 후속 실험
이번 결론도 특정 커널과 특정 툴체인 조합에서 관찰한 결과에 기반한다. 그래서 아래 리스크는 같이 남겨야 한다.
- 같은 C 코드라도 clang 최적화 결과가 달라지면 instruction shape가 바뀔 수 있다.
- 커널 버전에 따라 verifier의 세부 동작이나 로그 문구가 달라질 수 있다.
- helper 기반 read와 direct stack read는 reject 지점이 다를 수 있다.
- 다른 program type이나 capability context에서는 같은 패턴이 다르게 보일 수 있다.
후속 실험으로는 아래를 생각하고 있다.
- 같은 코드를
-O0,-O2에서 각각 비교하기. - helper 기반 read와 direct stack read를 같은 표에서 비교하기.
- 동일 케이스를 다른 커널 버전에서 반복해 보기.
- branch join이 더 복잡한 nested control flow를 따로 분리해 보기.
결론
이 글을 partial-init 실패 사례 모음으로만 쓰고 싶지는 않다. 더 정확히는, 비슷한 코드 12개를 통해 verifier의 stack initializedness와 branch join 모델을 역으로 설명하는 글로 쓰고 싶다.
사람 눈에는 거의 같은 의미처럼 보이는 코드라도 verifier는 그렇게 보지 않는다. verifier는 초기화된 바이트 범위와 경로별 상태를 본다. 그리고 실제로 읽는 범위가 그 증명 범위를 넘으면 reject한다. 이번 실험에서 B, E, F가 각각 보여 준 것이 바로 그 점이다.