[Learning eBPF] eBPF 관찰 코드는 무엇을 남기고 정보흐름 강제 엔진으로 교체됐나

bocopile·2026년 9월 5일

Learning-eBPF

목록 보기
11/14
post-thumbnail

eunomia-bpf/agentsight와 eunomia-bpf/ActPlane에는 이름이 같은 bpf/process.bpf.c가 있다.
AgentSight 쪽은 327줄이고, ActPlane 쪽은 3871줄이다. 이 글은 두 질문에 답한다.

  1. ActPlane은 AgentSight 저장소에서 무엇을 물려받았고, 이름이 같은 process.bpf.c는 코드까지 같은가?
  2. 두 프로젝트 모두 커널 이벤트를 JSON 한 줄로 유저스페이스에 내보내는데, "관찰"과 "강제"의 차이는 소스 코드의 어디에서 생기는가?

두 저장소를 읽게 된 계기는 필자가 만들고 있는 bocopile/AgentTaint다.
에이전트의 정보흐름을 eBPF로 다루는 선행 구현이 어떻게 짜였는지 알아야 해서 이 두 프로젝트를 골랐다.
이 글은 두 프로젝트 자체만 다루고 AgentTaint와의 비교는 하지 않는다.

두 저장소를 직접 clone해서 bpf/*.bpf.c의 실제 SEC() 훅, Rust 쪽 실제 구조체와 함수를 읽고 답한다. AgentSight를 먼저, ActPlane을 그다음 순서로 읽고, 각 프로젝트는 전체 아키텍처를 먼저 본 뒤 코드로 내려간다.

독자 전제는 eBPF의 uprobe/uretprobe, tracepoint, LSM 훅이 무엇인지 알고 C와 Rust 구조체를 읽을 수 있는 정도다. 커널 내부나 verifier 동작은 몰라도 된다.

검증 범위

이 글은 Linux에서 실행한 결과가 아니라 특정 커밋의 소스 코드를 읽은 정적 분석이다.
macOS 환경이라 eBPF를 로드하지 못했다. 그래서 이 글의 "확인"은 전부 "소스에서 읽어 확인"이고, "실행으로 검증"한 것은 없다.

본문에서는 실행으로 확인한 것처럼 읽힐 위험이 있는 문장에만 "소스 확인"이나 "미확인"을 따로 붙인다.
훅 개수 같은 숫자는 전부 소스를 직접 세어 나온 값이다.

저장소고정 커밋날짜커밋 메시지
eunomia-bpf/agentsight934f4412026-08-25chore: release agentsight 1.0.30 [skip ci]
eunomia-bpf/ActPlanecd865b72026-09-03docs:-update-AgPlane-paper-submodule

이 글의 숫자는 같은 커밋을 받아 아래 명령으로 다시 셀 수 있다. 주석의 값이 이 글에서 쓰는 개수다.

git clone https://github.com/eunomia-bpf/agentsight && git -C agentsight checkout 934f441e
git clone https://github.com/eunomia-bpf/ActPlane  && git -C ActPlane  checkout cd865b72

grep -c '^SEC('        agentsight/bpf/sslsniff.bpf.c   # 12  SSL BPF 프로그램
grep -c '^SEC('        agentsight/bpf/process.bpf.c    # 5   프로세스 훅
grep -c '^SEC('        agentsight/bpf/stdiocap.bpf.c   # 4   stdio 훅 (이 글은 읽지 않음)
grep -c 'probe_SSL_rw_enter' agentsight/bpf/sslsniff.c # 11  진입 프로그램 하나가 붙는 자리 수
grep -c '^SEC("lsm/'   ActPlane/bpf/process.bpf.c      # 14  LSM 훅
grep -c '^SEC("tp/'    ActPlane/bpf/process.bpf.c      # 76  tracepoint
wc -l < ActPlane/bpf/process.bpf.c                     # 3871

간단 요약

AgentSight는 관찰 전용 eBPF 프레임워크다. 이 글이 읽은 두 파일의 BPF 프로그램 17개(SSL 12 + 프로세스 5. stdio 캡처용 stdiocap.bpf.c의 4개는 따로 있다)가 잡은 이벤트를 필터만 거쳐 전부 JSON으로 stdout에 찍고, Rust 쪽이 Event 구조체로 통일해 분석기·SQLite·웹 UI로 보낸다.

ActPlane은 이 저장소의 스냅숏에서 출발해 관찰 코드를 들어내고, eBPF 프로그램 중 bpf/process.bpf.c 하나만 남긴 자리에 LSM 훅 14개 + tracepoint 76개짜리 정책 엔진을 넣었다. 커널 안에서 라벨을 전파하고 규칙과 대조해 매치된 위반만 emit_violation()으로 내보낸다. 정책 DSL은 Rust 컴파일러가 C 구조체와 바이트 단위로 같은 바이트 덩어리(blob)로 만들어 커널 배열 맵에 그대로 넣는다.

두 프로젝트가 갈라지는 지점은 "무엇을 내보내는가"다. 관찰을 목적으로 이벤트를 수집하던 구조가, 같은 이벤트를 정보흐름 정책의 판단 입력으로 쓰는 구조로 바뀌었다. 아래 그림은 두 파이프라인이 커널 이벤트에서 출발해 어디서 갈라지는지를 보여준다. 두 프로젝트가 같이 잡는 이벤트는 exec/exit/open과 read/write이고 나머지는 각자 다른 지점을 잡는다. 화살표는 데이터가 흘러가는 순서다.

관찰에서 강제로: 커널 이벤트를 받아 무엇을 내보내는가

읽는 법은 이렇다.

  1. 두 열의 1번 카드는 둘 다 커널 eBPF 훅이다. 개수(17 vs 90)보다 그 뒤에 무엇이 오는지를 본다.
  2. 왼쪽은 2번부터 전부 유저스페이스다. 커널은 잡기만 하고, 해석은 밖에서 한다.
  3. 오른쪽은 판정까지 커널 안이다. 라벨 전파, 규칙 대조, 판정이 전부 커널 안에서 끝나고, 4번 카드 끝의 로더 출력만 유저스페이스다. 밖으로는 매치된 위반만 나간다.

먼저 바로잡아야 할 표현

"ActPlane은 AgentSight를 참고해서 새로 만든 프로젝트다"는 부정확하다. ActPlane 저장소의 CLAUDE.md는 이렇게 적었다.

"The repo descends from AgentSight (an eBPF observability framework); the SSL/HTTP analyzer chain, runners, web server, and frontend were removed. What remains is the labeled information-flow engine plus a minimal Rust compiler/driver."

저장소 자체가 AgentSight에서 갈라져 나왔다. 다만 GitHub fork처럼 git 히스토리를 공유하지는 않는다. ActPlane의 초기 커밋 96cd9b2c는 AgentSight 히스토리에 없는 객체이고, 그 안의 bpf/process.bpf.c는 AgentSight 파일의 이전 버전이다. 스냅숏에서 출발한 것이다.

그래서 이 글은 먼저 AgentSight의 "지워진 부분"이 실제로 무엇을 하던 코드였는지 보고, 그다음 ActPlane에 "남고 새로 생긴 부분"을 본다. 아래 그림이 그 세 갈래를 한 장에 놓은 것이다. 두 프로젝트를 같은 계층(커널 eBPF, C 로더, Rust 크레이트)에 나란히 두고, 지워진 것은 흐리게, 이어진 것은 회색 실선, 새로 생긴 것은 초록 굵은 테두리로 표시했다.

AgentSight와 ActPlane 아키텍처 한 장: 무엇이 지워지고, 이어지고, 새로 생겼나

같은 층끼리 비교하면 유저스페이스 Rust 층은 통째로 교체됐고, C 로더 층과 커널 층은 process.c·process.bpf.c라는 이름과 "ring buffer를 읽어 JSON 한 줄을 찍는" 패턴만 이어졌다. 그 이름 안의 코드가 어떻게 달라졌는지가 이 글의 나머지다.

한 장면으로 보는 차이: .env를 읽은 에이전트가 외부에 연결할 때

코드로 내려가기 전에, 같은 행동 하나를 두 프로젝트가 각각 어떻게 다루는지 먼저 본다. 장면은 ActPlane docs/rule-language.md의 E1 시나리오다. 에이전트가 디버깅 중에 .env를 읽고, 같은 작업 안에서 다른 도구가 외부 연결을 연다.

아래 JSON과 메시지는 두 로더의 printf 포맷과 런타임의 메시지 템플릿을 소스에서 그대로 옮기고 값만 가상으로 채운 것이다. macOS라 실행하지 못했으므로 실제 출력이 아니다.

실제 출력은 줄바꿈이 없는 한 줄인데, 아래에서는 필드마다 줄을 나눠 실었다. 필드 이름과 값, 순서는 그대로다.

AgentSight: 두 사건을 두 줄로 기록한다

sudo ./agentsight record -- claude로 Claude Code를 띄우면(CLAUDE.md L45) 다음이 일어난다.

  1. Claude Code가 .env를 연다. process.bpf.c의 tp/syscalls/sys_enter_openat이 잡고, 로더 process.c가 JSON 한 줄을 찍는다(필드는 print_file_open_event, L448-472).
{
  "timestamp": 1757000000000000000,
  "event": "FILE_OPEN",
  "comm": "claude",
  "pid": 12345,
  "count": 1,
  "filepath": "/home/me/proj/.env",
  "flags": 0
}
  1. Claude Code가 api.anthropic.com에 HTTPS 요청을 보낸다. Claude Code는 BoringSSL을 정적 링크하므로 record -- claude가 바이너리를 찾아 오프셋으로 probe_SSL_rw_enter를 붙인다(sslsniff.c L491-492, CLAUDE.md L105·L172). 진입에서 버퍼 포인터를 저장하고 반환에서 길이를 읽어 평문을 ring buffer로 올리면, sslsniff.c가 JSON 한 줄을 찍는다(필드는 L596-650).
{
  "function": "WRITE/SEND",
  "timestamp_ns": 1757000000123456789,
  "comm": "HTTP Client",
  "pid": 12345,
  "len": 1832,
  "buf_size": 1832,
  "uid": 1000,
  "tid": 12360,
  "latency_ms": 0.031,
  "is_handshake": false,
  "data": "POST /v1/messages HTTP/1.1\r\nhost: api.anthropic.com\r\n...",
  "truncated": false
}

comm이 "claude"가 아니라 "HTTP Client"인 것을 봐 두면 뒤의 --comm 절이 바로 읽힌다.
bpf_get_current_comm()은 스레드 이름을 돌려준다.

  1. Rust BinaryExecutor가 두 줄을 읽어 Event{timestamp, source, pid, comm, data}로 통일한다. 프로세스 이벤트의 source는 "process"다(runners/process.rs L59). 분석기와 SQLite를 거쳐 웹 UI에 "이 프로세스가 .env를 열었고, 그다음 무엇을 보냈다"가 순서대로 남는다.

여기서 끝이다. .env의 값이 요청 본문에 들어갔는지는 사람이 data를 읽어야 안다. 두 번째 줄을 막을지 판단하는 코드는 커널에도 유저스페이스에도 없다.

ActPlane: 첫 사건 때문에 두 번째 사건을 막는다

같은 명령을 E1 정책 아래에서 띄운다(CLAUDE.md "Running").

sudo ./target/release/actplane --rule "$(cat policy.dsl)" run claude
source SECRET = file "**/.env"
rule sensitive-context-boundary:
  block connect endpoint "*"        if SECRET
  because "sensitive task context must stay local unless redacted first"
  1. 로더가 컴파일된 규칙을 ts_rules·ts_updates 맵에 넣고, --seed-pid로 받은 루트 PID를 감시 대상에 묶는다(process.c L44. actplane run이 이 값을 넘기는 코드는 runtime 크레이트라 읽지 않았다). 이후 fork되는 자손은 te_fork가 부모의 라벨 상태를 복사해 같은 계보에 넣는다.

  2. Claude Code가 .env를 연다. lsm/file_open이 te_handle_file로 넘기고, 읽기 경로의 te_read_domain이 경로를 source 규칙과 대조해 매치된 SECRET을 프로세스에 더한다. 여기까지는 아무것도 밖으로 나가지 않는다.

