Certified Kubernetes Security Specialist (CKS) #8 kernel hardening, capabilities, /proc 보호
#6 AppArmor 프로파일과 #7 seccomp 프로파일에서는 컨테이너가 부를 수 있는 시스템 콜과 접근할 수 있는 파일을 커널 수준에서 좁혔습니다. 이번 글은 같은 System Hardening도메인에서 한 단계 더 위, 즉 Pod와 컨테이너에 처음부터 과한 권한을 주지 않는 법을 정리하겠습니다. AppArmor와 seccomp가 “할 수 있는 행동"을 막는 도구라면, 이번 글의 securityContext 설정은 “애초에 가진 권한"을 깎는 도구입니다.
CKS 시험에서 가장 자주 나오는 유형 가운데 하나가 과한 권한으로 도는 Pod를 찾아 최소 권한으로 고치는 작업입니다. privileged: true를 떼고, capabilities를 drop ALL로 비운 뒤 필요한 것만 add하고, root로 도는 컨테이너를 비특권 사용자로 내리는 작업이 단골입니다. 이번 글에서 그 손놀림을 하나씩 익히겠습니다.
왜 권한을 깎는가 #
컨테이너는 호스트 커널을 공유합니다. 가상머신처럼 커널까지 분리되어 있지 않으므로, 컨테이너 안에서 권한을 충분히 모으면 호스트로 탈출할 길이 열립니다. 공격자가 노리는 것은 정확히 이 지점입니다. 취약한 애플리케이션으로 컨테이너 안에 발을 들인 뒤, 컨테이너가 가진 과한 권한을 발판으로 호스트와 다른 워크로드까지 장악하는 흐름입니다.
그래서 System Hardening의 핵심은 단순합니다. 컨테이너에 꼭 필요한 권한만 남기고 나머지를 모두 제거하는 것입니다. 권한이 없으면 그 권한을 악용한 공격도 성립하지 않습니다. 이번 글에서 다루는 모든 설정은 이 한 문장의 구체적인 실천입니다.
CKAD #15 SecurityContext와 Capabilities에서 securityContext의 기본 사용법을 다룬 적이 있습니다. CKS는 같은 필드를 공격 표면을 줄이는 보안 관점에서 다시 봅니다. 각 설정이 어떤 공격을 막는지를 함께 잡겠습니다.
Linux capabilities #
전통적인 유닉스는 권한을 root와 비root 둘로만 나눴습니다. root는 모든 것을 할 수 있고, 비root는 거의 아무것도 할 수 없었습니다. 이 이분법은 너무 거칠어서, 포트 1024 미만 바인딩처럼 작은 권한 하나가 필요해도 프로세스 전체를 root로 돌려야 했습니다. Linux capabilities는 root의 전능한 권한을 약 40개의 작은 권한 단위로 쪼갠 것입니다. 프로세스에 필요한 capability만 정확히 주면, root 없이도 그 일을 할 수 있습니다.
위험한 capabilities #
몇몇 capability는 사실상 root와 맞먹는 위력을 가집니다. 시험과 실무에서 특히 경계해야 하는 것들입니다.
| capability | 무엇을 허용하는가 | 위험 |
|---|---|---|
SYS_ADMIN | 마운트, 네임스페이스 조작 등 광범위한 관리 작업 | 사실상 root. 컨테이너 탈출의 단골 |
NET_ADMIN | 네트워크 인터페이스,라우팅,방화벽 규칙 변경 | 트래픽 가로채기, 네트워크 우회 |
NET_RAW | raw 소켓 생성 | 패킷 스푸핑, ARP 스푸핑 |
SYS_PTRACE | 다른 프로세스 메모리 추적,조작 | 같은 노드 프로세스 침해 |
SYS_MODULE | 커널 모듈 로드,언로드 | 커널 수준 장악 |
DAC_OVERRIDE | 파일 권한 검사 우회 | 임의 파일 읽기,쓰기 |
대부분의 애플리케이션은 이 가운데 어느 것도 필요하지 않습니다. 그런데도 컨테이너 런타임은 기본으로 여러 capability를 부여합니다. 그래서 보안의 기본은 전부 떨어뜨린 뒤 정말 필요한 것만 다시 더하는 방식입니다.
drop ALL 후 필요한 것만 add #
securityContext.capabilities의 drop과 add로 capability를 조정합니다. 가장 안전한 패턴은 drop: ["ALL"]로 모든 capability를 비운 뒤, 애플리케이션이 실제로 요구하는 것만 add에 적는 것입니다.
apiVersion: v1
kind: Pod
metadata:
name: cap-minimal
spec:
containers:
- name: app
image: nginx:1.27
securityContext:
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE위 예제는 모든 capability를 떨어뜨린 뒤, 1024 미만 포트 바인딩에 필요한 NET_BIND_SERVICE 하나만 다시 더했습니다. 대부분의 웹 서버는 이 정도면 충분합니다. capability 이름은 CAP_ 접두사 없이 적는다는 점을 기억해 두겠습니다. 매니페스트에서는 NET_BIND_SERVICE로 쓰지만, getcap이나 커널 문서에서는 CAP_NET_BIND_SERVICE로 나옵니다.
컨테이너가 어떤 capability를 들고 도는지는 노드에서 다음처럼 확인합니다.
# 컨테이너 PID 확인 후
grep CapEff /proc/<pid>/status
# capsh로 사람이 읽는 형태로 해독
capsh --decode=00000000a80425fb시험에서는 “이 컨테이너에서 NET_ADMIN을 제거하라"처럼 특정 capability를 떼는 작업도 나옵니다. 이때는 add 목록에서 그것을 지우거나, drop에 해당 capability를 명시하면 됩니다.
privileged 컨테이너의 위험 #
securityContext.privileged: true는 컨테이너에 호스트의 거의 모든 권한을 한 번에 줍니다. 모든 capability가 부여되고, 호스트의 모든 디바이스에 접근할 수 있으며, AppArmor와 seccomp 같은 보호 장치도 기본적으로 풀립니다. 사실상 컨테이너라는 경계가 없는 것과 같습니다.
# 절대 피해야 하는 설정
securityContext:
privileged: trueprivileged 컨테이너 안에서는 호스트의 디스크를 마운트하거나, 커널 모듈을 로드하거나, 다른 컨테이너의 프로세스를 들여다보는 일이 가능합니다. 즉 컨테이너 탈출의 가장 넓은 길입니다. 일부 시스템 수준 워크로드(스토리지 드라이버, 네트워크 플러그인)는 privileged를 요구하기도 하지만, 일반 애플리케이션에는 절대 필요하지 않습니다. CKS 시험에서 privileged Pod를 발견하면 그 자체가 고쳐야 할 결함입니다.
privileged: false로 명시하거나 아예 필드를 두지 않는 것이 기본입니다. privileged가 필요해 보이는 워크로드라도, 대개는 특정 capability 몇 개만 add하면 충분한 경우가 많습니다.
allowPrivilegeEscalation: false #
allowPrivilegeEscalation은 컨테이너 안의 프로세스가 자신을 시작한 부모보다 더 많은 권한을 얻는 것을 허용할지를 정합니다. 이 값이 기본 true이면, setuid 바이너리나 파일 capability를 통해 프로세스가 스스로 권한을 끌어올릴 수 있습니다.
securityContext:
allowPrivilegeEscalation: false이 한 줄을 false로 두면 컨테이너 안에서 권한 상승 경로 하나가 닫힙니다. 리눅스 커널의 no_new_privs 플래그를 설정하는 것과 같습니다. 비root로 도는 컨테이너라도 이 값을 명시적으로 끄는 것이 안전합니다. root로 도는 컨테이너에 setuid 바이너리가 있다면 권한 상승의 통로가 되므로, 이 설정은 특히 중요합니다.
runAsNonRoot와 runAsUser #
컨테이너를 root(UID 0)로 돌리면, 컨테이너 탈출 시 호스트에서도 강력한 권한을 가질 가능성이 커집니다. 그래서 컨테이너는 비root 사용자로 도는 것이 기본이어야 합니다.
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 3000runAsNonRoot: true는 이미지가 root로 시작하려 하면 컨테이너 기동을 거부합니다. 이미지 자체가 비root를 보장하지 않아도, 런타임이 한 번 더 막아 주는 안전장치입니다.runAsUser: 1000은 프로세스가 도는 UID를 직접 지정합니다. 이미지의 기본 사용자를 덮어씁니다.runAsGroup은 기본 그룹 ID를 지정합니다.
runAsNonRoot: true만 두고 runAsUser를 비워도, 이미지가 비root 사용자로 빌드되어 있다면 정상 기동합니다. 이미지가 root로만 도는 경우에는 runAsUser로 비root UID를 명시해 줘야 합니다. 다만 임의의 UID로 돌릴 때는 애플리케이션이 그 UID의 홈 디렉터리나 파일에 접근할 수 있는지 확인이 필요합니다.
readOnlyRootFilesystem #
readOnlyRootFilesystem: true는 컨테이너의 루트 파일시스템을 읽기 전용으로 만듭니다. 공격자가 컨테이너 안에 들어와도 악성 바이너리를 떨어뜨리거나 기존 파일을 변조할 수 없습니다.
securityContext:
readOnlyRootFilesystem: true대부분의 애플리케이션은 로그나 임시 파일을 위해 쓰기 가능한 경로가 일부 필요합니다. 이때는 루트는 읽기 전용으로 두고, 쓰기가 필요한 경로에만 emptyDir 볼륨을 붙입니다.
apiVersion: v1
kind: Pod
metadata:
name: readonly-root
spec:
containers:
- name: app
image: nginx:1.27
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /var/cache/nginx
- name: run
mountPath: /var/run
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
- name: run
emptyDir: {}이렇게 하면 애플리케이션은 정상 동작하면서도, 루트 파일시스템은 변조 불가능한 상태를 유지합니다. 이 패턴은 #18 Container immutability에서 다룰 불변 컨테이너의 기초이기도 합니다.
procMount로 /proc 보호 #
컨테이너 런타임은 기본적으로 /proc 아래 민감한 경로를 마스킹합니다. /proc/kcore(커널 메모리), /proc/sys의 일부, /proc/keys 같은 경로는 읽거나 쓰면 호스트 정보가 새거나 커널 설정이 바뀔 수 있어, 기본적으로 가려지거나 읽기 전용으로 묶입니다. 이 동작을 제어하는 것이 procMount입니다.
securityContext:
procMount: DefaultprocMount에는 두 값이 있습니다.
Default: 런타임이/proc의 민감한 경로를 마스킹,읽기 전용으로 처리합니다. 기본값이자 안전한 값입니다.Unmasked: 마스킹을 모두 해제해 컨테이너가/proc전체를 그대로 봅니다.
Unmasked는 컨테이너 안에서 호스트 커널 메모리와 설정에 접근할 길을 여는 위험한 설정입니다. 일부 디버깅 도구나 중첩 컨테이너 환경에서만 정당한 쓰임이 있고, 일반 워크로드에는 절대 필요하지 않습니다. CKS 시험에서 procMount: Unmasked를 발견하면 Default로 되돌리거나 필드를 제거하는 것이 정답입니다. 참고로 Unmasked를 쓰려면 Pod Security 정책상 가장 느슨한 수준이 허용되어야 하므로, #9 Pod Security Admission의 정책으로도 이를 차단할 수 있습니다.
host 네임스페이스 차단 #
Pod 수준에서 호스트의 네임스페이스를 공유하도록 여는 필드들이 있습니다. 이 필드들은 컨테이너와 호스트 사이의 격리를 직접 허무므로, 보안 관점에서 가장 먼저 점검해야 합니다.
| 필드 | 켜면 무슨 일이 일어나는가 | 위험 |
|---|---|---|
hostPID: true | 컨테이너가 호스트의 모든 프로세스를 봄 | 다른 워크로드 프로세스 추적,종료 |
hostNetwork: true | 컨테이너가 호스트의 네트워크 스택을 그대로 씀 | 모든 노드 포트 노출, 트래픽 가로채기 |
hostIPC: true | 컨테이너가 호스트의 IPC 네임스페이스 공유 | 호스트,다른 컨테이너의 공유 메모리 접근 |
# 모두 false가 기본이자 안전
spec:
hostPID: false
hostNetwork: false
hostIPC: false이 세 필드는 모두 기본값이 false이므로, 안전한 매니페스트라면 아예 등장하지 않습니다. true로 설정된 것이 보이면 그 자체가 결함 신호입니다. 시험에서는 “이 Pod가 호스트 프로세스를 보지 못하게 하라” 같은 지시가 나오며, 이는 hostPID를 떼라는 뜻입니다.
host 경로 마운트의 위험 #
hostPath 볼륨은 호스트의 파일시스템 경로를 컨테이너 안으로 직접 마운트합니다. 편리하지만 위험이 큽니다. /나 /etc, /var/run/docker.sock 같은 경로를 마운트하면, 컨테이너 안에서 호스트의 설정과 자격 증명, 컨테이너 소켓까지 손에 넣게 됩니다.
# 매우 위험한 마운트
volumes:
- name: host-root
hostPath:
path: /특히 docker.sock이나 containerd 소켓을 마운트하면, 컨테이너가 호스트의 컨테이너 런타임을 직접 조종해 새 privileged 컨테이너를 띄울 수 있습니다. 이는 곧장 호스트 장악으로 이어집니다. CKS 시험에서 위험한 hostPath를 발견하면 제거하거나, 정말 필요한 경우 마운트 범위를 최소한의 하위 경로로 좁히고 가능하면 읽기 전용(readOnly: true)으로 두는 것이 정답입니다.
종합: hardened Pod #
지금까지 본 설정을 한 매니페스트에 모으면 다음과 같습니다. 일반 웹 애플리케이션을 안전하게 돌리는 출발점으로 삼을 만합니다.
apiVersion: v1
kind: Pod
metadata:
name: hardened-app
spec:
hostPID: false
hostNetwork: false
hostIPC: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 2000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: nginx:1.27
securityContext:
allowPrivilegeEscalation: false
privileged: false
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
procMount: Default
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}이 매니페스트는 비root로 돌고, 권한 상승을 막고, capability를 비웠으며, 루트 파일시스템을 읽기 전용으로 두고, /proc을 보호하며, 호스트 네임스페이스를 공유하지 않습니다. Pod 수준 securityContext와 컨테이너 수준 securityContext가 겹칠 때는 컨테이너 수준이 우선한다는 점을 기억해 두겠습니다.
시험 포인트 #
- capabilities는 drop ALL 후 add. 모든 capability를 비운 뒤 필요한 것만 더하는 것이 정석입니다. 이름은
CAP_접두사 없이 적습니다. - privileged: true는 결함 신호. 발견하면 제거가 기본입니다. 대개 특정 capability 몇 개로 대체됩니다.
- allowPrivilegeEscalation: false. 권한 상승 경로를 닫는 한 줄이며 비root 컨테이너에도 명시하는 것이 안전합니다.
- runAsNonRoot,runAsUser. 컨테이너를 비root로 내립니다. 이미지가 root로만 돌면
runAsUser로 UID를 지정합니다. - readOnlyRootFilesystem: true + emptyDir. 루트는 읽기 전용으로, 쓰기 경로에는
emptyDir을 붙입니다. - procMount: Unmasked는 위험. 발견하면
Default로 되돌립니다. - hostPID,hostNetwork,hostIPC,hostPath. 모두 호스트와의 격리를 허무는 설정입니다.
true나 위험한 경로가 보이면 제거합니다. - 시험의 단골은 과한 권한 Pod를 찾아 최소 권한으로 고치는 작업입니다. 매니페스트를 읽고 위험 필드를 즉시 짚어 내는 눈을 길러야 합니다.
정리 #
이번 글에서 잡은 것:
- System Hardening의 핵심은 컨테이너에 꼭 필요한 권한만 남기는 것입니다. 권한이 없으면 악용도 없습니다.
- Linux capabilities는 root의 권한을 작은 단위로 쪼갠 것이며,
drop: ["ALL"]후 필요한 것만add하는 것이 기본입니다. privileged,allowPrivilegeEscalation,runAsNonRoot,readOnlyRootFilesystem,procMount는 컨테이너의 공격 표면을 좌우하는 핵심 필드입니다.hostPID,hostNetwork,hostIPC, 위험한hostPath마운트는 호스트와의 격리를 직접 허무므로 가장 먼저 점검합니다.- 시험에서는 과한 권한 Pod를 최소 권한으로 고치는 작업이 단골이므로, 위험 필드를 한눈에 짚어 내는 연습이 점수로 이어집니다.
다음: Pod Security Admission #
지금까지는 매니페스트 하나하나를 직접 hardened 상태로 고쳤습니다. 그런데 클러스터에 들어오는 모든 Pod를 사람이 일일이 검사할 수는 없습니다. 그래서 위험한 Pod를 admission 단계에서 자동으로 거부하는 장치가 필요합니다.
#9 Pod Security Admission (PSA, Pod Security Standards)에서는 네임스페이스 라벨 하나로 privileged,baseline,restricted 수준의 정책을 강제하는 법, 이번 글에서 본 위험 필드들이 어느 수준에서 차단되는지, 그리고 enforce,audit,warn 모드를 어떻게 조합하는지를 직접 적용하며 정리하겠습니다.