[Linux Kernel eBPF] Analyzing bpf selftests pkt_access.c
목차
배경
selftest를 보는 것이 BPF 공부에 좋다고 해서 시작했다.
공부 순서
분석 순서
prog_tests/<name>.c를 먼저 본다.- 대응하는
progs/<원본>.c를 본다. - 1번을 통해 유저 공간에서 무엇을 기대하는지 먼저 본다.
- BPF 프로그램이 실제로 무슨 일을 하는지 본다.
- 커밋 히스토리를 보고 어떤 식으로 변화했는지 본다.
+ plus
요약 양식
- 테스트의 주장
- verifier가 민감해할 코드 위치
- 커널 코드에서 판정이 나는 지점
- 패치를 한다면 무엇을 바꿀지
prog_tests/pkt_access.c
이걸 고른 이유는 "eBPF selftest의 기본 흐름이 아주 짧고 선명하게 드러나기 때문"이라고 LLM이 추천해 줬기 때문이다.
// linux/tools/testing/selftests/bpf/prog_tests/pkt_access.c
// SPDX-License-Identifier: GPL-2.0
#include <test_progs.h>
#include <network_helpers.h>
void test_pkt_access(void)
{
const char *file = "./test_pkt_access.bpf.o";
struct bpf_object *obj;
int err, prog_fd;
LIBBPF_OPTS(bpf_test_run_opts, topts,
.data_in = &pkt_v4,
.data_size_in = sizeof(pkt_v4),
.repeat = 100000,
);
err = bpf_prog_test_load(file, BPF_PROG_TYPE_SCHED_CLS, &obj, &prog_fd);
if (CHECK_FAIL(err))
return;
err = bpf_prog_test_run_opts(prog_fd, &topts);
ASSERT_OK(err, "ipv4 test_run_opts err");
ASSERT_OK(topts.retval, "ipv4 test_run_opts retval");
topts.data_in = &pkt_v6;
topts.data_size_in = sizeof(pkt_v6);
topts.data_size_out = 0; /* reset from last call */
err = bpf_prog_test_run_opts(prog_fd, &topts);
ASSERT_OK(err, "ipv6 test_run_opts err");
ASSERT_OK(topts.retval, "ipv6 test_run_opts retval");
bpf_object__close(obj);
}자세한 분석
const char *file = "./test_pkt_access.bpf.o";
struct bpf_object *obj;
int err, prog_fd;
LIBBPF_OPTS(bpf_test_run_opts, topts,
.data_in = &pkt_v4,
.data_size_in = sizeof(pkt_v4),
.repeat = 100000,
);
// network_helpers.h
/* ipv4 test vector */
struct ipv4_packet {
struct ethhdr eth;
struct iphdr iph;
struct tcphdr tcp;
} __packed;
extern struct ipv4_packet pkt_v4;
LIBBPF_OPTS: 이 매크로는 libbpf에서 자주 쓰는 옵션 구조체 초기화 매크로이다.
struct bpf_test_run_opts topts를 만들고 내부 필드를 지정값으로 초기화한다. (topts는 테스트 실행 설정 묶음이다.)
여기서 pkt_v4는 network_helpers.h에 정의된 IPv4 테스트 구조체이다. 이것을 통해 BPF 테스트를 실행한다는 것을 옵션에 적어 넣는다.
err = bpf_prog_test_load(file, BPF_PROG_TYPE_SCHED_CLS, &obj, &prog_fd);
if (CHECK_FAIL(err))
return;
bpf_prog_test_load 함수의 역할:
BPF_PROG_TYPE_SCHED_CLS 타입의 tc classifier 계열 BPF 프로그램을 로드한다.
성공하면 obj에 오브젝트 핸들을 저장하고 prog_fd에 프로그램 FD를 저장한다.
err = bpf_prog_test_run_opts(prog_fd, &topts);
ASSERT_OK(err, "ipv4 test_run_opts err");
ASSERT_OK(topts.retval, "ipv4 test_run_opts retval");
아까 로드한 BPF 프로그램을 topts에 들어 있는 입력 패킷(pkt_v4)으로 실행한다.
ASSERT_OK는 값이 0이면 통과, 0이 아니면 실패 처리한다. 결국 실행 반환값과 retval이 모두 0이면 통과한다는 뜻이다.
topts.data_in = &pkt_v6;
topts.data_size_in = sizeof(pkt_v6);
topts.data_size_out = 0; /* reset from last call */
err = bpf_prog_test_run_opts(prog_fd, &topts);
ASSERT_OK(err, "ipv6 test_run_opts err");
ASSERT_OK(topts.retval, "ipv6 test_run_opts retval");
bpf_object__close(obj);
이 부분은 pkt_v6를 위해 다시 세팅하고, 아까와 같이 테스트를 돌린다.
bpf_object__close는 BPF 오브젝트를 닫아 리소스를 정리한다.
progs/test_pkt_access.c
const char *file = "./test_pkt_access.bpf.o";를 보고 progs/test_pkt_access.c라는 것을 알 수 있다.
// linux/tools/testing/selftests/bpf/progs/test_pkt_access.c
// SPDX-License-Identifier: GPL-2.0-only
/* Copyright (c) 2017 Facebook
*/
#include <stddef.h>
#include <string.h>
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/if_packet.h>
#include <linux/ip.h>
#include <linux/ipv6.h>
#include <linux/in.h>
#include <linux/tcp.h>
#include <linux/pkt_cls.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
#include "bpf_misc.h"
/* llvm will optimize both subprograms into exactly the same BPF assembly
*
* Disassembly of section .text:
*
* 0000000000000000 test_pkt_access_subprog1:
* ; return skb->len * 2;
* 0: 61 10 00 00 00 00 00 00 r0 = *(u32 *)(r1 + 0)
* 1: 64 00 00 00 01 00 00 00 w0 <<= 1
* 2: 95 00 00 00 00 00 00 00 exit
*
* 0000000000000018 test_pkt_access_subprog2:
* ; return skb->len * val;
* 3: 61 10 00 00 00 00 00 00 r0 = *(u32 *)(r1 + 0)
* 4: 64 00 00 00 01 00 00 00 w0 <<= 1
* 5: 95 00 00 00 00 00 00 00 exit
*
* Which makes it an interesting test for BTF-enabled verifier.
*/
static __attribute__ ((noinline))
int test_pkt_access_subprog1(volatile struct __sk_buff *skb)
{
return skb->len * 2;
}
static __attribute__ ((noinline))
int test_pkt_access_subprog2(int val, volatile struct __sk_buff *skb)
{
return skb->len * val;
}
#define MAX_STACK (512 - 2 * 32)
__attribute__ ((noinline))
int get_skb_len(struct __sk_buff *skb)
{
volatile char buf[MAX_STACK] = {};
__sink(buf[MAX_STACK - 1]);
return skb->len;
}
__attribute__ ((noinline))
int get_constant(long val)
{
return val - 122;
}
int get_skb_ifindex(int, struct __sk_buff *skb, int);
__attribute__ ((noinline))
int test_pkt_access_subprog3(int val, struct __sk_buff *skb)
{
return get_skb_len(skb) * get_skb_ifindex(val, skb, get_constant(123));
}
__attribute__ ((noinline))
int get_skb_ifindex(int val, struct __sk_buff *skb, int var)
{
volatile char buf[MAX_STACK] = {};
__sink(buf[MAX_STACK - 1]);
return skb->ifindex * val * var;
}
__attribute__ ((noinline))
int test_pkt_write_access_subprog(struct __sk_buff *skb, __u32 off)
{
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
struct tcphdr *tcp = NULL;
if (off > sizeof(struct ethhdr) + sizeof(struct ipv6hdr))
return -1;
tcp = data + off;
if (tcp + 1 > data_end)
return -1;
/* make modification to the packet data */
tcp->check++;
return 0;
}
SEC("tc")
int test_pkt_access(struct __sk_buff *skb)
{
void *data_end = (void *)(long)skb->data_end;
void *data = (void *)(long)skb->data;
struct ethhdr *eth = (struct ethhdr *)(data);
struct tcphdr *tcp = NULL;
__u8 proto = 255;
__u64 ihl_len;
if (eth + 1 > data_end)
return TC_ACT_SHOT;
if (eth->h_proto == bpf_htons(ETH_P_IP)) {
struct iphdr *iph = (struct iphdr *)(eth + 1);
if (iph + 1 > data_end)
return TC_ACT_SHOT;
ihl_len = iph->ihl * 4;
proto = iph->protocol;
tcp = (struct tcphdr *)((void *)(iph) + ihl_len);
} else if (eth->h_proto == bpf_htons(ETH_P_IPV6)) {
struct ipv6hdr *ip6h = (struct ipv6hdr *)(eth + 1);
if (ip6h + 1 > data_end)
return TC_ACT_SHOT;
ihl_len = sizeof(*ip6h);
proto = ip6h->nexthdr;
tcp = (struct tcphdr *)((void *)(ip6h) + ihl_len);
}
if (test_pkt_access_subprog1(skb) != skb->len * 2)
return TC_ACT_SHOT;
if (test_pkt_access_subprog2(2, skb) != skb->len * 2)
return TC_ACT_SHOT;
if (test_pkt_access_subprog3(3, skb) != skb->len * 3 * skb->ifindex)
return TC_ACT_SHOT;
if (tcp) {
if (test_pkt_write_access_subprog(skb, (void *)tcp - data))
return TC_ACT_SHOT;
if (((void *)(tcp) + 20) > data_end || proto != 6)
return TC_ACT_SHOT;
barrier(); /* to force ordering of checks */
if (((void *)(tcp) + 18) > data_end)
return TC_ACT_SHOT;
if (tcp->urg_ptr == 123)
return TC_ACT_OK;
}
return TC_ACT_UNSPEC;
}큰 그림부터
test_pkt_access()는 들어온 패킷이 IPv4인지 IPv6인지 확인하고 TCP 헤더의 위치를 계산한 다음,
skb->len접근이 서브 프로그램에서 잘 되는지skb->ifindex접근이 함수 체인 안에서도 잘 되는지- 패킷 데이터의 TCP 체크섬 필드를 실제로 수정할 수 있는지
- TCP 헤더 경계 검사가 제대로 되는지
를 검사한다.
하나라도 이상하면 TC_ACT_SHOT을 반환한다. 즉, 패킷 드롭이다.
함수들
static __attribute__ ((noinline))
int test_pkt_access_subprog1(volatile struct __sk_buff *skb)
{
return skb->len * 2;
}
- 이건
skb->len필드 읽기 테스트이다. - 단순히 패킷 길이의 2배를 반환한다.
/* llvm will optimize both subprograms into exactly the same BPF assembly
*
* Disassembly of section .text:
*
* 0000000000000000 test_pkt_access_subprog1:
* ; return skb->len * 2;
* 0: 61 10 00 00 00 00 00 00 r0 = *(u32 *)(r1 + 0)
* 1: 64 00 00 00 01 00 00 00 w0 <<= 1
* 2: 95 00 00 00 00 00 00 00 exit
*
* 0000000000000018 test_pkt_access_subprog2:
* ; return skb->len * val;
* 3: 61 10 00 00 00 00 00 00 r0 = *(u32 *)(r1 + 0)
* 4: 64 00 00 00 01 00 00 00 w0 <<= 1
* 5: 95 00 00 00 00 00 00 00 exit
*
* Which makes it an interesting test for BTF-enabled verifier.
*/
static __attribute__ ((noinline))
int test_pkt_access_subprog2(int val, volatile struct __sk_buff *skb)
{
return skb->len * val;
}
skb->len접근 + 인자 전달 테스트이다.val = 2를 넣으면 결과적으로 위의test_pkt_access_subprog1과 같은 결과가 된다.- 주석에 쓰여 있듯, 이 함수는 BTF-enabled verifier가 서로 다른 원본 함수인데도 같은 어셈블리처럼 보이는 경우를 제대로 처리하는지 보는 테스트이다.
#define MAX_STACK (512 - 2 * 32)
__attribute__ ((noinline))
int get_skb_len(struct __sk_buff *skb)
{
volatile char buf[MAX_STACK] = {};
__sink(buf[MAX_STACK - 1]);
return skb->len;
}
- 큰 stack frame을 사용하는 함수이다.
- 그 안에서도
skb->len접근이 가능한지 테스트한다. - BPF 스택 제한은 512바이트라서 약간의 여유를 줬다.
- 여기서
__sink는buf가 최적화로 사라지지 않게 한다.
결국 stack을 많이 써도 skb 필드 읽기가 제대로 처리되나를 검증한다.
__attribute__ ((noinline))
int get_constant(long val)
{
return val - 122;
}
- 이 함수는
123을 넣으면1을 반환한다. - 의도는 호출 체인을 더 복잡하게 만들어 verifier와 호출 처리 경로를 테스트하려는 데 가깝다.
__attribute__ ((noinline))
int get_skb_ifindex(int val, struct __sk_buff *skb, int var)
{
volatile char buf[MAX_STACK] = {};
__sink(buf[MAX_STACK - 1]);
return skb->ifindex * val * var;
}
skb->ifindex필드 접근 테스트이다.- 큰 stack을 사용하고 인자를 3개 전달한다.
- 호출 체인 안에서 verifier가 문맥을 잘 추적하는지 확인한다.
__attribute__ ((noinline))
int test_pkt_access_subprog3(int val, struct __sk_buff *skb)
{
return get_skb_len(skb) * get_skb_ifindex(val, skb, get_constant(123));
}
- 단순히 지금까지 본 함수들을 묶는 역할을 한다.
skb->len * skb->ifindex * val를 계산해서 반환한다.
__attribute__ ((noinline))
int test_pkt_write_access_subprog(struct __sk_buff *skb, __u32 off)
{
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
struct tcphdr *tcp = NULL;
if (off > sizeof(struct ethhdr) + sizeof(struct ipv6hdr))
return -1;
tcp = data + off;
if (tcp + 1 > data_end)
return -1;
/* make modification to the packet data */
tcp->check++;
return 0;
}
- 패킷 쓰기 접근 테스트이다.
skb->data,skb->data_end로 패킷 범위를 가져온다.off가 너무 크면 실패한다.data + off를 TCP 헤더 포인터로 간주한다.tcp + 1 > data_end면 실패한다.- TCP 헤더 1개 전체가 패킷 범위 안에 있어야 한다.
tcp->check++- TCP 체크섬 필드를 1 증가시킨다. 즉, 쓰기 행위다.
- 성공 시
0을 반환한다.
즉, 서브 프로그램 안에서 packet data를 안전하게 write할 수 있나를 보는 테스트이다.
SEC("tc")
int test_pkt_access(struct __sk_buff *skb)
- 이 함수가 tc hook에 붙는 실제 eBPF 프로그램이다.
if (eth + 1 > data_end)
return TC_ACT_SHOT;
Ethernet header 하나 전체가 패킷 안에 있어야 한다.
if (eth->h_proto == bpf_htons(ETH_P_IP)) {
struct iphdr *iph = (struct iphdr *)(eth + 1);
if (iph + 1 > data_end)
return TC_ACT_SHOT;
ihl_len = iph->ihl * 4;
proto = iph->protocol;
tcp = (struct tcphdr *)((void *)(iph) + ihl_len);
}
- IPv4 패킷을 처리한다.
- Ethernet 다음을
iphdr로 처리한다. - 최소 IP 헤더 범위를 확인한다.
ihl * 4로 실제 헤더 길이를 계산한다.protocol을 저장한다.- TCP header 위치를 계산한다.
else if (eth->h_proto == bpf_htons(ETH_P_IPV6)) {
struct ipv6hdr *ip6h = (struct ipv6hdr *)(eth + 1);
if (ip6h + 1 > data_end)
return TC_ACT_SHOT;
ihl_len = sizeof(*ip6h);
proto = ip6h->nexthdr;
tcp = (struct tcphdr *)((void *)(ip6h) + ihl_len);
}
- IPv6 패킷을 처리한다.
- Ethernet 다음을
ipv6hdr로 처리한다. - IPv6 기본 헤더는 길이가 고정이라
sizeof로 처리한다. - next header를
proto에 저장한다. - TCP 헤더 위치를 계산한다.
if (test_pkt_access_subprog1(skb) != skb->len * 2)
return TC_ACT_SHOT;
- 서브 프로그램에서 읽은
skb->len값이 메인 함수의skb->len과 일치하는지 검증한다. - 호출, 레지스터 전달, BTF 문맥 처리가 맞는지 보는 것이다.
if (test_pkt_access_subprog2(2, skb) != skb->len * 2)
return TC_ACT_SHOT;
- 인자 전달이 들어간 서브 프로그램도 정상인지 본다.
- 앞 함수와 동일한 BPF assembly가 나와도 verifier가 올바르게 처리하는지 본다.
if (test_pkt_access_subprog3(3, skb) != skb->len * 3 * skb->ifindex)
return TC_ACT_SHOT;
- 여러 함수를 통해 다수의 경로를 함께 검증한다.
if (tcp) {
if (test_pkt_write_access_subprog(skb, (void *)tcp - data))
return TC_ACT_SHOT;
if (((void *)(tcp) + 20) > data_end || proto != 6)
return TC_ACT_SHOT;
barrier(); /* to force ordering of checks */
if (((void *)(tcp) + 18) > data_end)
return TC_ACT_SHOT;
if (tcp->urg_ptr == 123)
return TC_ACT_OK;
}
- IPv4나 IPv6로 파싱돼서 TCP 위치를 계산할 수 있을 때만 진입한다.
- 패킷 write가 verifier와 runtime에서 허용되는지 테스트한다.
- TCP 최소 헤더가 20바이트이므로, 그 범위가 패킷 안에 있는지 검증하고
proto를 보고 진짜 TCP인지 검증한다. barrier는 컴파일러가 검사 순서를 이상하게 재배치하지 못하도록 막는다.urg_ptr은 TCP 헤더 안쪽 필드라서, 그 필드까지 안전하게 읽을 수 있는지 별도로 확인한다.+20검사와+18검사가 왜 중복되는지는 검사 순서와 verifier의 packet-range reasoning을 시험하는 목적이다.
다음 단계
이제 함수를 다 분석했으니, 좀 더 깊고 넓은 시각으로 "왜?"에 초점을 두자.
이 테스트가 실제로 검증하려는 것
겉으로 보면 그냥 IPv4/IPv6 패킷을 넣고 TC_ACT_OK가 오는지 검증하는 단순한 테스트처럼 보인다. 하지만 핵심은 훨씬 깊다. 다음은 이 테스트에서 검증하고자 하는 것이다.
skb_buff필드 접근이 서브 프로그램 안에서도 안전한가- 서로 다른 C 함수가 같은 BPF asm으로 최적화돼도 verifier가 잘 처리하는가
- 큰 stack frame과 다단계 함수 호출이 있어도 괜찮은가
- packet data read/write 경계 검사가 제대로 추적되는가
- IPv4/IPv6 모두 문제없는가
verifier 관점에서 다시 보기
위에서 함수별 코드로 따라가며 pkt_access가 무엇을 하는지 확인했다. 이제는 같은 코드를 verifier가 어떤 사실을 알고 있어야 통과시킬 수 있는지 기준으로 다시 본다.
1. verifier는 먼저 "이 포인터가 무엇을 가리키는가"를 구분해야 한다.
eBPF verifier에게 중요한 것은 단순한 값이 아니라 그 값의 종류이다. 위 테스트 코드에서는 크게 두 종류가 나온다.
skb->len,skb->ifindex같은 context field accessdata,data_end,tcp같은 packet data pointer access
결국 verifier는 __sk_buff 문맥에서 읽는 필드인지, 아니면 실제 packet buffer 내부를 가리키는 포인터인지 계속 구분해서 추적해야 한다.
여기서 포인트는 테스트 코드에서 두 가지 종류의 코드를 같이 넣어서 서로 다른 종류의 접근 검증이 동시에 흔들리지 않는지를 본다는 점이다.
2. skb->len, skb->ifindex는 단순 필드 읽기가 아니라 "문맥 보존" 테스트이다.
겉으로 보면 skb->len, ifindex 같은 필드 읽기는 쉬워 보인다. 하지만 verifier 입장에서는 단순하지 않다.
- 바로 메인 함수에서 읽힌다.
- 인자가 추가된 subprog 안에서 읽히기도 한다.
- 큰 stack frame을 쓰는 함수 안에서도 읽힌다.
- 여러 함수를 거친 뒤 계산식 안에서도 읽힌다.
즉, 여기서 verifier가 확인해야 하는 것은 "이 함수가 여전히 올바른 __sk_buff * 문맥 위에서 실행되고 있는가?"라는 점이다.
그래서 이것은 subprog 호출이 섞여도 context access의 의미가 보존되는가를 보는 테스트이다.
3. subprog1과 subprog2는 같은 asm이면 같은 의미인가를 묻는 코드이다.
앞에서 말했듯, 이 코드는 서로 다른 C 함수가 최적화 이후 동일한 BPF asm으로 보여도 그대로 같다고 보면 안 된다는 점을 드러낸다.
서로 다른 subprog이 최적화 후 같은 asm처럼 보여도 verifier는 함수의 정체성과 인자 문맥을 잃지 않고 올바르게 검증해야 한다.
이것이 핵심이다.
4. 큰 stack frame과 함수 체인은 이 테스트가 일부러 복잡도를 올린 흔적으로 보인다.
큰 stack frame은 "간단한 직선형 코드에서는 통과하지만, 조금만 구조가 복잡해져도 verifier가 보수적으로 막지 않는가?"를 보는 것이다.
TCP 접근 블록이 흥미로운 이유
이 테스트의 가장 흥미로운 부분은 마지막 TCP 접근 블록이다.
if (tcp) {
if (test_pkt_write_access_subprog(skb, (void *)tcp - data))
return TC_ACT_SHOT;
if (((void *)(tcp) + 20) > data_end || proto != 6)
return TC_ACT_SHOT;
barrier(); /* to force ordering of checks */
if (((void *)(tcp) + 18) > data_end)
return TC_ACT_SHOT;
if (tcp->urg_ptr == 123)
return TC_ACT_OK;
}
- 먼저 packet pointer를 계산한다.
- 그 pointer가 packet 범위 안에 있다는 사실을 증명한다.
- 그다음에야 실제 필드를 읽는다.
중요한 것은 읽기가 안전하다는 사실을 verifier가 추적할 수 있는 순서로 코드를 작성하는 것이다.
중복처럼 보이는 bounds check이 왜 남아 있을까?
아래 두 검사는 중복처럼 보인다.
if (((void *)(tcp) + 20) > data_end || proto != 6)
return TC_ACT_SHOT;
barrier();
if (((void *)(tcp) + 18) > data_end)
return TC_ACT_SHOT;
if (tcp->urg_ptr == 123)
return TC_ACT_OK;
이미 tcp + 20 <= data_end를 확인했다면 tcp + 18 <= data_end은 논리적으로 더 약한 검사처럼 보인다.
그런데도 굳이 남겨 둔 이유는, 이 테스트가 사람에게 필요한 최소 검사 수를 묻는 것이 아니라 verifier의 range reasoning이 특정 코드 구조에서 안정적으로 유지되는가를 보기 위한 테스트이기 때문이다.
이 테스트 하나로 정리한 규칙
한 줄로 요약하면 이렇다.
eBPF에서 중요한 것은 패킷을 읽고 쓰는 행위 자체보다, 그 접근이 안전하다는 사실을 verifier가 따라갈 수 있는 형태로 코드를 쓰는 것이다.
- context access는 호출 구조가 복잡해져도 문맥이 보존되어야 한다.
- packet pointer는 계산보다 증명이 먼저이다.
- 사람이 보기에 같은 코드도 verifier에게는 다를 수 있다.
소감
BPF 관련 테스트를 처음 봤는데, 이 간단한 테스트 안에 많은 것들이 들어 있었다.