2020 数字中国创新大赛虎符网络安全赛道 WriteUp
🏛️ 组织架构
主办单位:数字中国建设峰会组委会
承办单位:福建省数字福建建设领导小组办公室、福建省工业和信息化厅、福州市人民政府、中国电子信息产业集团有限公司、奇安信集团
协办单位:北京永信至诚科技股份有限公司
📅 赛程与赛制
赛事分为线上初赛和线下决赛两个阶段
线上初赛
时间:2020 年 4 月 19 日
形式:CTF(Capture The Flag,夺旗赛)
内容:包含 Web 渗透、软件逆向、二进制漏洞挖掘和利用(Pwn)、密码学等 15 道赛题。所有赛题均基于国产 “PK系统” 的真实场景和历史漏洞设计
计分:采用动态积分模式,题目分值随解出队伍数量增加而下降
线下决赛
时间:2020 年 10 月 13 日
地点:福州海峡国际会展中心
形式:AWD(Attack with Defense,攻防兼备)模式
创新环节:增设了 PK 系统众测挑战,该环节分数占总分的35%。每队有 5 次挑战机会,每次限时 30 分钟
📊 赛事规模
报名情况:共吸引 681 支战队、1894 名选手报名参赛
覆盖范围:覆盖全国 30 个省、自治区、直辖市
决赛队伍:35 支晋级战队与 5 支顶级特邀战队同场竞技
🏆 决赛获奖名单
| 奖项 | 获奖队伍 | 所属单位 |
|---|---|---|
| 一等奖 | M01N 战队 | 北京神州绿盟信息安全科技股份有限公司 |
| 二等奖 | 战狼队 | 中国移动通信集团福建有限公司 |
| 二等奖 | f61d 战队 | 信息工程大学 |
| 二等奖 | W&M 战队 | 杭州电子科技大学信息工程学院等 |
| 三等奖 | ROIS 战队 | 福州大学 |
| 三等奖 | L3Hsec 的小车车战队 | 华中科技大学 |
| 三等奖 | aurora 战队 | 深圳大学 |
| 三等奖 | NEURON 战队 | 山石网科 |
| 三等奖 | 0ops 战队 | 上海交通大学 |
Crypto
GM(RSA Goldwasser-Micali 加密)

