이번 장에서는 eBPF 중에서도 검증기(verifier)의 버그를 악용하여 권한 상승에 도전합니다. 먼저 LK06 (Brahman) 배포 파일을 준비해 주세요.
패치 확인
이번에는 연습용으로 eBPF를 취약하게 만들기 위해, 검증기에 버그를 심는 패치가 적용되어 있습니다. patch/verifier.diff에 그 내용이 있으니 확인해 봅시다.
1 | 7957c7957,7958 |
kernel/bpf/verifier.c의 7957번째 줄[1]에 변경이 들어갔습니다.
scalar32_min_max_or 함수 도입부에서 __mark_reg32_known이라는 함수가 호출되는데, 패치 적용 후 주석 처리되었습니다. 그 외의 변경 사항은 없으니 이 부분을 자세히 살펴보겠습니다.
scalar32_min_max_or 읽기
변경이 들어간 scalar32_min_max_or의 호출자는 adjust_scalar_min_max_vals입니다. 이 함수는 ADD나 XOR 같은 ALU 연산 후 대상 레지스터의 범위 추적을 구현합니다.
수정된 부분은 BPF_OR입니다.
1 | case BPF_OR: |
먼저 tnum_or로 대상 레지스터의 var_off를 갱신합니다. 구현은 단순해서, OR하는 비트 양쪽 모두 불명확하면 대상도 불명확하다고 봅니다. 한쪽 비트가 불명확하더라도 다른 쪽이 1이면 OR 결과는 반드시 1이 되므로, mask의 대응하는 비트는 0이 됩니다.
1 | struct tnum tnum_or(struct tnum a, struct tnum b) |
예를 들어 (mask=0xffff0000; value=0x1001)과 (mask=0xffffff00; value=0x2)를 OR하면 (mask=0xffffef00; value=0x1003)이 됩니다.
var_off를 갱신하면 문제의 scalar32_min_max_or가 호출됩니다. 삭제된 부분은 src_known, dst_known이 true일 때 도달합니다.
1 |
|
tnum_subreg_is_const는 레지스터의 하위 32비트 부분이 상수일 때 true를 반환합니다. 즉, OR하는 레지스터 양쪽 모두 하위 32비트가 상수일 때, 본래는 __mark_reg32_known이 호출되어야 했습니다.
__mark_reg32_known은 상수의 var_off를 사용해 s32_min_value, s32_max_value, u32_min_value, u32_max_value를 갱신합니다.
1 | static void __mark_reg32_known(struct bpf_reg_state *reg, u64 imm) |
패치 내 주석에 'scalar_min_max_or will handler the case’라고 되어 있으므로, scalar_min_max_or도 따라가 보겠습니다.
1 |
|
기본적으로는 scalar32_min_max_or의 64비트 버전입니다. 여기서 양쪽 모두 64비트 값이 상수일 때 __mark_reg_known이 호출됩니다. __mark_reg_known은 64비트 부분에 더해 32비트 범위도 상수로 변경합니다.
1 |
|
즉, OR의 64비트 레지스터가 양쪽 모두 상수인 경우, scalar32_min_max_or에서 __mark_reg32_known을 호출하지 않아도 뒤의 scalar_min_max_or에서 문제없이 상수가 되는 구조입니다.
그렇다면 64비트 레지스터의 상위 32비트가 상수가 아닌 경우는 어떨까요. scalar32_min_max_or는 즉시 return하지만, scalar_min_max_or에서 __mark_reg_known은 호출되지 않습니다.
이때 scalar_min_max_or 안의 다음 경로에 도달합니다.
1 | /* We get our maximum from the var_off, and our minimum is the |
umin_value, umax_value, smin_value, smax_value를 갱신한 뒤 __update_reg_bounds가 호출됩니다.
1 | static void __update_reg_bounds(struct bpf_reg_state *reg) |
여기서도 32비트, 64비트 모두 범위를 갱신하고 있습니다. 그렇다면 패치는 불필요한 처리를 삭제한 것일까요?
__update_reg32_bounds 읽기
__update_reg32_bounds의 처리를 자세히 봅시다.
1 | static void __update_reg32_bounds(struct bpf_reg_state *reg) |
__mark_reg32_known이 호출되지 않았기 때문에, 32비트의 min, max는 오래된 상태 그대로입니다. 이를 이용해 갱신되는 min, max에 불일치를 일으킬 수 없을까요? 간단하게 부호 없는 경우를 생각해보겠습니다.
1 | reg->u32_min_value = max_t(u32, reg->u32_min_value, (u32)var32_off.value); |
지금 레지스터의 하위 32비트는 src, dst 모두 상수입니다. 따라서 var32_off.mask는 0이며, 다음과 같이 다시 쓸 수 있습니다.
1 | reg->u32_min_value = max(reg->u32_min_value, var32_off.value); |
u32_min_value와 u32_max_value는 대상 레지스터의 원래 상태가 유지됩니다. 하위 32비트는 상수여야 하므로, 원래의 u32_min_value와 u32_max_value는 모두 X라는 값이었다고 가정합니다. 여기에 어떤 상수 Y를 OR하여 결과가 X|Y가 됩니다. 그러면 X|Y > X일 때,
1 | reg->u32_min_value = max(X, X|Y); // min=X|Y |
가 되어 u32_min_value가 u32_max_value보다 커지는 불일치가 발생합니다.
버그 재현
간단하게 X=0, Y=1로 생각해 봅시다.
먼저 다음과 같은 레지스터 R1, R2를 준비합니다.
1 | R1: var_off=(value=0; mask=0xffffffff00000000) |
이를 BPF_OR(R1, R2)로 OR했을 때의 변화를 살펴봅시다.
var_off=(value=0xfffffffe00000001; mask=0x100000000)u32_min_value = max(0, 1) = 1u32_max_value = min(0, 1) = 0
이로써 32비트 부분의 최솟값이 1, 최댓값이 0인 망가진 레지스터가 생성됩니다. 실제로 코드로 확인해 봅시다.
1 | // BPF 맵 생성 |
이 프로그램을 로드하고 검증기 로그 verifier_log를 출력해 보세요.
1 | / $ ./pwn |
14번째 OR 명령에서
1 | R1_w=Pscalar(...,s32_min=1,s32_max=0,u32_min=1,u32_max=0) |
와 같이 범위 추적이 망가져 있음을 알 수 있습니다.

이 버그는 실제로 OR, AND, XOR 명령에서 과거에 존재했던 것이야.
주소 릭
이번처럼 min_value > max_value라는 조건이 생긴 경우, 이를 악용하는 방법은 몇 가지 있습니다. 먼저 맵의 주소 릭에 사용해 봅시다.
eBPF에서는 포인터에 대한 스칼라 값의 가감산이 허용됩니다. 포인터와 스칼라 값 연산 시의 오프셋 업데이트는 adjust_ptr_min_max_vals에 구현되어 있습니다.
1 | static int adjust_ptr_min_max_vals(struct bpf_verifier_env *env, |
위 코드를 읽어보면, 이번처럼 스칼라 값 쪽의 추적이 망가진 케이스에서는 연산 결과를 __mark_reg_unknown으로 불명확한 값으로 만듭니다.
즉, 추적을 망가뜨린 레지스터와 포인터를 더하면 결과는 스칼라 값으로 취급됩니다. 스칼라 값은 BPF 맵에 쓸 수 있으므로 주소 릭이 가능합니다. 바로 map_lookup_elem으로 얻은 BPF 맵 포인터를 릭 해봅시다.
아까 s32_min_value 등의 추측을 망가뜨렸지만, 위 코드에서는 smin_val 등 64비트 레지스터가 망가져 있어야 합니다. 32비트 값을 64비트 값으로 확장하려면 x86-64와 마찬가지로 BPF_MOV32_REG를 사용해 32비트 레지스터에 복사하면 됩니다.
1 | ... |
다음과 같이 BPF 맵의 주소가 릭 된다면 성공입니다. R1에는 1이 들어 있었으므로, 덧셈에 의해 실제보다 주소가 1 어긋나 있음에 주의하세요.
이 주소를 보면 올바르게 배열의 첫 번째 요소(릭 한 데이터)가 들어 있습니다.
0x110 뺀 곳은 메타데이터를 포함한 BPF 맵의 시작 부분입니다. 이번에는 배열 형식으로 만들었으므로 bpf_array 구조체가 존재합니다. 예를 들어 선두에 있는 0xffffffff81c124a0이라는 값은 bpf_map 구조체의 ops라는 함수 테이블입니다. 이번에는 사용하지 않지만 eBPF 공격에서는 이 ops를 덮어써서 권한 상승하는 기법도 있습니다.
이 방법은 adjust_ptr_min_max_vals 코드를 모르면 깨닫기 힘들지만, 실제로는 이를 이용하지 않아도 exploit을 작성할 수 있습니다. (예제 참조)
맵 주소를 가지고 있으면 이후 kASLR 릭이 쉬워지므로 주소는 가지고 있도록 합시다. 맵 fd를 넘기면 (마지막에 1을 뺀) 주소를 반환하도록 함수화해두면 코드가 깔끔해집니다.

root 권한에서는 표준적으로 포인터 릭이 허용되어 있으니, eBPF exploit을 디버깅할 때는 반드시 일반 사용자 권한에서의 동작 확인도 잊지 마.
범위 밖 참조 (OOB Access)
앞 장에서도 조금 다뤘지만, 2022년 현재 ALU sanitation이라는 완화 기법이 들어가 있어서 예전처럼 단순히 범위 밖 참조를 할 수 없습니다.
하지만 우선은 ‘단순한’ 범위 밖 참조를 시도해 봅시다.
사실 ALU sanitation은 bpf_bypass_spec_v1이라는 함수가 true를 반환할 때는 스킵됩니다. 이 함수는 root 권한에서는 true를 반환하므로, root 권한에서는 지금도 범위 밖 참조를 시도할 수 있습니다.
그래서 먼저 root 권한으로 ‘단순한’ 범위 밖 참조를 시도해 봅시다.
추적이 깨진 상수 생성
검증기의 오류를 악용하는 데 있어 편리한 것은, '검증기가 X라고 생각하지만 실제로는 Y인 상수(XX!=Y)'를 만드는 것입니다. 특히 X=0, Y!=0일 때는 무엇을 곱해도 검증기는 0이라고 판단하므로 범위 밖 참조 오프셋을 만드는 데 유리합니다.
먼저 '검증기가 0이라고 생각하지만 실제로는 1인 상수’를 만들어 봅시다.
지금 R1은 u32_min_value가 1이고 u32_max_value가 0입니다. 반대로 R2에 u32_min_value가 0이고 u32_max_value가 1인 (망가지지 않은) 값을 넣습니다. 여기서 R1과 R2의 덧셈을 생각해보면 범위는 [1,0]+[0,1]=[1,1]이 됨을 알 수 있습니다.
최솟값, 최댓값이 같은 레지스터는 MOV 등의 타이밍에 상수 취급이 됩니다. R1의 실제 값은 1이었지만, R2는 0이나 1을 가집니다. 따라서 덧셈 결과는 [1,2]여야 합니다. 하지만 검증기는 덧셈 후의 R1을 상수 1로 판단해 버려서, 실제로는 2가 들어 있는 상황이 생깁니다.
이제 R1에서 1을 빼면 목적했던 '검증기는 0이라고 생각하지만 실제로는 1인 상수’를 만들 수 있습니다.
u32_min_value가 0이고 u32_max_value가 1인 R2는 논리·산술 연산을 조합하거나, 조건 분기로 1보다 큰 케이스를 버려서 생성할 수 있습니다.
1 | // BPF 맵 생성 |
이 프로그램을 실행한 후 맵(R1의 실제 값)을 확인해보면 1이 되어 있음을 알 수 있습니다. 반면 22번째 명령 종료 시점에서 검증기는 R1에 0이 들어 있다고 추측하고 있습니다.
범위 밖 참조 확인
방금 전 코드로 추적 결과가 상수 0이 됨에도 불구하고 실제로는 1을 가지는 레지스터를 만들었습니다. 이 레지스터에 적당한 수를 곱해서 맵 포인터에 더하면, 결과적으로 범위 밖을 가리키는 유효한 포인터를 만들 수 있습니다.
실제로 시도해 봅시다.
다음 BPF 프로그램은 망가진 레지스터에 0x100을 곱함으로써 '추측값=0 / 실제값=0x100’인 상황을 만들고, 그것을 사용해 맵의 범위 밖을 BPF_LDX_MEM으로 읽고 있습니다.
1 | int main() { |
이 프로그램을 root 권한으로 실행하면, 다음과 같이 설정한 적 없는 값을 읽을 수 있음을 알 수 있습니다.
실제로 gdb에서 확인하면 맵 주소에서 0x100 떨어진 곳에 릭 한 데이터가 존재합니다.
또한 0x100이라는 상수를 MOV로 넘기면 검증기가 범위 밖 참조를 탐지하므로, 취약점에 기인하여 범위 밖 참조를 일으켰음을 알 수 있습니다.
하지만 일반 사용자 권한으로 같은 프로그램을 실행하면, ALU sanitation이 덧셈에 의한 범위 밖 참조를 0 덧셈으로 변환해 버리기 때문에, 다음과 같이 아무 데이터도 릭 할 수 없습니다. (처음부터 넣었던 값 1이 나온 것으로 보아 ALU sanitation에 의해 덧셈이 의미 없게 되었음을 알 수 있습니다.)

ALU sanitation이 없던 시절에는 이 기법으로 bpf_map 구조체의 ops 등을 읽고 쓰는 공격이 주류였어.
ALU sanitation 우회
다행히 이번 대상인 커널 v5.18.14에서는 ALU sanitation을 우회하는 방법이 존재합니다. 아이디어는 포인터에 대한 (범위를 벗어나는) 가감산이 패치되어 버리므로, 기존 헬퍼 함수에게 그 처리를 맡기자는 발상입니다.
일반 사용자가 쓸 수 있는 헬퍼 함수는 적지만, 오프셋이나 크기를 인자로 받는 함수를 찾아봅시다. 그러면 소켓 필터에서는 예를 들어 skb_load_bytes라는 함수를 사용할 수 있습니다.
1 |
|
이 함수는 패킷 내용을 BPF 측(맵이나 스택)으로 복사할 수 있습니다.
첫 번째 인자를 컨텍스트, 두 번째 인자를 복사하고 싶은 패킷 데이터의 오프셋, 세 번째 인자를 복사할 버퍼, 네 번째 인자를 복사할 크기로 지정합니다. 복사 원본은 패킷 데이터이므로 write로 소켓에 보낸 데이터가 복사됩니다.
이 함수를 호출할 때 인자가 범위를 벗어나지 않는지 판단하지만, ALU sanitation의 영향은 받지 않습니다. 따라서 함수 내부에서 범위 밖 데이터 복사를 실현할 수 있습니다.
실제로 시도해 봅시다. 현재 BPF 맵 데이터 크기는 8이므로, 8바이트 이상 복사할 수 있으면 성공입니다.
시험 삼아 검증기가 1로 판단하고 실제 값이 0x10인 레지스터를 만듭니다. write로 0x10바이트 이상의 데이터를 보내고, 그것이 맵에 복사되었는지 gdb로 확인합니다. (크기로 0(으로 추측되는 값)을 넘기면 검증기가 경고하므로 주의)
1 | ... |
위 프로그램에서는 BPF 맵의 0번째 요소(처음에 얻은 주소 R9에 저장함)에 대해 skb_load_bytes로 패킷 데이터를 씁니다. 실제로는 0x10바이트 써지지만, 검증기는 1바이트라고 추측하고 있어 허용됩니다.
프로그램을 호출할 때 다음과 같이 0x10바이트 데이터를 보내봅시다.
1 | char payload[0x10]; |
실행 후 gdb에서 맵 주소를 확인하면, 아래 그림과 같이 이번 데이터 크기인 8바이트를 초과하여 써진 것을 알 수 있습니다.
힙에서의 범위 밖 쓰기가 실현되었으므로, 이후에는 여러분이 좋아하는 방법으로 exploit 할 수 있을 것입니다. 예를 들어 BPF 맵을 2개 나란히 두고, 뒤쪽 맵의 ops를 덮어쓰는 등의 방법이 생각납니다.
하지만 모처럼이니 이번에는 BPF의 특징을 살려 AAR/AAW를 실현해 봅시다.
AAR/AAW 생성
BPF 스택에는 포인터를 쓸 수 있다는 점을 떠올려 봅시다. 스택에 저장한 데이터는 타입이나 범위가 추적되고 있습니다.
따라서 skb_load_bytes를 사용해 스택 위에서 범위 밖[2] 쓰기를 하면, 스택에 저장된 포인터를 패킷 데이터로 덮어쓸 수 있습니다. 덮어씌워진 후에도 검증기는 포인터로 인식하고 있기 때문에, 가짜 포인터를 읽고 쓸 수 있습니다.
그림으로 나타내면 다음과 같습니다.
마지막으로 덮어씌워진 FP-0x18에 있는 데이터는 포인터로서 마크되어 있기 때문에, BPF_LDX_MEM으로 꺼내면 포인터로 취급할 수 있습니다.
이처럼 BPF의 특징을 살리면 간단히 AAR/AAW를 만들 수 있습니다.
AAR/AAW의 PoC
1 | /** |
kASLR 우회 및 권한 상승
지금 맵 주소를 가지고 있으므로, bpf_map의 ops 등을 가리키는 가짜 포인터를 만듦으로써 커널 베이스 주소를 릭 할 수 있습니다. 또한 베이스 주소를 얻으면 AAW로 modprobe_path 등을 덮어써서 권한 상승할 수 있습니다.
각자 exploit을 완성해 봅시다. exploit 코드 예시는 여기에서 다운로드할 수 있습니다.
min_value > max_value라는 상황을 만들 수 있었기 때문에, adjust_ptr_min_max_vals를 악용하여 BPF 맵 주소를 릭 했습니다.(1) BPF 맵이나 BPF 스택, 컨텍스트 주소를 릭 하지 않고 exploit을 완성해 주세요.
(2) 또한
skb_load_bytes에 의한 힙 오버플로우를 이용하여 (BPF 스택을 쓰지 않고) exploit을 완성해 주세요.