LabHub
배우기 러닝패스 코스

Container Security

Tightening a Container to Least Privilege

LabHub 에서 이어서 보기

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

이 실습은 진짜 VM 에서 돕니다

이 상자는 파드가 아니라 KubeVirt 가 띄운 가상머신입니다. 리눅스 커널이 따로 돌고 systemd 가 실제로 서비스를 관리하며, docker 는 흉내가 아니라 진짜 도커 엔진입니다. docker run 으로 띄운 컨테이너는 실제로 프로세스가 되고 docker execdocker logs 도 그대로 동작합니다.

예전에는 이 실습이 파드 안에서 돌았습니다. 커널 권한을 전부 내려놓은 상자라 컨테이너를 띄우는 단계가 막혀 있었고, 그래서 이미지 아카이브를 직접 풀어 보는 우회로 배웠습니다. 이제 우회가 필요 없습니다.

알아 둘 것이 둘 있습니다.

목표

컨테이너를 비특권 사용자로 실행하는 두 가지 방법(런타임 플래그와 이미지 내장)을 모두 만들어 보고, 읽기 전용 루트 파일시스템·capability 전면 제거·권한 상승 차단을 하나씩 적용합니다. 마지막에는 이 조건을 만족하는 이미지를 직접 완성합니다.

왜 중요한가

"컨테이너는 격리돼 있으니 안에서 root 여도 된다"는 말이 틀린 이유는 단순합니다. 유저 네임스페이스를 쓰지 않으면 컨테이너의 UID 0 은 호스트의 UID 0 과 같은 값이고, 볼륨으로 뚫린 경로에는 그 권한이 그대로 적용됩니다. 그래서 최소 권한은 선택이 아니라 기본값이어야 합니다. 특히 USER숫자로 쓰는 습관이 중요합니다. 쿠버네티스의 runAsNonRoot 는 이름으로 된 사용자를 root 가 아니라고 판별하지 못해 파드를 거부하고, 그 오류 메시지를 처음 보면 원인을 찾는 데 한참 걸립니다. capability 도 마찬가지입니다. 필요한 것만 빼는 방식은 언젠가 빠뜨리지만, 전부 떨어뜨린 뒤 필요한 하나만 더하는 방식은 빠뜨릴 수가 없습니다.

단계

  1. /root/sec1 디렉터리를 만들고, alpine:3.20 을 아무 사용자 지정 없이 실행했을 때의 숫자 UID 만 /root/sec1/default-uid.txt 에 저장합니다. 파일에는 0 하나만 있어야 합니다. 컨테이너를 띄우지 않고도 확답할 수 있습니다. 실행 사용자는 이미지 config 의 config.User 필드에 적혀 있고, 비어 있으면 런타임이 uid 0 을 씁니다 — skopeo inspect --config oci-archive:/opt/images/alpine_3.20.tar | jq -r '.config.User' 로 확인하세요.
  2. alpine:3.20 으로 sec-u 컨테이너를 --user 1000:1000 으로 백그라운드 실행하고 채점 시점까지 떠 있게 유지합니다.
  3. /root/sec1/Dockerfile 을 작성해 labhub/sec:v1 이미지를 빌드합니다. USER 지시어로 숫자 UID 10001 을 지정해서 docker run --rm labhub/sec:v1 id -u10001 을 내야 합니다.
  4. 비특권 사용자로 /etc 아래 파일을 만들려고 시도하고, 나온 Permission denied 오류를 표준 오류까지 포함해 /root/sec1/denied.txt 에 저장합니다.
  5. sec-ro 컨테이너를 --read-only 로 백그라운드 실행하고, 그 안에서 파일 쓰기를 시도해 나온 Read-only file system 오류를 /root/sec1/ro.txt 에 저장합니다.
  6. sec-caps 컨테이너를 --cap-drop=ALL --cap-add=NET_BIND_SERVICE 로 백그라운드 실행합니다.
  7. sec-nnp 컨테이너를 --security-opt no-new-privileges:true 로 백그라운드 실행합니다.
  8. /root/sec1/hardened.Dockerfile 을 작성해 labhub/sec:v2 이미지를 빌드합니다. 다음 네 가지를 모두 만족해야 합니다.
    • FROM 이 구체적인 태그로 고정돼 있고 latest 가 아닐 것 (예: alpine:3.20)
    • 마지막 USER숫자 UID 이고 값이 10000 이상일 것
    • org.opencontainers.image.source 라벨이 있을 것
    • 실제로 실행했을 때 id -u 가 그 UID 를 낼 것

