Docker 보안 기초 — 컨테이너 격리와 탈출이 가능한 이유
웹해킹을 공부하다가 어쩌다보니 Docker의 구조까지 파고들게 되었다.
CJ Ticket Shop 모의해킹을 진행하면서 관리자 기능에 접근했고, 진단 기능을 통해 현재 프로세스가 uid=0, 즉 root 권한으로 실행되고 있다는 사실을 확인했다. 처음에는 root 권한을 얻었으니 목표에 거의 도달했다고 생각했다.
하지만 여기서 한 가지 문제가 생겼다.
현재 확보한 root 권한은 EC2 호스트의 root가 아니라 Docker 컨테이너 내부의 root 권한이었다. 이름은 똑같이 root지만, 컨테이너의 격리 경계 안에 있기 때문에 호스트 시스템을 완전히 제어할 수 있다는 의미는 아니었다.
결국 다음 단계로 넘어가기 위해서는 먼저 Docker 컨테이너가 어떤 방식으로 프로세스와 파일시스템을 격리하는지, 컨테이너 root와 호스트 root는 무엇이 다른지, 그리고 어떤 잘못된 설정이 컨테이너 탈출로 이어질 수 있는지를 이해해야 했다.
이번 글에서는 특정 공격 명령을 외우기보다 Docker 격리의 핵심이 되는 namespace, cgroup, Linux capability 등의 개념을 살펴보고, 어떤 조건에서 컨테이너 탈출 가능성이 생기는지를 원리 중심으로 정리해보려고 한다.
Docker는 미니 가상머신이 아니다
제일 먼저 깨야 했던 오해가 이것이었다.
나는 처음에 Docker 컨테이너가 VM과 비슷한 것이라고 생각했는데, 실제로는 완전히 다른 개념이었다.
- VM: 가상 하드웨어 위에서 VM마다 자기만의 커널을 따로 실행한다. 그래서 컨테이너보다 상대적으로 무겁고 시작 속도도 느리다.
- 컨테이너: 호스트의 커널을 공유하면서 프로세스를 격리해, 마치 독립된 시스템처럼 보이게 만든다. 그래서 가볍고 빠르다.
즉, 컨테이너는 결국 호스트 위에서 실행되는 하나 이상의 일반 Linux 프로세스다.
쉽게 비유하면 VM은 건물을 따로 짓는 것에 가깝고, 컨테이너는 같은 건물의 한 층을 파티션으로 나눠 여러 사무실을 만드는 것에 가깝다.
여기서 커널을 공유한다는 사실이 중요하다.
컨테이너의 격리는 별도의 커널을 사용하는 방식이 아니라, 호스트 커널의 여러 기능을 이용해 프로세스를 분리하는 방식이다. 따라서 컨테이너에 위험한 권한이 부여되거나 공유하는 커널에 문제가 생기면 호스트까지 영향을 받을 가능성이 생긴다.
그중 컨테이너 구조를 이해하기 위한 대표적인 기능이 namespace와 cgroup이다.
namespace — “무엇이 보이는가”
Linux 프로세스들은 기본적으로 같은 시스템의 프로세스, 파일시스템, 네트워크 등의 자원을 공유한다.
namespace는 커널이 특정 프로세스에게 별도의 자원 범위를 보여주는 기능이다. 쉽게 말하면 커널이 해당 프로세스에게 “너는 이 범위만 볼 수 있어”라고 구분된 화면을 제공하는 것이다.
- PID namespace: 컨테이너 안에서
ps를 실행하면 해당 namespace에 속한 프로세스만 보인다. - Mount namespace: 컨테이너 프로세스가 호스트와 다른 마운트 목록과 파일시스템 구조를 보게 한다.
- Network namespace: 컨테이너 전용 네트워크 인터페이스와 네트워크 환경을 제공한다.
- User namespace: 컨테이너 내부의 root를 호스트의 다른 UID로 매핑할 수 있다.
Mount namespace는 호스트 파일시스템을 단순히 숨기는 기능이라기보다, 프로세스마다 서로 다른 마운트 구조를 보게 하는 기능에 가깝다.
따라서 컨테이너 안에서 /를 보더라도, 일반적으로 우리가 보고 있는 것은 호스트의 /가 아니라 컨테이너에 따로 구성된 루트 파일시스템이다.
cgroup — “얼마나 사용할 수 있는가”
cgroup은 CPU, 메모리, I/O 같은 시스템 자원의 사용량을 제한하고 관리하는 기능이다.
namespace가 컨테이너에서 “무엇을 볼 수 있는가”를 정한다면, cgroup은 “자원을 얼마나 사용할 수 있는가”를 정한다고 이해할 수 있다.
비유로 정리하면 다음과 같다.
건물과 기반 시설은 호스트 커널이고, 파티션으로 나뉜 사무실들은 각각의 컨테이너다.
하지만 이 파티션은 VM처럼 별도의 커널로 만들어진 벽이 아니라, 호스트 커널의 설정을 이용해 만든 경계다. 따라서 위험한 권한을 실수로 부여하거나 커널 자체에 결함이 존재하면 컨테이너의 경계를 넘어 호스트에 영향을 줄 가능성이 생긴다.

