LabHub
배우기 러닝패스 코스

CKS — Kubernetesセキュリティスペシャリスト

seccomp・AppArmor・capabilityで締める

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

파드 스펙만으로 컨테이너가 커널에 요청할 수 있는 일을 줄이는 방법을 손에 익히고, 클러스터에 남아 있는 privileged 컨테이너를 찾아내는 탐지 절차를 만듭니다.

왜 중요한가

컨테이너는 호스트 커널을 공유합니다. 탈출 취약점은 대부분 시스템 콜이나 capability 를 통해 커널을 찌릅니다. 그래서 방어는 침입을 막는 쪽이 아니라, 침입한 프로세스가 할 수 있는 일을 미리 줄여 두는 쪽으로 설계합니다. seccomp 는 호출 가능한 시스템 콜을, AppArmor 는 접근 가능한 파일과 기능을, capabilities 는 root 권한의 조각을 각각 제한합니다. 세 층이 겹치기 때문에 하나가 뚫려도 나머지가 남습니다. 반대로 privileged: true 는 이 세 층을 한 번에 무효화하므로, 운영 클러스터에서 privileged 컨테이너를 세는 일이 곧 위험도 측정입니다.

주의: 이 실습 환경에는 실제 컨테이너 런타임이 없습니다. seccomp·AppArmor 프로파일이 정말로 강제되는지는 확인할 수 없고, 노드에 프로파일 파일이 있는지도 검사하지 않습니다. 채점은 파드 스펙의 필드가 정확히 쓰였는지만 봅니다. CKS 시험에서 채점하는 것도 결국 이 스펙입니다.

단계

  1. 네임스페이스 cks-sys 를 만들고 그 안에 파드 seccomp-default(이미지 nginx:1.27-alpine)를 만든다. 파드 레벨 spec.securityContext.seccompProfile.typeRuntimeDefault 다.

  2. cks-sys 에 파드 seccomp-custom 을 만든다. 파드 레벨 seccompProfile 의 typeLocalhost, localhostProfileprofiles/audit.json 이다.

  3. cks-sys 에 파드 apparmor-app(컨테이너 이름 app, 이미지 nginx:1.27-alpine)을 만들고 AppArmor 프로파일 k8s-custom-profile 을 건다. 컨테이너 securityContext.appArmorProfiletypeLocalhost, localhostProfilek8s-custom-profile 이다. (구버전 형식인 container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-custom-profile 어노테이션으로 해도 통과한다.)

  4. cks-sys 에 파드 capped(컨테이너 이름 app)를 만든다. 컨테이너 securityContext 에서 capabilities.dropALL 하나, capabilities.addNET_BIND_SERVICE 하나이고 allowPrivilegeEscalationfalse 다.

  5. 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 로 직접 확인해 보라.

  6. cks-sys 에 파드 legacy-agent(컨테이너 이름 agent)를 만들되 컨테이너 securityContext 의 privilegedtrue 로 둔다(레거시 워크로드 재현). 그다음 /root/cks-system-hardening/find-privileged.sh 에 탐지 스크립트를 쓰고, 그 실행 결과를 /root/cks-system-hardening/privileged.txt 에 저장한다. 결과는 cks-sys 네임스페이스에서 privileged 컨테이너(초기화 컨테이너 포함)를 가진 파드를 cks-sys/<파드이름> 형식으로 한 줄에 하나씩, 사전순 정렬한 것이다.

  7. cks-sys 에 파드 hardened(컨테이너 이름 app)를 만든다. 컨테이너 securityContext 에 readOnlyRootFilesystem: true, runAsNonRoot: true, runAsUser: 1000, allowPrivilegeEscalation: false, capabilities.drop: [ALL] 을 모두 넣고, 파드 레벨 seccompProfile.typeRuntimeDefault 로 둔다. 쓰기가 필요한 /tmp 는 이름이 tmp 인 emptyDir 볼륨으로 마운트한다.

참고

런타임 기본 seccomp 프로파일

네임스페이스 cks-sys 를 만들고 그 안에 파드 seccomp-default(이미지 nginx:1.27-alpine)를 만든다. 파드 레벨 spec.securityContext.seccompProfile.typeRuntimeDefault 다.

seccompProfile 은 파드 레벨 spec.securityContext 에도, 컨테이너 레벨에도 쓸 수 있습니다. 여기서는 파드 레벨입니다.

커스텀 seccomp 프로파일 지정

cks-sys 에 파드 seccomp-custom 을 만든다. 파드 레벨 seccompProfile 의 typeLocalhost, localhostProfileprofiles/audit.json 이다.

타입이 LocalhostlocalhostProfile 이 반드시 함께 있어야 하고, 경로는 노드의 seccomp 루트 기준 상대 경로입니다.

AppArmor 프로파일 붙이기

cks-sys 에 파드 apparmor-app(컨테이너 이름 app, 이미지 nginx:1.27-alpine)을 만들고 AppArmor 프로파일 k8s-custom-profile 을 건다. 컨테이너 securityContext.appArmorProfiletypeLocalhost, localhostProfilek8s-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.dropALL 하나, capabilities.addNET_BIND_SERVICE 하나이고 allowPrivilegeEscalationfalse 다.

dropALL 을 넣고 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 의 privilegedtrue 로 둔다(레거시 워크로드 재현). 그다음 /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.typeRuntimeDefault 로 둔다. 쓰기가 필요한 /tmp 는 이름이 tmp 인 emptyDir 볼륨으로 마운트한다.

루트를 읽기 전용으로 만들면 임시 파일 경로가 막힙니다. emptyDir 볼륨을 그 경로에 마운트해 주세요. 앞 단계에서 쓴 필드들을 한 파드에 모두 모읍니다.