处理线性方程组题型
写在前面
镇楼:届不到的爱恋
感谢leyi发的新生赛RE题,不愧是逆神的题目,我第二题就卡住了,脱壳没有什么问题,但是我点开IDA后发现流程图长这样:
这个时候,有人就要问了:B哥,B哥,这他妈什么jb?
那我问你,我才刚学,我知道个蛋
于是我去问了AI,说是有两种题型:
1.线性方程组题型
2.控制流平坦化题型
线性方程组题型的典型特征
- 结构:主函数中只有一个巨大的
if语句,里面包含几十个由&&连接的等式。 - 表达式形式:每个等式都是形如
(系数1 * 字节1 + 系数2 * 字节2 + ... + 系数32 * 字节32) == 常数的线性组合。 - 变量命名:IDA 可能会自动将输入分解为几个连续的变量(如
v4, v5, v6, v7),每个变量通过SBYTE1、SBYTE2等宏访问其内部的字节。 - 没有循环/switch:除了输入读取和字符范围检查的循环外,验证部分全是顺序的数学运算,没有复杂跳转。
控制流平坦化题型的典型特征
- 结构:反编译后是一个巨大的
while(1)循环,里面嵌套switch或大量if-else,所有功能块都被打散。 - 状态变量:存在一个局部变量(如
v1)作为“状态”,控制当前执行哪个块。 - 分发器:每次循环都会根据状态变量跳转到对应块,块末尾会更新状态变量并跳回循环开头。
- 代码碎片化:原本顺序的算法被拆成许多小块,块之间通过状态变量连接。
如何快速确认是线性方程组
当你看到类似你贴出的代码时,可以按以下步骤确认:
观察输入处理
代码开头通常有
get_input(&v4),并且v4是 8 字节变量(__int64)。随后可能有一个循环调用
check_char验证每个字符的范围(比如是否为可打印字符)。这说明输入被存储在连续的 32 字节中,分在 4 个 8 字节变量里。