좌표는 lsm/file_open이 process.bpf.c L2360, te_read_domain이 taint_engine.bpf.h L1728이고, 라벨을 더하는 지점은 L1748, 경로를 대조하는 함수는 te_collect_file_updates(op는 TOP_OPEN)다. 훅에서 te_read_domain까지의 중간 호출은 따라가지 않았다.

  1. Claude Code가 자식 프로세스(예: curl)로 외부 주소에 연결한다. 자식은 fork 시점에 SECRET을 물려받았다. lsm/socket_connect → te_handle_net → te_check_net_labels가 규칙을 찾고, emit_violation() 뒤 -EPERM을 돌려준다. connect()는 커널 안에서 실패하고 소켓은 열리지 않는다.

  2. 로더가 위반 한 줄을 NDJSON으로 찍는다(필드는 process.c L471-481. op 3은 TOP_CONNECT, provenance의 op 1은 TOP_OPEN, taint.h L88-94).

{
  "timestamp": 1757000000123456789,
  "event": "TAINT_VIOLATION",
  "effect": "block",
  "blocked": true,
  "killed": false,
  "comm": "curl",
  "pid": 12400,
  "ppid": 12345,
  "op": 3,
  "domain_id": 0,
  "session_root": 12345,
  "target": "93.184.216.34",
  "rule_id": 0,
  "taint_label": 1,
  "matched_label": 1,
  "matched_labels": 1,
  "provenance": {
    "label": 1,
    "timestamp_ns": 1757000000000000000,
    "pid": 12345,
    "op": 1,
    "ip": 0,
    "target": "/home/me/proj/.env"
  }
}

