[Linux Kernel eBPF] Analyzing Negative Index Handling in BPF CO-RE

배경

리눅스 커널의 eBPF 공부를 하기 위해서 관련 메일을 분석해봤다.

메일

원본 이메일은 여기서 볼 수 있다. -> lore.kernel.org

From: Weiming Shi <[email protected]>
Subject: [PATCH bpf] bpf: reject negative CO-RE accessor indices in bpf_core_parse_spec()

...

CO-RE accessor strings are colon-separated indices that describe a path
from a root BTF type to a target field, e.g. "0:1:2" walks through
nested struct members. bpf_core_parse_spec() parses each component with
sscanf("%d"), so negative values like -1 are silently accepted.  The
subsequent bounds checks (access_idx >= btf_vlen(t)) only guard the
upper bound and always pass for negative values. When -1 reaches
btf_member_bit_offset() it gets cast to u32 0xffffffff, producing an
out-of-bounds read far past the members array.

A crafted BPF program with a negative CO-RE accessor on any struct that
exists in vmlinux BTF (e.g. task_struct) crashes the kernel during
BPF_PROG_LOAD:

 BUG: unable to handle page fault for address: ffffed11818b6626
 #PF: supervisor read access in kernel mode
 #PF: error_code(0x0000) - not-present page
 PGD 7f74e067 P4D 7f74e067 PUD 0
 Oops: Oops: 0000 [#1] SMP KASAN NOPTI
 CPU: 0 UID: 0 PID: 85 Comm: poc Not tainted 7.0.0-rc6 #18 PREEMPT(full)
 Hardware name: QEMU Ubuntu 24.04 PC v2 (i440FX + PIIX, arch_caps fix, 1996), BIOS 1.16.3-debian-1.16.3-2 04/01/2014
 RIP: 0010:bpf_core_parse_spec (tools/lib/bpf/relo_core.c:348)
 RAX: 00000000ffffffff RBX: ffff88800c5b3128 RCX: 0000000000000000
 Call Trace:
  <TASK>
  bpf_core_calc_relo_insn (tools/lib/bpf/relo_core.c:1319)
  bpf_core_apply (kernel/bpf/btf.c:9507)
  bpf_check (kernel/bpf/verifier.c:26031)
  bpf_prog_load (kernel/bpf/syscall.c:3089)
  __sys_bpf (kernel/bpf/syscall.c:6228)
  __x64_sys_bpf (kernel/bpf/syscall.c:6339)
  do_syscall_64 (arch/x86/entry/syscall_64.c:94)
  </TASK>

CO-RE accessor indices are inherently non-negative (field index, array
index, or enumerator index), so reject them after parsing.

Fixes: ddc7c3042614 ("libbpf: implement BPF CO-RE offset relocation algorithm")
Reported-by: Xiang Mei <[email protected]>
igned-off-by: Weiming Shi <[email protected]>

마지막에 Signed-off-by에서 S가 빠진채로 메일을 보내셨다.

메일 내용 분석

먼저 CO-RE accessor strings 에 대해서 알아보자.

CO-RE accessor strings

CO-RE accessor strings는 이 필드에 접근이 원래 어떤 타입의 어느 내부 멤버를 가리켰는지를 표현하는 경로 문자열이다.

쉽게 설명하자면

task->mm->exe_file->f_inode->i_ino

처럼 생긴 task에서 i_ino까지 어떻게 들어갔는지를 저장하는 특정 필드까지의 접근 경로이다.

CO-RE: 소스 코드에 본 구조체 레이아웃과 실행 대상 커널의 구조체 레이아웃이 달라져도 동작하게 하려는 메커니즘

만드는 곳은 Clang의 __builtin_preserve_access_index()를 컴파일 할때 이다.

쓰는 곳은 libbpf가 대상 커널의 BTF와 대조해서 필드를 찾아낼 때 쓴다.

메일 내용에 따르면 "0:1:2" 처럼 생긴 것 같다.

bpf_core_parse_spec()

이 함수는 CO-RE relocation 하나에 들어있는 accessor string을 읽어서, libbpf가 실제로 따라갈 수 있는 내부 경로 표현으로 바꾸는 함수이다.

e.g.) "0:1:2" -> bpf_core_parse_spec() -> bpf_core_spec 내부의 데이터 채우기

