CKS — 쿠버네티스 보안 전문가 · 시스템 하드닝 · 이론
컨테이너는 격리가 아니라 제한이다
한 줄 요약
컨테이너는 호스트 커널을 공유합니다. 그래서 시스템 하드닝은 "격리를 강화한다"가 아니라
"공유한 커널에 대고 무엇을 요청할 수 있는지 줄인다"에 가깝습니다.
왜 이게 필요했나
가상 머신은 커널이 따로 있습니다. 게스트에서 커널 취약점을 찔러도 하이퍼바이저 경계를 넘기가
어렵습니다. 컨테이너는 다릅니다. 노드 위 모든 파드가 같은 커널을 쓰고, 커널의 시스템 콜
인터페이스는 수백 개입니다. 컨테이너 탈출 CVE 는 대부분 이 표면 어딘가를 찌릅니다.
그래서 방어 논리가 바뀝니다. 침입 자체를 막는 대신, 침입한 프로세스가 커널에 요청할 수 있는
동작을 미리 줄여 둡니다. 리눅스에는 이 목적의 장치가 세 층으로 존재하고, 쿠버네티스는 셋 다
파드 스펙 필드로 노출합니다.
| 장치 | 제한 대상 | 파드 스펙 필드 |
| --- | --- | --- |
| seccomp | 호출 가능한 시스템 콜 목록 | securityContext.seccompProfile |
| AppArmor / SELinux | 접근 가능한 파일·네트워크·기능 (MAC) | securityContext.appArmorProfile |
| capabilities | root 권한의 세부 조각 | securityContext.capabilities |
어떻게 동작하나
seccomp 는 RuntimeDefault 와 Localhost 두 타입이 실무의 전부입니다. RuntimeDefault 는
컨테이너 런타임이 들고 있는 기본 차단 목록을 쓰고, Localhost 는 노드의<seccomp-root>/profiles 아래 JSON 프로파일을 localhostProfile 경로로 지정합니다.
프로파일 파일이 노드에 없으면 파드는 기동하지 못합니다. 스펙에 썼다는 것과 노드에 있다는 것은
별개입니다.
AppArmor 는 쿠버네티스 1.30 에서 어노테이션에서 정식 필드로 승격했습니다. 예전 형식은container.apparmor.security.beta.kubernetes.io/<컨테이너이름> 어노테이션이었고, 지금은securityContext.appArmorProfile.type 과 localhostProfile 입니다. 시험에서는 클러스터
버전에 따라 둘 다 나올 수 있으므로 두 형태를 모두 알아야 합니다. 여기서도 프로파일은 노드에
미리 로드돼 있어야 하고, aa-status 로 확인합니다.
capabilities 는 이해가 가장 쉽고 효과는 가장 큽니다. 컨테이너의 root 는 진짜 root 가 아니라
capability 묶음을 가진 UID 0 입니다. drop: ["ALL"] 로 전부 버린 뒤 정말 필요한 것만add 하면, 예컨대 80 포트를 열어야 하는 웹 서버는 NET_BIND_SERVICE 하나만 있으면 됩니다.privileged: true 는 이 모든 것을 무효로 만듭니다. 모든 capability 를 주고, 모든 디바이스
접근을 열고, AppArmor·SELinux·seccomp 프로파일까지 비활성화합니다. 그래서 privileged 컨테이너를
찾는 일이 CKS 의 단골 문제입니다.
호스트 네임스페이스 공유도 같은 층입니다. hostPID: true 면 컨테이너에서 노드의 모든 프로세스가
보이고, /proc/<PID>/environ 을 통해 다른 프로세스의 환경변수를 읽을 수 있습니다. 시크릿을
환경변수로 넣은 워크로드가 옆에 있다면 그것으로 끝입니다. hostNetwork 는 네트워크 정책을
사실상 무의미하게 만들고, hostIPC 는 공유 메모리 경계를 지웁니다.
마지막이 readOnlyRootFilesystem: true 입니다. 침입자가 바이너리를 떨어뜨리거나 기존 바이너리를
바꿔치기하는 경로를 지웁니다. 쓰기가 필요한 경로는 emptyDir 로 따로 마운트합니다.
현장에서 만나는 모습
컨테이너 이미지 스캔 결과를 읽다 보면 이 층의 필요성이 분명해집니다. node:22 이미지 하나에
설치된 패키지가 432 개이고 취약점 리포트는 1,247 건(CRITICAL 9, HIGH 137)이 나옵니다.
그런데 ldd 로 애플리케이션이 실제로 링크하는 공유 라이브러리를 세어 보면 8 개입니다.
나머지는 베이스 이미지가 딸려 온 것이고, 그 대부분은 셸과 패키지 관리자와 유틸리티입니다.
침입자에게는 그것이 도구 상자입니다. 이미지에서 지우는 것이 최선이고, 지울 수 없다면
capability 와 seccomp 로 그 도구들이 커널에 요청할 수 있는 일을 줄이는 것이 차선입니다.
CRI 층에서도 같은 이야기가 반복됩니다. containerd 는 파드 스펙의 SecurityContext 를 OCI 스펙으로
번역합니다. readOnlyRootFilesystem: true 는 root.readonly = true 가 되고,capabilities 는 process.capabilities 로, seccompProfile 은 linux.seccomp 로 내려갑니다.
그리고 privileged: true 는 이 번역 과정에서 모든 capability 부여, 모든 디바이스 허용,
AppArmor·SELinux·seccomp 비활성화로 펼쳐집니다. 필드 하나가 세 층을 동시에 무너뜨리는
구조입니다. 실제로 적용됐는지는 노드에서 crictl inspect 로 최종 스펙을 읽어 확인합니다.
다음 실습에서 할 것
이 환경에는 실제 컨테이너 런타임이 없으므로 프로파일이 정말 강제되는지는 확인할 수 없습니다.
대신 CKS 가 실제로 채점하는 것, 즉 파드 스펙의 필드를 정확히 쓰는 연습을 합니다. seccomp 두
타입, AppArmor 프로파일, capability drop/add, 호스트 네임스페이스 차단, 그리고 privileged
컨테이너를 찾아내는 탐지 스크립트까지 손으로 만듭니다.