In this challenge we will exploit NoSQL inejction and php deserialization to get the flag.
First we are greeted with a list of products, and we can put them on the wishlist (offline) and place and order on them. Here note that listing the products is a POST request oddly enough instead of a get.
I check the request and I see that some JSON is being passed that looks like a mongodb query.
I check the mongodb documentation to find out what options we have and I find the unionWith
option.
Now checking the entrypoint.sh
file we know what collections there are, so we can simply send the following request to the products endpoint:
[
{
"$unionWith": "users"
}
]
The response will contain the username and password of all users, which is only the admin in our case.
In the response I also spotted the access
field, which seems to be a PHP serialized array.
Now we can login with the admin users and see what it can do. Most of the functionality seems fake, however we can use the edit user feature, to change the password of a user.
Now let's take a look at what happens when the user password is upadted:
{
"_id": 1,
"password": "password",
"username": "admin"
}
Let's try and see if we can somehow abuse this request again to our advantage.
// backend/controllers/UserController.php
public function update($router)
{
$json = file_get_contents('php://input');
$data = json_decode($json, true);
if (!$data['_id'] || !$data['username'] || !$data['password'])
{
$router->jsonify(['message' => 'Insufficient parameters!'], 400);
}
if ($this->user->updateUser($data)) {
$router->jsonify(['message' => 'User updated successfully!']);
}
$router->jsonify(['message' => 'Something went wrong!', 'status' => 'danger'], 500);
}
Here we see that it verifies that the id
, username
and password
fields are in the request and then updates the user data.
// backend/models/UserModel.php
public function updateUser($data)
{
return $this->database->update('users', $data['_id'], $data);
}
Here is the code that triggers the database update, we see it just passes the collection name the idea and all data
// backend/Database.php
public function update($collection, $index, $data)
{
$collection = $this->db->$collection;
$updateResult = $collection->updateOne(
[ '_id' => intval($index) ],
[ '$set' => $data ]
);
if ($updateResult->getMatchedCount()) {
return true;
}
return false;
}
And here is the function that update the database itself.
We see that the only way this fails is if the ID is not matched to an existing object, however all the data is directly passed to the $set
option.
Even though the website doesn't let us, we can still perform a request to update the access
field of the user.
This is cool, since we have already seen that it is potentially a PHP serialized array, so we might have found an unsafe deserialization bug!
// models/UserModel.php
public function __construct()
{
parent::__construct();
$this->username = $_SESSION['username'] ?? '';
$this->email = $_SESSION['email'] ?? '';
$this->access = unserialize($_SESSION['access'] ?? '');
var_dump($this->access);
}
And indeed each time a UserModel
class is instantiated we see that the access
field is read from the session and then deserialized.
// controlles/AuthController.php
public function login($router)
{
$username = $_POST['username'];
$password = $_POST['password'];
if (empty($username) || empty($password)) {
$router->jsonify(['message' => 'Insufficient parameters!', 'status' => 'danger'], 400);
}
$login = $this->user->login($username, $password);
if (empty($login)) {
$router->jsonify(['message' => 'Wrong username or password!', 'status' => 'danger'], 400);
}
$_SESSION['username'] = $login->username;
$_SESSION['access'] = $login->access;
$router->jsonify(['message' => 'Login was successful!', 'status' => 'success']);
}
And here we see that upon user login, the access
field is fetched from the database, and then it is stored in the session.
Ok great! Now to find the PHP deserialization -> RCE...
Yeah this took quite long to find. In general PHP deserialization is not as insecure as some of the others we had during this contest.
To get RCE there needs to be some classes which have specific magic functions (__wakeup
, __destruct
, etc..), and on top of that the magic functions need to interact with some fields of the object that we control and somehow allow for code execution with eval
or system
for example.
I looked for these magic functions, however there aren't many and the few we have sets fields to null
, or returns an element of an array as a string, not very RCE.
Upon looking more into PHP deserialization resources, I have found the phpggc project, which contains payloads using a more advanced technique: POP chaining, which I like to think of as the ROP chains of PHP deserialization.
The exploits on here contain chains that exist in specific php libraries/frameworks.
However none of the ones listed in the project appear inside of the backend vendor
folder.
So are we stuck?
Not really, let's try harder.
Let's look into the vendor
folder in the frontend part of the app.
Here we see a dependency that could be interesting: monolog
The phpggc
project lists several chains for the dependency and many of them grant us RCE even!
Further more we know that monolog is pulled as a dependency for maxbanton/cwh
from the composer.json
file.
Upon checking the package page we see that monolog 2.x is pulled, so this limits the chains we can use from phpggc
but still we have some RCE ones.
Now to address the elephant in the room: the frontend contains no unserialize
statement, so how are we going to to use our chain from phpggc
if our deserialization takes place in the backend code?
So again, are we stuck?
Still not, keep looking a bit more!
Look no further than the index.php
file in the backend code:
require __DIR__ . "/vendor/autoload.php";
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;
}
});
Interesting... this functions seems to suffer from an LFI vulnerability, however we do have a restriction for .php
files.
But how do we call this function at all? Turns out this function is using the autoload mechanism, let's read the docs of this function.
Okay, so this function replaced the deprecated __autoload
function, which
Attempt to load undefined class
Great! this is exactly what we want, we have some classes we want to refer to from the frontend, and we want them to be loaded, so we can execute the POP chain that phpggc
gives us!
However this does not automatically work, since just refering to the classes like the generated payload does, will try to look for them inside the backend
code still.
We need to manually try and unserialize object that will cause the classes to be loaded from the frontend code.
Let's take a closer look at how the autoloading function works here
- If the class name ends in
Controller
we look inside the controllers folder for a php file with the same name as the class - If the class name ends in
Model
we look inside the models folder for a php file with the same name as the class - If the class name contains
_
, we replace all underscores with/
instead - We append
/
to the start and.php
to the end of the parsed name - Check if the file exists as an absolute path and load it if yes
- Check if the file exists relative to current folder and load it if yes
So by providing _
instead of /
we can load arbitrary php file from anywhere on the system.
Let's use this to load all the php files that give us the classes the the phpggc
chain will use:
GroupHandler
FingersCrossedHandler
For my chosen exploit generated by
➜ web_unearthly_shop ./phpggc Monolog/RCE5 system "/readflag" > ./payload
For example an object with name www_frontend_vendor_monolog_monolog_src_Monolog_Handler_GroupHandler
will load the GroupHandler
class from the frontend vendor folder.
After this the class can be referred to as usual with Monolog\Handler\GroupHandler
.
Keep in mind however that these classes have their own dependencies, that are not loaded into the current context, and will be loaded using the same function. We need to include these in advance, otherwise the exploit will fail!
Let's go over the plan one more time:
- Login as admin using the NoSQL injection vuln
- Change the access field of the admin to our malicious serialized payload
- Login again as admin to trigger the deserialization
Our serialized exploit will look as follows:
- It will be an array of objects.
- The last element will be exactly the payload generated by
phpggc
- The elements before will be objects that load dependencies from the frontend folder
- these classes will specify absolute paths to php files
- these will need to include all transitive dependencies, not just direct ones of the
phpggc
chain
- Some care needs to be taken to properly send this exploit over JSON
- escape backslash
- escape quotes
\u0000
escape for null bytes, yes, the generated exploit contains null bytes
The full details can be seen in the exploit.py
file, that construct the serialized string and encodes it properly for JSON transportation.
Now we just send this as the access
variable to the endpoint that can update the user password.
After this logout, then login as admin, and see the flag :)