root Inside the Container Is root on the Host
한국어 원문으로 표시합니다.
한 줄 요약
유저 네임스페이스를 켜지 않았다면 컨테이너의 root 는 호스트의 root 와 같은 UID 0 입니다. "컨테이너는 격리돼 있으니 안에서 root 여도 된다"는 말은 틀렸습니다.
왜 이게 필요했나
대부분의 공식 이미지가 아직도 root 로 뜹니다. docker run --rm node:22-bookworm-slim id
를 쳐 보면 uid=0(root) 가 나옵니다. 여기까지는 놀랍지 않은데, 다음 한 줄이
문제입니다.
docker run --rm -v /etc:/host-etc alpine:3.20 sh -c 'echo "# injected" >> /host-etc/hosts'
이게 그대로 먹습니다. 호스트의 /etc/hosts 가 수정됩니다. 컨테이너가 상자였다면
불가능한 일이지만, 앞 코스에서 본 대로 컨테이너는 상자가 아니라 시야가 제한된
프로세스이고, 마운트로 뚫린 경로에는 그 프로세스의 UID 0 권한이 그대로 적용
됩니다. 격리가 깨진 것이 아니라 처음부터 그렇게 설계된 것입니다.
어떻게 동작하나
정석은 이미지 자체를 비특권 사용자로 만드는 것입니다.
RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin app
COPY --chown=10001:10001 . /app
USER 10001:10001
이름이 아니라 숫자 UID 여야 하는 이유가 있습니다. 쿠버네티스의
runAsNonRoot 는 이미지의 USER 가 이름이면 root 인지 아닌지 판별하지 못하고
파드를 거부합니다.
Error: container has runAsNonRoot and image has non-numeric user (app),
cannot verify user is non-root
비특권 사용자는 1024 미만 포트를 열 수 없습니다. 여기서 NET_BIND_SERVICE
capability 를 더하고 싶어지는데, 대개는 앱 포트를 8080 으로 바꾸고 서비스
계층에서 80 으로 노출하는 편이 훨씬 단순합니다. 권한을 더하는 선택은 마지막에
두세요.
capability 는 root 권한을 40여 조각으로 쪼갠 것이고, 컨테이너 기본 세트는 그중
14개입니다(CapEff: 00000000a80425fb). cap_chown, cap_dac_override,
cap_fowner, cap_setuid, cap_setgid, cap_net_bind_service, cap_net_raw,
cap_sys_chroot, cap_mknod, cap_setfcap 등이 들어 있고 SYS_ADMIN, NET_ADMIN,
SYS_PTRACE 는 빠져 있습니다.
권장 조합은 이렇습니다.
--cap-drop=ALL --cap-add=NET_BIND_SERVICE
--security-opt no-new-privileges:true
--read-only --tmpfs /tmp
--user 10001:10001
seccomp 기본 프로파일은 400여 개 시스템 콜 중 약 44개를 막습니다. 그 안에
컨테이너 탈출에 실제로 쓰인 open_by_handle_at, 그리고 keyctl, kexec_load
가 포함돼 있습니다.
현장에서 만나는 모습
--privileged 는 위 보호를 한 번에 전부 해제하는 스위치입니다. 그리고
도커 소켓을 마운트하는 것도 사실상 같은 의미입니다. 그 소켓이 있으면 호스트
루트 파일시스템을 마운트한 특권 컨테이너를 새로 띄울 수 있기 때문입니다. CI 러너
설정에서 이 두 가지를 습관처럼 켜 두는 경우가 많은데, 그 순간 나머지 보안 설정은
장식이 됩니다.
실제 사고도 이 그림 위에 있습니다. CVE-2019-5736 은 /proc/self/exe 를 통해
호스트의 runc 바이너리를 덮어썼고, CVE-2024-21626 은 runc 가 파일 디스크립터를
닫지 않은 탓에 WORKDIR 조작만으로 호스트 파일시스템에 접근할 수 있었습니다.
둘 다 커널 버그가 아니라 런타임 버그였지만, 컨테이너가 호스트와 같은 커널
아래 있기 때문에 성립했습니다.
권한을 실제로 어디까지 깎을 수 있나
"루트로 안 돌린다" 는 시작일 뿐이다. 컨테이너에서 깎을 수 있는 것은 사용자, 권한(capability), 파일 시스템, 시스템 콜, 네 층이다. 네 층을 다 만지면 침해 뒤에 할 수 있는 일이 크게 줄어든다.
securityContext:
runAsNonRoot: true
runAsUser: 10001
allowPrivilegeEscalation: false # setuid 로 권한을 되찾는 길을 막는다
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
allowPrivilegeEscalation: false 가 조용히 큰 역할을 한다. 이것이 켜져 있으면
컨테이너 안의 setuid 바이너리로 권한을 다시 올릴 수 있다. 사용자를 낮춰 놓고도
이 값을 안 끄면 절반만 한 셈이다.
권한은 전부 버리고 필요한 것만 되돌린다. 1024 미만 포트를 열어야 한다면
NET_BIND_SERVICE 하나만 더한다. 더 나은 답은 애초에 8080 을 듣게 하고 서비스가
80으로 매핑하는 것이라, 권한을 더할 이유 자체가 사라진다.
읽기 전용 루트는 쓰기가 필요한 자리를 드러낸다. 켜면 대개 처음에 깨지는데,
그 자리가 곧 그 프로그램이 어디에 쓰는지다. emptyDir 를 /tmp 와 캐시 경로에만
붙이면 나머지는 못 쓰게 된다. 공격자가 도구를 내려받아 저장할 자리가 없어지는
것이 이 설정의 진짜 값이다.
파일 소유권 때문에 못 뜨는 것이 가장 흔한 걸림돌이다. 이미지가 루트 소유로
만들어졌는데 다른 사용자로 실행하면 읽지 못한다. 이미지를 만들 때
COPY --chown=10001:10001 로 미리 맞추는 편이 낫다. 볼륨이라면 fsGroup 이
마운트 시점에 그룹을 바꿔 준다.
seccomp 는 기본 프로파일만으로 충분한 경우가 많다. RuntimeDefault 는
unshare, ptrace 등 위험한 시스템 콜 수십 개를 막는다. 직접 만든 프로파일은
정확하지만, 커널이나 런타임이 바뀌면 조용히 깨진다는 유지 비용이 붙는다.
기본을 켜는 것만으로 대부분의 이득을 얻는다.
다음 실습에서 할 것
기본 이미지가 root 로 뜨는 것을 확인하고, 런타임 플래그와 Dockerfile 두 가지 방법으로 비특권 실행을 만들고, 읽기 전용 루트와 capability 전면 제거, 권한 상승 차단을 하나씩 겁니다. 마지막에는 이 조건을 모두 만족하는 이미지를 직접 만듭니다.