eBPF 실전 (1) — libbpf와 CO-RE로 첫 커널 프로그램 작성하기

커널 동작을 들여다보려면 예전에는 커널 모듈을 만들거나 커널을 다시 빌드해야 했다. 모듈 버그 하나가 곧 커널 패닉이고, 커널 버전이 바뀔 때마다 다시 컴파일해야 한다. eBPF는 검증된 작은 프로그램을 실행 중인 커널에 붙이는 방식으로 이 문제를 푼다. 지금은 관측(tracing)뿐 아니라 스케줄러(sched_ext), 네트워크(XDP), 보안(BPF LSM)까지 커널의 주요 서브시스템을 확장하는 표준 수단이 됐다.

이 시리즈는 libbpf와 CO-RE로 eBPF 프로그램을 직접 작성하고, 테스트 VM에서 실행한 결과로 동작을 검증한다. 첫 편인 이 글에서는 개발 환경을 갖추고, 첫 프로그램을 빌드·로드한 뒤 bpftool로 커널 안의 모습을 확인한다. 이어서 CO-RE로 같은 바이너리가 다른 커널에서 도는 것과 verifier가 코드를 거부하는 사례를 살펴본다.

이 시리즈의 다른 글

eBPF 프로그램이 커널에 들어가는 과정

개념 설명은 eBPF로 Linux 커널 동작 실시간 추적하기에서 다뤘으니, 여기서는 이 글에서 직접 거칠 단계만 정리한다.

단계담당이 글에서 확인하는 것
컴파일clang -target bpfC 소스 → BPF 바이트코드 오브젝트(.bpf.o)
스켈레톤 생성bpftool gen skeleton오브젝트를 임베드한 C 헤더(.skel.h)
로드·재배치libbpfCO-RE 재배치로 구조체 오프셋을 대상 커널에 맞춤
검증커널 verifier메모리 접근·루프 종료를 정적으로 증명, 실패하면 로드 거부
JIT커널바이트코드 → 네이티브 기계어
부착(attach)libbpftracepoint 등 훅에 연결, 이벤트마다 실행
데이터 전달맵, 링 버퍼커널 ↔ 유저 공간 데이터 교환

개발 환경 준비

테스트 환경은 Ubuntu 24.04(커널 6.8.0-139-generic, 6코어 VM)다. clang, libbpf 헤더, 커널 버전에 맞는 bpftool을 설치한다.

sudo apt install clang llvm libbpf-dev libelf-dev zlib1g-dev make \
    linux-tools-$(uname -r)

vmlinux.h는 커널의 모든 타입 정의를 담은 헤더로, 실행 중인 커널의 BTF에서 뽑아낸다.

mkdir -p ~/ebpf && cd ~/ebpf
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
ls -lh vmlinux.h /sys/kernel/btf/vmlinux
-r--r--r-- 1 root  root  5.9M Sep 19 15:47 /sys/kernel/btf/vmlinux
-rw-rw-r-- 1 noble noble 3.1M Sep 19 16:12 vmlinux.h

커널 헤더 수십 개를 include하는 대신 이 파일 하나면 된다. /sys/kernel/btf/vmlinux가 없다면 커널이 CONFIG_DEBUG_INFO_BTF=y로 빌드되지 않은 것이다.

첫 번째 프로그램: execve 추적

execve() 시스템 콜이 호출될 때마다 프로세스 이름과 PID를 커널 트레이스 버퍼에 찍는다.

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

char LICENSE[] SEC("license") = "GPL";

SEC("tracepoint/syscalls/sys_enter_execve")
int handle_execve(void *ctx)
{
	char comm[16];

	bpf_get_current_comm(comm, sizeof(comm));
	bpf_printk("execve by %s (pid %d)", comm, bpf_get_current_pid_tgid() >> 32);
	return 0;
}

SEC()로 지정한 섹션 이름이 곧 부착 위치다. libbpf가 tracepoint/syscalls/sys_enter_execve를 보고 알아서 해당 tracepoint에 연결한다.

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include "hello.skel.h"

static volatile int stop;
static void on_sig(int sig) { stop = 1; }

int main(void)
{
	struct hello_bpf *skel;

	skel = hello_bpf__open_and_load();
	if (!skel) {
		fprintf(stderr, "open_and_load failed\n");
		return 1;
	}
	if (hello_bpf__attach(skel)) {
		fprintf(stderr, "attach failed\n");
		goto out;
	}
	signal(SIGINT, on_sig);
	printf("attached. cat /sys/kernel/tracing/trace_pipe\n");
	while (!stop)
		sleep(1);
out:
	hello_bpf__destroy(skel);
	return 0;
}