provenance가 "이 라벨이 어디서 왔는지"다. 어느 pid가 어떤 대상을 열어서 SECRET이 붙었는지가 위반 한 줄에 같이 실린다.

  1. 런타임이 이 줄을 .actplane/last-violation.txt에 corrective feedback으로 적고, 훅이 다음 모델 턴에 주입한다(README L156-158, 문서 기준). block일 때의 메시지 템플릿은 crates/actplane-runtime/src/feedback.rs L68-75에 있다. 위반에 provenance가 실려 있으면 Reason 줄 다음에 - Provenance: PID … acquired label … via … 한 줄이 더 들어가는데(provenance_line, L116-124), 아래 예시에서는 그 줄을 뺐다.
[ActPlane] Operation blocked by rule `sensitive-context-boundary`.
- Target operation: connect 93.184.216.34
- Reason: sensitive task context must stay local unless redacted first
- The BPF-LSM hook returned EPERM before the operation committed; retrying the same operation will not succeed.
- Next step: use an equivalent path that satisfies the policy, or explain to the user why no compliant alternative exists.

앞의 AgentSight 출력에서 두 줄은 서로 무관한 기록이었다. 여기서는 첫 사건이 남긴 라벨이 두 번째 사건을 거부하는 이유가 되고, 그 판정이 커널 안에서 끝난다. 라벨이 fork를 따라 자식에게 복사되므로 bash -c나 python -c로 한 겹 감싸도 같은 결과라는 것이 README(L12)와 E9의 주장이고, 이 글은 그것을 실행으로 확인하지는 않았다. 아래부터는 이 장면의 각 단계가 실제로 어느 파일의 어느 함수인지 확인한다.

AgentSight: 관찰 코드는 무엇을 잡았나

여기서부터 코드다. 전체 아키텍처에서 시작해 SSL 훅, 프로세스 훅, Rust 쪽 Event까지 내려간다. 미리 말하면 이 층들 어디에도 무엇을 막을지 판단하는 코드는 없다.

전체 아키텍처

AgentSight 저장소의 CLAUDE.md는 자신의 아키텍처를 이렇게 요약한다.

"eBPF Programs (kernel) → JSON stdout → Capture Core → Analysis Extension → Output/Web Extension/Files"

이 한 줄을 실제 파일과 경계로 펼치면 커널은 첫 칸 하나이고, 나머지 네 칸이 전부 유저스페이스다.

AgentSight CLAUDE.md의 한 줄 아키텍처를 펼치면

커널 쪽은 C로 쓴 eBPF 프로그램 세 개(sslsniff, process, stdiocap)와 짝이 되는 유저스페이스 로더다. 유저스페이스 쪽은 Rust 크레이트 agentsight-capture(수집), ext/analysis(분석), collector(CLI·SQLite·웹서버), ext/web과 frontend(UI)로 나뉜다.

커널과 유저스페이스 사이는 ring buffer(커널이 쓰고 유저스페이스가 읽는 공유 메모리 큐)가, 로더와 Rust 사이는 파이프 위의 JSON 한 줄이 이어 준다. 이 글이 코드까지 읽은 것은 초록 테두리 부분이고, 나머지는 CLAUDE.md 서술로만 확인했다.

AgentSight 전체 아키텍처: 커널이 잡고, 유저스페이스가 해석한다

