eBPF verifier가 보장하는 것과 보장하지 않는 것

반응형

malloc 호출을 eBPF로 추적한 실습 뒤에는 자연스럽게 이런 질문이 남았다.

kernel 안에서 실행되는 program을 사용자가 올릴 수 있다면, system을 망가뜨리지 않는다는 보장은 어디에서 오는가?

짧게 답하면 eBPF program은 load 단계에서 verifier의 검사를 받아야 하고, 허용된 program type·attach point·helper의 경계 안에서 실행된다. 하지만 이를 “verifier가 모든 위험을 막는다”거나 “eBPF는 관찰만 하므로 안전하다”로 줄이면 중요한 부분이 빠진다.

eBPF의 안전성은 정적 검증, 권한, kernel이 제공한 interface, 운영 통제가 함께 만드는 결과다.

load부터 실행까지의 경로

일반적인 흐름을 단순화하면 다음과 같다.

eBPF bytecode
      ↓ bpf() system call
kernel verifier
      ├─ 거부 → verifier log와 error
      └─ 승인
           ↓ interpreter 또는 JIT compilation
program type에 맞는 hook에 attach
           ↓
event가 발생할 때 kernel context에서 실행

libbpf를 사용하면 ELF object를 열고, map을 만들고, program을 load하고, hook에 attach하는 userspace 작업을 도와준다. CO-RE를 쓰는 경우에는 실행 중인 kernel의 BTF 정보를 이용해 type layout 차이에 맞춘 relocation도 수행한다.

여기서 verifier 통과는 “이 program이 의도대로 동작한다”는 인증이 아니다. kernel이 허용한 실행 model 안에서 실행 가능한지를 판정하는 gate에 가깝다.

verifier가 확인하는 대표적인 것

Linux kernel verifier 문서는 register와 stack slot의 상태를 추적하며 instruction path를 분석하는 방식을 설명한다. 실제 허용 범위는 kernel version, program type, helper와 instruction에 따라 달라지지만 핵심은 다음과 같다.

초기화되지 않은 값을 읽지 않는가

register와 stack 값은 사용 전에 초기화되어야 한다. verifier는 각 register가 scalar인지 pointer인지, 어떤 범위를 가질 수 있는지 추적한다.

R1: context pointer
R2: known scalar range
R3: map value pointer or NULL

map lookup처럼 실패할 수 있는 helper의 반환값은 NULL 여부를 확인한 뒤 역참조해야 한다.

struct value *value = bpf_map_lookup_elem(&counts, &key);
if (!value)
    return 0;

value->count++;

허용된 memory 범위 안에서 접근하는가

verifier는 stack, map value, packet data, context 같은 pointer의 type과 offset 범위를 추적한다. 증명할 수 없는 out-of-bounds 접근은 load 단계에서 거부한다.

packet parser에서는 datadata_end를 비교하는 code가 필요한 이유다.

void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
struct ethhdr *eth = data;

if ((void *)(eth + 1) > data_end)
    return XDP_ABORTED;

helper와 context를 program type에 맞게 쓰는가

모든 eBPF program이 같은 helper와 kernel context에 접근하는 것은 아니다. XDP, tracing, cgroup, LSM 등 program type마다 attach 위치와 가능한 return action, helper 집합이 다르다.

helper call에서는 argument type과 반환 type도 검사한다. 임의의 kernel function을 아무 signature로나 호출하는 model이 아니다. kfunc도 kernel이 BTF와 metadata를 통해 노출한 범위에서 사용한다.

실행이 끝난다는 것을 증명할 수 있는가

초기 eBPF 설명에는 “loop를 쓸 수 없다”는 문장이 자주 등장한다. 현재 kernel에서는 verifier가 종료와 complexity를 증명할 수 있는 bounded loop를 사용할 수 있다. helper 기반 iteration이나 open-coded iterator 같은 mechanism도 있다.

중요한 것은 loop의 존재 자체가 아니라 가능한 path가 유한하고 verifier가 이를 분석할 수 있는지다. 범위가 지나치게 크거나 path가 폭발하면 논리상 끝나는 code도 verifier complexity 제한 때문에 거부될 수 있다.

verifier가 주는 보장을 정확히 읽는다

verifier가 막으려는 것 verifier만으로 보장하지 않는 것
증명할 수 없는 pointer·범위 접근 business logic의 정답 여부
초기화되지 않은 register·stack 사용 관측 대상과 field가 정확한지
program type에서 허용하지 않은 helper·context 사용 CPU·memory·network 비용이 적절한지
종료를 증명할 수 없는 control flow 개인정보와 secret을 수집하지 않는지
일부 잘못된 helper argument와 type 사용 map key cardinality가 안전한지

따라서 verifier 승인 메시지는 memory-safety와 실행 model에 대한 중요한 방어선이지만 다음을 뜻하지 않는다.

  • program이 성능 저하를 만들지 않는다.
  • 수집한 data가 정확하다.
  • race나 logical bug가 없다.
  • security policy가 올바르다.
  • kernel, JIT, verifier, helper 구현에 취약점이 없다.

eBPF는 관찰만 하는 기술이 아니다

tracing program으로 event를 읽고 map이나 ring buffer에 기록하는 use case가 익숙해서 eBPF를 read-only 관측 도구로 오해하기 쉽다. 실제 효과는 program type과 helper가 정한다.

  • XDP와 traffic-control program은 packet을 drop, redirect하거나 허용된 범위에서 수정할 수 있다.
  • cgroup networking program은 socket·packet policy에 관여할 수 있다.
  • BPF LSM program은 security hook에서 접근을 허용하거나 거부할 수 있다.
  • map update는 kernel 안의 state를 바꾼다.

