DOCKER SECURITY CTF · PART 1
file-upload-ctf-docker 전체 설계도
같은 파일 업로드 취약점에서 시작해도 Docker 설정이 달라지면 어디까지 영향을 줄 수 있는지 비교하기 위해 만든 개인 실습 환경을 정리한다.
실습 범위
본인이 만든 CTF를 본인 소유의 폐기 가능한 Kali VM과 격리망에서만 검증했다. 실제 서비스, 타인의 시스템, 공인망에서는 재현하지 않는다. 저장소는 비공개로 유지하고 Flag, 토큰, 해시, 공인 주소와 난수 파일명은 공개하지 않는다.
- 공통 진입점은 검증 없는 파일 업로드다.
- 6개 컨테이너는 같은 이미지를 사용하고 Docker 설정만 다르다.
- 구조적인 오구성은 최신 보안 계층이 있어도 여전히 치명적이었다.
- 오래된 capability 기반 기법은 최신 환경에서 전제 조건이 사라진 경우가 있었다.
1. 왜 만들었나
이 프로젝트의 출발점은 이전 CTF에서 겪은 막힘이었다. 컨테이너 안에서 명령을 실행하는 데까지는 성공했지만, 그다음 단계에서 capability, namespace, cgroup과 mount 개념이 한꺼번에 섞였다.
무엇이 보여서 가능한 건지, 어떤 권한이 부족해서 실패한 건지 설명하지 못했다. 그래서 오구성 패턴을 한 가지씩 분리하고 같은 애플리케이션 위에서 비교하기로 했다.
- 애플리케이션 취약점은 동일하게 유지한다.
- 컨테이너별 권한과 mount 설정만 바꾼다.
- 성공 과정뿐 아니라 실패한 이유도 기록한다.
- 문제를 푼 직후 같은 화면에서 원리를 복습할 수 있게 한다.
2. 전체 구조
하나의 compose.yaml이 6개 컨테이너를 관리한다. 모든 컨테이너가 같은 애플리케이션 이미지를 사용하므로 차이는 Docker 실행 설정에서만 생긴다.
사용자 브라우저
│
▼
파일 업로드 페이지 ── 웹 루트에 업로드 ── 업로드 파일 실행
│
▼
컨테이너 내부 명령 실행
│
├─ hostshare : 제한된 host 디렉터리
├─ socket : Docker daemon socket
├─ privileged : host device + 광범위한 권한
├─ rootmount : host 루트 파일시스템
├─ dacread : 단일 capability 기반 조건부 PoC
└─ sysptrace : host PID + 프로세스 조작 권한

Compose 빌드 완료 후 6개 컨테이너가 실행된 상태
프로젝트 파일 구성
file-upload-ctf-docker/
├─ app/ 공통 파일 업로드 애플리케이션
├─ writeups/ 정답 확인 후 보여줄 원리 설명
├─ compose.yaml 6개 시나리오의 Docker 설정
├─ Dockerfile 공통 이미지 빌드
├─ .env.example 공개 가능한 환경변수 형식
└─ README.md 격리 환경 실행 및 초기화 안내

프로젝트 디렉터리 구성
3. 공통 진입점: 검증 없는 파일 업로드
6개 시나리오가 공유하는 시작점은 의도적으로 취약하게 만든 파일 업로드 기능이다.
- 허용 확장자 검증이 없다.
- MIME 타입과 매직바이트를 확인하지 않는다.
- 업로드된 파일을 실행 불가능한 위치로 격리하지 않는다.
- 파일이 웹 루트 아래 저장되어 URL로 직접 접근된다.
결과적으로 애플리케이션 단계에서는 모든 컨테이너가 같은 조건으로 명령 실행에 노출된다. 그 이후 host에 미치는 영향은 각 컨테이너의 Docker 설정이 결정한다.