이 파이프라인을 코드로 읽는 순서는 여섯 단계다. 이 글은 앞의 셋만 코드까지 읽었다.

  1. bpf/sslsniff.bpf.c, bpf/process.bpf.c: 커널 eBPF 프로그램. JSONL을 stdout에 직접 출력한다.
  2. agentsight-capture/src/runners/*.rs: BinaryExecutor가 1을 서브프로세스로 실행하고 stdout JSON 스트림을 받는다.
  3. agentsight-capture/src/event.rs: JSON 한 줄을 Event{timestamp, source, pid, comm, data}로 만든다.
  4. ext/analysis/: Analyzer 체인이 Event 스트림을 순차 변환한다(코드는 읽지 않음).
  5. collector/src/sources/session_db.rs: 세션별 SQLite 파일에 기록한다(코드는 읽지 않음).
  6. collector/src/server/web.rs: :7395 웹 UI가 저장된 이벤트를 조회한다(코드는 읽지 않음).

커널 프로그램은 잡아서 JSON으로 넘기기만 하고, 해석·저장·표시는 전부 유저스페이스에 있다.

SSL_read/SSL_write는 어디서 잡히는가 (bpf/sslsniff.bpf.c, bpf/sslsniff.c)

bpf/sslsniff.bpf.c에는 SEC()가 붙은 BPF 프로그램이 12개 있다. 이 파일의 SEC() 문자열은 attach 지점이 아니라 라벨이다.

실제 attach는 유저스페이스 로더 bpf/sslsniff.c가 정한다. bpf_program__attach_uprobe_opts()에 심볼 이름(.func_name)이나 오프셋을 넘기는 방식이다(L32-38, L396-411). 12개 프로그램이 어느 함수에 붙는지는 sslsniff.c를 봐야 알 수 있고, 그 기준으로 묶으면 네 묶음이다.

AgentSight의 SSL BPF 프로그램 12개는 어디에 붙는가

첫 번째 묶음, SSL_read/SSL_write(OpenSSL의 옛 API)는 진입 프로그램 하나(probe_SSL_rw_enter, L288)를 두 함수가 공유하고, 반환은 함수별 uretprobe로 잡는다.

SEC("uretprobe/SSL_read")
int BPF_URETPROBE(probe_SSL_read_exit) {
    return (SSL_exit(ctx, 0));
}

SEC("uretprobe/SSL_write")
int BPF_URETPROBE(probe_SSL_write_exit) {
    return (SSL_exit(ctx, 1));
}

진입 프로그램 probe_SSL_rw_enter는 버퍼 포인터(buf)와 시각을 스레드 ID(tid) 키로 bufs, start_ns 맵에 저장하고(L300-302), 반환 프로그램이 부르는 SSL_exit는 bufs[tid]가 없으면 아무것도 내보내지 않는다(L318-320). 옛 API도 진입과 반환 두 지점을 쓴다. 바이트 수는 반환값(PT_REGS_RC)에서 읽는다.

두 번째 묶음, SSL_write_ex/SSL_read_ex(OpenSSL 1.1.1+ 신규 API)도 진입과 반환에 각각 프로그램을 두는데, 진입에서 저장하는 것이 하나 더 있다.

SEC("uprobe/SSL_write_ex")
int BPF_UPROBE(probe_SSL_write_ex_enter, void *ssl, void *buf, size_t num, size_t *readbytes) {
    ...
    bpf_map_update_elem(&bufs, &tid, &buf, BPF_ANY);
    bpf_map_update_elem(&start_ns, &tid, &ts, BPF_ANY);
    bpf_map_update_elem(&readbytes_ptrs, &tid, &readbytes, BPF_ANY);
    return 0;
}

_ex 계열의 반환값은 성공/실패(1/0)뿐이고 실제 바이트 수는 호출자가 넘긴 출력 포인터 readbytes로 돌아온다. 그래서 진입에서 buf뿐 아니라 readbytes 포인터까지 readbytes_ptrs[tid]에 저장해 두고, 반환 프로그램이 그 포인터를 역참조해 바이트 수를 얻는다. 옛 API와 _ex는 잡는 지점이 같고, 바이트 수를 어디서 읽느냐만 다르다.

세 번째 묶음 uprobe/rustls_write, rustls_write_vectored, rustls_buffer_plaintext는 OpenSSL이 아니라 Rust TLS 스택(rustls)을 쓰는 에이전트를 위한 훅이다. 네 번째 묶음 핸드셰이크는 probe_SSL_do_handshake_enter(L525)와 probe_SSL_do_handshake_exit(L542) 두 프로그램이다.

첫 번째 묶음의 진입 프로그램 probe_SSL_rw_enter는 SEC("uprobe/do_handshake") 라벨을 달고 있지만 핸드셰이크에는 붙지 않는다.

sslsniff.c는 이 프로그램 하나를 SSL_write/SSL_read뿐 아니라 GnuTLS(gnutls_record_send/recv, L416-419), NSS(PR_Write/PR_Send/PR_Read/PR_Recv, L425-432), BoringSSL 오프셋(L491-492, L503-504)에도 그대로 붙인다. 그래서 프로그램은 12개지만 attach 지점은 그보다 많다.

bpf/process.bpf.c는 이보다 짧다. 훅은 5개다.

tp/sched/sched_process_exec/exit가 CLAUDE.md가 말한 "tracks process lifecycle"이고, tp/syscalls/sys_enter_open(at)이 파일 접근 추적이다. uretprobe//usr/bin/bash:readline은 문서에는 없던 것인데, bash의 대화형 입력 자체를 캡처하는 훅이다.

eBPF 바이너리는 어떻게 Rust 쪽으로 넘어가는가 (agentsight-capture/src/runners/process.rs)

agentsight-capture/src/runners/process.rs를 보면, eBPF 프로그램은 서브프로세스로 실행되고, Rust는 그 stdout을 JSON 스트림으로 읽는다. IPC나 ring buffer를 직접 공유하지 않는다.

async fn run(&mut self) -> Result<EventStream, RunnerError> {
    let json_stream = self.executor.get_json_stream().await?;
    let errors = Arc::new(AtomicU64::new(0));
    let stream = json_stream.map(move |v| Self::parse_process_event(v, &errors));
    AnalyzerProcessor::process_through_analyzers(Box::pin(stream), &mut self.analyzers).await
}

BinaryExecutor(같은 크레이트의 runners/common.rs)가 컴파일된 bpf/process(C 유저스페이스 로더)를 자식 프로세스로 실행하고, 그 표준출력을 한 줄씩 JSON으로 파싱한다. eBPF 프로그램(.bpf.c)이 커널 안에서 ring buffer로 이벤트를 올리면, 짝을 이루는 유저스페이스 로더(.c, 예: sslsniff.c, process.c)가 그 ring buffer를 읽어 JSON 한 줄로 직렬화해 stdout에 찍는다. Rust는 그 프로세스의 stdout만 본다.

커널-유저스페이스 경계는 ring buffer가, 유저스페이스 프로세스 경계는 파이프+JSONL이 넘는다. eBPF 프로그램과 Rust 사이에 공유 메모리는 없다.

모든 이벤트는 어떻게 하나의 Event가 되는가 (agentsight-capture/src/event.rs)

agentsight-capture/src/event.rs의 Event는 필드가 다섯 개다.

pub struct Event {
    pub timestamp: u64,
    pub source: String,
    pub pid: u32,
    pub comm: String,
    pub data: serde_json::Value,
}

SSL 이벤트든 프로세스 이벤트든 stdio 이벤트든 이 다섯 필드로 통일된다. source가 "어떤 eBPF 프로그램에서 왔는지"를 구분하고, 나머지 구체적인 내용은 전부 data(임의 JSON) 안에 들어간다. 새 eBPF 프로그램을 추가해도 Event 자체는 안 바뀌고 data의 내용만 달라진다.

실전에서 부딪히는 지점: --comm 필터가 SSL 트래픽을 지워버리는 조건 (collector/src/cmd_trace.rs)

AgentSight의 CLAUDE.md는 실제로 겪은 함정 하나를 "Critical" 절에 적어 두었다. 조건이 붙어 있는 문장이라 앞 줄까지 같이 인용한다.

"When --binary-path is specified: ... 2. The --comm filter is NOT passed to sslsniff (only to the process runner) — because bpf_get_current_comm() returns the thread name, not the process name. Claude's SSL traffic runs on an 'HTTP Client' thread, so -c claude would filter out all SSL traffic."

bpf_get_current_comm()은 스레드 이름을 돌려준다. Claude Code가 SSL 트래픽을 별도 스레드("HTTP Client")에서 처리하면, 그 스레드의 comm은 "claude"가 아니라 "HTTP Client"다. sslsniff에 -c claude 필터를 넘기면 SSL 이벤트가 전부 걸러져 사라진다.

이 로직은 collector/src/cmd_trace.rs의 build_ssl_args()에 있다. CLAUDE.md가 말한 build_trace_agent()(실제 이름은 build_trace_agent_with_view(), L160)가 L169에서 이 함수를 부른다.

// Skip --comm for sslsniff when --binary-path is set: SSL traffic runs on
// "HTTP Client" thread, not the process name, so comm filter drops everything.
if cfg.binary_path.is_none() {
    if let Some(comm) = cfg.comm.as_deref() {
        args.extend(["-c".to_string(), comm.to_string()]);
    }
}

--comm을 sslsniff에 안 넘기는 것은 --binary-path가 있을 때뿐이다. 기본 경로에서는 -c가 sslsniff에 그대로 전달된다.

--binary-path는 Claude(BoringSSL)나 Node.js(OpenSSL)처럼 SSL 라이브러리를 정적 링크한 바이너리를 지정할 때 쓰는 옵션이고, 그 경우에만 "HTTP Client" 스레드 문제가 생기므로 필터를 뺀다. Python처럼 시스템 libssl.so를 쓰는 런타임은 comm 필터가 붙은 채로 sslsniff가 잡는다. CLAUDE.md도 이 경로를 "system-libssl + comm-filter path"(L145)라고 부른다. 프로세스 이름으로 필터하면 다른 스레드에서 처리되는 SSL 트래픽이 사라진다는 함정은 그대로지만, 그 회피는 조건부다.

정리: AgentSight가 내보내는 것

이 글이 읽은 17개 프로그램이 잡은 이벤트는 CLI/cgroup 필터만 거쳐 전부 JSON 한 줄로 유저스페이스에 나간다. 커널 안에서는 어떤 판정도 하지 않는다. 해석은 Rust 쪽 분석기와 저장소, 웹 UI의 일이다. 다음에 볼 ActPlane은 이 구조에서 "커널 안에서 판정하지 않는다"는 부분을 뒤집는다.

ActPlane: 남은 파일 하나에 무엇을 채웠나

같은 순서로 ActPlane을 읽는다. 남은 bpf/process.bpf.c 하나에 무엇이 들어갔고, 정책이 어떤 형태로 커널까지 내려가며, 어느 지점에서 -EPERM이 나오는지다.

전체 아키텍처

ActPlane의 CLAUDE.md는 아키텍처를 한 줄로 그린다.

"policy.dsl ─▶ actplane-ifc-compiler ─▶ struct taint_config ─▶ eBPF engine ─▶ TAINT_VIOLATION (+reason)"

칸으로 끊어 보면 앞의 세 칸이 유저스페이스 컴파일, 네 번째 칸이 커널 판정, 마지막 칸이 유저스페이스로 올라오는 보고다.

ActPlane CLAUDE.md의 한 줄 아키텍처를 펼치면

유저스페이스에는 Rust 크레이트 세 개가 있다. actplane-cli(명령), actplane-runtime(정책 파일 해석, 엔진 로딩, MCP 통합, corrective feedback), actplane-ifc-compiler(DSL을 커널 ABI 구조체로 낮추는 컴파일러)다.

커널 쪽 bpf/에서 이 글이 읽은 파일은 넷이다. 규칙 ABI taint.h, 라벨 맵과 규칙 맵과 전파 헬퍼가 든 taint_engine.bpf.h, 훅 90개가 든 process.bpf.c, blob을 맵에 적재하고 위반을 NDJSON으로 찍는 로더 process.c다. NDJSON은 줄마다 JSON 하나를 놓는 형식이고, AgentSight의 JSONL과 같다. 같은 디렉터리의 capability.bpf.h, channel.bpf.h, process.h, test_taint.c는 읽지 않았다.

데이터는 두 방향으로 흐른다. 정책은 컴파일러 → blob → 로더 → 커널 맵으로 내려가고, 위반은 커널 훅 → emit_violation() → ring buffer → 로더 → 런타임 → 에이전트로 올라온다. 로더와 커널 엔진만으로도 동작한다(sudo ./bpf/process --config policy.bin). cli·runtime 크레이트는 문서 서술로만 확인했다.

아래 그림이 그 두 방향을 한 장에 놓은 것이다.

ActPlane 전체 아키텍처: 컴파일러가 만든 규칙을 커널 엔진이 집행한다

같은 방식으로 ActPlane을 읽으면 일곱 단계이고, 이 글은 1~5를 코드까지 읽었다(1은 lower.rs만).

  1. crates/actplane-ifc-compiler/src/dsl/{parse,ast,lower}.rs: DSL → #[repr(C)] 구조체(parse.rs, ast.rs는 읽지 않음).
  2. bpf/taint.h: 커널·Rust 공유 ABI(byte-identical).
  3. bpf/taint_engine.bpf.h: ts_proc/ts_file/ts_endp 라벨 맵, ts_rules/ts_updates 규칙 맵, te_* 헬퍼.
  4. bpf/process.bpf.c: LSM 훅 14개 + tracepoint 76개, emit_violation().
  5. bpf/process.c: 로더. --config로 blob을 맵에 적재하고 NDJSON을 출력한다.
  6. crates/actplane-runtime/: 정책 로딩, MCP, corrective feedback(코드는 읽지 않음).
  7. crates/actplane-cli/: actplane 명령(코드는 읽지 않음).

그 코드에서 무엇이 사라졌나 (git log --follow bpf/process.bpf.c)

CLAUDE.md의 표현을 그대로 코드로 확인하면, AgentSight의 bpf/에 있던 sslsniff.bpf.c, stdiocap.bpf.c, browsertrace.bpf.c와 agentsight-capture/, ext/web, frontend/, controller/는 ActPlane 저장소에 없다. bpf/ 아래 eBPF 프로그램(.bpf.c) 중 남은 것은 process.bpf.c 하나다(Makefile, README, vmlinux/ 같은 뼈대 파일 30여 개는 그대로 이어졌다). 지워진 자리를 대신하는 것이 위에서 본 세 개의 Rust crate다.

남은 파일 하나는 이름이 같아서 "코드를 재활용했나"라고 생각하기 쉽다. 아래 그림은 두 process.bpf.c가 무엇을 하는 파일인지와, git 히스토리가 보여주는 전환 시점이다.

이름이 같은 process.bpf.c는 왜 다른 파일인가

ActPlane 저장소의 히스토리를 따라가면(git log --follow bpf/process.bpf.c) 초기 커밋 96cd9b2c(2026-05-22)의 process.bpf.c는 296줄이다. AgentSight의 현재 파일(327줄)과 diff 출력 43줄, 변경 행으로는 33줄만 다르다.

지금 헤더 주석의 원형("ActPlane in-kernel taint enforcer …")이 처음 등장하는 것은 같은 날 커밋 f4e47d57 "kernel: violation-only enforcer with 4 real sink kinds; delete userspace DSL + dead tracer"다.

교체는 한 커밋에 끝나지 않고 같은 날 여러 커밋에 걸쳐 진행됐고, 세 커밋 앞 f67f09f5 "taint: extract engine to header, add file-open sink, simplify loaders"에서 이미 taint 엔진이 들어와 있었다.

33줄 차이는 빈 줄 하나를 빼고 전부 AgentSight 쪽에 나중에 추가된 것(cgroup 필터 is_cgroup_tracked() 게이트, process_ext/*.h include)이라, ActPlane 초기 파일은 AgentSight 파일의 이전 버전이다. 이 파일은 AgentSight의 파일로 시작해 같은 저장소 안에서 교체됐고, 현재 커밋에서는 3871줄이다.

AgentSight가 관찰에 쓰던 BPF 프로그램은 ActPlane에서 그대로 유지되지 않는다. SSL 훅과 stdio 훅은 파일째 사라졌다.

process.bpf.c의 5개 훅 중 bash readline uretprobe는 ActPlane 파일에 없고, exec/exit/open(at) 4개는 같은 tracepoint 이름으로 남아 있지만(ActPlane process.bpf.c L2536-2661, L2904-2916) 파일 헤더 주석대로 "각 훅이 taint를 전파하고 컴파일된 규칙을 평가하는" 코드가 됐다(L4-6).

다만 "지웠다"보다 "재편했다"가 정확하다. 이어진 것은 파일명과 tracepoint 이름이고 역할은 바뀌었으며, ActPlane 쪽 process.bpf.c에는 훨씬 많은 훅이 있다. 다음 여섯 절이 그 내용이다.

정책은 어떻게 커널까지 들어가는가 (dsl/lower.rs, bpf/taint.h, bpf/process.c)

ActPlane의 DSL은 유저스페이스 정책 파일로 남지 않는다. Rust 컴파일러가 정책을 #[repr(C)] 구조체(Rust 구조체를 C 컴파일러와 같은 필드 순서·정렬로 배치하라는 속성)로 낮추고, 로더가 그 구조체를 그대로 커널 배열 맵에 넣는다. CLAUDE.md는 이 경계를 "Critical"로 표시했다.

"crates/actplane-ifc-compiler/src/dsl/lower.rs's #[repr(C)] structs are byte-identical to the C structs in bpf/taint.h. The blob is serialized with from_raw_parts and read directly into the BPF rodata. Any change to taint.h MUST be mirrored in lower.rs (and vice versa)."

다만 규칙 테이블이 어디에 들어가는지는 문서와 코드가 다르다. 이 글은 코드 쪽을 따른다.

CLAUDE.md와 taint.h의 주석(L153-156)은 "rodata"라고 적었다. rodata는 BPF 프로그램의 읽기 전용 데이터 섹션이고, 로드 전에만 값을 채울 수 있다.

그런데 고정 커밋의 코드에서 규칙과 갱신 테이블은 쓰기 가능한 BPF_MAP_TYPE_ARRAY 맵 ts_rules/ts_updates에 들어간다(taint_engine.bpf.h L33-47). 같은 자리의 주석도 "Stored in writable array maps so userspace can append admitted runtime deltas"다.

로더는 process_bpf__load() 뒤에 bpf_map_update_elem()으로 한 건씩 채운다(process.c L678-693). rodata에 실제로 들어가는 것은 enforce_mode와 policy_features 두 값뿐이다(process.bpf.c L16-17).

정책 파일이 커널 맵에 앉기까지의 변환은 아래 그림이다. 화살표는 데이터가 변환되는 순서다.

정책 파일 한 장이 커널 규칙 테이블이 되기까지

왜 이렇게 했는지는 docs/security_model.md에 적혀 있다. "The kernel should not parse YAML or DSL in the admission path"(L181), "The kernel does not parse YAML or DSL"(L201).

커널에 파서를 두지 않는다는 원칙이 먼저 있고, byte-identical 구조체는 그 원칙을 지키는 방법이다. 규칙은 구조체 그대로 배열 맵에 들어가 훅이 인덱스로 읽는다. 런타임 파싱 비용이 없다는 것은 구조에서 따라 나오는 결과이지, 이 글이 성능 수치로 확인한 사실은 아니다.

"byte-identical"은 어디까지 같다는 뜻인가 (dsl/lower.rs, bpf/taint.h)

앞 절 그림 3번 카드의 "#[repr(C)] 구조체 = C 구조체"가 실제로 어디까지 같은지는 두 쪽을 나란히 놓으면 보인다.

lower.rs의 CUpdate와 taint.h의 taint_update는 필드 하나만 빼고 이름·타입·순서가 그대로 겹친다.

#[repr(C)]
#[derive(Clone, Copy)]
struct CUpdate {
    op: u8,
    m: u8,
    target: [u8; PAT],
    arg: [u8; ARG],
    add: u64,
    del: u64,
    gates: u64,
    invals: u64,
    ipv4: u32,
    ipv4_mask: u32,
    gate_exit_code: i32,
    domain_id: u32,
}
struct taint_update {
	unsigned char op;    /* enum taint_op */
	unsigned char match; /* enum taint_match for target; connect uses ipv4/mask */
	char target[TAINT_PAT_LEN];
	char arg[TAINT_ARG_LEN]; /* exec positional arg, "" = ignore */
	unsigned long long add;
	unsigned long long del;
	unsigned long long gates;
	unsigned long long invals;
	unsigned int ipv4;
	unsigned int ipv4_mask;
	int gate_exit_code;       /* TAINT_GATE_IMMEDIATE or 0..255 = stamp on matching exit */
	unsigned int domain_id;   /* 0 = inherited/global, else cap_state target id */
};