eBPF program이 임의의 kernel memory를 자유롭게 덮어쓰도록 허용되는 것은 아니다. 그렇다고 system behavior를 바꿀 수 없다는 뜻도 아니다. kernel이 program type과 helper로 허용한 효과 안에서는 실제 traffic과 policy에 영향을 줄 수 있다.

권한은 root 하나로 설명되지 않는다

과거에는 많은 BPF operation이 포괄적인 CAP_SYS_ADMIN에 묶여 있었다. Linux 5.8부터 CAP_BPF가 privileged BPF operation을 분리하기 위해 추가됐다. 하지만 실제 program을 load하고 attach하는 데 필요한 capability는 use case에 따라 더 있을 수 있다.

  • tracing·performance event 접근: CAP_PERFMON과 관련 설정
  • networking configuration: CAP_NET_ADMIN이 필요한 operation
  • BPF object load·map 관리: CAP_BPF 또는 호환을 위한 CAP_SYS_ADMIN

또한 다음 환경 차이가 작동한다.

  • kernel.unprivileged_bpf_disabled
  • kernel build configuration과 version
  • distribution의 security policy
  • LSM, lockdown mode와 container security context
  • namespace와 capability bounding set

현재 값은 다음처럼 읽을 수 있다.

sysctl kernel.unprivileged_bpf_disabled

kernel 문서에서 값 0은 unprivileged bpf() call 허용, 1은 되돌릴 수 없는 비활성화, 2는 administrator가 다시 변경할 수 있는 비활성화를 뜻한다. default는 kernel configuration에 따라 달라질 수 있으므로 숫자를 확인하지 않고 정책을 가정하면 안 된다.

container에 privileged: true를 주는 것은 필요한 capability만 설계하는 것보다 훨씬 넓은 권한을 연다. 도구가 실제로 필요로 하는 program type, attach point와 capability를 먼저 확인하는 편이 안전하다.

현실에서 남는 위험

verifier·JIT·helper도 kernel code다

verifier는 복잡한 static analyzer이고 JIT compiler와 helper도 kernel의 일부다. 이 구성요소의 취약점은 security issue가 될 수 있다. production kernel과 distribution security update를 적용해야 하는 이유다.

너무 뜨거운 hook은 작은 code도 비싸다

packet마다, system call마다, allocation마다 실행되는 program은 호출 빈도가 매우 높다. event 하나당 작은 비용도 전체 CPU 사용량에 영향을 줄 수 있다.

다음을 함께 관찰해야 한다.

  • program run count와 run time
  • ring buffer drop
  • map memory와 entry count
  • user-space consumer backlog
  • target workload의 latency와 CPU 변화

kernel의 kernel.bpf_stats_enabled를 켜면 program runtime 통계 수집 자체에도 overhead가 있다고 문서에 명시돼 있다. 진단 기능도 비용이 없는 것은 아니다.

map은 memory와 lifecycle을 가진다

BPF map은 kernel과 userspace 사이에서 data를 공유하는 핵심 primitive다. 동시에 type, maximum entries, key cardinality와 eviction policy를 잘못 잡으면 memory 압박이나 stale state를 만들 수 있다.

pinning된 map과 program은 loader process가 종료된 뒤에도 reference가 남을 수 있다. 배포·rollback 때 누가 생성하고 교체하고 제거하는지 lifecycle을 정해야 한다.

관측 data도 민감정보가 될 수 있다

argument, path, network payload, user identifier를 수집하면 debugging에는 도움이 되지만 privacy와 security 위험이 커진다. 가능한 field만 allowlist로 수집하고, kernel에서 user space로 보내기 전에 redaction·aggregation할 수 있는지 검토한다.

load 실패는 verifier log부터 읽는다

verifier가 program을 거부하면 무작정 instruction을 줄이기보다 log에서 어느 invariant를 증명하지 못했는지 본다.

자주 만나는 원인은 다음과 같다.

  • map lookup 뒤 NULL check 누락
  • packet의 data_end boundary check 누락
  • scalar range를 verifier가 충분히 좁히지 못함
  • program type에서 사용할 수 없는 helper 호출
  • stack limit 또는 instruction-path complexity 초과
  • kernel version과 BTF·helper 지원 차이

libbpf의 debug output이나 loader가 제공하는 verifier log를 함께 남기고, 실행 중인 kernel release와 object의 build 정보를 기록해야 재현이 쉽다.

production 적용 전 체크리스트

  1. 지원할 kernel·distribution version 범위를 명시했는가?
  2. program type과 attach point, return action을 검토했는가?
  3. 필요한 capability만 부여했는가?
  4. verifier log를 build·test 환경에서 보존하는가?
  5. hot path의 CPU overhead와 event loss를 부하 조건에서 측정했는가?
  6. map의 maximum entries, memory와 eviction policy를 정했는가?
  7. 수집 field에 credential·개인정보가 포함되지 않는가?
  8. program·link·pinned object의 upgrade와 rollback 절차가 있는가?
  9. kernel security update와 eBPF toolchain version을 추적하는가?

eBPF를 안전하다고 부를 수 있는 이유는 kernel module처럼 아무 code나 바로 실행하기 때문이 아니라, verifier와 type별 interface가 실행 가능 범위를 좁히기 때문이다. 동시에 그 범위 안에서도 packet을 바꾸고 policy를 적용하며 많은 resource를 쓸 수 있다.

다음 글인 eBPF 내부 구조eBPF hook 종류를 볼 때도 “어디에 attach하고 무엇을 허용받는가”를 함께 보면 기능과 위험을 더 정확히 읽을 수 있다.

참고 자료

반응형
KEEP READING
카테고리 전체 보기 →

댓글