유저 공간 쪽은 스켈레톤이 만들어 준 hello_bpf__open_and_load(), __attach(), __destroy() 세 함수만 부르면 된다.

BPF_CLANG ?= clang
ARCH := $(shell uname -m | sed 's/x86_64/x86/;s/aarch64/arm64/')
APPS := hello execsnoop

.SECONDARY:

all: $(APPS)

%.bpf.o: %.bpf.c ../vmlinux.h
	$(BPF_CLANG) -g -O2 -target bpf -D__TARGET_ARCH_$(ARCH) -I.. -c $< -o $@

%.skel.h: %.bpf.o
	bpftool gen skeleton $< > $@

$(APPS): %: %.c %.skel.h
	$(CC) -g -O2 -Wall $< -o $@ -lbpf -lelf -lz

clean:
	rm -f *.o *.skel.h $(APPS)
$ make hello
clang -g -O2 -target bpf -D__TARGET_ARCH_x86 -I.. -c hello.bpf.c -o hello.bpf.o
bpftool gen skeleton hello.bpf.o > hello.skel.h
cc -g -O2 -Wall hello.c -o hello -lbpf -lelf -lz

.SECONDARY:가 없으면 make가 중간 산출물인 .bpf.o를 지워 버려 나중에 bpftool로 살펴볼 수 없다. 이제 root로 실행하고, 다른 터미널에서 PATH를 일부러 틀리게 준 명령을 실행한다.

# 터미널 1
sudo ./hello
# 터미널 2
sudo cat /sys/kernel/tracing/trace_pipe
# 터미널 3
env PATH=/nope1:/nope2:/usr/bin date
            bash-2370    [004] ...21  1358.366328: bpf_trace_printk: execve by env (pid 2370)
            bash-2370    [004] ...21  1358.366455: bpf_trace_printk: execve by env (pid 2370)
            bash-2370    [004] ...21  1358.366460: bpf_trace_printk: execve by env (pid 2370)

env가 /nope1/date, /nope2/date, /usr/bin/date 순서로 시도해서 세 번 찍혔다. sys_enter_execve는 시스템 콜 진입 시점이라 실패한 호출도 잡힌다.

bpftool로 커널 안의 프로그램 보기

로더가 떠 있는 동안 bpftool로 커널에 올라간 프로그램과 맵을 확인한다. 아래는 다음 절의 execsnoop을 띄워 둔 상태다.

$ sudo bpftool prog show name handle_exec
77: tracepoint  name handle_exec  tag a9c049e1d6dbbe60  gpl
	loaded_at 2026-09-19T15:51:28+0000  uid 0
	xlated 384B  jited 233B  memlock 4096B  map_ids 16
	btf_id 126

$ sudo bpftool map show name events
16: ringbuf  name events  flags 0x0
	key 0B  value 0B  max_entries 262144  memlock 275864B

$ sudo bpftool link show | grep -A2 tracepoint
	tracepoint sched_process_exec

xlated는 verifier를 통과한 뒤의 바이트코드 크기, jited는 JIT가 만든 x86 기계어 크기다. 변환된 바이트코드는 소스 줄과 함께 볼 수 있다.

$ sudo bpftool prog dump xlated id 77 | head -20
int handle_exec(struct trace_event_raw_sched_process_exec * ctx):
; int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
   0: (bf) r6 = r1
; struct task_struct *task = (struct task_struct *)bpf_get_current_task();
   1: (85) call bpf_get_current_task#-130816
   2: (bf) r8 = r0
; unsigned int off = ctx->__data_loc_filename & 0xFFFF;
   3: (61) r9 = *(u32 *)(r6 +8)
; e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
   4: (18) r1 = map[id:16]
   6: (b7) r2 = 92
   7: (b7) r3 = 0
   8: (85) call bpf_ringbuf_reserve#352720
...
  15: (b7) r1 = 2504
  16: (0f) r8 += r1

15번 명령의 2504가 task_struct 안에서 real_parent 필드의 오프셋이다. 이 숫자가 커널마다 어떻게 바뀌는지는 CO-RE 절에서 확인한다.

링 버퍼로 이벤트를 유저 공간에 전달하기

bpf_printk()는 디버깅용이라 모든 출력이 시스템 전역 트레이스 버퍼 하나로 섞인다. 실제 도구는 구조체 단위로 이벤트를 링 버퍼에 담아 유저 공간으로 보낸다.

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "GPL";