u8↔unsigned char, u64↔unsigned long long, u32↔unsigned int, i32↔int로 한 줄씩 대응한다. 이름이 다른 필드는 두 번째 것 하나뿐이다(Rust m / C match). match가 Rust 예약어라 그대로 못 쓴 것이고, 위치와 타입은 똑같다.

"byte-identical"은 타입·순서·크기가 같다는 뜻이고, 필드 이름까지 같아야 한다는 뜻은 아니다. 이 필드가 그 예다. 20개 필드짜리 CRule/taint_rule도 같은 방식으로 대응한다(lower.rs L57-80 vs taint.h L126-151).

두 쪽이 어긋나면 로드 자체가 실패한다. CLAUDE.md의 "Common Issues"가 이 실패 모드를 적어 두었다. "ABI size mismatch on load (\"config size mismatch\"): lower.rs and taint.h drifted — re-sync the structs"다.

드리프트를 막는 장치는 dsl/mod.rs의 config_blob_is_fixed_size 테스트(L548-555)다. 어떤 정책을 컴파일해도 blob 길이가 TAINT_CONFIG_SIZE(74,760바이트)와 같은지 확인한다. CLAUDE.md(L127)가 "fixed-size test"라고 부르는 것이 이 테스트다. 직렬화 포맷을 썼다면 파싱 단계에서 걸러졌을 어긋남이 여기서는 로드 실패로 나타난다.