근데 이 함수에서 각 숫자를 sscanf("%d") 로 파싱하는 바람에 -1같은 것들도 통과된다. 그리고 나중에하는 경계 검사에서 음수는 거르지 않기 때문에 결국은 통과되서 OOB read가 발생한다는 것이다.

이걸 실제 음수 CO-RE accessor string을 가지고 실험해본 것 같다.

실험 - 음수 CO-RE accessor string BPF 프로그램 만들기

계획: 정상 BPF 프로그램 -> 정교하게 바이너리 패치 -> 실험

정상 BPF 프로그램 제작

일단 먼저 bpftool로 vmlinux.h를 만든다.

bpftool btf dump file /path/to/vmlinux format c > vmlinux.h

bpf랑 btf관련 옵션 키고 다시 빌드했다.

vmlinux.h 뽑고나서는 아래 C코드를 컴파일 했다.

// task_core.bpf.c
// clang -O2 -g -target bpf -D__TARGET_ARCH_x86 \
  -I./libbpf/src \
  -I. \
  -c task_core.bpf.c -o task_core.bpf.o

#include "vmlinux.h"
#include "bpf_helpers.h"
#include "bpf_core_read.h"
#include "bpf_tracing.h"

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

struct event {
    __u32 pid;
    __u32 tgid;
    __u32 ppid;
    char comm[16];
};


struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 1 << 24);
} events SEC(".maps");

SEC("tp_btf/sched_switch")
int BPF_PROG(handle_switch)
{
    struct task_struct *task;
    struct task_struct *parent;
    struct event *e;

    task = (struct task_struct *)bpf_get_current_task_btf();

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

    e->pid  = BPF_CORE_READ(task, pid);
    e->tgid = BPF_CORE_READ(task, tgid);
    bpf_core_read_str(&e->comm, sizeof(e->comm), task->comm);

    parent = BPF_CORE_READ(task, real_parent);
    e->ppid = parent ? BPF_CORE_READ(parent, tgid) : 0;

    bpf_ringbuf_submit(e, 0);
    return 0;
}

헤더 오류가 나서 bpf관련 헤더는 libbpf git 레포를 가져와서 해결했다.

여기서 문제는 바이너리 패치를 하려면 어느 위치를 음수로 바꿔야하는지를 알아야하는데...

이걸 모른다고 생각해서 포기하려고 했다가 gpt 살살 달래서 정상 bpf에서 단순히 0:81같이 생긴 것들 0:-1같이 바꾸면 된다는 사실을 알아냈다.

# patch.py
from elftools.elf.elffile import ELFFile

with open("task_core.bpf.o", "rb") as f:
    data = bytearray(f.read())

with open("task_core.bpf.o", "rb") as f:
    elf = ELFFile(f)
    btf = elf.get_section_by_name(".BTF")
    if not btf:
        raise RuntimeError(".BTF not found")

    blob = btf.data()
    needle = b"0:81\x00"
    off = blob.find(needle)
    if off < 0:
        raise RuntimeError("accessor string not found")

    file_off = btf['sh_offset'] + off
    repl = b"0:-1\x00"
    data[file_off:file_off+len(repl)] = repl

with open("repro-neg.bpf.o", "wb") as f:
    f.write(data)
// loader.c
// cc -O2 -g loader.c -o loader -lbpf -lelf -lz
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <signal.h>
#include <unistd.h>
#include <bpf/libbpf.h>

static volatile sig_atomic_t stop;

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

struct event {
    __u32 pid;
    __u32 tgid;
    __u32 ppid;
    char comm[16];
};


