M4 — 런타임: Tetragon eBPF로 셸 실행 차단¶
미션: data tier 파드에서 셸(/bin/sh 류) 실행을 커널에서 즉시 SIGKILL하되, 정상 바이너리는
건드리지 않는 선택적 TracingPolicy를 작성한다. DB 컨테이너가 셸을 띄울 일은 없다 — 그건 침입이다.
학습 성과 (면접에서 말할 수 있는 것): 셸 exec만 선택적으로 SIGKILL하는 TracingPolicy를 작성하고, 이 룰이 못 막는 것(나이브 직접 execve만 잡음 — renamed/execveat 우회, M8에서 측정)을 정직히 말할 수 있다. → 캡스톤 M4
클러스터 필요. 편집 파일: labs/m4/tracingpolicy.yaml (selectors).
선행: M2/M3 권장(같은 세션). 배경:
docs/04-runtime.md.selector 문법이 막막하면 → 배우기 모드: LEARN.md. kprobe 한 개(matchArgs Postfix + Sigkill)를 주석과 함께 읽고 → operator를 빈칸으로 채우고 → 나머지는 직접. 이 룰이 못 막는 우회까지 짚어준다.
왜 런타임 통제인가¶
네트워크 정책·Cedar·admission은 요청 전/시점에 막는다. 하지만 워크로드가 이미 털린 후엔 아무것도 안 본다. Tetragon(eBPF)은 런타임을 지속 감시한다 — data tier에서 셸 exec이 보이면 침입으로 간주하고 커널에서 죽인다.
무슨 공격을 막나: post-exploit 페이로드 실행이다. 앱 RCE(역직렬화·SSTI·log4shell류)로 코드 실행이
잡히면 다음 손동작은 거의 항상 셸을 띄워 발판(reverse shell, sh -i, 정찰·횡적 이동)을 만드는 것이다.
data tier db는 정상 운영에서 자식을 fork-exec할 일이 없다(프로브가 httpGet이라 exec조차 안 탄다) — 거기서
보이는 셸 exec은 정의상 침입이다. 통제 지점이 execve인 이유: 새 프로그램을 돌리려면 결국 execve(2)를
호출해야 하고, 그 첫 인자(arg0)가 실행 파일 경로다. 진입을 kprobe로 후킹해 경로를 보고 SIGKILL하면 셸은
첫 명령 전(이미지 로드 이전)에 죽는다 — detection이 아니라 prevention이다.
정직한 범위(과장 금지): 이 룰은 셸 이름의 직접 execve만 죽인다(arg0 Postfix). 셸을 다른 이름으로 띄우면 우회된다(renamed-binary/execveat/fd-exec — Q4). 정확한 주장은 "셸을 못 띄운다"가 아니라 "나이브 직접 셸-명 execve를 죽인다". 그 경계를 라이브로 측정하는 게 M8, 강건한 답은 zero-exec(execve+execveat 전부 Sigkill)·distroless다 — matchBinaries는 호출자를 매칭해 답이 아니다(THREAT_MODEL 잔여위험). 그 zero-exec가 실제 shipped 기본 (
k8s/tracingpolicy.yaml)이고 이 랩은 그 출발점인 학습용 프리미티브다. 강건화 기록: ADR 0001.
Step 0 — 베이스라인¶
클러스터가 떠 있다고 가정한다(M2~M5 한 세션). 안 떴으면 PowerShell에서
scripts\up.ps1먼저. 채점기는 Git Bash에서 (forward slash). SETUP.
# Git Bash 창에서:
kubectl cluster-info --context kind-cloudsec # 떴는지 확인 (에러면 → PowerShell: scripts\up.ps1)
bash labs/m4/grade.sh # 시작: selectors가 비어서 아무것도 안 죽인다 → 셸이 살아서(rc=0) FAIL
빈 selectors는 "매칭 없음 → 액션 없음" → 셸이 안 죽는다. 즉 통제가 없다.
id는 당연히 산다. 둘 다 살아있으면 "통제 없음"이지 "선택적 kill"이 아니다.
Step 1 — selectors 작성¶
labs/m4/tracingpolicy.yaml의 selectors를 채워라:
selectors:
- matchArgs:
- index: 0
operator: "Postfix"
values: ["/sh", "/bash", "/dash", "/ash", "/busybox"]
matchActions:
- action: Sigkill
index: 0= execve의 첫 인자(실행 파일 경로).operator: "Postfix"= 경로가 그 값으로 끝나면 매칭./bin/sh·/usr/bin/bash등 접두가 달라도 잡는다./usr/bin/id는 목록에 없으니 안 잡힌다 → 선택적.action: Sigkill= 커널에서 즉시 종료(rc=137).
전체 정책 한 줄씩(정답지 기준): tracingpolicy.solution.yaml을 위에서 아래로 — 각 줄이 없으면
무엇이 깨지는지가 그 줄의 존재 이유다.
| 줄 | 의미 | 빠지면 |
|---|---|---|
podSelector.matchLabels.tier: data |
이 정책은 tier: data 라벨 파드에만 붙는다. |
라벨 없으면 web/api까지 셸 kill → 정상 셸 쓰는 티어가 깨진다(과잉, ED3 범위 밖). |
kprobes[].call: "sys_execve" |
후킹 지점 = execve syscall 진입. | 다른 syscall이면 exec를 아예 못 본다. |
syscall: true |
call 값을 raw 함수명이 아닌 syscall로 해석(아키텍처별 __x64_sys_execve 래핑을 Tetragon이 처리). |
생략하면 심볼명을 직접 맞춰야 하고 이식성이 깨진다. |
args[].index: 0 + type: "string" |
execve 첫 인자를 문자열로 읽겠다고 선언. 이게 있어야 아래 matchArgs가 그 인자를 검사할 수 있다. |
인자를 안 읽으면 matchArgs가 검사할 대상이 없다. |
selectors[].matchArgs[].operator: "Postfix" |
"경로가 values 중 하나로 끝나면" 참. | Equal이면 /bin/sh를 못 잡는다(Step 2-②). 비우면 매칭 0 → 아무것도 안 죽음. |
values: ["/sh","/bash","/dash","/ash","/busybox"] |
죽일 셸 이름 목록(접두 무시). /usr/bin/id는 없음 → 생존. |
좁으면 우회(/dash만 빼도 dash 생존), 넓으면 과잉(Step 2-③). |
matchActions[].action: Sigkill |
매칭 시 커널에서 SIGKILL. | 비우면 "매칭은 되는데 액션 없음" → 안 죽음. sigkill/SIGKILL 오타도 무효(대소문자 정확히 Sigkill). |
args(무엇을 읽을지)와 selectors.matchArgs(읽은 걸 어떻게 판정할지)는 별개라 둘 다 index: 0을
가리켜야 같은 인자를 본다 — 위 표의 4행·5행이 짝이다.
Step 2 — break-and-fix (예측 → 확인)¶
values를["/sh"]하나만 남긴다 →sh는?bash는? (이 데모 이미지엔 어떤 셸이 있나 — busybox 기반이면?)operator를"Postfix"에서"Equal"로 바꾼다 →/bin/sh로 실행되면 매칭되나? (Equal은 정확히 그 문자열)values에/id를 추가한다 →id케이스는? (이제 과잉 kill — 정상 바이너리를 죽인다)matchActions를[]로 비운다(나머지는 그대로) →sh는? (매칭은 되는데 액션이 없다)values에Postfix로["/sh"]를 두되kprobe.args블록(index:0/type:string선언)을 통째로 지운다 → apply는 되나?sh는 죽나?
2번 후 열 것
Equal은 인자가 정확히 "/sh"여야 매칭. 실제 exec 경로는 "/bin/sh"라 매칭 안 됨 → 셸이 살아남아 FAIL. 그래서 경로 접두를 모르는 상황엔 Postfix가 맞다. 통제는 *실제 관측되는 값*에 맞춰야 한다.3번이 가르치는 것
과잉 차단도 결함이다 — 정상 운영(헬스체크가 id류를 쓸 수 있음)을 깨면 가용성 사고. 채점기가 "id는 살아야 PASS"를 요구하는 이유: 선택성이 핵심이지 "다 죽이기"가 아니다.4번 예측 → 확인
예측: matchArgs가 참이라 죽을 것 같다? 아니다. selector는 "조건(matchArgs) AND 액션(matchActions)"이다 — 액션이 비면 매칭돼도 *할 일이 없어* 셸이 산다(rc=0). 채점은 sh=137을 기대하므로 FAIL. matchArgs는 "언제", matchActions는 "무엇을"이고 둘 다 있어야 통제가 성립한다.5번이 가르치는 것 (조용한 실패의 함정)
apply는 *성공한다* — 스키마상 args는 필수가 아니다. 그래서 위험하다: 정책이 적용됐다고 안심하지만, arg를 읽도록 *선언*하지 않으면 matchArgs가 검사할 인자 데이터가 없어 매칭이 안 잡힐 수 있다(셸 생존, rc=0). "apply 성공 = 통제 작동"이 아니다. 통제는 항상 *동작*으로 검증해야 한다(grade.sh의 rc 확인) — 이게 채점기를 신뢰하고 yaml lint를 신뢰하지 않는 이유다.흔한 실수 (실제로 막히는 지점)¶
syscall: true누락. 빼면 환경에 따라 후킹이 안 붙어 FAIL인데 정책은 로드된 것처럼 보인다(표 3행).- eBPF 로드 지연. apply 직후 바로 테스트하면 아직 attach 전이라 셸이 산다 → false FAIL.
grade.sh가
sleep 6을 두는 이유. 수동 테스트 땐 몇 초 기다려라. - rc=137 vs 143. grade.sh는 둘 다 PASS로 친다 — 시그널 전달 타이밍/셸 래핑에 따라 137(SIGKILL)이 143(SIGTERM)으로 보일 수 있어서다. 죽었다는 사실이 핵심.
Step 3 — 구두 문답¶
왜 "셸이 죽었다"만으로는 부족하고 "id는 살았다"도 같이 봐야 하나?
전부 죽이는 정책(또는 파드가 그냥 안 떠서 모든 exec 실패)도 "셸 죽음"을 만족한다. id가 rc=0으로 살아야 "셸만 골라 죽인다 + 파드는 건강하다"가 증명된다 — false-pass 방지(이 repo의 적대적 검증이 실제로 이 클래스의 false-pass를 잡았다).eBPF 런타임 통제가 admission/netpol과 다른 점은?
admission/netpol은 생성/연결 시점의 사전 통제. Tetragon은 실행 중 syscall을 지속 관찰하는 사후 탐지·차단. 침해 후(post-exploit) 단계를 본다.Sigkill을 커널에서 하는 것과 사용자공간 에이전트가 죽이는 것의 차이는?
eBPF는 커널에서 syscall 시점에 즉시 죽여 타이밍 레이스엔 강하다. 단 "어느 syscall/이름을 후킹하느냐"는 별개 문제다 — arg0-Postfix는 renamed-binary로 쉽게 우회된다(Q4). 사용자공간 에이전트는 폴링/지연이 있어 빠른 공격이 빠져나갈 수 있다.이 통제의 한계(THREAT_MODEL 기준)는?
data tier에만, 셸 이름의 직접 execve만 본다(arg0 Postfix). 그래서 셸을 다른 이름으로 띄우면 우회된다: 쓰기가능 /tmp + busybox로cp /bin/busybox /tmp/x && /tmp/x sh, execveat(별도 syscall, 미후킹), fd-exec(arg0=/proc/self/fd/N). 즉 ED1은 "나이브 직접 셸-명 execve를 죽인다"이지 "셸을 못 띄운다"가 아니다 — 강건한 답은 zero-exec(execve+execveat 전부 Sigkill), allowlist가 필요하면 BPF-LSM(matchBinaries 아님 — Q5). 그밖에 nc/python로 셸 없이 하는 짓, 다른 tier, 노드 루트도 범위 밖(평가에서 ED2/ED3는 CONFIGURED/NOT_COVERED). 측정: M8.왜 shipped 기본은 "이름 목록"을 못 버리고 통째로 zero-exec로 갔나? matchBinaries로 nginx만 allowlist하면 안 되나?
matchBinaries는 execve의 호출자 바이너리를 매칭하지, 새로 뜰 이미지를 매칭하지 않는다. 그래서matchBinaries NotIn [nginx]는nginx -v(호출자=런타임 exec shim)를 잘못 죽이면서, nginx-RCE로 띄운 셸(호출자=nginx)은 놓친다 — "어떤 바이너리가 실행되나"엔 틀린 도구. data tier db는 정당하게 exec할 일이 0(PID1 본체 + httpGet 프로브)이므로, 이름·호출자 whack-a-mole 대신sys_execve+sys_execveat전부 SIGKILL이 정답. 이름 기반 allowlist가 정말 필요하면 arg0 문자열이 아니라 BPF-LSM(bprm_check_security)의 바이너리 신원이어야 한다. 근거: ADR 0001.zero-exec가 db의 entrypoint(PID1)도 execve로 뜨는데 왜 db가 CrashLoop 안 하나? distroless면 안전한가?
restart-tolerance는 이미지가 아니라 Tetragon의 enforcement-attach 창 덕분이다 — 컨테이너 PID1 entrypoint의 execve가 정책 attach 이전에 일어나 빠져나간다(이미지 무관, alpine·distroless 둘 다 라이브로 Ready 확인). fragile하다: 더 빠른 attach는 entrypoint를 죽여 CrashLoop을 낼 수 있다. distroless의 진짜 이점은 restart-safety가 아니라 이미지층이다 — chainguard/nginx는/bin/sh·busybox가 애초에 없어(정책 적용 전에 이미 "no such file") 공격자가 쓰기가능 마운트에 바이너리를 심어야만 한다. 즉 distroless(셸 제거) + zero-exec(심은 것도 kill)는 2중 방어지 같은 방어가 아니다. 라이브 caveat:k8s/tracingpolicy.yaml헤더 / ADR 0001.execve는 SIGKILL이 prevention인데, 같은 Tetragon으로 파일 write를 SIGKILL하면 그것도 prevention인가?
아니다 — kill 타이밍이 다르다. execve+Sigkill은 이미지 로드 이전에 죽여 셸이 첫 명령조차 못 한다(prevention-grade). 하지만 write() 같은 I/O syscall은 Tetragon 문서가 명시하듯 SIGKILL이 바이트 미기록을 보장하지 않는다 — 프로세스가 동기적으로 죽어도 커널이 이미 일부 I/O를 수행했을 수 있다(detection-point ≠ prevention-point). I/O를 prevention-grade로 하려면 Sigkill+Override(syscall 자체에 에러 반환)가 필요하다 — "Tetragon=차단"이 아니라 어느 syscall이냐가 prevention/detection을 가른다. 배경:docs/04-runtime.md"kill 타이밍".
현실 연결: Log4Shell(CVE-2021-44228)의 전형적 체인은 JNDI로 코드 실행 → 곧바로 reverse shell(
bash -i/nc)로 발판 확보였다. data tier에서 셸 exec을 커널에서 끊으면 그 다음 손동작을 차단한다(RCE 자체는 앞 계층 문제). 컨테이너를 최소 표면으로 운영(불필요한 셸·도구 제거)하라는 NIST SP 800-190의 권고를 distroless + zero-exec가 구현한다.
더 깊이 (1차 출처)¶
- Tetragon TracingPolicy / kprobe·selectors 문법: https://tetragon.io/docs/concepts/tracing-policy/
- selector operator(Postfix/Equal/Prefix…)·matchArgs·matchActions: https://tetragon.io/docs/concepts/tracing-policy/selectors/
- enforcement(Sigkill/Override)와 kill 타이밍 caveat: https://tetragon.io/docs/concepts/enforcement/
- 이 repo의 강건화 결정: ADR 0001 · 잔여위험:
THREAT_MODEL.md - 컨테이너 최소화 표준: NIST SP 800-190 (Application Container Security Guide)
졸업 기준¶
- [ ]
grade.shid=0 + sh=137 둘 다 PASS - [ ] "둘 다 요구"가 왜 false-pass를 막는지 설명할 수 있다
- [ ] Postfix vs Equal, 과잉 kill이 왜 결함인지 안다
- [ ] 구두 문답을 펼치기 전에 답할 수 있다 (특히 Q5 matchBinaries=호출자, Q7 execve=prevention vs write=detection)
- [ ] 이 룰이 못 죽이는 것(renamed-shell
/tmp/x sh·execveat·fd-exec)을 말할 수 있다 → 강건한 답은 zero-exec(execve+execveat)/distroless(matchBinaries 아님 — 호출자 매칭), M8에서 라이브 검증 - [ ]
labs/m4/tracingpolicy.solution.yaml과 비교 (그리고 실제 shipped 기본은 zero-execk8s/tracingpolicy.yaml임을 안다 — 이 선택적 룰은 그 출발점)
다음: M5 — 데이터 암호화 (run & interpret) (같은 세션에서).