CKS — Kubernetes Security Specialist
Tightening With seccomp, AppArmor and Capabilities
한국어 원문으로 표시합니다.
목표
파드 스펙만으로 컨테이너가 커널에 요청할 수 있는 일을 줄이는 방법을 손에 익히고, 클러스터에 남아 있는 privileged 컨테이너를 찾아내는 탐지 절차를 만듭니다.
왜 중요한가
컨테이너는 호스트 커널을 공유합니다. 탈출 취약점은 대부분 시스템 콜이나 capability 를 통해
커널을 찌릅니다. 그래서 방어는 침입을 막는 쪽이 아니라, 침입한 프로세스가 할 수 있는 일을
미리 줄여 두는 쪽으로 설계합니다. seccomp 는 호출 가능한 시스템 콜을, AppArmor 는 접근 가능한
파일과 기능을, capabilities 는 root 권한의 조각을 각각 제한합니다. 세 층이 겹치기 때문에
하나가 뚫려도 나머지가 남습니다. 반대로 privileged: true 는 이 세 층을 한 번에 무효화하므로,
운영 클러스터에서 privileged 컨테이너를 세는 일이 곧 위험도 측정입니다.
주의: 이 실습 환경에는 실제 컨테이너 런타임이 없습니다. seccomp·AppArmor 프로파일이 정말로 강제되는지는 확인할 수 없고, 노드에 프로파일 파일이 있는지도 검사하지 않습니다. 채점은 파드 스펙의 필드가 정확히 쓰였는지만 봅니다. CKS 시험에서 채점하는 것도 결국 이 스펙입니다.
단계
-
네임스페이스
cks-sys를 만들고 그 안에 파드seccomp-default(이미지nginx:1.27-alpine)를 만든다. 파드 레벨spec.securityContext.seccompProfile.type은RuntimeDefault다. -
cks-sys에 파드seccomp-custom을 만든다. 파드 레벨 seccompProfile 의type은Localhost,localhostProfile은profiles/audit.json이다. -
cks-sys에 파드apparmor-app(컨테이너 이름app, 이미지nginx:1.27-alpine)을 만들고 AppArmor 프로파일k8s-custom-profile을 건다. 컨테이너securityContext.appArmorProfile의type은Localhost,localhostProfile은k8s-custom-profile이다. (구버전 형식인container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-custom-profile어노테이션으로 해도 통과한다.) -
cks-sys에 파드capped(컨테이너 이름app)를 만든다. 컨테이너 securityContext 에서capabilities.drop은ALL하나,capabilities.add는NET_BIND_SERVICE하나이고allowPrivilegeEscalation은false다. -
cks-sys에 파드no-host-ns를 만든다.hostNetwork,hostPID,hostIPC,shareProcessNamespace를 모두false로 명시한다.여기에 감사에서 반드시 알아야 할 사실이 하나 숨어 있다. PodSpec 에서 앞의 세 필드는
bool+omitempty라,false로 적어도 직렬화 단계에서 통째로 빠진다. 즉 저장된 오브젝트만 보면 "false 라고 적었다" 와 "아예 안 적었다" 를 구별할 수 없다. 그래서 실제 감사 기준은 "false 라고 적혀 있나" 가 아니라 "true 가 아닌가" 다. 반면shareProcessNamespace는*bool이라false가 그대로 남는다 — 채점기도 이 차이를 그대로 반영한다. 적용한 뒤kubectl get pod no-host-ns -n cks-sys -o yaml로 직접 확인해 보라. -
cks-sys에 파드legacy-agent(컨테이너 이름agent)를 만들되 컨테이너 securityContext 의privileged를true로 둔다(레거시 워크로드 재현). 그다음/root/cks-system-hardening/find-privileged.sh에 탐지 스크립트를 쓰고, 그 실행 결과를/root/cks-system-hardening/privileged.txt에 저장한다. 결과는cks-sys네임스페이스에서 privileged 컨테이너(초기화 컨테이너 포함)를 가진 파드를cks-sys/<파드이름>형식으로 한 줄에 하나씩, 사전순 정렬한 것이다. -
cks-sys에 파드hardened(컨테이너 이름app)를 만든다. 컨테이너 securityContext 에readOnlyRootFilesystem: true,runAsNonRoot: true,runAsUser: 1000,allowPrivilegeEscalation: false,capabilities.drop: [ALL]을 모두 넣고, 파드 레벨seccompProfile.type은RuntimeDefault로 둔다. 쓰기가 필요한/tmp는 이름이tmp인 emptyDir 볼륨으로 마운트한다.
참고
kubectl run seccomp-default --image=nginx:1.27-alpine -n cks-sys --dry-run=client -o yaml > pod.yaml로 초안을 뽑고 편집하는 편이 빠릅니다.kubectl explain pod.spec.securityContext.seccompProfile로 필드 이름을 확인하세요.- 탐지 스크립트 힌트:
kubectl get pods -n cks-sys -o json | jq -r '...'에서.spec.containers[]와.spec.initContainers[]를 함께 훑습니다. - 흔한 실수 1:
capabilities를 파드 레벨 securityContext 에 넣습니다. capabilities 는 컨테이너 레벨에만 있습니다. - 흔한 실수 2:
localhostProfile에 절대 경로를 씁니다. 노드의 seccomp 루트 기준 상대 경로여야 합니다.
런타임 기본 seccomp 프로파일
네임스페이스 cks-sys 를 만들고 그 안에 파드 seccomp-default(이미지
nginx:1.27-alpine)를 만든다. 파드 레벨 spec.securityContext.seccompProfile.type 은
RuntimeDefault 다.
seccompProfile 은 파드 레벨 spec.securityContext 에도, 컨테이너 레벨에도 쓸 수 있습니다. 여기서는 파드 레벨입니다.
커스텀 seccomp 프로파일 지정
cks-sys 에 파드 seccomp-custom 을 만든다. 파드 레벨 seccompProfile 의 type 은
Localhost, localhostProfile 은 profiles/audit.json 이다.
타입이 Localhost 면 localhostProfile 이 반드시 함께 있어야 하고, 경로는 노드의 seccomp 루트 기준 상대 경로입니다.
AppArmor 프로파일 붙이기
cks-sys 에 파드 apparmor-app(컨테이너 이름 app, 이미지 nginx:1.27-alpine)을 만들고
AppArmor 프로파일 k8s-custom-profile 을 건다. 컨테이너
securityContext.appArmorProfile 의 type 은 Localhost, localhostProfile 은
k8s-custom-profile 이다. (구버전 형식인
container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-custom-profile
어노테이션으로 해도 통과한다.)
1.30 부터는 컨테이너 securityContext.appArmorProfile 필드를 씁니다. 예전 클러스터라면 container.apparmor.security.beta.kubernetes.io/<컨테이너이름> 어노테이션 형식입니다. 둘 중 하나면 됩니다.
capability 전부 버리고 하나만 되찾기
cks-sys 에 파드 capped(컨테이너 이름 app)를 만든다. 컨테이너 securityContext 에서
capabilities.drop 은 ALL 하나, capabilities.add 는 NET_BIND_SERVICE 하나이고
allowPrivilegeEscalation 은 false 다.
drop 에 ALL 을 넣고 add 에 꼭 필요한 것만 적습니다. capability 이름에는 CAP_ 접두어를 붙이지 않습니다.
호스트 네임스페이스 차단
cks-sys 에 파드 no-host-ns 를 만든다. hostNetwork, hostPID, hostIPC,
shareProcessNamespace 를 모두 false 로 명시한다.
hostNetwork, hostPID, hostIPC, shareProcessNamespace 는 전부 파드 스펙 최상위 필드입니다. 넷 다 false 로 적어 두되, 저장된 오브젝트를 다시 읽으면 앞의 셋은 사라지고 shareProcessNamespace 만 남습니다 — 왜 그런지는 지시문 5번을 보세요.
privileged 컨테이너 탐지
cks-sys 에 파드 legacy-agent(컨테이너 이름 agent)를 만들되 컨테이너
securityContext 의 privileged 를 true 로 둔다(레거시 워크로드 재현).
그다음 /root/cks-system-hardening/find-privileged.sh 에 탐지 스크립트를 쓰고,
그 실행 결과를 /root/cks-system-hardening/privileged.txt 에 저장한다. 결과는 cks-sys
네임스페이스에서 privileged 컨테이너(초기화 컨테이너 포함)를 가진 파드를
cks-sys/<파드이름> 형식으로 한 줄에 하나씩, 사전순 정렬한 것이다.
kubectl get pods -o json 을 jq 로 훑되 initContainers 도 함께 봐야 놓치지 않습니다. 출력 형식은 네임스페이스/파드이름 입니다.
읽기 전용 루트 파일시스템 종합
cks-sys 에 파드 hardened(컨테이너 이름 app)를 만든다. 컨테이너 securityContext 에
readOnlyRootFilesystem: true, runAsNonRoot: true, runAsUser: 1000,
allowPrivilegeEscalation: false, capabilities.drop: [ALL] 을 모두 넣고,
파드 레벨 seccompProfile.type 은 RuntimeDefault 로 둔다. 쓰기가 필요한 /tmp 는
이름이 tmp 인 emptyDir 볼륨으로 마운트한다.
루트를 읽기 전용으로 만들면 임시 파일 경로가 막힙니다. emptyDir 볼륨을 그 경로에 마운트해 주세요. 앞 단계에서 쓴 필드들을 한 파드에 모두 모읍니다.