static int libbpf_print_fn(enum libbpf_print_level level,
                           const char *format, va_list args)
{
    return vfprintf(stderr, format, args);
}

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

    printf("pid=%u tgid=%u ppid=%u comm=%s\n",
           e->pid, e->tgid, e->ppid, e->comm);
    return 0;
}


int main(void)
{
    struct bpf_object *obj = NULL;
    struct bpf_program *prog;
    struct bpf_link *link = NULL;
    struct ring_buffer *rb = NULL;
    int map_fd;
    int err;

    signal(SIGINT, on_sigint);
    signal(SIGTERM, on_sigint);

    libbpf_set_strict_mode(LIBBPF_STRICT_ALL);
    libbpf_set_print(libbpf_print_fn);

    obj = bpf_object__open_file("task_core.bpf.o", NULL);
    if (!obj) {
        fprintf(stderr, "failed to open BPF object\n");
        return 1;
    }

    err = bpf_object__load(obj);
    if (err) {
        fprintf(stderr, "failed to load BPF object: %d\n", err);
        goto cleanup;
    }

    prog = bpf_object__find_program_by_name(obj, "handle_switch");
    if (!prog) {
        fprintf(stderr, "failed to find program: handle_switch\n");
        err = -ENOENT;
        goto cleanup;
    }

    link = bpf_program__attach(prog);
    if (!link) {
        err = -errno;
        fprintf(stderr, "failed to attach program: %d\n", err);
        goto cleanup;
    }

    map_fd = bpf_object__find_map_fd_by_name(obj, "events");
    if (map_fd < 0) {
        err = map_fd;
        fprintf(stderr, "failed to find map fd: %d\n", err);
        goto cleanup;
    }

    /* ringbuf 소비는 선택 사항이지만, 안 읽으면 이벤트만 쌓입니다. */
    // rb = ring_buffer__new(map_fd, NULL, NULL, NULL);
    rb = ring_buffer__new(map_fd, handle_event, NULL, NULL);
    if (!rb) {
        err = -errno;
        fprintf(stderr, "failed to create ring buffer: %d\n", err);
        goto cleanup;
    }

    printf("program loaded and attached\n");

    while (!stop) {
        err = ring_buffer__poll(rb, 100 /* ms */);
        if (err == -EINTR)
            break;
        if (err < 0) {
            fprintf(stderr, "ring_buffer__poll failed: %d\n", err);
            goto cleanup;
        }
    }

    err = 0;

cleanup:
    ring_buffer__free(rb);
    bpf_link__destroy(link);
    bpf_object__close(obj);
    return err != 0;
}

해본 결과 커널 크래시는 아니고 libbpf 크래시다. 라고 생각하고 메일을 다시 한번 더 봤는데 누가봐도 저 메일은 커널 크래시였다. ㅜㅜ

libbpf: sec 'tp_btf/sched_switch': found 4 CO-RE relocations
libbpf: CO-RE relocating [13] struct task_struct: found target candidate [136253] struct task_struct in [vmlinux]
libbpf: prog 'handle_switch': relo #0: <byte_off> [13] struct task_struct.pid (0:80 @ offset 1456)
libbpf: prog 'handle_switch': relo #0: matching candidate #0 <byte_off> [136253] struct task_struct.pid (0:80 @ offset 1456)
libbpf: prog 'handle_switch': relo #0: patched insn #9 (ALU/ALU64) imm 1456 -> 1456
loader[70]: segfault at 55f4b45f4e68 ip 00007f320e693a6d sp 00007ffc5e7211c0 error 4 in libbpf.so.1[42a6d,7f320e65a000+45000]
Segmentation fault

아니 여기서 어느 함수에서 터지는 지랑 왜 저 코드가 문제가 됐는지랑 어떻게 고쳤는지를 확인해봐야하는데 qemu안의 gdbserver랑 호스트의 gdb랑 안 붙는다 ㅜㅜ

이리저리 GPT랑 씨름하다가 붙였다 !!!

