第四节:符号化输入——创建符号位向量与设置参数
好,咱们继续往下走。上一节我们把angr的环境搭好了,也跑通了第一个demo。但说实话,那个demo太玩具了——直接把二进制文件扔进去,让angr自己从头跑到尾。实际逆向中哪有这么简单?你想想看,真实程序都有输入,有命令行参数,有标准输入流。这些输入怎么让angr理解?
答案就是——符号化。
说白了,符号化就是把一个具体的值变成一个「符号」。比如你平时调试时给变量赋值为5,那是具体值。符号化之后,这个变量可以代表「任意可能的整数」。angr会去探索所有可能的路径,看看哪些输入能触发哪些行为。我个人觉得,这是angr最核心、也最迷人的地方。
4.1 符号位向量(BitVector)是什么
先别被名字吓到。BitVector,位向量,其实就是一串二进制位。你可以把它理解成一个「未知的变量」,只不过这个变量在angr的世界里是用比特来表示的。
举个例子:
import angr
import claripy
# 创建一个32位的符号位向量
sym_var = claripy.BVS('my_input', 32)
这里 claripy.BVS 就是创建符号位向量的函数。第一个参数是名字,随便起,方便你调试时识别。第二个参数是位宽,32就是32位,也就是4个字节。
核心概念:符号位向量 = 一个未知的、但长度固定的比特序列。它可以是整数、字符、甚至是内存地址。
我在项目中遇到过一个问题:新手容易把位宽和字节数搞混。32位是4字节,64位是8字节。如果你要符号化一个char类型,那就是8位;int是32位;long long是64位。千万别搞错,否则约束求解时会出一些莫名其妙的错误。
4.2 创建符号位向量的几种方式
实际使用中,我们不会只创建单个变量。angr提供了多种创建方式,我整理了一张表:
| 方法 | 说明 | 示例 |
|---|---|---|
| claripy.BVS(name, size) | 创建单个符号位向量 | BVS('flag', 64) |
| claripy.BVV(value, size) | 创建具体值的位向量 | BVV(0x41, 8) # 字符'A' |
| claripy.BVS(name, size, explicit_name=True) | 强制使用指定名字(避免重命名) | BVS('idx', 32, explicit_name=True) |
嗯,这里要注意 explicit_name 参数。默认情况下,如果你创建两个同名的符号变量,angr会自动在后面加后缀来区分。但有时候你需要精确控制变量名(比如调试时),那就把这个参数设为True。
4.3 符号化命令行参数(argc/argv)
好了,理论说完了,咱们来点实战。假设你有一个C程序,它接受命令行参数:
// test.c
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
if (argc != 2) {
printf("Usage: %s <password>\n", argv[0]);
return 1;
}
if (strcmp(argv[1], "secret123") == 0) {
printf("Access granted!\n");
} else {
printf("Access denied!\n");
}
return 0;
}
这个程序很简单,但它是典型的「基于命令行参数做判断」的逆向场景。怎么用angr符号化它的输入?
看代码:
import angr
import claripy
proj = angr.Project('./test', auto_load_libs=False)
# 第一步:创建符号化的argv[1]
# 假设密码最长16字节,我们创建128位(16字节)的符号变量
password_len = 16
password = claripy.BVS('password', password_len * 8)
# 第二步:创建符号化的argv
# argv[0] 是程序名,我们用具体值
# argv[1] 是密码,用符号值
# argv[2] 是NULL结束符
argv = [proj.filename, password, claripy.BVV(0, 64)] # 注意:指针是64位
# 第三步:设置符号化参数
state = proj.factory.entry_state(
args=argv,
argc=claripy.BVV(2, 64) # argc = 2,固定值
)
个人经验:我刚开始做的时候,总忘记argv最后要加NULL指针。C语言要求argv数组以NULL结尾,angr也遵循这个约定。不加的话,angr会一直读内存读到崩溃。
这里有个细节:argc 我用了 BVV(2, 64),是具体值。为什么?因为在这个场景下,argc是确定的——程序只接受一个参数。但如果你要分析的程序对argc有分支判断(比如argc==1走A逻辑,argc==2走B逻辑),那argc本身也应该符号化。
4.4 符号化标准输入(stdin)
命令行参数搞定了,那标准输入呢?很多CTF题目和实际程序都是从stdin读数据的。比如:
// read_stdin.c
#include <stdio.h>
#include <string.h>
int main() {
char buf[16];
fgets(buf, 16, stdin);
if (strcmp(buf, "flag{hello}\n") == 0) {
printf("Correct!\n");
} else {
printf("Wrong!\n");
}
return 0;
}
符号化stdin的方式稍微不同:
import angr
import claripy
proj = angr.Project('./read_stdin', auto_load_libs=False)
# 创建符号化的stdin内容
# 假设输入最多16字节
stdin_size = 16
stdin_symbol = claripy.BVS('stdin', stdin_size * 8)
# 创建状态时,指定stdin
state = proj.factory.entry_state(
stdin=angr.SimFile('/dev/stdin', content=stdin_symbol)
)
这里用到了 angr.SimFile。它模拟了一个文件对象,content 参数就是文件的内容。我们把符号位向量传进去,angr就知道这个文件的内容是未知的。
避坑指南:我曾经犯过一个错误——stdin的符号变量长度设得太短。比如程序用fgets(buf, 100, stdin),但我只给了16字节的符号变量。结果angr在路径探索时,发现程序读不到足够的数据,直接走异常路径了。记住:符号变量的长度要 >= 程序实际读取的长度。
4.5 符号化输入的核心逻辑图
说了这么多,我画了一张图帮你理清思路:
这张图展示了整个流程:从不同的输入源出发,经过符号化处理,最终生成一个包含符号变量的初始状态。有了这个状态,angr才能去探索所有可能的执行路径。
4.6 综合示例:符号化所有输入
最后,给一个综合性的例子。假设程序同时使用命令行参数和stdin:
import angr
import claripy
proj = angr.Project('./complex_input', auto_load_libs=False)
# 符号化命令行参数
arg1 = claripy.BVS('arg1', 8 * 8) # 8字节
arg2 = claripy.BVS('arg2', 16 * 8) # 16字节
argv = [proj.filename, arg1, arg2, claripy.BVV(0, 64)]
# 符号化stdin
stdin_data = claripy.BVS('stdin_data', 32 * 8) # 32字节
stdin_file = angr.SimFile('/dev/stdin', content=stdin_data)
# 创建状态
state = proj.factory.entry_state(
args=argv,
argc=claripy.BVV(3, 64), # 程序名 + 2个参数 = 3
stdin=stdin_file
)
print("符号化状态创建成功!")
print(f"符号变量列表: {state.solver.get_variables()}")
运行这段代码,你会看到angr成功创建了三个符号变量:arg1、arg2、stdin_data。每个变量都有明确的位宽,angr知道它们代表什么。
我的习惯:每次创建符号变量后,我都会打印一下 state.solver.get_variables() 来确认。这能帮你快速发现变量名冲突或者位宽错误。调试时省不少时间。
好了,这一节的内容就到这。符号化输入是angr的基石,你后面做的所有分析——路径探索、约束求解、漏洞挖掘——都建立在这个基础之上。多动手写几个例子,把BVS、BVV、SimFile这几个API用熟,后面就顺了。
公众号:蓝海资料掘金营,微信deep3321