참고

기본 이미지는 root 로 뜬다

/root/sec1 디렉터리를 만들고, alpine:3.20 을 아무 사용자 지정 없이 실행했을 때의 숫자 UID 만 /root/sec1/default-uid.txt 에 저장합니다. 파일에는 0 하나만 있어야 합니다. 컨테이너를 띄우지 않고도 확답할 수 있습니다. 실행 사용자는 이미지 config 의 config.User 필드에 적혀 있고, 비어 있으면 런타임이 uid 0 을 씁니다 — skopeo inspect --config oci-archive:/opt/images/alpine_3.20.tar | jq -r '.config.User' 로 확인하세요.

id 전체 출력이 아니라 숫자 UID 하나만 저장해야 합니다. uid=0(root) gid=0... 형태를 그대로 넣으면 숫자가 여러 개 섞여 실패합니다. 숫자만 내주는 옵션이 있습니다.

런타임 플래그로 UID 바꾸기

alpine:3.20 으로 sec-u 컨테이너를 --user 1000:1000 으로 백그라운드 실행하고 채점 시점까지 떠 있게 유지합니다.

이미지를 다시 빌드하지 않고도 실행 시점에 사용자를 바꾸는 플래그가 있습니다. UID:GID 형식으로 줍니다. 채점이 컨테이너 안에서 명령을 실행하므로 컨테이너가 계속 떠 있어야 합니다.

이미지 자체를 비root 로

/root/sec1/Dockerfile 을 작성해 labhub/sec:v1 이미지를 빌드합니다. USER 지시어로 숫자 UID 10001 을 지정해서 docker run --rm labhub/sec:v1 id -u10001 을 내야 합니다.

실행하는 사람이 플래그를 잊어도 안전하려면 이미지에 기본값이 박혀 있어야 합니다. 사용자를 먼저 만든 뒤 USER 로 전환하고, 이름이 아니라 숫자로 지정하세요. alpine 에는 useradd 대신 adduser 가 있습니다.

권한이 없으면 무슨 일이 일어나는가

비특권 사용자로 /etc 아래 파일을 만들려고 시도하고, 나온 Permission denied 오류를 표준 오류까지 포함해 /root/sec1/denied.txt 에 저장합니다.

오류 메시지는 표준 출력이 아니라 표준 오류로 나옵니다. 리다이렉션할 때 표준 오류까지 함께 담지 않으면 파일이 비어 실패합니다.

읽기 전용 루트 파일시스템

sec-ro 컨테이너를 --read-only 로 백그라운드 실행하고, 그 안에서 파일 쓰기를 시도해 나온 Read-only file system 오류를 /root/sec1/ro.txt 에 저장합니다.

루트 파일시스템을 읽기 전용으로 만드는 플래그를 붙여 컨테이너를 띄우고, 그 안에서 쓰기를 시도해 나온 오류를 저장합니다. 여기서도 표준 오류를 함께 담아야 합니다. 실제 서비스라면 쓰기가 필요한 경로에는 tmpfs 를 따로 붙입니다.

capability 전부 떨어뜨리고 하나만 더하기

sec-caps 컨테이너를 --cap-drop=ALL --cap-add=NET_BIND_SERVICE 로 백그라운드 실행합니다.

순서가 중요합니다. 필요한 것만 빼는 방식이 아니라 전부 떨어뜨린 뒤 필요한 하나만 다시 더하는 방식이어야 합니다. 더할 것은 낮은 포트 바인딩에 쓰이는 그 capability 하나뿐입니다.

권한 상승 경로 막기

sec-nnp 컨테이너를 --security-opt no-new-privileges:true 로 백그라운드 실행합니다.

setuid 비트가 붙은 바이너리를 실행해 권한이 올라가는 경로를 커널 수준에서 막는 보안 옵션이 있습니다. --security-opt 로 지정하며 값 뒤에 :true 를 붙입니다.

강화된 이미지 완성하기

/root/sec1/hardened.Dockerfile 을 작성해 labhub/sec:v2 이미지를 빌드합니다. 다음 네 가지를 모두 만족해야 합니다.

네 가지를 동시에 만족해야 합니다 — 태그 고정(latest 금지), 숫자 UID 10000 이상, 출처 라벨, 그리고 실제로 실행했을 때 그 UID 로 뜰 것. 라벨 키는 OCI 표준 이름이라 철자가 정확해야 합니다.