gef> bt
#0  0x00007f83fa2ee665 in btf_member_bit_offset (t=0x556b18f35ec0, member_idx=0xffffffff)
    at /home/rand/nomads/kernel-dev/kernel-lab/bpf/libbpf/src/btf.h:627
#1  0x00007f83fa2ef09b in bpf_core_parse_spec (prog_name=0x556b18f2db30 "handle_switch", btf=0x556b18f35d50, relo=0x556b18f3e65c,
    spec=0x7fff5da89e90) at relo_core.c:348
#2  0x00007f83fa2f164b in bpf_core_calc_relo_insn (prog_name=0x556b18f2db30 "handle_switch", relo=0x556b18f3e65c, relo_idx=0x1,
    local_btf=0x556b18f35d50, cands=0x556b18f2dd10, specs_scratch=0x7fff5da89e90, targ_res=0x7fff5da8ae90) at relo_core.c:1319
#3  0x00007f83fa2bc1c0 in bpf_core_resolve_relo (prog=0x556b18f2da10, relo=0x556b18f3e65c, relo_idx=0x1, local_btf=0x556b18f35d50,
    cand_cache=0x556b18f2b5b0, targ_res=0x7fff5da8ae90) at libbpf.c:6022
#4  0x00007f83fa2bc53c in bpf_object__relocate_core (obj=0x556b18f2b310, targ_btf_path=0x0) at libbpf.c:6115
#5  0x00007f83fa2bef77 in bpf_object__relocate (obj=0x556b18f2b310, targ_btf_path=0x0) at libbpf.c:7379
#6  0x00007f83fa2c33c7 in bpf_object_prepare (obj=0x556b18f2b310, target_btf_path=0x0) at libbpf.c:8921
#7  0x00007f83fa2c355b in bpf_object_load (obj=0x556b18f2b310, extra_log_level=0x0, target_btf_path=0x0) at libbpf.c:8960
#8  0x00007f83fa2c36db in bpf_object__load (obj=0x556b18f2b310) at libbpf.c:8996
#9  0x0000556b149de1c4 in main () at repro-loader.c:61
#10 0x00007f83f9fb0b57 in ?? () from target:/lib64/libc.so.6
#11 0x00007f83f9fb0c15 in __libc_start_main () from target:/lib64/libc.so.6
#12 0x0000556b149de3c5 in _start ()

이게 libbpf가 터진 직후의 back trace이다.

메일에서 봤던 bpf_core_calc_relo_insn 가 보인다. 😀

자 이제 천천히 코드를 따라가보자.

코드를 보고 버그의 작동 원리에 대해서 알아보자

일단 bt의 역순을 따라가보자.

// repro-loader.c
    err = bpf_object__load(obj);
    if (err) {
        fprintf(stderr, "failed to load BPF object: %d\n", err);
        goto cleanup;
    }

위 파일이 내가 만든 조작된 bpf프로그램 로더 코드다.

직접 libbpf의 bpf_object__load를 부르고 있다.

// libbpf.c
int bpf_object__load(struct bpf_object *obj)
{
	return bpf_object_load(obj, 0, NULL);
}

단순하게 bpf_object_load를 부른다.

// libbpf.c
static int bpf_object_load(struct bpf_object *obj, int extra_log_level, const char *target_btf_path)
{
    ...

	if (obj->state < OBJ_PREPARED) {
		err = bpf_object_prepare(obj, target_btf_path);
		if (err)
			return libbpf_err(err);
	}
	err = bpf_object__load_progs(obj, extra_log_level);
	err = err ? : bpf_object_init_prog_arrays(obj);
	err = err ? : bpf_object_prepare_struct_ops(obj);

    ...
}

여기서 bpf_object_prepare를 보자.

// libbpf.c
static int bpf_object_prepare(struct bpf_object *obj, const char *target_btf_path)
{   
    ...

	err = err ? : bpf_object__relocate(obj, obj->btf_custom_path ? : target_btf_path);
	err = err ? : bpf_object__sanitize_and_load_btf(obj);

    ...
	
    if (err) {
		bpf_object_unpin(obj);
		bpf_object_unload(obj);
		obj->state = OBJ_LOADED;
		return err;
	}
}