struct event {
	u32 pid;
	u32 ppid;
	u32 uid;
	char comm[16];
	char filename[64];
};

struct {
	__uint(type, BPF_MAP_TYPE_RINGBUF);
	__uint(max_entries, 256 * 1024);
} events SEC(".maps");

SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
	struct task_struct *task = (struct task_struct *)bpf_get_current_task();
	unsigned int off = ctx->__data_loc_filename & 0xFFFF;
	struct event *e;

	e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
	if (!e)
		return 0;

	e->pid  = bpf_get_current_pid_tgid() >> 32;
	e->ppid = BPF_CORE_READ(task, real_parent, tgid);
	e->uid  = bpf_get_current_uid_gid();
	bpf_get_current_comm(e->comm, sizeof(e->comm));
	bpf_probe_read_str(e->filename, sizeof(e->filename), (void *)ctx + off);

	bpf_ringbuf_submit(e, 0);
	return 0;
}

BPF_CORE_READ(task, real_parent, tgid)는 task->real_parent->tgid를 안전하게 읽으면서 각 필드 접근을 CO-RE 재배치 대상으로 기록한다. 이번에는 exec가 성공한 뒤에 발생하는 sched_process_exec tracepoint를 썼다.

#include <stdio.h>
#include <signal.h>
#include <bpf/libbpf.h>
#include "execsnoop.skel.h"

struct event {
	unsigned int pid, ppid, uid;
	char comm[16];
	char filename[64];
};

static volatile int stop;
static void on_sig(int sig) { stop = 1; }

static int on_event(void *ctx, void *data, size_t len)
{
	const struct event *e = data;

	printf("%-7u %-7u %-5u %-16s %s\n", e->pid, e->ppid, e->uid, e->comm, e->filename);
	return 0;
}

int main(void)
{
	struct execsnoop_bpf *skel;
	struct ring_buffer *rb = NULL;

	skel = execsnoop_bpf__open_and_load();
	if (!skel)
		return 1;
	if (execsnoop_bpf__attach(skel))
		goto out;

	rb = ring_buffer__new(bpf_map__fd(skel->maps.events), on_event, NULL, NULL);
	if (!rb)
		goto out;

	signal(SIGINT, on_sig);
	printf("%-7s %-7s %-5s %-16s %s\n", "PID", "PPID", "UID", "COMM", "FILENAME");
	while (!stop)
		ring_buffer__poll(rb, 100);
out:
	ring_buffer__free(rb);
	execsnoop_bpf__destroy(skel);
	return 0;
}
$ sudo ./execsnoop
PID     PPID    UID   COMM             FILENAME
2368    2361    0     sudo             /usr/bin/sudo
2369    2368    1000  bash             /usr/bin/bash
2370    2369    1000  env              /usr/bin/env
2370    2369    1000  date             /usr/bin/date
2371    2361    0     sleep            /usr/bin/sleep

같은 명령인데 env의 실패한 두 번의 시도는 보이지 않는다. PID 2370이 env에서 date로 바뀐 것은 exec가 PID를 유지한 채 프로그램만 교체하기 때문이다.

CO-RE: 한 번 컴파일해서 다른 커널에서 실행하기

task_struct의 필드 위치는 커널 버전과 설정마다 다르다. CO-RE(Compile Once – Run Everywhere)는 컴파일 시점의 오프셋을 기록해 두고 로드할 때 대상 커널의 BTF를 보고 고쳐 쓰며, 그 과정이 libbpf 디버그 로그에 남는다.

$ uname -r
6.8.0-139-generic
$ sudo bpftool -d prog load execsnoop.bpf.o /sys/fs/bpf/es 2>&1 | grep -iE "core|relo"
libbpf: sec 'tp/sched/sched_process_exec': found 3 CO-RE relocations
libbpf: CO-RE relocating [23] struct task_struct: found target candidate [82] struct task_struct in [vmlinux]
libbpf: prog 'handle_exec': relo #1: <byte_off> [23] struct task_struct.real_parent (0:96 @ offset 2504)
libbpf: prog 'handle_exec': relo #1: matching candidate #0 <byte_off> [82] struct task_struct.real_parent (0:96 @ offset 2504)
libbpf: prog 'handle_exec': relo #1: patched insn #15 (ALU/ALU64) imm 2504 -> 2504
libbpf: prog 'handle_exec': relo #2: patched insn #22 (ALU/ALU64) imm 2492 -> 2492

vmlinux.h를 이 커널에서 뽑았으니 2504 → 2504로 값이 그대로다. 같은 execsnoop.bpf.o와 execsnoop 바이너리를 다시 컴파일하지 않고 defconfig 기반으로 직접 빌드한 6.12 커널(QEMU)에서 실행했다.