공통 파일 업로드 및 Flag 검증 화면
비교 기준
“웹셸이 실행됐다”는 사실은 동일하다. 이제부터 비교할 것은 컨테이너가 어떤 host 자원을 볼 수 있고, 실제로 읽거나 조작할 권한까지 가졌는가이다.
4. 공통 이미지와 Compose 설계
4-1. 공통 Docker 이미지
베이스 이미지는 가벼운 PHP Alpine 계열이다. 각 시나리오에서 필요한 관찰 도구와 C 코드 컴파일 도구를 공통 이미지에 포함했다.
docker-cli: Docker socket 시나리오 검증util-linux: mount와 block device 관찰gcc및 헤더: 조건부 PoC를 컨테이너 내부에서 컴파일gdb: 프로세스 권한과 디버깅 동작 비교
이미지 기본 사용자는 비특권 UID로 두었다. root가 필요한 시나리오만 Compose에서 명시적으로 덮어써서 비특권 실행과 root 실행의 차이도 비교할 수 있게 했다.
4-2. Compose 공통부와 서비스별 차이
YAML 앵커에 이미지, 빌드 방식, 재시작 정책과 init 같은 공통값을 모았다. 각 서비스에는 필요한 차이만 적었다.
# 공개용 구조 예시. 실제 값과 경로는 생략했다.
x-common: &common
image: common-file-upload-app
build: .
restart: "no"
init: true
services:
scenario:
<<: *common
user: "<UID:GID>"
cap_drop:
- ALL
cap_add:
- <REQUIRED_CAPABILITY>
volumes:
- <HOST_PATH>:<CONTAINER_PATH>:<MODE>
이 글에서는 실제 저장소의 토큰, Flag 해시, 실제 환경 식별값과 난수 파일명을 공개하지 않는다. 예시는 모두 <PLACEHOLDER>로 바꿨다.
5. Flag 검증과 해설 전달
5-1. 평문 대신 해시만 전달
컨테이너 환경변수에 평문 Flag를 넣으면 웹셸에서 환경변수만 확인해도 답이 노출된다. 그래서 Flag가 있는 4개 시나리오는 비교용 SHA-256 해시만 애플리케이션에 전달한다.
사용자 입력
│
├─ SHA-256 계산
│
├─ CTF_FLAG_HASH와 안전하게 비교
│
└─ 일치할 때만 해당 시나리오 해설 표시
평문 Flag는 host의 실습 파일에만 존재
5-2. 두 가지 해설 전달 방식
정답 해시가 일치하면 공개 웹 루트 밖의 해설 조각을 서버가 읽어 같은 페이지에 표시한다.
실습 과정에서 찾은 토큰을 별도 엔드포인트가 검증한 뒤 해설을 보여준다. 실제 토큰과 완성 URL은 공개하지 않는다.
처음에는 Flag 파일 자체에 한글 해설을 넣었지만 일부 웹셸에서 문자 인코딩이 깨졌다. 그래서 해설은 웹 애플리케이션이 UTF-8 HTTP 응답으로 직접 렌더링하도록 구조를 바꿨다.
6. 여섯 개 시나리오 설계
표 하나에 모든 내용을 몰아넣는 대신, 각 시나리오의 핵심만 카드 형태로 나눴다. 상세 명령과 캡처는 이후 풀이 글에서 다룬다.
비특권 사용자, capability 전체 제거, 권한 상승 제한을 적용한다. 대신 host의 특정 실습 디렉터리 하나만 읽기·쓰기로 연결한다. 목적은 탈출이 아니라 host와 컨테이너 경로가 어떻게 이어지는지 관찰하는 것이다.
컨테이너 capability를 제거해도 Docker daemon socket에 접근할 수 있으면 별개의 관리 경로가 열린다. 컨테이너 내부 권한보다 “host의 Docker daemon에 명령할 수 있는가”가 핵심이다.
privileged: true는 capability, device 접근과 여러 기본 제한을 넓게 완화한다. 이 시나리오는 host block device 가시성과 실제 mount 가능 여부가 다른 문제임을 보여준다.
capability는 전부 제거하지만 host 루트 파일시스템이 이미 컨테이너 경로에 읽기·쓰기로 연결돼 있다. 새 mount 권한이 없어도 노출된 파일에 대한 일반적인 파일 접근은 별개의 문제다.
단일 capability와 저수준 파일 handle API를 학습하기 위한 시나리오다. 최신 환경에서는 저장 드라이버와 파일 generation 값 때문에 과거의 블라인드 방식이 그대로 성립하지 않았다. 정확한 힌트를 제공하는 조건부 개념 증명으로 목적을 변경했다.
host PID namespace 공유로 프로세스를 볼 수 있게 하고, 프로세스 조작에 필요한 capability를 조합한다. 가시성만으로는 부족하며 LSM 정책과 메모리 실행 보호 같은 추가 방어 계층도 결과에 영향을 줬다.
Flag 검증 4개: socket · privileged · rootmount · sysptrace
개념 증명 2개: hostshare · dacread
7. 직접 풀며 바뀐 설계
설계만 하고 끝내지 않고 6개 시나리오를 직접 검증했다. 이 과정에서 “이론상 가능하다”와 “현재 환경에서 재현된다”가 다르다는 것을 여러 번 확인했다.
7-1. cgroup v1 기반 시나리오 폐기
처음에는 오래된 cgroup v1 동작을 이용하는 시나리오를 만들었다. 하지만 사용한 최신 Kali 환경에서는 legacy 계층 자체가 원하는 형태로 활성화되지 않았다. 설정 실수가 아니라 전제 조건이 사라진 경우였기 때문에 시나리오를 교체했다.
7-2. dacread_abuse(PORT : 8085, DAC_READ) 시나리오를 원리 확인 실습으로 변경
처음에는 DAC_READ_SEARCH 권한을 이용해 파일을 직접 찾아 읽는 문제로 설계했다. 하지만 최신 Docker 저장 구조에서는 파일을 식별하는 값이 일정하지 않아 기존 방식으로는 안정적으로 재현할 수 없었다. 따라서 필요한 정보를 힌트로 제공하고, 해당 권한과 파일 접근 API가 어떻게 동작하는지 확인하는 실습으로 변경했다.
7-3. SYS_PTRACE는 여러 방어 계층을 만남
capability와 host PID namespace만 있다고 바로 결과가 나오지 않았다. AppArmor, mount namespace 차이, 심볼 부재와 NX 같은 방어 계층이 각각 다른 지점에서 동작했다. 최종 재현 여부보다 각 계층의 역할을 구분한 것이 더 중요한 결과였다.
가장 큰 교훈
오래된 기법은 최신 환경에서 막힐 수 있지만 Docker socket, privileged 실행, host 루트 mount처럼 격리 경계를 구조적으로 포기하는 설정은 여전히 직접적이고 치명적이었다.
8. 안전장치와 초기화
- 폐기 가능한 Kali VM과 복구 가능한 스냅샷을 사용한다.
- NIC는 Host-only 또는 외부와 분리된 내부망만 사용한다.
- 서비스가 전체 네트워크 인터페이스에 노출되지 않도록 바인딩 주소를 확인한다.
- Flag, 토큰과 실제 환경값은 공개 글과 캡처에서 제거한다.
- 사용자 세션 사이에는 컨테이너를 재생성해 이전 mount와 실행 상태를 남기지 않는다.
- 실습 종료 후 컨테이너 삭제만 믿지 않고 VM 스냅샷을 복구한다.
특히 host 루트 쓰기, Docker daemon 제어와 host 프로세스 조작은 컨테이너를 삭제해도 host에 남긴 변경을 자동으로 되돌리지 않는다.
- 실제 Flag 또는 Flag 해시가 보이지 않는가?
- 토큰이나 토큰이 포함된 완성 URL이 없는가?
- 공인 IP와 환경 식별값이 캡처에 남지 않았는가?
- 비공개 저장소 링크와 난수 파일명을 노출하지 않았는가?
9. 정리와 다음 글
이 프로젝트는 파일 업로드 취약점 하나를 6번 반복하는 실습이 아니다. 같은 명령 실행 권한을 얻었을 때 Docker가 노출한 자원과 보안 계층이 결과를 어떻게 바꾸는지 비교하는 실습이다.
가장 제한적인 컨테이너에서 시작한다. capability를 모두 제거하고 비특권 사용자로 실행해도 bind mount를 통해 host 파일이 보이는 이유와, 제한된 mount가 어디까지 영향을 주는지 캡처와 함께 정리한다.
이 글은 공개용으로 재구성한 설계 기록이다. 실제 저장소 설정과 문제 정답에 해당하는 값은 의도적으로 생략하거나 자리표시자로 바꿨다.
'정보보호' 카테고리의 다른 글
| 정보보호 제품, IDS/IPS 그리고 Snort (0) | 2026.04.08 |
|---|