이 함수는 ELF에서 읽어온 BPF 오브젝트를 전처리해 두고, 커널에 올릴 준비 완료 상태로 만드는 함수이다.

그중에서 bpf_object__relocate가 컴파일 시점의 심볼, 오프셋 등을 지금 실행 중인 커널에 맞게 고쳐주는 함수이다.

// libbpf.c
static int bpf_object__relocate(struct bpf_object *obj, const char *targ_btf_path)
{
    ...

	if (obj->btf_ext) {
		err = bpf_object__relocate_core(obj, targ_btf_path);
		if (err) {
			pr_warn("failed to perform CO-RE relocations: %s\n",
				errstr(err));
			return err;
		}
		bpf_object__sort_relos(obj);
	}

    ...
}

이 함수가 맨처음으로 하는 것은 obj에 .BTF.ext가 있다면 CO-RE relocation을 하는 것이다.

// libbpf.c
static int
bpf_object__relocate_core(struct bpf_object *obj, const char *targ_btf_path)
{
    ...
    
    err = bpf_core_resolve_relo(prog, rec, i, obj->btf, cand_cache, &targ_res);
    if (err) {
        pr_warn("prog '%s': relo #%d: failed to relocate: %s\n",
            prog->name, i, errstr(err));
        goto out;
    }

    err = bpf_core_patch_insn(prog->name, insn, insn_idx, rec, i, &targ_res);
    if (err) {
        pr_warn("prog '%s': relo #%d: failed to patch insn #%u: %s\n",
            prog->name, i, insn_idx, errstr(err));
        goto out;
    }

    ...
}

이 함수는 BPF 프로그램이 참조하는 커널 타입,필드,enum 등의 정보가 현재 타깃 커널에서는 어디에 해당하는지 계산해서, 해당 BPF instruction을 실제 값으로 고쳐 넣는 함수다.

// libbpf.c
static int bpf_core_resolve_relo(...)
{
    ...

	return bpf_core_calc_relo_insn(prog_name, relo, relo_idx, local_btf, cands, specs_scratch,
				       targ_res);
}

이 함수는 로컬 BTF에서 relocation 대상 타입을 찾고, 최종 relocation 값을 계산하는 함수다.

// relo_core.c
int bpf_core_calc_relo_insn(...)
{
    ...

	local_id = relo->type_id;
	local_type = btf_type_by_id(local_btf, local_id);
	local_name = btf__name_by_offset(local_btf, local_type->name_off);
	if (!local_name)
		return -EINVAL;

	err = bpf_core_parse_spec(prog_name, local_btf, relo, local_spec);
	if (err) {
		const char *spec_str;

		spec_str = btf__name_by_offset(local_btf, relo->access_str_off);
		pr_warn("prog '%s': relo #%d: parsing [%d] %s %s + %s failed: %d\n",
			prog_name, relo_idx, local_id, btf_kind_str(local_type),
			str_is_empty(local_name) ? "<anon>" : local_name,
			spec_str ?: "<?>", err);
		return -EINVAL;
	}

    ...
}

이 함수는 최종 relocation값을 확정짓는 함수다.

bpf_core_parse_spec을 통해서 relocation access string을 spec으로 파싱한다.

