2020 SCTF WriteUp
🏛️ 组织架构
主办方:XCTF 联赛
组织方:成都信息工程大学 Syclover(三叶草)战队
技术支持:赛宁网安
📝 赛事概况
比赛时间:2020 年 7 月 4 日 09:00 至 7 月 6 日 09:00,共 48 小时
比赛形式:在线 CTF(Capture The Flag)夺旗挑战赛
题目类别:涵盖 Web、IOT、Reverse、Pwn、Misc 五大方向
奖金(美元):
一等奖:1500 美元
二等奖:800 美元
三等奖:500 美元
晋级资格:冠军队伍可直接晋级第六届 XCTF 总决赛
Misc
Can you hear(SSTV 无线电)

使用软件直接解密即可

EasyMisc(十六进制倒序 + 文件头篡改 + RC4)

附件名 galf_si_erehw 提示了倒序,where_is_flag
对附件同样进行倒序
1 | from binascii import hexlify, unhexlify |
倒序后打不开文件,发现缺少了 JPG 头 ffd8ff,补充上去后打开是个假的 flag

继续用 WinHex 查看时找到可疑字符串并提示 RC4
1 | xoBTuw36SfH4hicvCzFD9ESj |

套板子解密拿到 flag:SCTF{St@Y_@T_H0Me}
1 | import base64 |
AndroidDisplayBridge(Android 无线 ADB + Scrcpy 投屏 + H.264 视频流)

用 Wireshark 打开后发现大量 TCP 5555 流量,5555 很容易联想到 Android 的无线 ADB
继续看流量里还能发现 scrcpy-server.jar,说明这是用 scrcpy 在电脑上投屏/控制 Android 手机
scrcpy 的屏幕画面是以 H.264 视频流传输的

H.264 的关键特征是流量中能搜到类似 00 00 00 01 67,这是 H.264 裸流里常见的 NAL 起始码加 SPS 数据
1 | tcp.port == 5555 && frame contains 00:00:00:01:67 |
过滤后追踪长度最大的流量包,筛选一下选择 192.168.1.103:5555 --> 192.168.1.107:35316 方向,它的大学占了绝大部分,应该就是传输的内容,导出来

在上面导出来的流量中,00 00 00 01 67 偏移位置是 968,直接截取出来
1 | # offset 是 0-based 的 968 |
然后转 MP4
1 | ffmpeg -err_detect ignore_err -f h264 -i out.h264 -c copy output.mp4 |
-err_detect ignore_err含义:设置错误检测/处理标志
具体作用:
ignore_err告诉 FFmpeg 的解码器,在读取输入数据时如果遇到比特流错误,不要抛出致命错误或中断解码,而是尽可能 “将错就错” 地跳过损坏部分并继续解析后续数据
-f h264- 含义:强制指定输入文件的格式
-i out.h264- 含义:指定输入文件
-c copy含义:流拷贝模式
具体作用:禁用重新编码。直接将输入的 H.264 视频流数据打包到输出容器中。这样做的好处是速度极快且画质无损。缺点是如果输入流有错误,错误也会被原样拷贝到输出文件中(除非被
-err_detect截断)
视频的最后出现了 flag

Reverse
signin(Python 反编译 + .pyz 恢复 + 线性变换)

提出可识别字符串发现是有 Python 写出来的
1 | main |
使用 pyinstxtractor.py 反编译出 .pyc 文件

main.pyc 反编译如下:
AccountChecker.__init__释放tmp.dll,加载 DLL 并获取enc函数self.accounts仅存一个用户:b'SCTFer',对应一个 Base64 解码后的 33 字节密文check比较buffer与预设密文,相等即返回 True,界面显示Succeeded! Flag is your password
从 tmp.dll 的导出表可以看到只有一个导出函数 enc
1 | int enc(char *username, char *password, char *out, int out_len) |
其中 transform() 是 64 轮类似 LFSR 的变换
1 | uint64_t transform(uint64_t x) |
由于这是 GF(2) 上的线性变换,可以对 64 位块求逆
1 | import base64 |
最后得到 flag:SCTF{We1c0m3_To_Sctf_2020_re_!!}
get_up(RC4)

IDA 反编译,首先是输入长度要小于等于 6,然后调用了 sub_401DF0() 函数

查看这个函数发现是在做 MD5 对比

撞库得到 sycsyc,所以这是程序第一步所需要输入的
在 IDA 里查字符串发现了 worry 以及 right
跳转到被引用的地方

一眼 RC4,伪代码上面就是定义的密文

