软件分析基础 - 从编译器的视角理解程序
在漏洞挖掘中,我们经常需要理解程序的行为:数据从哪里来?流向哪里?哪些代码会被执行?本章将介绍一些程序分析相关的核心概念。
1. 抽象语法树(Abstract Syntax Tree, AST)
1.1 什么是 AST?
在介绍控制流和数据流之前,我们需要先了解程序的一种基础表示形式:抽象语法树(AST)。
当编译器或分析工具读取源代码时,第一步是解析(Parsing),将文本形式的代码转换为结构化的树形表示。这棵树就是 AST。
定义:AST 是源代码语法结构的树状表示,每个节点代表代码中的一个语法结构(如表达式、语句、声明等)。
如下图
为什么叫"抽象"?
因为它省略了源代码中不影响语义的细节,比如: - 括号(通过树的结构隐含表示优先级) - 分号、逗号等分隔符 - 空格和注释
1.2 一个简单的例子
考虑这行代码:
int result = a + b * 2;
它的 AST 大致如下:
VariableDeclaration
/ \
Type Init
| |
"int" Assignment
/ \
Var BinaryExpr(+)
| / \
"result" Var BinaryExpr(*)
| / \
"a" Var Literal
| |
"b" 2
注意:
- 乘法 * 比加法 + 先计算,这通过树的结构(* 是 + 的子节点)自然表达
- 源代码中的括号、分号等都不在 AST 中
1.3 AST 节点的常见类型
不同语言的 AST 结构有所不同,但通常包含以下类型的节点:
| 节点类型 | 示例 | 说明 |
|---|---|---|
| Literal | 42, "hello", true |
字面量值 |
| Identifier | x, count, printf |
变量名、函数名 |
| BinaryExpr | a + b, x < 10 |
二元运算表达式 |
| UnaryExpr | !flag, -x, *ptr |
一元运算表达式 |
| CallExpr | printf("hi") |
函数调用 |
| IfStmt | if (cond) {...} |
条件语句 |
| WhileStmt | while (cond) {...} |
循环语句 |
| Assignment | x = 10 |
赋值语句 |
| FunctionDecl | int foo() {...} |
函数声明 |
| VarDecl | int x = 5 |
变量声明 |
1.4 AST 的遍历
分析工具通过遍历 AST 来检查代码。常见的遍历方式:
前序遍历(Pre-order):先访问当前节点,再访问子节点
访问顺序:根 → 左子树 → 右子树
后序遍历(Post-order):先访问子节点,再访问当前节点
访问顺序:左子树 → 右子树 → 根
示例:用 AST 遍历查找所有函数调用
def find_calls(node):
if node.type == "CallExpr":
print(f"Found call to: {node.function_name}")
for child in node.children:
find_calls(child) # 递归遍历子节点
1.5 AST vs 其他程序表示
| 表示形式 | 特点 | 适用场景 |
|---|---|---|
| AST | 保留语法结构,接近源代码 | 代码风格检查、重构、简单的模式匹配 |
| CFG | 表示控制流,基本块为节点 | 控制流分析、可达性分析 |
| IR | 中间表示,如 LLVM IR | 编译器优化、跨语言分析 |
| SSA | 静态单赋值形式 | 数据流分析、优化 |
| PDG | 程序依赖图 | 切片分析、并行化 |
AST 是最接近源代码的表示,而 CFG、IR 等是在 AST 基础上进一步抽象的结果。很多分析工具会先构建 AST,再从 AST 生成 CFG 等其他表示。
1.6 符号化 AST(Symbolic AST)
在符号执行工具(如 angr)中,表达式也以 AST 形式表示,但节点可以是符号变量而非具体值:
import claripy
# 创建一个符号变量(代表未知的用户输入)
user_input = claripy.BVS('user_input', 32) # 32位符号位向量
# 构建符号表达式
expr = user_input + 10
# 查看 AST 结构
print(expr) # <BV32 user_input + 0xa>
print(expr.op) # '__add__'
print(expr.args) # (<BV32 user_input>, <BV32 0xa>)
这种符号 AST 允许我们:
- 追踪值的来源(如果 AST 中包含 user_input,说明值受用户输入影响)
- 使用约束求解器求解满足特定条件的输入
这也是为什么在 angr 的污点分析中,我们可以通过检查寄存器值的 AST 是否包含特定符号变量来判断污点是否传播到了该位置。
2. 控制流分析基础
2.1 什么是控制流?
控制流(Control Flow) 描述的是程序执行的顺序。简单来说,就是"程序下一步会执行哪条语句"。
看一个简单的例子:
int x = 1; // 语句1
if (x > 0) { // 语句2
x = x + 1; // 语句3
} else {
x = x - 1; // 语句4
}
printf("%d", x); // 语句5
这段代码的控制流是: - 语句1 → 语句2(顺序执行) - 语句2 → 语句3 或 语句4(条件分支) - 语句3 → 语句5(跳过else) - 语句4 → 语句5(合并)
在 x86-64 汇编代码层面,控制流的表征是 rip 寄存器的变化,它指向下一条要执行的指令地址。
1.2 基本块(Basic Block)
基本块是控制流分析中最重要的概念之一。
定义:基本块是一段连续的代码,满足两个条件: 1. 只能从第一条语句进入 2. 只能从最后一条语句离开
换句话说,基本块中的语句要么全部执行,要么全部不执行,没有中间状态。
为什么需要基本块?
程序可能有成千上万条语句,如果逐条分析效率太低。把语句打包成基本块后,我们可以把整个块当作一个单元来分析,大大提高效率。
此外,基本块是构建控制流图(CFG)的节点,是后续数据流分析的基础,也是一些编译器优化 Pass 的基本单位(如 basic block reordering)
如何划分基本块?
找到以下三种语句,它们就是基本块的边界:
- 入口语句(Leader):
- 程序的第一条语句
- 跳转目标(如
if的两个分支起点、循环开始处) -
跳转语句的下一条语句
-
从一个入口语句开始,到下一个入口语句之前的所有语句,构成一个基本块。
示例:
int main(){
int sum = 0; // B1: 入口语句(程序开始)
int i = 0; // B1
while (i < 10) { // B2: 入口语句(循环开始,也是跳转目标)
sum = sum + i; // B3: 入口语句(条件为真的分支)
i = i + 1; // B3
}
printf("%d", sum); // B4: 入口语句(循环后的语句)
return 0; // B4
}
这段代码被划分为4个基本块: - B1:初始化部分 - B2:循环条件判断 - B3:循环体 - B4:循环结束后的代码
1.3 控制流图(Control Flow Graph, CFG)
控制流图是用图的形式表示程序控制流的数据结构。
- 节点(Node):每个基本块是一个节点
- 边(Edge):如果基本块A执行完后可能执行基本块B,就从A画一条边到B
上面的示例对应的CFG:
┌─────┐
│ B1 │ (sum=0, i=0)
└──┬──┘
↓
┌─────┐
┌──→│ B2 │ (i < 10 ?)
│ └──┬──┘
│ │
│ ┌──┴──────────┐
│ ↓ (true) ↓ (false)
│ ┌─────┐ ┌─────┐
│ │ B3 │ │ B4 │ (printf)
│ └──┬──┘ └─────┘
│ │
└────┘
CFG的用途: - 分析程序的所有可能执行路径 - 发现死代码(永远不会执行的代码) - 进行数据流分析 - 在漏洞挖掘中,帮助理解程序的执行逻辑
1.4 特殊节点:Entry 和 Exit
为了方便分析,CFG通常会添加两个虚拟节点:
- Entry:程序的入口点,所有分析从这里开始
- Exit:程序的出口点,所有返回/结束都汇聚到这里
┌───────┐
│ Entry │
└───┬───┘
↓
[实际的CFG]
↓
┌───────┐
│ Exit │
└───────┘
3. 数据流分析基础
2.1 什么是数据流?
数据流(Data Flow) 描述的是数据在程序中的传播。简单来说,就是"变量的值从哪里来,会流向哪里"。
考虑以下代码:
int a = 1; // a的值在这里产生
int b = a + 2; // a的值流向这里,b的值在这里产生
int c = b * 3; // b的值流向这里
数据流分析关注的问题包括: - 某个变量的值可能来自哪些语句?(到达定义分析) - 某个变量在某个点之后还会被使用吗?(活跃变量分析) - 某个变量的值是什么范围?(范围分析)
2.2 变量的定义与使用
在数据流分析中,有两个核心概念:
定义(Definition / def):给变量赋值的语句,通俗理解为“写变量”
x = 10; // 定义了 x
y = x + 1; // 定义了 y
使用(Use):读取变量值的语句(“读变量”)
y = x + 1; // 使用了 x
z = y * 2; // 使用了 y
注意:一条语句可以同时定义和使用变量:
x = x + 1; // 使用了 x(右边),也定义了 x(左边)
2.3 数据流分析的通用框架
大多数数据流分析都遵循一个通用模式:
- 在CFG上进行:沿着控制流图的边传播信息
- 迭代计算:反复计算直到结果不再变化
- 方向性:
- 前向分析:信息从Entry向Exit传播(如到达定义分析)
- 后向分析:信息从Exit向Entry传播(如活跃变量分析)
4. 活跃变量分析(Live Variable Analysis)
3.1 什么是活跃变量?
定义:如果变量
v在程序点p的值,在p之后的某条路径上会被使用(请注意“使用”和“访问”的区别,“使用”特指变量的值被读取),那么v在p点是活跃的(live)。
反过来,如果变量的值之后不会被使用,它就是死亡的(dead)。
示例:
1: a = 1;
2: b = a + 2; // 在这里,a是活跃的(第2行要用)
3: c = b * 3; // 在这里,b是活跃的(第3行要用),a是死亡的
4: return c; // 在这里,c是活跃的(要返回),a和b都是死亡的
3.2 活跃变量分析的用途
- 寄存器分配:编译器可以把死亡变量占用的寄存器分配给其他变量
- 死代码消除:如果给一个死亡变量赋值,这条语句可能是无用的
- 漏洞分析:
- 检查是否有未初始化的变量被使用
- 分析敏感数据(如密码)是否在不再需要后被正确清除
3.3 分析算法
活跃变量分析是一个后向分析,因为我们需要知道变量"之后"会不会被使用。
核心公式:
对于基本块 B:
IN[B] = use_B ∪ (OUT[B] - def_B)
OUT[B] = ∪ IN[S] (对于B的所有后继S)
use_B:在B中被使用(且在使用前没有被定义)的变量def_B:在B中被定义的变量IN[B]:在B入口处活跃的变量OUT[B]:在B出口处活跃的变量
直观理解: - 如果变量在某个后继块中是活跃的,它在当前块出口处也是活跃的 - 如果变量在当前块中被使用,它在入口处是活跃的 - 如果变量在当前块中被定义,它的"活跃性"被终止(除非在定义前使用过)
算法步骤:
1. 初始化所有 IN[B] = OUT[B] = ∅
2. 重复以下步骤直到没有变化:
对于每个基本块 B(逆序遍历):
OUT[B] = 所有后继块的 IN 的并集
IN[B] = use_B ∪ (OUT[B] - def_B)
完整示例:
// 基本块 B1
1: a = 1;
2: b = 2;
// 基本块 B2 (条件分支)
3: if (a > 0) {
// 基本块 B3
4: c = a + b;
5: b = c;
// 基本块 B4
6: } else {
7: c = a - b;
8: }
// 基本块 B5
9: return c;
分析结果:
| 基本块 | use_B | def_B | IN[B] | OUT[B] |
|---|---|---|---|---|
| B5 | {c} | {} | {c} | {} |
| B4 | {a, b} | {c} | {a, b} | {c} |
| B3 | {a, b} | {c, b} | {a, b} | {c} |
| B2 | {a} | {} | {a, b} | {a, b} |
| B1 | {} | {a, b} | {} | {a, b} |
5. 死代码检测(Dead Code Detection)
5.1 什么是死代码?
死代码分为两类:
1. 不可达代码(Unreachable Code):永远不会被执行的代码
int foo() {
return 1;
printf("hello"); // 不可达:return之后的代码
}
void bar() {
if (false) {
printf("never"); // 不可达:条件永远为假
}
}
2. 无用代码(Useless Code):执行了但结果没有被使用
int baz() {
int x = 1; // 无用:x的值从未被使用
int y = 2;
return y;
}
int qux() {
int a = 1;
a = 2; // 第一个赋值无用:被第二个覆盖
return a;
}
5.2 检测不可达代码
使用 CFG可达性分析:
- 从 Entry 节点开始
- 沿着CFG的边遍历所有可达的基本块
- 未被访问到的基本块就是不可达代码
def find_unreachable(cfg):
reachable = set()
worklist = [cfg.entry]
while worklist:
block = worklist.pop()
if block not in reachable:
reachable.add(block)
worklist.extend(block.successors)
return cfg.all_blocks - reachable
5.3 检测无用代码
利用活跃变量分析的结果:
如果一条赋值语句
x = ...中,x在语句之后不是活跃的,那么这条语句就是无用的(其结果不会被使用)。
示例:
1: a = 1; // a在第2行后不活跃 → 无用代码!
2: a = 2; // a在第3行被使用 → 有用
3: b = a;
4: return b;
检测算法:
def find_dead_assignments(cfg, live_vars):
dead = []
for block in cfg.blocks:
for stmt in block.statements:
if is_assignment(stmt):
defined_var = get_defined_var(stmt)
if defined_var not in live_vars.OUT[stmt]:
dead.append(stmt)
return dead
5.4 死代码与漏洞挖掘
死代码检测在安全分析中很有用:
- 发现逻辑错误:不可达代码可能暗示着逻辑bug
- 减少分析范围:排除死代码可以加快漏洞扫描
- 发现敏感信息泄露:有时候开发者"删除"敏感代码只是让它不可达
- 识别后门:某些后门可能隐藏在看似不可达的代码中
6. 范围分析(Range Analysis)/ 区间分析
6.1 什么是范围分析?
范围分析试图确定程序中数值变量的可能取值范围。
int x = 5; // x ∈ [5, 5]
int y = x + 10; // y ∈ [15, 15]
if (y > 0) {
int z = y * 2; // z ∈ [30, 30]
}
更复杂的例子:
int x = input(); // x ∈ [-∞, +∞](用户输入,未知范围)
if (x > 0 && x < 100) {
// 在这个分支内,x ∈ [1, 99]
int y = x * 2; // y ∈ [2, 198]
}
6.2 范围分析的用途
-
数组越界检测:
c int arr[10]; int i = get_index(); // 分析 i 的范围 arr[i] = 0; // 如果 i ∈ [0, 9],安全;否则可能越界 -
整数溢出检测:
c int a = get_value(); // a ∈ [0, 1000000] int b = a * a; // 可能溢出!1000000² 超过 int 范围 -
除零检测:
c int divisor = calc(); if (divisor >= 0) { // divisor ∈ [0, +∞] int result = 100 / divisor; // 危险:0在范围内! } -
优化条件判断:
c int x = 5; if (x > 10) { // 范围分析知道 x=5,条件永假 // 可以被优化掉 }
6.3 区间运算规则
范围分析需要定义区间上的运算:
加法:[a, b] + [c, d] = [a+c, b+d]
减法:[a, b] - [c, d] = [a-d, b-c]
乘法:[a, b] × [c, d] = [min(ac,ad,bc,bd), max(ac,ad,bc,bd)]
除法:需要特殊处理除零情况
示例:
[1, 5] + [2, 3] = [3, 8]
[1, 5] - [2, 3] = [-2, 3]
[1, 5] × [2, 3] = [2, 15]
6.4 条件分支的范围收窄
当程序进入条件分支时,我们可以根据条件收窄变量的范围:
int x = input(); // x ∈ [-∞, +∞]
if (x > 10) {
// 在这里 x ∈ [11, +∞]
if (x < 20) {
// 在这里 x ∈ [11, 19]
}
}
这种技术叫做路径敏感分析(Path-Sensitive Analysis)。
6.5 循环中的范围分析
循环给范围分析带来挑战:
int i = 0;
while (i < 100) {
i = i + 1;
}
如果精确分析,第1次迭代后 i=1,第2次后 i=2... 这样需要100次才能得出结论。
实际的分析器会使用加宽(Widening) 技术来加速收敛:
- 第1次迭代:i ∈ [0, 0]
- 第2次迭代:i ∈ [0, 1]
- 加宽后:i ∈ [0, +∞]
- 结合循环条件:i ∈ [0, 99](在循环内)
7. 实战:在漏洞挖掘中应用这些概念
7.1 用CFG分析程序逻辑
当审计一个函数时,画出它的CFG可以帮助理解: - 有哪些执行路径 - 错误处理是否完整 - 是否有遗漏的边界情况
7.2 用数据流追踪污点
污点分析(Taint Analysis) 是数据流分析的应用:
char* input = get_user_input(); // 污点源
char* cmd = concat("ls ", input); // 污点传播
system(cmd); // 污点汇聚点 → 命令注入!
追踪数据从"不可信源"到"危险函数"的流动,可以发现注入漏洞。
7.3 用范围分析发现溢出
int size = atoi(argv[1]); // size ∈ [-∞, +∞]
if (size > 0) { // size ∈ [1, +∞]
char* buf = malloc(size + 1); // 可能整数溢出!
// 如果 size = INT_MAX,size+1 会溢出变成负数
}
范围分析可以标记这类潜在的整数溢出问题。
7.4 综合示例
void vulnerable_function(int user_index, int user_value) {
int buffer[100];
// 范围分析:user_index ∈ [-∞, +∞]
// 没有边界检查!
if (user_index >= 0) {
// 范围收窄:user_index ∈ [0, +∞]
// 仍然可能 >= 100,越界!
buffer[user_index] = user_value; // 数组越界写入
// 活跃变量:buffer[user_index] 的值是否被使用?
// 如果不使用,可能是无意的bug
}
return;
// 死代码:return 之后的代码不会执行
cleanup();
}
8. 指针分析(Pointer Analysis)
8.1 为什么需要指针分析?
在前面的数据流分析中,我们假设变量之间是相互独立的。但在实际程序中,指针的存在让情况变得复杂:
int a = 1;
int b = 2;
int *p;
if (condition) {
p = &a;
} else {
p = &b;
}
*p = 10; // 这条语句修改了谁?a 还是 b?
如果不知道 p 指向谁,我们就无法准确分析 *p = 10 的影响。指针分析(也叫别名分析,Alias Analysis)就是为了回答这个问题:在程序的某个点,一个指针可能指向哪些内存位置?
8.2 指针分析的核心概念
指向关系(Points-to Relation)
指针分析的目标是计算一个指向集合(Points-to Set),表示每个指针可能指向的对象:
int a, b, c;
int *p = &a; // pt(p) = {a}
int *q = &b; // pt(q) = {b}
if (cond) {
p = &c; // pt(p) = {a, c}(可能是a或c)
}
int **pp = &p; // pt(pp) = {p}
别名(Alias)
如果两个指针可能指向同一个内存位置,它们就是别名:
int x;
int *p = &x;
int *q = &x;
// p 和 q 是别名,因为它们都指向 x
别名分析在漏洞挖掘中非常重要,因为通过一个指针的修改可能影响到另一个看似无关的变量。
8.3 指针分析的分类
根据精度和效率的权衡,指针分析有多种变体:
1. 流敏感 vs 流不敏感(Flow-Sensitive vs Flow-Insensitive)
- 流敏感:考虑语句的执行顺序,不同程序点的指向关系可能不同
- 流不敏感:忽略顺序,计算整个程序中所有可能的指向关系
int *p;
p = &a; // 流敏感:此处 pt(p) = {a}
p = &b; // 流敏感:此处 pt(p) = {b}
// 流不敏感:pt(p) = {a, b}(整个程序)
2. 上下文敏感 vs 上下文不敏感(Context-Sensitive vs Context-Insensitive)
- 上下文敏感:区分函数的不同调用点
- 上下文不敏感:合并所有调用点的信息
int* identity(int *x) {
return x;
}
int a, b;
int *p = identity(&a); // 上下文敏感:pt(p) = {a}
int *q = identity(&b); // 上下文敏感:pt(q) = {b}
// 上下文不敏感:pt(p) = pt(q) = {a, b}
3. 字段敏感 vs 字段不敏感(Field-Sensitive vs Field-Insensitive)
- 字段敏感:区分结构体的不同字段
- 字段不敏感:将整个结构体视为一个对象
struct S { int *f1; int *f2; };
struct S s;
int a, b;
s.f1 = &a;
s.f2 = &b;
// 字段敏感:pt(s.f1) = {a}, pt(s.f2) = {b}
// 字段不敏感:pt(s.*) = {a, b}
8.4 Andersen 算法(包含式分析)
Andersen 算法是最经典的流不敏感指针分析算法,基于约束求解:
约束规则:
| 语句类型 | 约束 |
|---|---|
p = &a |
{a} ⊆ pt(p) |
p = q |
pt(q) ⊆ pt(p) |
p = *q |
对于 q 指向的每个 r:pt(r) ⊆ pt(p) |
*p = q |
对于 p 指向的每个 r:pt(q) ⊆ pt(r) |
示例:
int a, b;
int *p = &a; // 约束:{a} ⊆ pt(p)
int *q = &b; // 约束:{b} ⊆ pt(q)
int *r = p; // 约束:pt(p) ⊆ pt(r)
求解后:pt(p) = {a}, pt(q) = {b}, pt(r) = {a}
8.5 指针分析在漏洞挖掘中的应用
1. Use-After-Free 检测
struct obj *p = malloc(sizeof(struct obj));
struct obj *q = p; // 指针分析:q 和 p 是别名
free(p);
q->data = 10; // UAF!因为 q 指向已释放的内存
指针分析帮助识别 q 和 p 指向同一对象,从而发现 UAF。
2. 空指针解引用检测
int *p = NULL;
if (cond) {
p = &x;
}
*p = 10; // 指针分析:pt(p) = {NULL, x},可能空指针解引用
3. 污点传播的精确追踪
char *input = get_user_input(); // 污点
char *p = input;
char *q = p;
// 指针分析告诉我们 q 也指向污点数据
system(q); // 漏洞!
9. 污点分析(Taint Analysis)
9.1 什么是污点分析?
污点分析是一种追踪"不可信数据"在程序中传播的技术。它的核心思想很简单:
- 标记某些数据为"污点"(不可信)
- 追踪污点数据如何在程序中流动
- 检查污点数据是否到达了"敏感操作"
如果污点数据未经验证就到达敏感操作,就可能存在安全漏洞。
其实这个概念也很好理解,想象一下你刚吃了糖醋排骨,手上沾了酱汁,这个时候来了一通电话,你把一根手指擦干净去接电话,没想到电话内容比较棘手,所以你接电话的时候会有一些小动作比如揉揉脸,搓头发,拿起桌边的小东西把玩这种。等到你挂了电话,问题来了,哪里被你刚才摸到了呢?答案就是你刚才吃糖醋排骨的时候手上沾的酱汁,这个酱汁就是污点,它通过你的手传播到了你的脸、头发和桌边的小东西上。如果这些地方是敏感的,比如你的眼睛或者食物,那就可能引发问题。
而污点分析也是类似的,程序中的不可信数据(比如用户输入)就像是手上的酱汁,通过变量赋值、函数调用等操作传播到程序的其他部分。如果这些污点数据最终被用在敏感操作(比如执行系统命令、数据库查询等)上,而没有经过适当的验证或净化,就可能导致安全漏洞。
9.2 污点分析的三要素
1. 污点源(Source)
不可信数据的来源,通常是:
- 用户输入:scanf(), read(), recv(), fgets()
- 环境变量:getenv()
- 文件内容:fread(), 配置文件
- 网络数据:recvfrom(), HTTP请求参数
char buffer[100];
read(fd, buffer, 100); // buffer 现在是污点数据
2. 污点传播规则(Propagation Rules)
定义污点如何从一个变量传播到另一个变量:
char *src = get_input(); // src 是污点
char *dst = malloc(100);
strcpy(dst, src); // dst 也变成污点(传播)
int len = strlen(src); // len 也是污点(依赖污点数据)
常见的传播规则:
- 赋值传播:y = x → 如果 x 是污点,y 也是污点
- 运算传播:z = x + y → 如果 x 或 y 是污点,z 也是污点
- 函数传播:strcpy(dst, src) → 如果 src 是污点,dst 也是污点
3. 污点汇聚点(Sink)
敏感操作,如果污点数据到达这里可能造成安全问题:
| 漏洞类型 | 典型 Sink |
|---|---|
| 命令注入 | system(), exec(), popen() |
| SQL注入 | mysql_query(), SQL执行函数 |
| 路径遍历 | open(), fopen() |
| 格式化字符串 | printf() 的格式化参数 |
| 缓冲区溢出 | strcpy(), memcpy() 的目标缓冲区 |
| XSS | HTML输出函数 |
9.3 污点分析示例
示例1:命令注入检测
char filename[256];
printf("Enter filename: ");
scanf("%s", filename); // Source: filename 被标记为污点
char cmd[512];
sprintf(cmd, "cat %s", filename); // 传播: cmd 也是污点
system(cmd); // Sink: 污点到达 system()
// 报告:可能的命令注入漏洞!
示例2:SQL注入检测
char *user_id = getenv("USER_ID"); // Source: user_id 是污点
char query[1024];
sprintf(query, "SELECT * FROM users WHERE id='%s'", user_id);
// 传播: query 是污点
mysql_query(conn, query); // Sink: 污点到达 SQL 执行
// 报告:可能的SQL注入漏洞!
示例3:正确的净化(Sanitization)
char *input = get_user_input(); // Source: input 是污点
// 净化:验证输入只包含数字
if (is_numeric(input)) { // 污点被"净化"
int value = atoi(input); // value 不再是污点
arr[value] = 0; // 但是也可能有溢出风险
}
9.4 污点分析的实现方式
1. 静态污点分析
在不运行程序的情况下,分析源代码或二进制: - 优点:覆盖所有代码路径,发现潜在漏洞 - 缺点:可能产生误报,难以处理复杂的控制流
2. 动态污点分析
在程序运行时追踪污点传播: - 优点:精确,没有误报 - 缺点:只能覆盖执行到的路径,有性能开销
3. 混合方法
结合静态和动态分析的优点: - 静态分析找出可疑路径 - 动态分析验证是否真的可利用
9.5 污点分析工具实战
以下是几个主流的支持污点分析的工具:
9.5.1 CodeQL
CodeQL 是 GitHub 开发的语义代码分析引擎,支持多种语言(C/C++, Java, Python, JavaScript 等)。
特点: - 将代码转换为数据库,用类SQL的查询语言分析 - 内置大量安全查询规则 - 支持自定义污点追踪规则
示例:查找命令注入
import cpp
import semmle.code.cpp.dataflow.TaintTracking
class CommandInjection extends TaintTracking::Configuration {
CommandInjection() { this = "CommandInjection" }
// 定义污点源:用户输入函数
override predicate isSource(DataFlow::Node source) {
exists(FunctionCall fc |
fc.getTarget().hasName(["gets", "scanf", "read"]) and
source.asExpr() = fc.getAnArgument()
)
}
// 定义污点汇聚点:危险函数
override predicate isSink(DataFlow::Node sink) {
exists(FunctionCall fc |
fc.getTarget().hasName(["system", "popen", "execve"]) and
sink.asExpr() = fc.getArgument(0)
)
}
}
from CommandInjection cfg, DataFlow::PathNode source, DataFlow::PathNode sink
where cfg.hasFlowPath(source, sink)
select sink, source, sink, "Possible command injection from $@.", source, "user input"
使用流程:
# 1. 创建数据库
codeql database create mydb --language=cpp --source-root=./src
# 2. 运行查询
codeql database analyze mydb ./queries/command-injection.ql --format=csv --output=results.csv
9.5.2 angr
angr 是一个强大的二进制分析框架,支持符号执行和污点分析。
特点: - 直接分析二进制文件,无需源代码 - 支持多种架构(x86, ARM, MIPS 等) - 可以与符号执行结合,自动生成触发漏洞的输入
示例:用 angr 进行污点分析
import angr
import claripy
# 加载二进制
proj = angr.Project('./vulnerable_binary', auto_load_libs=False)
# 创建符号化的输入(相当于污点源)
symbolic_input = claripy.BVS('user_input', 100 * 8) # 100字节符号输入
# 创建初始状态
state = proj.factory.entry_state(
args=['./vulnerable_binary'],
stdin=angr.SimFileStream(name='stdin', content=symbolic_input)
)
# 创建模拟管理器
simgr = proj.factory.simulation_manager(state)
# 定义危险地址(如 system 调用点)
dangerous_addr = 0x401234 # 假设这是调用 system() 的地址
# 探索到危险点的路径
simgr.explore(find=dangerous_addr)
if simgr.found:
found_state = simgr.found[0]
# 求解触发漏洞的具体输入
solution = found_state.solver.eval(symbolic_input, cast_to=bytes)
# judge if rdi contains our input
rdi_value = found_state.reg.rdi
if "user_input" in str(rdi_value): # 在后续章节会提到,angr 的符号化变量以 AST 形式表现,如果 rdi 包含 "user_input" 符号化变量,说明污点传播到了 system() 的参数
print("potential user input reached system()")
print(f"触发漏洞的输入: {solution}")
angr 的污点追踪:
from angr.analyses.reaching_definitions import ReachingDefinitionsAnalysis
# 进行到达定义分析(数据流分析的基础)
rd = proj.analyses.ReachingDefinitions(
subject=proj.kb.functions['main'],
track_tmps=True
)
# 分析哪些寄存器/内存被用户输入影响
# angr 会追踪符号值的传播
9.5.3 Joern
Joern 是一个开源的代码分析平台,特别适合 C/C++ 的漏洞挖掘。
特点: - 基于代码属性图(Code Property Graph,CPG) - 支持交互式查询 - 可以表示代码的语法、控制流和数据流
示例:查找缓冲区溢出
// 在 Joern shell 中执行
// 查找 strcpy 的调用
cpg.call("strcpy").code.l
// 查找污点源到 strcpy 的数据流
def source = cpg.call("gets").argument
def sink = cpg.call("strcpy").argument(1)
sink.reachableBy(source).code.l
9.5.4 Semgrep
Semgrep 是一个轻量级的静态分析工具,支持自定义规则。
特点: - 规则编写简单,类似 grep 但理解代码结构 - 支持多种语言 - 适合集成到 CI/CD 流水线
示例:检测 SQL 注入
rules:
- id: sql-injection
languages: [python]
message: "Possible SQL injection"
severity: ERROR
mode: taint
pattern-sources:
- pattern: flask.request.args.get(...)
- pattern: flask.request.form.get(...)
pattern-sinks:
- pattern: cursor.execute($QUERY, ...)
focus-metavariable: $QUERY
pattern-sanitizers:
- pattern: escape_string(...)
使用:
semgrep --config ./rules/sql-injection.yaml ./src/
9.6 污点分析的局限性
1. 隐式流(Implicit Flow)
污点可能通过控制流间接传播:
int secret = get_secret();
int leak = 0;
if (secret > 0) {
leak = 1; // leak 的值依赖于 secret,但没有直接数据流
}
大多数污点分析只追踪显式数据流,会遗漏这种情况。
2. 指针别名问题
char *p = get_input(); // p 是污点
char *q = p; // q 也是污点
char *r = q; // r 也是污点
// 如果指针分析不精确,可能丢失污点
3. 过度污点(Over-tainting)
过于保守的传播规则会导致大量误报:
int len = strlen(tainted_str); // len 是污点?
char buf[100];
memset(buf, 0, len); // 如果 len 是污点,这算漏洞吗?
4. 净化函数的识别
分析器需要知道哪些函数能"清洗"污点:
char *safe = escape_html(user_input); // safe 还是污点吗?
延伸阅读
- 《编译原理》(龙书)第9章:数据流分析
- 《Static Program Analysis》- Anders Møller & Michael I. Schwartzbach
- Tai-e 静态分析教程
- 南京大学《软件分析》课程
- CodeQL 官方文档
- angr 官方文档
- LLVM 官方文档中的 Data Flow Analysis
Your Tasks
- 阅读并理解上述内容
- 自学 太阿--南京大学软件分析课