// relo_core.c
/*
 * Turn bpf_core_relo into a low- and high-level spec representation,
 * validating correctness along the way, as well as calculating resulting
 * field bit offset, specified by accessor string. Low-level spec captures
 * every single level of nestedness, including traversing anonymous
 * struct/union members. High-level one only captures semantically meaningful
 * "turning points": named fields and array indicies.
 * E.g., for this case:
 *
 *   struct sample {
 *       int __unimportant;
 *       struct {
 *           int __1;
 *           int __2;
 *           int a[7];
 *       };
 *   };
 *
 *   struct sample *s = ...;
 *
 *   int x = &s->a[3]; // access string = '0:1:2:3'
 *
 * Low-level spec has 1:1 mapping with each element of access string (it's
 * just a parsed access string representation): [0, 1, 2, 3].
 *
 * High-level spec will capture only 3 points:
 *   - initial zero-index access by pointer (&s->... is the same as &s[0]...);
 *   - field 'a' access (corresponds to '2' in low-level spec);
 *   - array element #3 access (corresponds to '3' in low-level spec).
 *
 * Type-based relocations (TYPE_EXISTS/TYPE_MATCHES/TYPE_SIZE,
 * TYPE_ID_LOCAL/TYPE_ID_TARGET) don't capture any field information. Their
 * spec and raw_spec are kept empty.
 *
 * Enum value-based relocations (ENUMVAL_EXISTS/ENUMVAL_VALUE) use access
 * string to specify enumerator's value index that need to be relocated.
 */
int bpf_core_parse_spec(const char *prog_name, const struct btf *btf,
			const struct bpf_core_relo *relo,
			struct bpf_core_spec *spec)
{
	int access_idx, parsed_len, i;
	struct bpf_core_accessor *acc;
	const struct btf_type *t;
	const char *name, *spec_str;
	__u32 id, name_off;
	__s64 sz;

	spec_str = btf__name_by_offset(btf, relo->access_str_off);
	if (str_is_empty(spec_str) || *spec_str == ':')
		return -EINVAL;

	memset(spec, 0, sizeof(*spec));
	spec->btf = btf;
	spec->root_type_id = relo->type_id;
	spec->relo_kind = relo->kind;

	/* type-based relocations don't have a field access string */
	if (core_relo_is_type_based(relo->kind)) {
		if (strcmp(spec_str, "0"))
			return -EINVAL;
		return 0;
	}

	/* parse spec_str="0:1:2:3:4" into array raw_spec=[0, 1, 2, 3, 4] */
	while (*spec_str) {
		if (*spec_str == ':')
			++spec_str;
		if (sscanf(spec_str, "%d%n", &access_idx, &parsed_len) != 1)
			return -EINVAL;
		if (spec->raw_len == BPF_CORE_SPEC_MAX_LEN)
			return -E2BIG;
		spec_str += parsed_len;
		spec->raw_spec[spec->raw_len++] = access_idx;
	}

	if (spec->raw_len == 0)
		return -EINVAL;

	t = skip_mods_and_typedefs(btf, relo->type_id, &id);
	if (!t)
		return -EINVAL;

	access_idx = spec->raw_spec[0];
	acc = &spec->spec[0];
	acc->type_id = id;
	acc->idx = access_idx;
	spec->len++;

	if (core_relo_is_enumval_based(relo->kind)) {
		if (!btf_is_any_enum(t) || spec->raw_len > 1 || access_idx >= btf_vlen(t))
			return -EINVAL;

		/* record enumerator name in a first accessor */
		name_off = btf_is_enum(t) ? btf_enum(t)[access_idx].name_off
					  : btf_enum64(t)[access_idx].name_off;
		acc->name = btf__name_by_offset(btf, name_off);
		return 0;
	}

	if (!core_relo_is_field_based(relo->kind))
		return -EINVAL;

	sz = btf__resolve_size(btf, id);
	if (sz < 0)
		return sz;
	spec->bit_offset = access_idx * sz * 8;