전체적인 구성은 이런 식으로 되어 있다.
그렇다면 이 격리는 왜 뚫릴 수 있는가?
컨테이너 프로세스도 결국 호스트 커널에 시스템 콜을 요청한다.
컨테이너 전용 커널이 따로 있는 것이 아니기 때문에 mount() 같은 시스템 콜 역시 호스트 커널이 처리한다. 다만 namespace가 존재한다고 해서 mount()만 호출하면 숨겨진 호스트 파일시스템이 바로 나타나는 것은 아니다.
해당 시스템 콜을 실행할 권한이 있어야 하고, 실제로 접근할 호스트의 디스크나 파일시스템이 컨테이너에 노출되어 있어야 한다.
여기서 내가 착각했던 부분이 있다.
이전에 웹해킹을 하면서 Docker 내부에서 root 권한을 얻은 적이 있었다. 그래서 이번에도 컨테이너 내부에서 root를 얻으면 당연히 mount()를 사용할 수 있을 것이라고 생각했다.
하지만 컨테이너 내부에서 root인 것과 mount()를 실행할 권한이 있는지는 별개의 문제였다.
Linux는 root의 강력한 권한을 capability라는 단위로 나누어 관리한다. mount()를 실행하려면 일반적으로 CAP_SYS_ADMIN이라는 capability가 필요한데, Docker는 컨테이너를 실행할 때 기본적으로 이 권한을 제외한다.
따라서 컨테이너가 처음 실행될 때 CAP_SYS_ADMIN이 제거되었다면, 컨테이너 내부에서 단순히 root 권한을 얻었다고 해서 해당 capability까지 자동으로 생기는 것은 아니다.
1. Capability를 이용한 탈출 원리
전통적인 Linux 권한 모델에서는 UID가 0인 root가 대부분의 관리자 작업을 수행할 수 있었다.
하지만 이렇게 모든 권한을 하나로 묶는 것은 위험하기 때문에 Linux는 root의 권한을 capability라는 개별 기능 단위로 나누었다.
CAP_SYS_ADMIN, CAP_SYS_PTRACE, CAP_NET_ADMIN 같은 capability가 있으며, 각각은 특정 종류의 관리자 기능을 사용할 수 있는 자격증과 비슷하다.
Docker는 기본적으로 위험한 capability를 제외한 상태로 컨테이너를 실행한다. 따라서 컨테이너 안에서 whoami를 실행했을 때 root가 나오더라도, 위험한 시스템 콜까지 모두 사용할 수 있는 것은 아니다.
또한 컨테이너에 어떤 capability를 부여할지는 컨테이너를 실행하는 시점에 외부에서 결정된다. 일반적인 설정에서는 처음부터 제거된 capability를 컨테이너 내부에서 마음대로 추가할 수 없다.
이와 관련해 가장 많이 언급되는 것이 CAP_SYS_ADMIN을 이용한 mount 방식이다.
mount 시스템 콜은 쉽게 말해 디스크나 파일시스템의 내용을 내가 원하는 디렉터리 경로에 연결해, 해당 경로를 통해 접근할 수 있게 하는 기능이다.
따라서 호스트의 디스크가 컨테이너 내부에 노출되어 있고 이를 마운트할 권한까지 가지고 있다면, 호스트 파일시스템으로 접근할 수 있는 경로가 생길 수 있다.
탈출 조건
이 방법에는 핵심적으로 두 가지 조건이 필요하다.
CAP_SYS_ADMINcapability가 있어야 한다.- 호스트 디스크가 컨테이너 안에 실제 블록 디바이스로 노출되어 있어야 한다.
권한이 있어도 노출된 호스트 디스크가 없다면 마운트할 대상이 없다.
반대로 호스트 디스크가 노출되어 있어도 CAP_SYS_ADMIN이 없다면 컨테이너 내부에서 해당 파일시스템을 마운트할 수 없다.
이 두 조건을 만족해 호스트 파일시스템을 컨테이너 내부 경로에 마운트하면, 해당 경로를 통해 호스트의 파일을 읽거나 수정할 가능성이 생긴다.
이후 chroot를 사용하면 /로 인식하는 경로를 마운트한 호스트 파일시스템으로 변경할 수 있다.
다만 chroot 자체가 컨테이너를 탈출시켜주는 것은 아니다. chroot는 프로세스가 루트 디렉터리로 인식하는 위치만 바꾸는 기능이다.
프로세스는 여전히 컨테이너의 namespace 안에 있지만, 호스트 파일시스템을 /처럼 다룰 수 있게 되기 때문에 호스트의 파일이나 실행파일에 접근하기 쉬워진다.
즉, 이 방법의 핵심은 chroot가 아니라 호스트 파일시스템을 컨테이너 내부에 마운트할 수 있게 되었다는 점이다.
2. Docker Socket 악용
Mount 방식이 컨테이너 프로세스가 직접 권한을 사용해 호스트 파일시스템에 접근하는 방식이라면, Docker Socket을 이용하는 방법은 접근 자체가 다르다.
Docker CLI의 docker run, docker ps 같은 명령은 컨테이너를 직접 제어하지 않는다. Docker CLI는 Docker daemon인 dockerd에게 API 요청을 보내는 역할을 한다.
실제로 컨테이너를 생성하고 실행하는 것은 Docker daemon이다. 일반적인 Docker 환경에서 daemon은 호스트의 root 권한으로 실행된다.
Docker CLI와 daemon이 통신할 때 사용하는 통로가 /var/run/docker.sock이라는 Unix Socket 파일이다.
그런데 이 소켓을 다음과 같이 컨테이너 안에 마운트하면, 컨테이너 내부에서 호스트의 Docker daemon에게 직접 API 요청을 보낼 수 있게 된다.
-v /var/run/docker.sock:/var/run/docker.sock
여기서 mount 방식과 결정적으로 다른 점이 나온다.
현재 컨테이너에 CAP_SYS_ADMIN이 없더라도 Docker daemon에게 필요한 작업을 대신 요청할 수 있다. 컨테이너 프로세스가 직접 호스트 파일시스템을 마운트하는 것이 아니라, 호스트에서 강한 권한으로 실행 중인 daemon에게 새로운 컨테이너를 만들어달라고 요청하는 방식이기 때문이다.
docker -H unix:///var/run/docker.sock run \
-v /:/host \
-it alpine \
chroot /host sh
이 명령을 쉽게 풀면 다음과 같다.
“Docker daemon아, 호스트의
/전체를/host에 마운트한 새 컨테이너를 실행해줘.”
Docker daemon이 호스트 권한으로 이 요청을 처리하면 새로 생성된 컨테이너 내부에서 호스트 파일시스템 전체에 접근할 수 있게 된다.
다만 여기서 실행된 셸 역시 엄밀하게 말하면 호스트에서 직접 실행된 셸은 아니다. 새 컨테이너 안에서 호스트 파일시스템을 바라보는 셸이다.
그럼에도 호스트의 파일을 읽거나 수정할 수 있기 때문에 매우 강한 영향을 줄 수 있다.
탈출 조건
Docker Socket을 이용하려면 다음 세 가지 조건이 필요하다.
- Docker Socket이 컨테이너 내부에 마운트되어 있어야 한다.
- Docker daemon에 요청을 보낼 수 있는 도구가 있어야 한다.
- 현재 프로세스가 Docker Socket에 요청을 보낼 권한을 가지고 있어야 한다.
Socket 자체가 마운트되어 있지 않다면 애초에 요청을 보낼 대상이 없다.
Docker CLI가 없더라도 curl처럼 Unix Socket에 직접 요청을 보낼 수 있는 도구가 있다면 요청 자체는 가능하다. 반대로 이런 도구가 전혀 없다면 Socket이 보여도 활용하기 어렵다.
마지막으로 Socket 파일도 결국 일반적인 Linux 파일 권한 체계를 따른다. 보통 root 소유에 docker 그룹만 접근 가능하도록 설정되어 있어서, 현재 프로세스가 root이거나 해당 그룹에 속해 있어야 실제로 쓰기 요청을 보낼 수 있다.
세 조건 중에서도 Socket이 컨테이너 내부에 노출되어 있는지가 가장 먼저 확인해야 할, 그리고 가장 결정적인 조건이다. 나머지 두 조건은 컨테이너가 보통 root로 실행되고 curl 정도는 설치된 경우가 많아서, 실전에서는 대체로 충족되는 편이다.
정리
처음에는 컨테이너 내부에서 root를 얻었으니 호스트에도 접근할 수 있을 것이라고 생각했다.
하지만 컨테이너 root는 호스트 root와 같지 않았다.
컨테이너 내부의 root는 해당 컨테이너에 부여된 namespace와 capability의 범위 안에서만 강한 권한을 가진다. 따라서 whoami가 root를 출력한다고 해서 호스트 디스크를 마운트하거나 Docker daemon을 제어할 수 있는 것은 아니다.
Capability를 이용한 방식에서는 CAP_SYS_ADMIN과 호스트 블록 디바이스의 노출 여부가 중요하다.
Docker Socket을 이용한 방식에서는 컨테이너가 직접 강한 권한을 사용할 필요 없이, 호스트의 Docker daemon에게 작업을 대신 요청할 수 있다는 점이 핵심이다.
다만 두 방식이 실전에서 등장하는 빈도는 다르다. Capability, 특히 privileged 설정 실수는 CI/CD나 개발 편의를 위해 별생각 없이 켜두는 경우가 많아 상대적으로 흔하게 발견된다. 반면 Docker Socket 노출은 “컨테이너 안에서 다른 컨테이너를 빌드하거나 실행해야 하는” Docker in Docker 패턴처럼 특정 목적을 가진 환경에 몰려있는 편이다.
결국 컨테이너 탈출 가능성을 판단할 때는 단순히 root인지 확인하는 것에서 끝나면 안 된다.
중요한 것은 root라는 이름이 아니라, 해당 프로세스에 실제로 어떤 권한과 호스트 자원이 주어져 있는가이다.
원본 · velog.io