动态调试拿到 Key 后编写解密脚本
1 | def rc4(data: bytes, key: bytes) -> bytes: |
得到 flag:SCTF{zzz~(|3[___]_rc4_5o_e4sy}
Orz(LCG + DES)

在 IDA 或 Ghidra 中分析主函数时,可以看到程序大致执行以下步骤:
读取用户输入;
判断输入长度是否为 32;
根据输入的第 7、16、30 个字符计算 seed;
通过 seed 生成 33 个伪随机数;
将输入和伪随机数做卷积式计算,生成 64 个
unsigned int;把这 64 个
unsigned int视为 32 个unsigned long long;每两个
uint64为一组,分别作为key和data;对每组执行 64 轮 DES 加密;
将最终结果和内置
cmp_data比较;如果全部相等,则输出成功。

程序先检查输入长度为 32,然后用输入中特定位置的字符计算 seed:
1 | seed = (~((input[6] + input[15] + input[29]) * 53)) & 0xfff; |
由于最终 & 0xfff,所以 seed 的范围只有:0x000 ~ 0xfff
也就是最多 4096 种可能。这意味着 seed 可以直接爆破
程序通过线性同余方式生成 33 个随机数:
1 | def gen_rand(seed): |
这个生成器有几个特点:
每个随机数范围在
0 ~ 253;只依赖初始 seed;
初始 seed 范围很小,可以枚举;
生成的是 33 个数,而 flag 长度是 32
程序将 32 字节输入和 33 个随机数做累加,得到 64 个 uint32
可以抽象为:
1 | for (int i = 0; i < 32; i++) { |
由于 i 的范围是 0 ~ 31,j 的范围是 0 ~ 32
所以 i + j 的范围是 0 ~ 63,因此输出正好是 b[0] ~ b[63] 共 64 个 uint32
第一阶段得到的是:unsigned int b[64];
第二阶段会把它强制转换为:unsigned long long *tmp2 = (unsigned long long *)b;
也就是说,每两个 uint32 会被拼成一个 uint64。
在常见 x86/x64 小端环境下:
1 | tmp2[0] = b[0] + (b[1] << 32) |
逆向时也必须按小端拆分:
1 | b[2*i] = tmp2[i] & 0xffffffff |
这个端序问题是本题非常容易出错的地方
正向每轮的 key 更新为:key = key * 2 mod 2^64
如果左移时发生溢出,程序会额外执行:key ^= 0x3FD99AEBAD576BA5
判断是否溢出的方式是:if (key < old_key)
因为无符号整数溢出后,左移结果会比原值小
这一题最重要的突破点是 key 的奇偶性
正向过程中:key * 2 一定是偶数
如果没有溢出:new_key = old_key * 2,那么 new_key 一定是偶数。
如果发生溢出:new_key = (old_key * 2 mod 2^64) ^ 0x3FD99AEBAD576BA5
而常量:0x3FD99AEBAD576BA5
最低位是 1,是奇数。所以:偶数 ^ 奇数 = 奇数
因此:
当前 key 是偶数 → 上一轮没有溢出 → 上一轮走 DES-ECB
当前 key 是奇数 → 上一轮发生溢出 → 上一轮走 DES-CBC
这就让我们能够从最终 key 一轮一轮倒推回去
1 | from Crypto.Cipher import DES |
最后得到 SCTF{b5c0b187fe309af0f4d35982fd}
Password Lock(STM32 固件)

打开附件可以看到这个典型的 Intel HEX 格式

第一行 :020000040800F2 含义是扩展线性地址,高 16 位是 0x0800,所以后面的数据加载基址是 0x08000000
这非常关键。因为 STM32F103 的 Flash 默认就是从 0x08000000 开始执行的
把 HEX 解析成二进制后,开头几个 4 字节小端整数是
1 | 0x08000000: 0x20000440 |
其中第一个值 0x20000440 是初始栈指针,位于 0x20000000 SRAM 区域
第二个值 0x08000101 是 Reset_Handler 地址,注意这里末尾是 1,这是 ARM Cortex-M 的特点,表示 Thumb 模式。真实函数地址要去掉最低位
1 | 0x08000101 -> 0x08000100 |
所以可以确定这是 ARM Cortex-M / STM32 固件,IDA 中选择 ARM little endian 反编译

假的 flag

定位到关键函数

真正决定答案的是下面这段
1 | unk_40010410 |= 2u; |
这里的 0x40010410 对应着软件中断事件寄存器,它的作用是不用真的按物理按键,也可以通过软件写寄存器来触发 EXTI 中断
EXTI_SWIER 是一个按 bit 控制的寄存器
| 数值 | 二进制 | 置位的是哪一位 | 对应 EXTI |
|---|---|---|---|
0x02 |
0000 0010 |
bit1 | EXTI1 |
0x04 |
0000 0100 |
bit2 | EXTI2 |
0x08 |
0000 1000 |
bit3 | EXTI3 |
0x10 |
0001 0000 |
bit4 | EXTI4 |
所以逐个转换
1 | 0x02 -> EXTI1 -> 按键 1 |
拿到 flag:SCTF{1442413}