	for (i = 1; i < spec->raw_len; i++) {
		t = skip_mods_and_typedefs(btf, id, &id);
		if (!t)
			return -EINVAL;

		access_idx = spec->raw_spec[i];
		acc = &spec->spec[spec->len];

		if (btf_is_composite(t)) {
			const struct btf_member *m;
			__u32 bit_offset;

			if (access_idx >= btf_vlen(t))
				return -EINVAL;

			bit_offset = btf_member_bit_offset(t, access_idx);
			spec->bit_offset += bit_offset;

			m = btf_members(t) + access_idx;
			if (m->name_off) {
				name = btf__name_by_offset(btf, m->name_off);
				if (str_is_empty(name))
					return -EINVAL;

				acc->type_id = id;
				acc->idx = access_idx;
				acc->name = name;
				spec->len++;
			}

			id = m->type;
		} else if (btf_is_array(t)) {
			const struct btf_array *a = btf_array(t);
			bool flex;

			t = skip_mods_and_typedefs(btf, a->type, &id);
			if (!t)
				return -EINVAL;

			flex = is_flex_arr(btf, acc - 1, a);
			if (!flex && access_idx >= a->nelems)
				return -EINVAL;

			spec->spec[spec->len].type_id = id;
			spec->spec[spec->len].idx = access_idx;
			spec->len++;

			sz = btf__resolve_size(btf, id);
			if (sz < 0)
				return sz;
			spec->bit_offset += access_idx * sz * 8;
		} else {
			pr_warn("prog '%s': relo for [%u] %s (at idx %d) captures type [%d] of unexpected kind %s\n",
				prog_name, relo->type_id, spec_str, i, id, btf_kind_str(t));
			return -EINVAL;
		}
	}

	return 0;
}

이 함수가 중요하다.

먼저 큰 그림 부터 그리자.

bpf_core_relo는 BPF명령의 어떤 타입/필드/enum정보를 로드 시점에 대상 커널에 맞게 다시 계산하는 relocation 기록이다.

.BTF.ext를 분석해서 현재 커널의 BTF를 보고 값을 다시 계산하는 것이다.


bpf_core_relo는 총 3종류이다.

  1. field-based relocation: 이 필드가 어디에 몇바이트로 존재하냐?
  2. type-based relocation: 타입 자체
  3. enum value-based relocation: enum 멤버 index가 어디에 존재하나?

다음으로 이 함수의 입출력을 보자.

input

  1. btf: 타입 정보
  2. relo: relocation 할 소스
  3. spec: 결과 저장 구조체

output

  1. raw_spec: access string을 숫자 배열로 파싱한 저수준 표현
  2. spec[]: 의미있는 지점만 뽑은 고수준 표현
  3. bit_offset: 최종 비트 오프셋

그림으로도 보자

struct sample {
    int __unimportant;
    struct {
        int __1;
        int __2;
        int a[7];
    };
};

struct sample *s = ...;
int x = &s->a[3]; // access string = '0:1:2:3'

위의 내용이 핵심이다.

이걸 그림으로 나타내면

+---------------------------+   struct sample
| __unimportant             |   field #0
+---------------------------+
| anonymous struct          |   field #1
|   +-------------------+   |
|   | __1               |   |   field #0
|   +-------------------+   |
|   | __2               |   |   field #1
|   +-------------------+   |
|   | a[0]              |   |   field #2
|   | a[1]              |   |
|   | a[2]              |   |
|   | a[3]   <--- here  |   |
|   | ...               |   |
|   +-------------------+   |
+---------------------------+

access string "0:1:2:3" 뜻은:

  • 0 : s[0] (s->... 는 사실상 s[0].... 과 같음)
  • 1 : struct sample의 field #1 → anonymous struct
  • 2 : 그 anonymous struct의 field #2 → a
  • 3 : 배열 a[3]

0:1:2:3
│ │ │ └─ array index 3
│ │ └─── field index 2 (a)
│ └───── field index 1 (anonymous struct)
└─────── root pointer first element

여기서 저수준이란

raw_spec = [0, 1, 2, 3]

이거이고 고수준이란 아래와 같이 의미있는 것만 남긴 것이다.

high-level spec = [
  root[0],
  field a,
  array[3]
]

함수를 요약하면 이렇다.

  1. access string 꺼냄
  2. spec 초기화
  3. type-based relocation이면 특수 처리
  4. "0:1:2:3" -> raw_spec[] 파싱
  5. root type 정리(typedef/mod 제거)
  6. enum 기반 relocation이면 특수 처리
  7. field 기반 relocation이면 타입을 실제로 따라 내려감
    • struct/union member 접근
    • array index 접근
    • bit offset 누적
    • 의미 있는 accessor 기록
  8. spec 완성