$ uname -r
6.12.0
$ bpftool -d prog load execsnoop.bpf.o /sys/fs/bpf/es 2>&1 | grep -E "relo #[12]: patched"
libbpf: prog 'handle_exec': relo #1: patched insn #15 (ALU/ALU64) imm 2504 -> 1680
libbpf: prog 'handle_exec': relo #2: patched insn #22 (ALU/ALU64) imm 2492 -> 1668
$ ./execsnoop
shell pid 104
PID     PPID    UID   COMM             FILENAME
104     98      0     exe              /proc/self/exe
105     104     0     bpftool          /bin/bpftool
104     98      0     bpftool          /bin/bpftool

real_parent 오프셋이 1680으로 고쳐졌고, 자식 프로세스의 PPID가 셸 PID(104)와 정확히 일치한다(첫 줄은 busybox 셸이 /proc/self/exe를 다시 exec한 것). 6.8 커널의 오프셋은 BTF에서 직접 확인할 수 있다.

$ bpftool btf dump file /sys/kernel/btf/vmlinux format raw | grep -A200 "STRUCT 'task_struct' size" \
    | grep -E "task_struct. size|'real_parent'|'tgid'"
[82] STRUCT 'task_struct' size=13760 vlen=273
	'tgid' type_id=40 bits_offset=19936
	'real_parent' type_id=83 bits_offset=20032

비트 오프셋 20032를 8로 나누면 2504, 19936은 2492다.

verifier가 거부하는 코드

verifier는 로드 시점에 모든 실행 경로를 따라가며 메모리 접근이 안전하고 프로그램이 반드시 끝난다는 것을 증명한다. 흔히 걸리는 세 가지 실수를 한 파일에 #ifdef로 나눠 넣었다.

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

char LICENSE[] SEC("license") = "GPL";

struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 1024);
	__type(key, u32);
	__type(value, u64);
} counts SEC(".maps");

u64 table[16];

#ifdef BUG_NULL
SEC("tp/syscalls/sys_enter_openat")
int bug_null(void *ctx)
{
	u32 pid = bpf_get_current_pid_tgid() >> 32;
	u64 *val = bpf_map_lookup_elem(&counts, &pid);

	*val += 1;			/* NULL 검사 없음 */
	return 0;
}
#endif

#ifdef BUG_BOUNDS
SEC("tp/syscalls/sys_enter_openat")
int bug_bounds(void *ctx)
{
	u32 idx = bpf_get_prandom_u32();

	table[idx] += 1;		/* idx 범위 검사 없음 */
	return 0;
}
#endif

#ifdef BUG_LOOP
SEC("tp/syscalls/sys_enter_openat")
int bug_loop(void *ctx)
{
	u32 n = bpf_get_prandom_u32();
	u64 sum = 0;

	for (u32 i = 0; i < n; i++)	/* 상한이 런타임 값 */
		sum += table[i & 15];
	table[0] = sum;
	return 0;
}
#endif

#ifdef FIXED
SEC("tp/syscalls/sys_enter_openat")
int fixed(void *ctx)
{
	u32 pid = bpf_get_current_pid_tgid() >> 32;
	u32 idx = bpf_get_prandom_u32();
	u64 one = 1, *val;

	val = bpf_map_lookup_elem(&counts, &pid);
	if (val)
		__sync_fetch_and_add(val, 1);
	else
		bpf_map_update_elem(&counts, &pid, &one, BPF_NOEXIST);

	table[idx & 15] += 1;		/* 0~15로 제한 */

	u64 sum = 0;
	for (u32 i = 0; i < 64 && i < idx; i++)
		sum += table[i & 15];
	table[0] += sum;
	return 0;
}
#endif
for d in BUG_NULL BUG_BOUNDS BUG_LOOP FIXED; do
  clang -g -O2 -target bpf -D$d -I.. -c verif.bpf.c -o $d.o
  sudo bpftool prog load $d.o /sys/fs/bpf/verif_$d
done

맵 조회 결과의 NULL 검사 누락

; u64 *val = bpf_map_lookup_elem(&counts, &pid);
5: (18) r1 = 0xffff8e0083094000       ; R1_w=map_ptr(map=counts,ks=4,vs=8)
7: (85) call bpf_map_lookup_elem#1    ; R0=map_value_or_null(id=1,map=counts,ks=4,vs=8)
; *val += 1;			/* NULL 검사 없음 */
8: (79) r1 = *(u64 *)(r0 +0)
R0 invalid mem access 'map_value_or_null'
processed 8 insns (limit 1000000) max_states_per_insn 0 total_states 1 peak_states 1 mark_read 1

