2020 网鼎杯-青龙组 WriteUp
🏛️ 组织架构
支持单位:国家网络与信息安全信息通报中心、深圳市人民政府、广东省公安厅
联合主办:深信服科技股份有限公司、北京永信至诚科技股份有限公司
联合协办:阿里巴巴、百度、腾讯、奇安信、清华大学网络科学与网络空间研究院、中国科学院信息工程研究所等
Misc
虚幻 2(RGB 隐写 + 汉信码修补)

识别文件是一张 PNG 图片,用 PIL 查看颜色
1 | from PIL import Image |

这些颜色对应关系如下
| RGB | 二进制 |
|---|---|
(255,255,255) |
111 |
(0,255,255) |
011 |
(255,0,0) |
100 |
(0,255,0) |
010 |
(255,0,255) |
101 |
(0,0,255) |
001 |
(0,0,0) |
000 |
(255,255,0) |
110 |
像素总数是 180+22+30+38+55+45+36+26 = 432
432 × 3 RGB 通道 = 1296 bit = 36 × 36,所以它就是把一个 36×36 的黑白码拆成了 36×12 的 RGB 图
1 | from PIL import Image, ImageOps |
可以看到图片还是有缺陷部分,需要调整颜色,大小以及方向

添加下面三行代码修复
1 | # ImageOps.invert(out):将图像 out 的所有颜色反转 |

手工/脚本修左下角定位符手机或在线汉信码解码器识别得到 flag:flag{eed70c7d-e530-49ba-ad45-80fdb7872e0a}
teslaaaaa(CAN 总线数据记录格式-ASC)

题目给的附件是常见的 CAN 总线日志格式,其中
1 | 730 Tx = 测试设备发给汽车 ECU 的数据 |
所以我们重点关注 730 Tx,第 27 行有这么一段请求 10 0B 34 00 44 08 00 00
ISO-TP(ISO 15765-2)是一个传输层协议。它的核心作用就是解决 CAN 总线一帧最多只能传 8 字节数据的限制
1 | 10 0B 34 00 44 08 00 00 |
1 (PCI 高 4 位): 值为 1,表示这是一个首帧 (FF)。它宣告了一个多帧传输的开始
0B (PCI 低 12 位): 十六进制
0x00B,即十进制的 11。这表示整个 UDS 指令的总长度是 11 个字节34 00 44 08 00 00 (后续 6 字节): 这是原始 UDS 指令的前 6 个字节的数据
第二段请求则是 21 00 00 00 20 00 AA AA,取剩下的 5 个字节,21 00 00 00 20
得到 34 00 44 08 00 00 00 00 00 20 00:
1 | 34 RequestDownload |
所以后面 ECU 要被刷入一段大小为 0x2000 = 8192 bytes 的固件,加载地址是 0x08000000
紧接着后面出现大量这种数据,这里是 ISO-TP 多帧
1 | 10 82 36 01 28 04 00 20 |
10 82 表示本次 ISO-TP 数据总长度 = 0x82 = 130 bytes
重组后的 UDS payload 是 36 01 + 128 bytes 固件数据
附件里从 36 01 到 36 40 一共有 0x40 块,每块有效固件数据是 128 字节,正好对应前面的 RequestDownload 大小
1 | import re |
IDA 反编译可以看到一个 flag,但可惜是假的

去看看谁引用了它,因为这个固件没有 main 函数

