2026 数字中国创新大赛网络安全子赛道团队赛 WriteUp
前言
第一次在星盟带队打比赛,解出了五道题排名第二

SecureDoc 是别的师傅爆破出了密码,最后由我绕过 WAF 拿到二血
asystem 其他师傅也解出来了,但是我交的快,哈哈,相当于五道题都是我解的,这波带飞了
虽然是松柏组,但是这个成绩在学生组也是能进决赛的(松柏前四)
Misc
冰碧蝎?
过滤 HTTP

追踪第一个 POST 请求拿到密钥 1ca3b8c9c81a5fdc

后续一直在访问 /upload/1774…….dcic.php 路径,确认这个就是 WebShell

解密方式是 base64 -> AES-128-CBC -> IV = 16字节全0
解密流量其中有一条关键命令
1 | cd /var/ww/html/upload/;echo 2i9Q8AtFEuzYHxwcUmpjFCQchUd1QqwQMf8mWfvUwm9LE8UaKVYDTaq5tG |
将 2i9Q8AtFEuzYHxwcUmpjFCQchUd1QqwQMf8mWfvUwm9LE8UaKVYDTaq5tG 解码 Base58 拿到 flag
1 | flag{dbeeed36-0d7e-211a-69db-66bd74ea91d5} |
Web
SecureDoc
在 /documents/apply-template 接口中,系统提供了一个“数字水印加密预览”功能,通过测试发现该加密方式为 AES-ECB 模式
由于用户输入的内容会被直接与一段未知的 Secret(包含管理员密码)拼接后进行加密,我们可以通过控制输入长度和内容,利用 ECB Oracle 攻击逐字节爆破出后端的 Secret 内容
1 | import requests |
通过爆破,得到管理员的账号和密码:
- username: suP3r@dm!n
- password: S3cur3P@ssZmQxYTdh!
使用获取到的管理员凭证登录后,进入 /admin/dashboard 管理面板
该面板提供了一个报告模板预览功能 /admin/report/preview,存在 SSTI 漏洞
该接口配置了严格的 WAF,过滤了大量关键字(如 class, request, os, popen 等)和特殊符号(如 [], +, ~)
为了绕过这些限制:
- 绕过关键字:利用字符串拼接,如
'c''lass'和'po''pen' - 绕过方括号:使用
|attr('getitem')过滤器替代[]取值 - 绕过函数调用检测:在
attr和括号之间添加空格,如attr ('...')
最终构造出以下 Payload,通过 os._wrap_close 类(在 subclasses() 中索引为 141)来调用 popen 执行系统命令 cat /flag:
1 | {{ ()|attr ('__c' 'lass__')|attr ('__b' 'ase__')|attr ('__subcl' 'asses__') ()|attr ('__ge' 'titem__') (141)|attr ('__in' 'it__')|attr ('__gl' 'obals__')|attr ('__ge' 'titem__') ('po' 'pen') ('c' 'at /f' 'lag')|attr ('re' 'ad') () }} |
asystem
在上传系统提供的下载接口 /download?filename= 中,存在任意文件读取漏洞
通过目录遍历(如 ../../../../../../../../../../app/app.py 和 ../server/app.js)成功获取了 Python 前端和 Node.js 后端的源码
在 Flask 的 /register 路由中,程序使用自定义的 merge() 函数将 JSON 数据合并到 User 对象中,并且在合并前检查全局 WAF 黑名单列表
我们可以通过构造含有 class.init.globals.WAF 键的恶意 JSON,将全局 WAF 列表替换为空字符串,从而让其后续过滤机制失效
因为关键字本身在 JSON 中也被过滤了,我们可以利用 Unicode 转义(如 \u005f 替代 _,\u0067\u006c... 替代 globals)绕过最开始的过滤层
Node.js 后端对于 .kmz 文件会使用 parse2-kmz 进行解压和 XML 解析,该组件使用的 xml2json 存在原型链污染漏洞
通过构造恶意的 doc.kml(打包成 test.kmz),利用 <proto><FIX_SECRET>hacked123</FIX_SECRET></proto>,成功污染全局变量 FIX_SECRET
随后调用 /api/fix-secret 将管理员密钥固定为 hacked123
获得了管理员密钥后,可访问 /protected 路由
由于该路由使用 render_template_string 渲染我们传入的 user 参数,并且前端过滤已被步骤 2 破坏,此处存在 SSTI 漏洞
由于应用重写并禁用了 os.popen 等模块,且返回内容被强制截断 [-40:],我们通过 {% set ... %} 使用内置函数 open() 将 /flag 读取并写入静态目录 /datas/upload_files/img/f.txt 中,最后再通过下载接口读取文件
1 | import requests |
Crypto
base
题目核心代码在 task.py
flag 被要求满足 flag{uuid4} 的格式,同时给出了 md5(flag)。然后做了两层同一个 64 表代换的 Base64 编码
1 | c = en(en(flag, key), key).encode() |
也就是说,先得到一个字符串 c,再把 c 的每一位比特送进 decision(),得到一个很长的样本列表,最后整体 LZMA 压缩写入 output
decision(t) 的逻辑是:
t = 0:返回urandom(16 * 4500)t = 1:返回AES(key).encrypt_ecb(urandom(16 * 4500)
问题出在 aes.py
正常 AES-128 最后一轮应该用第 10 轮轮密钥,也就是 self.round_keys[40:44]
但题目里的实现最后用了:
1 | self.add_round_key(state, self.round_keys[16:20]) |
这相当于把最后一轮轮密钥切片用错了
于是 aes.encrypt_ecb(m) 虽然看起来“挺随机”,但它已经不是一个足够好的随机置换族实现了
和真正的 urandom(...) 比,统计特征会有偏差
所以这题第一步就变成了:
对 output 里的每个样本判断它更像真随机(表示 bit=0)还是错误 AES 输出(表示 bit=1)
每个样本长度是 16 * 4500 = 72000 字节,也就是 4500 个 16-byte block
如果只是看整串字节频率,0 和 1 很接近;但按 16 个字节位置分别做统计,偏差会明显很多
做法:
- 把一个样本按 16 字节分块
- 对每个字节位置
pos = 0..15 - 统计该位置上 4500 个字节的分布
- 对 256 个取值做卡方统计
- 把 16 个位置的卡方值加总,作为这个样本的分数
经验上:
- 真随机分数更低
- 错误 AES 分数更高
于是就能把 607 个样本恢复成 607 个比特
恢复出的外层密文是:
1 | import ast |
这里要注意一个细节:
题目里用了 bin(bytes_to_long(c))[2:],所以如果原始字节串最高位是 0,它会被丢掉。恢复时要手动补回去
题目里的 en(m, key) 实际上是:
1 | base64(m).translate(table -> key) |
也就是
1 | en(m) = g(base64(m)) |
其中 g 是一个 64 字符置换
因此整体可写成
1 | c = g(base64(g(base64(flag)))) |
同时题目还给了两个极强约束:
- flag 形如
flag{xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx} - md5(flag) =
ec2783d8262e2621eece6e9e236479dc
所以最后一步不是盲猜,而是:
- 把 flag{uuid4} 的结构写成约束
- 把双层同表代换写成约束
- 让候选满足外层密文
- 最后用 MD5 做唯一筛选
最终得到
1 | #!/usr/bin/env python3 |
Pwn
keep_stack
这题的洞点是栈上 “隔 4 写 2” 的越界写,而且还能自改 idx,所以不是普通 ret2libc,而是一个要利用 glibc 启动栈帧的题
1 | #!/usr/bin/env python3 |
