软件分析基础 - 从编译器的视角理解程序

在漏洞挖掘中,我们经常需要理解程序的行为:数据从哪里来?流向哪里?哪些代码会被执行?本章将介绍一些程序分析相关的核心概念。

1. 抽象语法树(Abstract Syntax Tree, AST)

1.1 什么是 AST?

在介绍控制流和数据流之前,我们需要先了解程序的一种基础表示形式:抽象语法树(AST)

当编译器或分析工具读取源代码时,第一步是解析(Parsing),将文本形式的代码转换为结构化的树形表示。这棵树就是 AST

定义:AST 是源代码语法结构的树状表示,每个节点代表代码中的一个语法结构(如表达式、语句、声明等)。

如下图

image

为什么叫"抽象"?

因为它省略了源代码中不影响语义的细节,比如: - 括号(通过树的结构隐含表示优先级) - 分号、逗号等分隔符 - 空格和注释

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)

如何划分基本块?

找到以下三种语句,它们就是基本块的边界:

  1. 入口语句(Leader)
  2. 程序的第一条语句
  3. 跳转目标(如 if 的两个分支起点、循环开始处)
  4. 跳转语句的下一条语句

  5. 从一个入口语句开始,到下一个入口语句之前的所有语句,构成一个基本块。

示例

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 数据流分析的通用框架

大多数数据流分析都遵循一个通用模式:

  1. 在CFG上进行:沿着控制流图的边传播信息
  2. 迭代计算:反复计算直到结果不再变化
  3. 方向性
  4. 前向分析:信息从Entry向Exit传播(如到达定义分析)
  5. 后向分析:信息从Exit向Entry传播(如活跃变量分析)

4. 活跃变量分析(Live Variable Analysis)

3.1 什么是活跃变量?

定义:如果变量 v 在程序点 p 的值,在 p 之后的某条路径上会被使用(请注意“使用”和“访问”的区别,“使用”特指变量的值被读取),那么 vp 点是活跃的(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 活跃变量分析的用途

  1. 寄存器分配:编译器可以把死亡变量占用的寄存器分配给其他变量
  2. 死代码消除:如果给一个死亡变量赋值,这条语句可能是无用的
  3. 漏洞分析
  4. 检查是否有未初始化的变量被使用
  5. 分析敏感数据(如密码)是否在不再需要后被正确清除

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可达性分析

  1. 从 Entry 节点开始
  2. 沿着CFG的边遍历所有可达的基本块
  3. 未被访问到的基本块就是不可达代码
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 死代码与漏洞挖掘

死代码检测在安全分析中很有用:

  1. 发现逻辑错误:不可达代码可能暗示着逻辑bug
  2. 减少分析范围:排除死代码可以加快漏洞扫描
  3. 发现敏感信息泄露:有时候开发者"删除"敏感代码只是让它不可达
  4. 识别后门:某些后门可能隐藏在看似不可达的代码中

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 范围分析的用途

  1. 数组越界检测c int arr[10]; int i = get_index(); // 分析 i 的范围 arr[i] = 0; // 如果 i ∈ [0, 9],安全;否则可能越界

  2. 整数溢出检测c int a = get_value(); // a ∈ [0, 1000000] int b = a * a; // 可能溢出!1000000² 超过 int 范围

  3. 除零检测c int divisor = calc(); if (divisor >= 0) { // divisor ∈ [0, +∞] int result = 100 / divisor; // 危险:0在范围内! }

  4. 优化条件判断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 指向已释放的内存

指针分析帮助识别 qp 指向同一对象,从而发现 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 什么是污点分析?

污点分析是一种追踪"不可信数据"在程序中传播的技术。它的核心思想很简单:

  1. 标记某些数据为"污点"(不可信)
  2. 追踪污点数据如何在程序中流动
  3. 检查污点数据是否到达了"敏感操作"

如果污点数据未经验证就到达敏感操作,就可能存在安全漏洞。

其实这个概念也很好理解,想象一下你刚吃了糖醋排骨,手上沾了酱汁,这个时候来了一通电话,你把一根手指擦干净去接电话,没想到电话内容比较棘手,所以你接电话的时候会有一些小动作比如揉揉脸,搓头发,拿起桌边的小东西把玩这种。等到你挂了电话,问题来了,哪里被你刚才摸到了呢?答案就是你刚才吃糖醋排骨的时候手上沾的酱汁,这个酱汁就是污点,它通过你的手传播到了你的脸、头发和桌边的小东西上。如果这些地方是敏感的,比如你的眼睛或者食物,那就可能引发问题。

而污点分析也是类似的,程序中的不可信数据(比如用户输入)就像是手上的酱汁,通过变量赋值、函数调用等操作传播到程序的其他部分。如果这些污点数据最终被用在敏感操作(比如执行系统命令、数据库查询等)上,而没有经过适当的验证或净化,就可能导致安全漏洞。

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 还是污点吗?

延伸阅读

Your Tasks