라벨은 어떻게 흐르는가 (bpf/taint_engine.bpf.h)

bpf/taint_engine.bpf.h가 유지하는 맵은 문서의 "process/file/endpoint 노드"를 그대로 코드로 옮긴 것이다. ts_proc(pid → labels + lineage gates), ts_file(파일 객체 → labels), ts_endp(IPv4 → labels), 그리고 계보와 세션을 추적하는 ts_root, ts_sess다.

ts_file의 키는 경로 문자열이 아니라 (dev, inode)이고, 훅이 파일 객체에 닿지 못하는 경로에서만 fnv1a(path) 해시로 대체한다(taint_engine.bpf.h L282-296). CLAUDE.md는 이 키를 fnv1a(path)라고만 적었는데, 코드의 기본 키는 inode 쪽이다.

어느 쪽이든 경로 문자열을 그대로 키로 쓸 수 없는 것은 eBPF map 키가 고정 크기여야 한다는 eBPF 쪽 제약 때문이다. 이 이유가 소스에 적혀 있지는 않다.

이 세 노드 사이에서 라벨이 오가는 경로는 그래프다. 순서가 있는 파이프라인으로 읽으면 안 된다. CLAUDE.md의 "Labeled information-flow model" 절이 이 그래프를 한 줄로 요약한다.

"Propagation: fork→inherit, exec→apply source/xform/gate, read→file labels into proc, write→proc labels into file, connect→proc labels to endpoint."

아래 그림의 화살표는 라벨(u64 비트마스크)이 복사되는 방향이다. 호출 순서가 아니다. 오른쪽은 docs/rule-language.md의 E1 예시(.env를 읽은 프로세스의 외부 connect를 막는 규칙. 전문은 다음 절에 인용한다)가 이 그래프 위에서 어떻게 움직이는지다.

라벨은 프로세스·파일·엔드포인트 사이를 어떤 경로로 옮겨 다니는가

