最小権限でコンテナを締める
한국어 원문으로 표시합니다.
이 실습은 진짜 VM 에서 돕니다
이 상자는 파드가 아니라 KubeVirt 가 띄운 가상머신입니다. 리눅스 커널이
따로 돌고 systemd 가 실제로 서비스를 관리하며, docker 는 흉내가 아니라
진짜 도커 엔진입니다. docker run 으로 띄운 컨테이너는 실제로 프로세스가
되고 docker exec 도 docker logs 도 그대로 동작합니다.
예전에는 이 실습이 파드 안에서 돌았습니다. 커널 권한을 전부 내려놓은 상자라 컨테이너를 띄우는 단계가 막혀 있었고, 그래서 이미지 아카이브를 직접 풀어 보는 우회로 배웠습니다. 이제 우회가 필요 없습니다.
알아 둘 것이 둘 있습니다.
- 처음 뜨는 데 1분 남짓 걸립니다. VM 이 부팅하고 도커를 설치하기 때문입니다. 파드 실습(보통 40초)보다 느립니다.
- 브라우저 미리보기가 없습니다. VM 으로 들어오는 연결은 채점 포트
하나만 열려 있습니다. 웹 서버를 띄웠다면 VM 안에서
curl로 확인하세요.
목표
컨테이너를 비특권 사용자로 실행하는 두 가지 방법(런타임 플래그와 이미지 내장)을 모두 만들어 보고, 읽기 전용 루트 파일시스템·capability 전면 제거·권한 상승 차단을 하나씩 적용합니다. 마지막에는 이 조건을 만족하는 이미지를 직접 완성합니다.
왜 중요한가
"컨테이너는 격리돼 있으니 안에서 root 여도 된다"는 말이 틀린 이유는 단순합니다.
유저 네임스페이스를 쓰지 않으면 컨테이너의 UID 0 은 호스트의 UID 0 과 같은 값이고,
볼륨으로 뚫린 경로에는 그 권한이 그대로 적용됩니다. 그래서 최소 권한은 선택이
아니라 기본값이어야 합니다. 특히 USER 를 숫자로 쓰는 습관이 중요합니다.
쿠버네티스의 runAsNonRoot 는 이름으로 된 사용자를 root 가 아니라고 판별하지
못해 파드를 거부하고, 그 오류 메시지를 처음 보면 원인을 찾는 데 한참 걸립니다.
capability 도 마찬가지입니다. 필요한 것만 빼는 방식은 언젠가 빠뜨리지만, 전부
떨어뜨린 뒤 필요한 하나만 더하는 방식은 빠뜨릴 수가 없습니다.
단계
/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'로 확인하세요.alpine:3.20으로sec-u컨테이너를--user 1000:1000으로 백그라운드 실행하고 채점 시점까지 떠 있게 유지합니다./root/sec1/Dockerfile을 작성해labhub/sec:v1이미지를 빌드합니다.USER지시어로 숫자 UID 10001 을 지정해서docker run --rm labhub/sec:v1 id -u가10001을 내야 합니다.- 비특권 사용자로
/etc아래 파일을 만들려고 시도하고, 나온Permission denied오류를 표준 오류까지 포함해/root/sec1/denied.txt에 저장합니다. sec-ro컨테이너를--read-only로 백그라운드 실행하고, 그 안에서 파일 쓰기를 시도해 나온Read-only file system오류를/root/sec1/ro.txt에 저장합니다.sec-caps컨테이너를--cap-drop=ALL --cap-add=NET_BIND_SERVICE로 백그라운드 실행합니다.sec-nnp컨테이너를--security-opt no-new-privileges:true로 백그라운드 실행합니다./root/sec1/hardened.Dockerfile을 작성해labhub/sec:v2이미지를 빌드합니다. 다음 네 가지를 모두 만족해야 합니다.FROM이 구체적인 태그로 고정돼 있고latest가 아닐 것 (예:alpine:3.20)- 마지막
USER가 숫자 UID 이고 값이 10000 이상일 것 org.opencontainers.image.source라벨이 있을 것- 실제로 실행했을 때
id -u가 그 UID 를 낼 것
참고
- 숫자 UID 만 얻으려면
id -u를 씁니다.id만 치면 여러 숫자가 섞여 나옵니다. - 표준 오류까지 파일에 담으려면
명령 > 파일 2>&1형태를 씁니다. - 4단계는 컨테이너 없이도 됩니다.
setpriv --reuid=1000 --regid=1000 --clear-groups <명령>은 uid/gid 를 내려놓고 명령을 실행합니다 — 컨테이너 런타임이--user로 하는 일과 같은 것입니다. - alpine 에서 사용자를 만들 때는
adduser -D -u 10001 app형태를 씁니다. - 라벨은
LABEL org.opencontainers.image.source="https://example.com/repo"처럼 적습니다. - 흔한 실수 1: 1단계에
id전체 출력을 저장하면uid=0(root) gid=0(root)의 숫자가 모두 걸려 실패합니다. - 흔한 실수 2: 2·5·6·7단계 컨테이너를
--rm이나 곧 끝나는 명령으로 띄우면 채점 시점에 사라집니다. - 흔한 실수 3: 8단계에서
USER app처럼 이름을 쓰면 실패합니다. 쿠버네티스가 거부하는 것과 같은 이유입니다.
기본 이미지는 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 -u 가 10001 을 내야 합니다.
실행하는 사람이 플래그를 잊어도 안전하려면 이미지에 기본값이 박혀 있어야 합니다. 사용자를 먼저 만든 뒤 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 이미지를 빌드합니다. 다음 네 가지를 모두 만족해야 합니다.
FROM이 구체적인 태그로 고정돼 있고latest가 아닐 것 (예:alpine:3.20)- 마지막
USER가 숫자 UID 이고 값이 10000 이상일 것 org.opencontainers.image.source라벨이 있을 것- 실제로 실행했을 때
id -u가 그 UID 를 낼 것
네 가지를 동시에 만족해야 합니다 — 태그 고정(latest 금지), 숫자 UID 10000 이상, 출처 라벨, 그리고 실제로 실행했을 때 그 UID 로 뜰 것. 라벨 키는 OCI 표준 이름이라 철자가 정확해야 합니다.