문제는 4번에서 생긴다.

아래가 4번 코드이다.

/* parse spec_str="0:1:2:3:4" into array raw_spec=[0, 1, 2, 3, 4] */
while (*spec_str) {
    if (*spec_str == ':')
        ++spec_str;
    if (sscanf(spec_str, "%d%n", &access_idx, &parsed_len) != 1)
        return -EINVAL;
    if (spec->raw_len == BPF_CORE_SPEC_MAX_LEN)
        return -E2BIG;
    spec_str += parsed_len;
    spec->raw_spec[spec->raw_len++] = access_idx;
}

변수:

  • sepc_str: "0:1:2:3:4" 저장하는 문자열
  • access_idx: 추출한 각 idx 임시 저장소
  • parsed_len: sscanf으로 입력받은 수
  • spec->raw_len: raw_spec 배열의 길이
  • spec->raw_spec: [0, 1, 2, 3, 4] 같은 저수준 배열
  • BPF_CORE_SPEC_MAX_LEN: 64 기본값

여기서 문제는 %d로 읽고 있기에 spec_str에 "0:-1"와 같이 음수를 넣는다면 spec->raw_sepc에 음수가 들어간다는 것이다. (spec_raw_spec은 int 배열이다)

음수가 들어가면 OOB가 난다.

이유는 다음과 같다.

  • enum 타입일 경우에는 아래와 같이 btf의 길이보다 큰것만 검사하므로 통과된다.
if (!btf_is_any_enum(t) || spec->raw_len > 1 || access_idx >= btf_vlen(t))
    return -EINVAL;
  • field일 경우 아래와 같이 btf의 길이보다 큰것만 검사해서 통과하게 된다.
if (access_idx >= btf_vlen(t))
    return -EINVAL;

bit_offset = btf_member_bit_offset(t, access_idx);

그리고 btf_member_bit_offset에서는 인자가 __u32이기 때문에 음수가 변환되면서 의도치 않은 값이 들어간다.

// btf.h
static inline __u32 btf_member_bit_offset(const struct btf_type *t,
					  __u32 member_idx)
{
	const struct btf_member *m = btf_members(t) + member_idx;
	bool kflag = btf_kflag(t);

	return kflag ? BTF_MEMBER_BIT_OFFSET(m->offset) : m->offset;
}
  • __u32타입인 bit_offset에 음수값을 대입하게 되면서 아래와 같이 의도하지 않은 값이 bit_offset에 들어간다.
  • 그 후 그 주소에 접근하게 되면서 segmentation fault가 터진다.
624         const struct btf_member *m = btf_members(t) + member_idx;
625         bool kflag = btf_kflag(t);
626   
        // m = 0x00007ffd8d4792b8  ->  0x0000563b994b5ec0
-> 627         return kflag ? BTF_MEMBER_BIT_OFFSET(m->offset) : m->offset;
628   }

패치

패치는 access_idx를 받은 직후 음수인지 검사하는 것으로 만들어 놓으셨다.

        ++spec_str;
    if (sscanf(spec_str, "%d%n", &access_idx, &parsed_len) != 1)
        return -EINVAL;
+	if (access_idx < 0)
+		return -EINVAL;
    if (spec->raw_len == BPF_CORE_SPEC_MAX_LEN)
        return -E2BIG;
    spec_str += parsed_len;

느낀 점

이 버그를 분석하면서 CO-RE에 대해서 많이 공부하게 된 것 같다.

환경 세팅이 쉽지 않았다.

다른 코드들을 보면서 비슷한 오류가 있나 찾아봤지만 못 찾았다.

이 버그를 악용해서 libbpf의 파싱을 잘해서 쉘을 딴다고 해도 어차피 libbpf를 쓰려면 대부분 이미 root이기 때문에 흠... 딱히 악용은 어려울 것 같다.