파일을 읽으면 ts_file에 저장된 라벨이 프로세스 쪽(ts_proc)에 더해지고, 파일에 쓰면 반대로 프로세스가 들고 있던 라벨이 파일 쪽에 더해진다. connect는 엔드포인트 쪽 source 라벨을 먼저 프로세스에 더한 뒤, 프로세스 라벨을 ts_endp에 합친다. fork는 부모가 감시 대상일 때 부모의 proc_state(라벨 포함)를 통째로 복사해 자식 pid 키로 넣고, ts_root에 계보 루트를 기록한다.

CLAUDE.md의 모델에 없는 엣지가 코드에는 하나 더 있다. endpoint에서 proc으로 오는 recv다. 반대로 exec 엣지는 모델 설명으로만 확인했고 코드는 읽지 않았다.

엣지별 좌표는 아래와 같다. 파일 표기가 없는 줄 번호는 전부 taint_engine.bpf.h 기준이다.

엣지방향함수좌표
forkproc → prochandle_fork → te_fork → te_copy_proc_domain_stateprocess.bpf.c L2526-2534 / L1226-1239 / L1207-1223
readfile → procte_read_domain (te_add_labels_domain() 호출)L1728-1757, 더하는 지점 L1748
writeproc → filete_write_flow_domain (fs->labels \|= pl)L1774-1808, 더하는 지점 L1800
connectendpoint ↔ procte_connect_flow_domainL1955-1972, 합치는 지점 L1968-1970
recvendpoint → procte_recv_flow_domain, lsm/socket_recvmsgL1985, process.bpf.c L2451
exec코드 미확인-CLAUDE.md 모델 설명만

읽기 엣지의 실제 코드는 te_read_domain에서 볼 수 있다. 이 함수들은 모두 domain_id를 인자로 받아 여러 계층에 걸쳐 반복 호출되는데, 이 도메인/권한 계층 자체는 이 글에서 다루지 않는다.

어디서 실제로 막는가 (bpf/process.bpf.c)

bpf/process.bpf.c(3871줄)는 LSM 훅 14개와 tracepoint 76개(tp/sched/ 8개 + tp/syscalls/ 68개)를 건다.

LSM (14개, 커밋 전에 개입 가능):
  bprm_check_security   file_open        file_permission   file_truncate
  mmap_file             file_mprotect    path_truncate     path_unlink
  path_rename            socket_connect   socket_recvmsg    task_kill
  ptrace_access_check    bpf

tracepoint (76개: tp/sched 8개 fork/exec/exit +
  tp/syscalls 68개, open·pipe·socketpair·bind·accept·unlink·rename·connect·
  read·write·mmap·mprotect·mremap·munmap·send/recv·close·
  dup/dup2/dup3·fcntl·sendfile64·copy_file_range·splice까지)

두 그룹은 성격이 다르다. LSM 훅은 반환값으로 syscall 자체를 실패시킬 수 있지만, tracepoint는 반환값으로 커널 동작을 바꾸지 못한다. 아래 그림은 14개와 76개 각각의 이름과, 이 글이 호출 코드까지 확인한 지점을 나눈 것이다.

ActPlane의 LSM 훅 14개와 tracepoint 76개는 각각 무엇을 하는가

이 중 connect가 막히는 경로를 끝까지 따라가 보자. E1 예시처럼 .env를 읽어 SECRET 라벨이 붙은 프로세스가 외부 주소로 connect할 때, 어떤 함수를 거쳐 -EPERM이 나오는가. 화살표는 함수 호출 순서이고, 줄 번호는 고정 커밋 기준이다.

SECRET 라벨을 가진 프로세스의 connect는 어떤 함수 경로로 -EPERM이 되는가

1번 카드의 실제 코드는 짧다. 원본은 탭 들여쓰기인데, 이 스니펫과 뒤의 te_protect_control_pid는 가독성을 위해 스페이스로 바꿨다. 코드 내용과 순서는 그대로다.

SEC("lsm/socket_connect")
int BPF_PROG(enforce_socket_connect, struct socket *sock, struct sockaddr *address,
             int addrlen)
{
    (void)sock;
    (void)addrlen;
    return te_handle_net(TE_REF_SOCKADDR_KERN, address, 0,
                         TE_ACCESS_CONNECT, TE_MODE_BLOCK);
}

이 함수가 판정하는 규칙은 docs/rule-language.md의 E1 같은 DSL에서 온다.

source SECRET = file "**/.env"
source SECRET = file "/etc/secrets/**"
rule sensitive-context-boundary:
  block connect endpoint "*"        if SECRET
  block write   file "/shared/**"   if SECRET
  because "sensitive task context must stay local unless redacted first"
declassify SECRET by exec "**/redact"

.env를 읽은 프로세스에는 SECRET 라벨이 붙는다(앞 절의 te_read_domain). 그 라벨을 가진 프로세스가 아무 주소로나 connect하려는 순간, enforce_socket_connect에서 시작한 호출 사슬이 te_check_net_labels로 이 규칙을 찾아내고 emit_violation() 뒤에 -EPERM을 돌려준다. redact를 실행하기 전까지는 라벨이 그대로 유지된다(declassify).

-EPERM이 나오려면 조건이 둘 다 맞아야 한다.

  • 규칙의 effect가 block이어야 한다. effect는 notify/block/kill 세 가지다(taint.h L105-109). notify면 위반을 보고만 하고 0을 돌려준다.
  • 커널의 BPF LSM이 활성이어야 한다. 로더는 시작할 때 bpf_lsm_active()로 확인하고(process.c L599), 아니면 tracepoint 모드로 내려가 "block rules will not fire"라고 경고한다(L669-671).

커널 쪽에도 같은 게이트가 있다. enforce_mode가 0이면 block 경로를 그냥 통과시킨다(te_handle_net L2098).

"우회 불가" 주장은 어디까지 코드로 확인되나 (te_fork, dup·splice tracepoint)

README는 "fine-grained sandboxing rules follow process lineage, no bypass via bash scripts or python"(L12)이라고 주장한다. 규칙이 프로세스 계보를 따르므로 bash 스크립트나 python으로는 우회할 수 없다는 뜻이다.

이 주장의 근거는 특정 syscall 훅이 아니라 앞 절에서 본 fork 라벨 상속(te_fork)이다. docs/rule-language.md E9(L325-334)는 git 도구 호출, bash -c 'git …', python -c "subprocess.run(['git', …])" 세 경로가 전부 에이전트의 자식 프로세스 트리 안에 떨어지므로 같은 규칙에 걸린다고 설명한다.

그러면 dup/dup2/dup3와 sendfile64/copy_file_range/splice tracepoint는 무엇을 하는가. 처음에는 "fd 복제나 제로카피 전송으로 감시를 피하는 경로를 막는 훅"이라고 읽었는데, 코드는 그렇지 않다.

handle_dup_exit(L3712-3727)는 새 fd에 기존 fd의 추적 정보를 복사할 뿐이고, handle_fd_copy_exit(L3804-3827)는 enforce_mode가 켜져 있으면 아무것도 하지 않고 돌아가며(L3812-3815), 꺼져 있을 때만 te_read/te_write_flow로 라벨을 옮긴다.