这里在修改 flag 的每一位,跳过前面的 flag{

编写脚本解密得到 flag
1 | fake = "flag{canoecr7-zd9h-1emi-or8m-f8vm2od81nfk}" |
得到 flag{3dad13db-cb48-495d-b023-3231d80f1713}
Crypto
boom(签到)
博客地址:http://www.fzwjscj.xyz/index.php/archives/27/

第一题是求 MD5

第二题解方程

第三题同理

最后拿到 flag

you raise me up(模幂离散对数)

题目
1 | n = 2 ** 512 |
也就是 c ≡ m ^ e (mod 2 ^ 512)
并且 m 被强制为奇数
1 | m = random.randint(2, n-1) | 1 |
奇数在模 2 ^ 512 下一定有乘法逆元(因为与模数互素),所以它属于乘法群
这是一个模 2 的幂下的离散对数问题,一般离散对数很难,但模 2 的幂的群结构很特殊——它的阶是 2 ^ 511
因为 φ(2 ^ 512) = 2 ^ 511
但我们可以利用 “模 4” 这个小的模数先偷看一点信息,这个信息就是指数 e 的奇偶性
规律:底数为 3 (mod 4) 时,奇数次幂永远是 3 (mod 4),偶数次幂永远是 1 (mod 4)

题目给出的 m 和 c 满足
1 | m % 4 = 3 |
所以指数 e 必为奇数
因为 e 是奇数,它可以写成
1 | e = 2x + 1 |
代入原方程
1 | c ≡ m ^ (2x + 1) (mod 2 ^ 512) |
两边同时乘 m 的模逆
1 | c * m ^ (-1) ≡ m ^ (2x + 1) * m ^ (-1) (mod 2 ^ 512) |
令 g = m ^ 2,h = c * m ^ (-1)
1 | g ^ x ≡ h (mod 2 ^ 512) |
现在是要解出这个 x,因为 m 是奇数,m ^ 2 模 8 一定等于 1
任何奇数的平方减 1 都能被 8 整除

所以,我们可以把 g 写成
1 | g = 1 + 8u |
在模 2 的幂的乘法群结构中有一个经典结论,对于形如 1 + 2^s * u(u为奇数) 且 s >= 2 的元素
它在模 2 ^ k 下的阶就是 2 ^ (k - s)
这里 k = 512, s = 3,所以 g 的阶就是
1 | ord(g) = 2 ^ 509 |
这意味着,g 的 2 ^ 509 次方才会第一次回到 1
g 的所有幂次形成了一个有 2 ^ 509 个元素的循环圈。我们要求的指数 x 就在 0 到 2 ^ 509 - 1 之间。这个范围虽然大,但它的结构(阶是 2 的幂)给了我们可乘之机
我们已知 g ^ x ≡ h (mod 2 ^ 512)
并且 g 的阶是 r = 509,即 g 的幂次是一个 509 位的二进制数就能完全覆盖的圈
这个算法的精髓就是:我们可以一位一位地确定指数 x 的二进制位,从最低位到最高位
假设我们要求的指数 x 的二进制表示是

我们的目标是求出每一个 b_i(0 或 1)
假设我们已经通过某种方法求出了 x 的最低 i 位。我们把这部分已知的数值记作 x_i
例如,如果已知最低两位是 01,那 x_2 = 1
现在 x 可以拆成两部分
1 | x = x_i + (剩下的高位部分) |
因为 g 是奇数,与 2 ^ 512 互素,所以 g ^ (x_i) 一定有逆元,记作 g ^ (-x_i)
1 | g ^ x ≡ h (mod 2 ^ 512) |
对原等式 g ^ x ≡ h 两边乘以 g ^ (-x_i)
1 | g ^ x * g ^ (-x_i) ≡ h * g ^ (-x_i) (mod 2 ^ 512) |
我们把右边这个已知的数值叫做 t
1 | t ≡ h * g ^ (-x_i) (mod 2 ^ 512) |
那么根据上面的等式,自然就有:
1 | t ≡ g ^ (x - x_i) (mod 2 ^ 512) |
因为 x_i 恰好包含了 x 的低 i 位,所以相减之后,低 i 位全部变成 0
于是我们可以写成

这里:
b_i * 2 ^ i就是我们要找的第i位的值(只能是 0 或 1)q是更高位的总和,每一项至少包含因子2 ^ (i + 1),所以可以提取2 ^ (i + 1)出来,剩下的用q表示
我们想 “过滤” 掉高位 q,只留下 b_i 的信息。因为 g 的阶是 2 ^ r,我们可以利用一个性质:
如果一个指数包含 2 ^ r 这个因子,那一项就会变成 1
于是,我们故意把等式两边都升到 2 ^ (r - 1 - i) 次方
1 | t ^ {2 ^ (r - 1 - i)} ≡ {g ^ (x - x_i)} ^ {2 ^ (r - 1 - i)} (mod 2 ^ 512) |
代入 x - x_i 表达式

所以,我们现在得到
1 | t ^ {2 ^ (r - 1 - i)} ≡ g ^ {b_i * 2 ^ (r - 1) + (q * 2 ^ r)} |
现在指数里有一项是 q * 2 ^ r,我们可以把它拆开
1 | g ^ {b_i * 2 ^ (r - 1) + (q * 2 ^ r)} = g ^ {b_i * 2 ^ (r - 1)} * g ^ (q * 2 ^ r) |
因为 g 的阶是 2 ^ r,也就是说 g ^ (2 ^ r) ≡ 1 (mod 2 ^ 512),所以:
1 | {g ^ (2 ^ r)} ^ q ≡ 1 ^ q |
于是整个式子简化为
1 | t ^ {2 ^ (r - 1 - i)} ≡ g ^ {b_i * 2 ^ (r - 1)} |
现在指数只剩下 b_i * 2 ^ (r - 1),因为 b_i 只能是 0 或 1,所以只有两种情况
如果
b_i = 0,指数为0 * 2 ^ (r - 1) = 0,于是t ^ {2 ^ (r - 1 - i)} ≡ g ^ 0 ≡ 1如果
b_i = 1,指数为1 * 2 ^ (r - 1),于是t ^ {2 ^ (r - 1 - i)} ≡ g ^ {2 ^ (r - 1)}
如何在实际算法中判断???我们预先计算好 “标记值”:
1 | marker = g ^ {2 ^ (r - 1)} (mod 2 ^ 512) |
这个 marker 有一个特点:它不等于 1,但是它的平方等于 1
因为
1 | [g ^ {2 ^ (r - 1)}] ^ 2 = g ^ (2 ^ r) = 1 |
在每一步(第 i 位),我们计算
1 | val = t ^ {2 ^ (r - 1 - i)} (mod 2 ^ 512) |
然后判断:
若
val == 1,则第i位是 0若
val == marker,则第i位是 1
1 | m = 391190709124527428959489662565274039318305952172936859403855079581402770986890308469084735451207885386318986881041563704825943945069343345307381099559075 |
Web
AreUSerialz(PHP 反序列化 + 弱比较绕过 + PHP 伪协议)

1 |
|
因为字符串 "2" 严格等于字符串 "2",所以 op 会被改成 "1"
1 | if($this->op === "2") |
此时执行的是写文件操作,无法读取 flag
1 | if($this->op == "1") { |
因为我们需要将 op 设置为整数 2 从而绕过
利用伪协议读取文件 php://filter/read=convert.base64-encode/resource=flag.php
1 |
|
序列化生成代码
1 | O:11:"FileHandler":3:{s:5:" * op";i:2;s:11:" * filename";s:57:"php://filter/read=convert.base64-encode/resource=flag.php";s:10:" * content";N;} |
删除乱码并减去相应长度得到 Payload
1 | ?str=O:11:"FileHandler":3:{s:2:"op";i:2;s:8:"filename";s:57:"php://filter/read=convert.base64-encode/resource=flag.php";s:7:"content";N;} |
Reverse
signal(VM 逆向)

IDA 逆向分析这个 vm_operad() 函数
传入的参数 v4 是 &unk_403040 地址的值

1 | int __cdecl vm_operad(int *a1, int a2) // a1: 字节码数组, a2: 字节码长度 |
程序中的 114 个整数如下
1 | 10, |
第一条指令是 10,即调用 read(Str) 读取输入
之后是 15 组字符变换,最后是 15 组比较指令
目标数组为
1 | 34, 63, 52, 50, 114, |
令输入字符为 x[i],首先是第 0 位
1 | 4,16 temp = x ^ 16 |
方程式
1 | (x ^ 16) - 5 = 34 |
由此得到
| 位置 | 变换 | 目标 | 解 |
|---|---|---|---|
| 0 | (x ^ 16) - 5 |
34 | 7 |
| 1 | (x ^ 32) * 3 |
63 | 5 |
| 2 | (x - 2) - 1 |
52 | 7 |
| 3 | (x + 1) ^ 4 |
50 | 5 |
| 4 | x * 3 - 33 |
114 | 1 |
| 5 | (x - 1) - 1 |
51 | 5 |
| 6 | (x ^ 9) - 32 |
24 | 1 |
| 7 | (x + 81) ^ 36 |
-89 | 2 |
| 8 | (x + 1) - 1 |
49 | 1 |
| 9 | x * 2 + 37 |
-15 | f |
| 10 | (x + 54) ^ 65 |
40 | 3 |
| 11 | (x + 32) * 1 |
-124 | d |
| 12 | x * 3 + 37 |
-63 | 4 |
| 13 | (x ^ 9) - 32 |
30 | 7 |
| 14 | (x + 65) + 1 |
122 | 8 |
所以 flag 就是 flag{757515121f3d478}
jocker(SMC 逆向 + IDA 脚本)

IDA 反编译发现对 Str 和 Destination 进行了操作
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
最后输出 flag 的校验是由 encrypt 函数决定
反编译这个函数报错,这是 SMC 解密前的密文被 IDA 强行当作指令反汇编的结果
proc far、sp-analysis failed、异常的 retf 和大量无意义指令都是明显特征,不需要继续分析这些乱码

回头看 main 函数的这段代码
1 | for ( i = 0; i <= 186; ++i ) |
找到 encrypt 的具体地址

因此需要处理
1 | 起始地址:0x401500 |
1 | import ida_auto |
点击脚本文件导入进去

解密出正确的指令

1 | // 验证输入前 19 字符:与 "hahahaha_do_you_find_me?" 异或后等于硬编码数组 |
查看 0x403040 处的数据,然后写脚本反推
1 | buffer = b"hahahaha_do_you_find_me?" |
得到 b'flag{d07abccf8a410c'
回到 main(),程序检查输入长度为 24 字节,还少了 5 字节
在 finally 函数中看到字符串 %tp&: 刚好是 5 字节

假设 ':' XOR key = '}',则 key 为 0x47
1 | cipher = b"%tp&:" |
得到 b'b37a}'
拼接得到 flag{d07abccf8a410cb37a}
bang(梆梆壳 + Frida 动态脱壳 + Jadx 反编译)

使用 APKiD 识别出加固过,用的是 SecShell/梆梆壳
[*] signed.apk!classes.dex*:APKiD 开始扫描 APK 包中的classes.dex文件(即 Java/Kotlin 字节码的核心文件)。末尾的*表示该文件在 APK 中处于被压缩状态,APKiD 会在内存中实时解压并扫描*|-> anti_vm : possible ro.secure check:在 DEX 字节码中检测到了反虚拟机(Anti-VM)特征,具体表现为检查系统属性ro.securero.secure:Android 系统的只读属性。在生产环境(真机)中通常为 1
*|-> compiler : dexlib 2.x:检测到 DEX 文件的编译器版本标记为dexlib 2.x[*] signed.apk!lib/arm64-v8a/libSecShell.so:libSecShell.so是百度旗下的 “SecShell” 加固壳(俗称 “百度加固”)的标志性文件名|-> anti_hook : syscalls:在该 SO 库中检测到了反 Hook 机制,具体手段为直接使用系统调用

这里我使用的是 Genymotion 模拟器,安卓版本是 8.1,其自带 adb.exe
1 | # 列出设备 |

1 | # 获取 Android 系统版本 |

将 APK 拖进去运行

然后查看对应的进程
1 | adb shell ps | findstr how |

先查壳有没有释放 dex
1 | # /data/data:Android 系统规定的所有应用程序私有数据的根目录 |

把缓存文件拉回电脑
1 | mkdir dump_cache |

结果发现都是被混淆加密的文件

使用 frida 工具,提前下载好 frida-server-17.17.0-android-x86.xz,解压后把它改名成 fs 放到附件目录中
1 | # adb push:电脑 → 手机的传输命令 |
运行后需要开另一个终端看能不能连接上
1 | # 测试列出进程 |

其中 2458 how_debug 是我们的 APK
1 | # frida-dexdump:基于 Frida 的动态脱壳工具 |
成功 dump 两个 dex,分别是 classes.dex 和 classes2.dex

使用 Jadx 反编译拿到 flag