题目源码
1 | def make_key(nbit): |
加密时有
1 | br = bin(r)[2:] |
br = bin(r)[2:]r是一个随机整数,且gcd(r, N) == 1bin(r)返回二进制字符串,例如:bin(5)得到'0b101'
[2:]去掉开头的'0b',只保留二进制数字部分所以
br是r的二进制字符串表示
c = (pow(x, int(br + bi, 2), N) * r ** 2) % N这里
bi是明文的某一位,取值是'0'或'1'int(br + bi, 2)br + bi是字符串拼接,int()函数将二进制转为十进制因为
br是r的二进制串,后面再拼接一个比特bi,相当于把r的二进制整体左移 1 位,再在最低位加上bi
根据 int(br + bi, 2),有以下结论
1 | # 5 = 0x101 |
加密公式 c = (pow(x, int(br + bi, 2), N) * r ** 2) % N 可以改写成
1 | c = x ^ int(br + bi, 2) * r ^ 2 (mod N) |
设 e = int(br + bi, 2),代入 int(br + '0', 2) = 2 * r 以及 int(br + '1', 2) = 2 * r + 1
1 | e = 2r + bi |
接下来分为两种情况,当 bi = 0 时
1 | c = x ^ (2r) * r ^ 2 (mod N) |
所以 c 是一个平方数,当 bi = 1 时
1 | c = x ^ (2r + 1) * r ^ 2 (mod N) |
也就是说,bi = 1 时,密文比 bi = 0 多乘了一个 x
所以题目的本质是:
1 | bit = 0 -> c 是 square |
接下来只需要证明:x 被故意选成了二次非剩余
题目中有一个非常关键的判断
1 | pow(q ** 2 * x, (p - 1) / 2, p) + \ |
先算右边
1 | N = pq |
再看左边第一项
1 | (q ^ 2 * x) ^ ((p - 1) / 2) mod p |
因为 q 和 p 是两个不同素数,所以 q 在模 p 下可逆
根据费马小定理
1 | q ^ (p - 1) ≡ 1 mod p |
这正是 x 在模 p 下的勒让德符号
简单来说,勒让德符号就是一个 “判断器”,用来判断一个整数 a 在模一个奇素数 p 下,到底是不是 “平方数(二次剩余)”
根据欧拉判别准则:
1 | x ^ ((p - 1) / 2) ≡ 1 mod p 表示 x 是模 p 的二次剩余 |
在 Python 取模结果里,-1 mod p 会显示为 p - 1
所以第一项只可能是 1 或 p - 1,同理,第二项
1 | (p ^ 2 * x) ^ ((q - 1) / 2) (mod q) = x ^ ((q - 1) / 2) (mod q) |
也只能是 1 或 q - 1,而题目要求两项相加等于 (p - 1) + (q - 1)
唯一可能就是:
1 | x ^ ((p - 1) / 2) ≡ p - 1 mod p |
接下来就是去求 p,q
1 | N = pq |
令 s = p + q = N - phi + 1,那么 p 和 q 是下面这个二次方程的两个根:
1 | (t - p)(t - q) = t^2 - (p + q)t + pq |
用求根公式
1 | p = (s - sqrt(s ^ 2 - 4N)) / 2 |
前面已经得到 c = x^(2r + bi) * r^2 (mod N),在模 p 下看它的勒让德符号
1 | (c / p) = (x ^ (2r + bi) * r ^ 2 / p) |
勒让德符号有乘法性质:(ab / p) = (a / p)(b / p)
1 | (c / p) = (x ^ (2r + bi) * r ^ 2 / p) |
因为 r ^ 2 一定是平方数,所以 (r ^ 2 / p) = 1
而前面已经证明 (x / p) = -1,因此
1 | (c / p) = (x / p) ^ (2r + bi) * (r ^ 2 / p) |
因为 2r 是偶数,所以
1 | (-1) ^ (2r + bi) = (-1) ^ bi |
于是
1 | bi = 0 -> (c / p) = 1 |
换成 Python 模运算
1 | bi = 0 -> pow(c, (p - 1) // 2, p) == 1 |
所以判断规则很简单
1 | 如果 c 是模 p 和模 q 下的二次剩余: |
1 | from Crypto.Util.number import long_to_bytes |
Web
just_escape(VM2 沙箱逃逸 + 模板字符串嵌套绕过)

首页给出了参数提示

访问 run.php 给出了文件源码,但很明显这里写的是 code,不是 $code
所以这个 PHP 文件大概率是假的

用插件识别出后端是 Express

测试看参数 code 是否可用,这里正常输出了说明是可用的

接着用 JS 的异常栈确认环境
1 | /run.php?code=Error().stack |
从输出可以判断出服务端接收用户输入,并将其作为 JavaScript 代码在 VM2 沙箱中执行

去找逃逸漏洞,发现了 https://github.com/patriksimek/vm2/issues/225

这个 issue 明确说明,可以通过 TypeError.prototype 和 Buffer.from("") 触发异常,从而逃出 vm2 沙箱,拿到宿主环境的 process 对象
GitHub issue 中的原始 PoC 逻辑是:给 TypeError.prototype 挂一个 get_process 方法,然后触发 TypeError,最后通过 f.constructor("return process")() 取到 Node.js 的 process
1 | // 立即执行函数,用于在沙箱上下文中运行 |
但是直接拿来用发现有 WAF 拦截了,可以用模板字符串嵌套拼出被过滤的关键字,再用数组调用访问对象属性
1 | (function (){ |

easy_login(敏感信息泄露 + JavaScript 类型混淆 + JWT none 算法伪造)

首页是登录页面,因为没有账号所以选择注册一个

注册时发现 admin 用户名被禁止注册,查看 JS 源代码

注释提示 koa-static 静态目录可能配置到了项目根目录导致后端源码泄露
JS FUZZ 发现了 /controllers/api.js

先看注册逻辑
1 | // 生成 18 字节随机数并转为十六进制字符串,作为 HS256 签名密钥 |
再来看登录逻辑
1 | // 从 JWT 的 payload 部分(索引1)解码 Base64 并解析 JSON,提取 secretid |
重点是这段判断:sid < global.secrets.length && sid >= 0
如果 {"secretid":0},则会取到 global.secrets[0]
这里我们的思路不是去伪造签名,而是让 global.secrets[sid] 取到 undefined
所以用 secretid: [] 绕过校验,如果 sid = [],JavaScript 比较时会发生类型转换
1 | [] >= 0 |
必须先注册一次,否则 global.secrets.length 是 0,[] < 0 不成立

然后再伪造 token 登录,先是类型混淆绕过
1 | { |
后端写的 JWT 是 jwt.verify(token, secret, {algorithm: 'HS256'});,正确的参数应该是 {algorithms: ['HS256']}
所以这个限制没有真正生效,可以再结合 JWT none 算法伪造
1 | { |
最终生成 token:eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzZWNyZXRpZCI6W10sInVzZXJuYW1lIjoiYWRtaW4iLCJwYXNzd29yZCI6IjEyMzQ1NiJ9.
抓取登录包替换拿到 Cookie

抓取访问 /api/flag 的包替换掉对应的 Cookie 拿到 flag

Reverse
GAME(Python 字节码)

题目附件给出的内容只能手动还原
1 | arr0 = [249, 91, 149, 113, 16, 91, 53, 41] |
1 | # Python 3 |
得到 flag:flag{5LZG50ex5Yi75VqE5YePLIKl541pNu3Fq}