tracepoint는 반환값으로 커널 동작을 바꿀 수 없으므로 애초에 차단 지점이 아니다. 이 훅들은 데이터가 fd 사이를 옮겨 다닐 때 라벨이 따라가게 하는 추적 장치이고, 파일 읽기·쓰기의 실제 차단은 lsm/file_permission(L2368-2373) 같은 LSM 훅에서만 일어난다. 훅이 있다는 것과 우회가 불가능하다는 것은 다른 말이고, 이 글은 실제 우회 시도를 하지 않았다.

자기 보호: 에이전트가 ActPlane 자체를 죽이면 어떻게 되는가 (te_protect_control_pid, bpf/process.c)

앞 절의 connect 경로 그림에서 오른쪽 열이 이 경로다. task_kill LSM 훅 바로 앞에 이런 헬퍼가 있다.

static __always_inline int te_protect_control_pid(struct task_struct *target)
{
    pid_t caller = bpf_get_current_pid_tgid() >> 32;
    pid_t target_tgid;

    if (!te_pid_active(caller))
        return 0;
    if (!target)
        return 0;
    target_tgid = BPF_CORE_READ(target, tgid);
    if (target_tgid <= 0 || target_tgid == caller)
        return 0;
    if (!te_pid_protected(target_tgid))
        return 0;
    return -EPERM;
}

감시당하는 프로세스(te_pid_active)가 보호 대상 프로세스(te_pid_protected)에 시그널(존재 확인용 시그널 0은 제외, L2484-2485)을 보내려 하면 -EPERM을 반환한다.

보호 대상이 누구인지는 로더 코드에 있다. te_pid_protected는 te_protected_pids 맵(process.bpf.c L173-180)을 조회하고, 그 맵은 로더 bpf/process.c의 protect_loader_pid()(L566-576)가 attach 직전에 getpid()로 자기 자신의 PID를 넣어 채운다. 로더가 종료되면 exit 핸들러가 항목을 지운다(L2674).

정책 규칙과 무관하게, 에이전트가 kill -9로 로더 프로세스를 죽이려는 시도를 커널 레벨에서 막아 두는 것이다. 같은 헬퍼는 lsm/task_kill(L2478)뿐 아니라 lsm/ptrace_access_check(L2489)에서도 호출된다. ptrace로 로더의 메모리를 들여다보거나 조작하려는 시도까지 같은 방식으로 막는다.

그래서 관찰과 강제의 차이는 무엇인가

지금까지 읽은 것을 축별로 놓으면 이렇다. 값은 전부 앞 절에서 코드로 확인한 것이다.

축AgentSightActPlane
커널이 하는 일이벤트를 잡아 ring buffer로 올린다라벨을 전파하고 규칙과 대조해 판정한다
유저스페이스가 하는 일해석, 저장, 표시정책 컴파일, 위반 보고 소비
eBPF 프로그램SEC() 17개(이 글이 읽은 두 파일)LSM 훅 14개 + tracepoint 76개
밖으로 나가는 것일어난 일 전부매치된 위반만
개입 시점없다LSM 반환값으로 커밋 전
정책없다byte-identical 구조체를 배열 맵에 적재
유저스페이스 연결파이프 위의 JSON 한 줄파이프 위의 JSON 한 줄

유저스페이스로 나가는 통로부터 보면 두 프로젝트는 같다. ActPlane의 로더 process.c는 AgentSight의 sslsniff.c/process.c와 같은 패턴으로 ring buffer를 읽어 TAINT_VIOLATION을 NDJSON 한 줄로 stdout에 찍는다.

차이는 그 한 줄에 담기는 내용이다. AgentSight는 일어난 일을 전부 담고, ActPlane은 막은 일만 담는다. process.bpf.c 주석이 이 설계를 한 줄로 요약한다: "single emit_violation() function, when a rule matches."

훅 개수를 비교하면 안 된다. 17개는 두 .bpf.c 파일의 SEC() 프로그램 수이고, 90개는 LSM 훅과 tracepoint를 따로 센 것이라 단위부터 다르다. "ActPlane이 더 많이 감시한다"는 결론은 여기서 나오지 않는다. 볼 것은 훅이 어디까지 하느냐다. AgentSight의 훅은 잡은 데이터를 밖으로 넘기는 데서 끝난다. ActPlane의 LSM 훅은 라벨을 전파하고 규칙과 대조해 판정한 뒤, 그 반환값으로 커널의 동작을 바꾼다.

같은 저장소에서 갈라진 두 프로젝트가 같은 유저스페이스 연결 방식(JSON 한 줄)을 유지한 채로, 그 한 줄의 의미를 "일어난 일"에서 "막은 일"로 바꾼 것이 이 전환이다.

이 전환의 출발점은 LSM 훅의 반환값이다. 커밋 전에 끼어들 수 있어야 거부가 성립한다. 거부할지 판단하려면 라벨이 프로세스뿐 아니라 파일과 엔드포인트에도 남아 있어야 하고, 그 판단 기준인 정책은 파서 없이 바이트 그대로 커널에 들어가 있어야 한다.

이 글에서 확인한 것은 여기까지다. 정책 엔진이 LSM 훅에서 -EPERM을 반환하도록 구현돼 있고, 그 경로가 소스에 실제로 존재한다는 사실이다. 이것이 모든 정보 유출 경로를 차단한다거나 실제 공격자가 우회할 수 없다는 뜻은 아니다. 그 주장을 하려면 Linux 환경에서 실제 정책을 로드하고 syscall/LSM 경로별 우회 테스트를 따로 수행해야 한다. 그리고 block은 BPF LSM이 활성인 커널에서만 동작한다는 조건이 항상 붙는다.

자료

  • AgentSight bpf/sslsniff.bpf.c: 이 글에서 다룬 12개 프로그램 전체와 rustls 지원 코드가 있다. 어디에 붙는지는 bpf/sslsniff.c와 같이 봐야 한다.
  • ActPlane bpf/process.bpf.c: 14개 LSM 훅과 76개 tracepoint 전체, emit_violation() 구현이 있다.
  • ActPlane bpf/taint.h와 dsl/lower.rs: 이 글에서 다룬 byte-identical 구조체의 양쪽을 나란히 보면 정확히 어디가 대응하는지 확인할 수 있다.
  • ActPlane bpf/taint_engine.bpf.h: 라벨이 실제로 오가는 te_read_domain/te_write_flow_domain/te_fork와, 이 글이 다루지 않은 도메인/권한 계층 코드가 있다.
  • ActPlane docs/rule-language.md: 이 글이 인용한 E1 외에 E2~E13까지 13개 worked example이 있다.
  • 두 저장소의 CLAUDE.md(AgentSight, ActPlane): 이 글의 핵심 근거(fork 관계, ABI 동기화 규칙, 커널 버전, taint 명명 설명)가 전부 여기서 나왔다. 다만 두 문서 모두 코드보다 늦은 곳이 있다.
profile
DevOps Engineer

0개의 댓글