查看验证部分
- 如果看到大量形如
a * SBYTE1(v4) + b * (char)v5 + ... == 常数的表达式,且系数都是常数,那就是线性组合。 - 注意移位运算如
(SBYTE1(v4) << 7)实际就是乘以 128,仍是线性。
79 * SBYTE5(v7)
是79` 乘以 v7 的第 5 个字节,等等。
这种形式就是典型的线性方程:Σ (系数_i * 字节_i) = 常数。所有项都是一次的,没有平方、异或等非线性运算。移位如 (x << 7) 也等价于 128 * x,仍然是线性。
计数方程个数
- 数一数
&&连接了多少个等式。如果个数正好等于输入字节数(32),那么极有可能是一个线性方程组,通过求解这些方程就能得到输入。
尝试简化
- 可以将所有字节编号为
b0~b31,把每个等式整理成Σ coeff_i * b_i = const的形式。如果系数矩阵是满秩的,那么就有唯一解。(这个以后再说)
补充知识
变量与字节的对应关系
根据之前的内存布局(栈帧 rbp-30h 到 rbp-11h),四个 64 位变量 v4, v5, v6, v7 连续存放了 32 个字符。每个变量内部,字节从小端序排列:
(char)v4→ 最低地址字节 → 输入的第 0 个字符SBYTE1(v4)→ 下一个字节 → 输入的第 1 个字符- …
SHIBYTE(v4)→ 最高地址字节 → 输入的第 7 个字符
即:每个宏对应一个唯一的字节索引
在 Z3 脚本中,我们不能直接写 SBYTE6(v7),因为 Z3 不认识这些宏。我们需要将方程中的每一项都转化为关于输入字符的线性组合。也就是说,我们需要把伪代码中的:
1 | 117 * SBYTE6(v7) + 79 * SBYTE5(v7) + ... |
翻译成 Z3 能理解的:
1 | 117 * flag[30] + 79 * flag[29] + ... |
字节映射
1 | int __fastcall main(int argc, const char **argv, const char **envp) |
- 定义了四个 8 字节变量:
v4,v5,v6,v7。它们的类型是__int64,即 64 位整数。 - 它们在栈上是连续的:
v4在rbp-30h,v5在rbp-28h,v6在rbp-20h,v7在rbp-18h。因为栈向下增长,所以v4地址最低,v7地址最高。 get_input(&v4)将用户输入的字符串存储到以v4为起始地址的缓冲区。由于v4是 8 字节,但后面跟着v5、v6、v7,所以实际上这 4 个变量连续占据了 32 字节的空间(4 * 8 = 32)。因此,输入的字符串会被完整放在这 32 字节中。
宏
宏是一种预处理符号,它代表一段代码片段。在 IDA 的反编译伪代码中,这些宏被用来简化对多字节变量(如 __int64)的字节级访问。它们不是 C 语言标准的一部分,而是 IDA 为了让反编译结果更易读而创造的。
字节顺序与宏的含义
在后续的方程中,你会看到诸如 (char)v4、SBYTE1(v4)、SHIBYTE(v4) 等宏。这些是 IDA 对位域或字节提取的表示,它们的含义取决于机器的小端序(x86/x64 是小端)。
(char)v4:取v4的最低字节(即地址最低的字节)。SBYTE1(v4):取v4的第二个字节(地址次低的字节)。SBYTE2(v4):第三个字节。- …
SBYTE6(v4):第七个字节。SHIBYTE(v4):最高字节(第八个字节)。
因为 v4 是 8 字节,所以它有 8 个字节,索引从 0(最低)到 7(最高)。同理 v5 也有 8 个字节,索引 8~15,以此类推。
完整的字节映射表
结合栈地址和字节顺序,我们可以将每个宏映射到一个具体的字节索引(b0 ~ b31)。假设输入字符串的第一个字符存入 v4 的最低字节,最后一个字符存入 v7 的最高字节。
举例说明
看第一个方程的一小部分:
text
1 | 117 * SBYTE6(v7) + 79 * SBYTE5(v7) + 56 * SBYTE2(v7) + 91 * SBYTE1(v7) + ... |
SBYTE6(v7)对应v7的第七个字节,即b30。SBYTE5(v7)对应b29。SBYTE2(v7)对应b26。SBYTE1(v7)对应b25。
因此,这一部分的系数贡献是:
- b30 加 117
- b29 加 79
- b26 加 56
- b25 加 91
以此类推,每个方程都要根据映射表将每个项的系数加到对应的字节索引上。
为什么这个映射很重要
如果你映射错了,比如把 SBYTE6(v7) 当成 b29 而不是 b30,那么整个方程组就会错位,Z3 就会无解。这是手动提取最容易出错的地方之一。
z3库与anger脚本
网上有很多,本人见识有限,后面会补充
解题
以下是基于IDA PRO:
左边functions下的functinon name,找到main, 点击F5看伪代码(就是上面那些图片
首先是AURORA给的:
1 | from z3 import * |
实际上这个你真用WSL跑是搞不出来的:
- 缩进错误:
for i in range(32):下面的s.add(...)必须缩进,否则会报IndentationError。 Or的用法错误:Or([...])应该改为Or(*[...]),因为 Z3 的Or需要多个参数,而不是一个列表。- 变量类型问题:用
BitVec(8)会导致乘法溢出(模256),而方程中的常数很大,应该用Int类型。
重点讲讲第三条吧,昨晚主要主要卡在这了
Int 与 BitVec的区别
有这样一个方程:
1 | 200 * x = 400 |
如果x是整数,那解为x=2
但如果x是8位计算机的整数,那乘法就会所谓溢出:
200 × 2 = 400,但 400 超过了 255,计算机只保留 400 mod 256 = 144。
所以实际方程变成了 200 × x = 144,解就不是 2 了。
使用z3,只要将看到的题目伪代码方程翻译成等式,加入字符集约束,即可求解
方程列表
刚刚的AURORA版本中代码大部分是那32个方程,伪代码中:
1 | 117 * SBYTE6(v7) + 79 * SBYTE5(v7) + ... == 422531 |
然后就是建立映射表,将每个宏替换成flag数组中的对映元素。
1 | 85 * flag[0] + 185 * flag[1] + ... + 98 * flag[31] == 422531 |
还有处理位移和括号,将”<<”变成乘法之类的
之后将等式化成Z3约束
在Z3中,我们需要定义一个长度为32的整数变量列表
flag[i],然后对每个等式,用sum(coeff[i] * flag[i]) == const的形式添加约束。先前WP中给出的代码是用
BitVec写的,但这是错误的,因为系数和常数很大,乘法会溢出。正确的做法是用Int类型。我们修正后的脚本就是用了Int。
最后添加字符集约束
从
check_char函数可以知道,每个字符必须属于一个特定的字母表。WP中给出了字母表b"AUVabcdhikorsuvyz012345{}_'",我们需要将每个变量限制为这个集合中的值。这通过Or(*[flag[i] == c for c in alphabet])实现。
anger
当然,对于B哥来说,现在就需要一点节约时间且简单的脚本来跳过调整31个方程这个过程
1 | #!/usr/bin/env python3 |
- BINARY_PATH:你的目标程序路径。
- INPUT_LEN:输入长度(字节)。如果未知,可以先设大一点(如 64),但会增加求解时间。通常题目会明确提示(如
printf("input %d chars:"))或可以从check_char循环次数推断。 - 成功地址:这是最关键的一步。你需要用 IDA 找到输出成功信息的指令地址。例如,在本题中是
0x40517C。如果找不到地址,可以改用字符串匹配方式(注释掉的is_success函数),但地址方式更可靠。 - 失败地址(可选):告诉 angr 避开哪些路径,可以大幅加速。
- 字符范围:如果已知输入只能是可打印字符或特定字母表,添加约束能缩小解空间,加速求解。如果不确定,可以注释掉,让 angr 自由探索。
常见问题与调整
- 地址不对:如果程序开启了 PIE(地址随机化),你在 IDA 中看到的可能是文件偏移,需要在脚本中加上基址(通常为
0x400000)或先在 IDA 中 rebase 到0x400000。 - 路径爆炸:启用
veritesting=True可以合并相似路径,极大缓解路径爆炸。如果还不行,可以尝试手动添加更多avoid地址(如所有错误输出的地址)。 - 长时间无结果:可以尝试去掉字符范围约束,或改用
simgr.run()然后手动查看状态,但通常explore就够了。 - 输入包含换行符:大多数程序用
gets或scanf读取字符串,不会把换行符存入缓冲区,所以我们构造符号输入时不加换行符。如果程序确实需要换行符(例如用read读取固定长度),可以在flag后面加上claripy.BVV(b'\n')。
这个题目,需要这样更改:
1 | BINARY_PATH = "./homework.exe" |
但是,我选择的是直接让ai给我修映射,因为当时用这个找答案的时候总是遇到卡死的问题。
然后在WSL上操作
1 | cd /mnt/你的地址 |
确保该目录下存在你的文件。
然后激活 conda 环境
1 | conda activate deflat |
确保命令行前缀变成 (deflat),表示已进入正确的 Python 环境。
然后就是python的事了
反思
我倒是觉得没啥好反思的,因为这是我遇到过的的第一个这种类型的题目,不过这个过程倒是学到很多东西,不赖
附件
我昨晚用这个做出来的
1 | from z3 import * |






