컨테이너 보안 · 최소 권한 · 실습
최소 권한으로 컨테이너 조이기
이 실습은 진짜 VM 에서 돕니다
이 상자는 파드가 아니라 KubeVirt 가 띄운 가상머신입니다. 리눅스 커널이
따로 돌고 systemd 가 실제로 서비스를 관리하며, docker 는 흉내가 아니라
진짜 도커 엔진입니다. docker run 으로 띄운 컨테이너는 실제로 프로세스가
되고 docker exec 도 docker logs 도 그대로 동작합니다.
예전에는 이 실습이 파드 안에서 돌았습니다. 커널 권한을 전부 내려놓은 상자라
컨테이너를 띄우는 단계가 막혀 있었고, 그래서 이미지 아카이브를 직접 풀어
보는 우회로 배웠습니다. 이제 우회가 필요 없습니다.
알아 둘 것이 둘 있습니다.
- 처음 뜨는 데 1분 남짓 걸립니다. VM 이 부팅하고 도커를 설치하기
- 브라우저 미리보기가 없습니다. VM 으로 들어오는 연결은 채점 포트
때문입니다. 파드 실습(보통 40초)보다 느립니다.
하나만 열려 있습니다. 웹 서버를 띄웠다면 VM 안에서 curl 로 확인하세요.
목표
컨테이너를 비특권 사용자로 실행하는 두 가지 방법(런타임 플래그와 이미지 내장)을
모두 만들어 보고, 읽기 전용 루트 파일시스템·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 -u 가 10001 을 내야 합니다.
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 를 낼 것
참고
- 숫자 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처럼 이름을 쓰면 실패합니다. 쿠버네티스가 거부하는 것과 같은 이유입니다.
단계 8개
- 기본 이미지는 root 로 뜬다
- 런타임 플래그로 UID 바꾸기
- 이미지 자체를 비root 로
- 권한이 없으면 무슨 일이 일어나는가
- 읽기 전용 루트 파일시스템
- capability 전부 떨어뜨리고 하나만 더하기
- 권한 상승 경로 막기
- 강화된 이미지 완성하기