bpf_map_lookup_elem()의 반환 타입이 map_value_or_null로 추적되고 있어서, NULL 검사 없이 역참조하는 순간 거부된다.

범위를 모르는 배열 인덱스

; table[idx] += 1;		/* idx 범위 검사 없음 */
3: (67) r0 <<= 3                      ; R0_w=scalar(smin=0,smax=umax=0x7fffffff8,...)
4: (18) r1 = 0xffffcf94c04dc000       ; R1_w=map_value(map=BUG_BOUN.bss,ks=4,vs=128)
6: (0f) r1 += r0
7: (79) r2 = *(u64 *)(r1 +0)
R1 unbounded memory access, make sure to bounds check any such access

전역 배열 table은 .bss 맵(128바이트)으로 만들어지는데, 인덱스의 최댓값이 0x7fffffff8이라 범위를 벗어날 수 있다고 판단한다.

상한이 정해지지 않은 루프

12: (0f) r4 += r2                     ; R2_w=24 R4_w=map_value(map=BUG_LOOP.bss,ks=4,vs=128,off=24)
13: (79) r2 = *(u64 *)(r4 +0)
BPF program is too large. Processed 1000001 insn
processed 1000001 insns (limit 1000000) max_states_per_insn 4 total_states 9526 peak_states 9526 mark_read 2

5.3부터 유한 루프는 허용되지만, verifier는 루프를 실제로 펼쳐 가며 검증하므로 상한이 32비트 난수면 명령 100만 개 한도를 넘는다. 처음에는 루프 본문을 sum += i로 썼다가 clang이 루프를 n*(n-1)/2 계산으로 바꿔 버려 그대로 통과했고, 그래서 메모리를 읽도록 바꿨다.

수정한 버전

$ sudo bpftool prog load FIXED.o /sys/fs/bpf/fixed autoattach
$ sudo bpftool map dump name counts | head -7
[{
        "key": 17341,
        "value": 17
    },{
        "key": 17350,
        "value": 17
    },{
오류 메시지원인수정
invalid mem access 'map_value_or_null'맵 조회 결과 역참조 전 NULL 검사 없음if (val) 분기 추가
unbounded memory access인덱스 범위를 verifier가 알 수 없음idx & 15 또는 if (idx < 16)
BPF program is too large루프 상한이 런타임 값상수 상한 추가(i < 64 && i < idx), 또는 bpf_loop()

주의사항

항목내용
권한일반 사용자로 실행하면 Couldn't load trivial BPF program으로 실패한다. Ubuntu는 kernel.unprivileged_bpf_disabled = 2가 기본값이라 root 또는 CAP_BPF(+ 트레이싱은 CAP_PERFMON)가 필요하다.
bpf_printk()출력이 전역 trace_pipe 하나로 모이고 느리다. 디버깅용으로만 쓰고 실제 데이터는 맵이나 링 버퍼로 보낸다.
라이선스bpf_probe_read_str() 등 상당수 헬퍼는 GPL 호환 라이선스 프로그램에서만 호출할 수 있다. LICENSE 섹션을 빼먹으면 로드 단계에서 거부된다.
CO-RE 전제 조건대상 커널에 BTF(/sys/kernel/btf/vmlinux)가 있어야 재배치가 된다. 필드가 아예 없는 커널을 지원하려면 bpf_core_field_exists()로 분기한다.
tracepoint 선택sys_enter_*는 실패한 호출까지 잡고, sched_process_exec는 성공한 exec만 잡는다. 같은 “exec 추적”이라도 결과가 다르다.
JIT 역어셈블Ubuntu 패키지의 bpftool은 역어셈블러 없이 빌드돼 prog dump jited가 No JIT disassembly support로 끝난다. xlated는 정상 동작한다.

마무리

clang으로 BPF 오브젝트를 만들고, 스켈레톤으로 로드·부착하고, bpftool로 커널 안의 상태를 확인하는 흐름이 이 시리즈 전체의 기본 틀이다. CO-RE 덕분에 6.8에서 빌드한 바이너리가 6.12에서 그대로 돌았고, verifier 로그만 읽을 줄 알면 거부된 이유를 대부분 바로 찾을 수 있다. 다음 편에서는 커널과 유저 공간 사이의 통로인 BPF 맵과 링 버퍼를 종류별로 비교한다.

참고

답글 남기기