[Linux Kernel eBPF] Analyzing bpf selftests pkt_access.c

배경

selftest를 보는 것이 BPF 공부에 좋다고 해서 시작했다.

공부 순서

분석 순서

  1. prog_tests/<name>.c를 먼저 본다.
  2. 대응하는 progs/<원본>.c를 본다.
  3. 1번을 통해 유저 공간에서 무엇을 기대하는지 먼저 본다.
  4. BPF 프로그램이 실제로 무슨 일을 하는지 본다.
  5. 커밋 히스토리를 보고 어떤 식으로 변화했는지 본다.

+ plus

요약 양식

  1. 테스트의 주장
  2. verifier가 민감해할 코드 위치
  3. 커널 코드에서 판정이 나는 지점
  4. 패치를 한다면 무엇을 바꿀지

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_v4network_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바이트라서 약간의 여유를 줬다.
  • 여기서 __sinkbuf가 최적화로 사라지지 않게 한다.

결국 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가 오는지 검증하는 단순한 테스트처럼 보인다. 하지만 핵심은 훨씬 깊다. 다음은 이 테스트에서 검증하고자 하는 것이다.

  1. skb_buff 필드 접근이 서브 프로그램 안에서도 안전한가
  2. 서로 다른 C 함수가 같은 BPF asm으로 최적화돼도 verifier가 잘 처리하는가
  3. 큰 stack frame과 다단계 함수 호출이 있어도 괜찮은가
  4. packet data read/write 경계 검사가 제대로 추적되는가
  5. IPv4/IPv6 모두 문제없는가

verifier 관점에서 다시 보기

위에서 함수별 코드로 따라가며 pkt_access가 무엇을 하는지 확인했다. 이제는 같은 코드를 verifier가 어떤 사실을 알고 있어야 통과시킬 수 있는지 기준으로 다시 본다.

1. verifier는 먼저 "이 포인터가 무엇을 가리키는가"를 구분해야 한다.

eBPF verifier에게 중요한 것은 단순한 값이 아니라 그 값의 종류이다. 위 테스트 코드에서는 크게 두 종류가 나온다.

  • skb->len, skb->ifindex 같은 context field access
  • data, data_end, tcp 같은 packet data pointer access

결국 verifier는 __sk_buff 문맥에서 읽는 필드인지, 아니면 실제 packet buffer 내부를 가리키는 포인터인지 계속 구분해서 추적해야 한다.

여기서 포인트는 테스트 코드에서 두 가지 종류의 코드를 같이 넣어서 서로 다른 종류의 접근 검증이 동시에 흔들리지 않는지를 본다는 점이다.

2. skb->len, skb->ifindex는 단순 필드 읽기가 아니라 "문맥 보존" 테스트이다.

겉으로 보면 skb->len, ifindex 같은 필드 읽기는 쉬워 보인다. 하지만 verifier 입장에서는 단순하지 않다.

  1. 바로 메인 함수에서 읽힌다.
  2. 인자가 추가된 subprog 안에서 읽히기도 한다.
  3. 큰 stack frame을 쓰는 함수 안에서도 읽힌다.
  4. 여러 함수를 거친 뒤 계산식 안에서도 읽힌다.

즉, 여기서 verifier가 확인해야 하는 것은 "이 함수가 여전히 올바른 __sk_buff * 문맥 위에서 실행되고 있는가?"라는 점이다.

그래서 이것은 subprog 호출이 섞여도 context access의 의미가 보존되는가를 보는 테스트이다.

3. subprog1subprog2는 같은 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;
}
  1. 먼저 packet pointer를 계산한다.
  2. 그 pointer가 packet 범위 안에 있다는 사실을 증명한다.
  3. 그다음에야 실제 필드를 읽는다.

중요한 것은 읽기가 안전하다는 사실을 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가 따라갈 수 있는 형태로 코드를 쓰는 것이다.

  1. context access는 호출 구조가 복잡해져도 문맥이 보존되어야 한다.
  2. packet pointer는 계산보다 증명이 먼저이다.
  3. 사람이 보기에 같은 코드도 verifier에게는 다를 수 있다.

소감

BPF 관련 테스트를 처음 봤는데, 이 간단한 테스트 안에 많은 것들이 들어 있었다.