LabHub
배우기 러닝패스 코스

Container Security

root Inside the Container Is root on the Host

LabHub 에서 이어서 보기

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

한 줄 요약

유저 네임스페이스를 켜지 않았다면 컨테이너의 root 는 호스트의 root 와 같은 UID 0 입니다. "컨테이너는 격리돼 있으니 안에서 root 여도 된다"는 말은 틀렸습니다.

Concept map: UID 0 권한이 그대로 · 이름이 아니라 숫자 UID 여야 하는 이유 · 14개 · 약 44개

왜 이게 필요했나

대부분의 공식 이미지가 아직도 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 는 기본 프로파일만으로 충분한 경우가 많다. RuntimeDefaultunshare, ptrace 등 위험한 시스템 콜 수십 개를 막는다. 직접 만든 프로파일은 정확하지만, 커널이나 런타임이 바뀌면 조용히 깨진다는 유지 비용이 붙는다. 기본을 켜는 것만으로 대부분의 이득을 얻는다.

다음 실습에서 할 것

기본 이미지가 root 로 뜨는 것을 확인하고, 런타임 플래그와 Dockerfile 두 가지 방법으로 비특권 실행을 만들고, 읽기 전용 루트와 capability 전면 제거, 권한 상승 차단을 하나씩 겁니다. 마지막에는 이 조건을 모두 만족하는 이미지를 직접 만듭니다.