现在你已经从内向外和从外向内两种角度接近了代码分析,是时候将二者结合起来了。当你深入钻研源与汇分析的细节时,或许会想知道是否可以将此过程自动化。这个问题在漏洞研究中常常遇到,答案是:视情况而定。
本章将介绍自动化代码分析的理论基础,并通过两个流行的开源静态代码分析工具 CodeQL 和 Semgrep 进行实践。接着,你将应用这些工具进行变种分析——从单一漏洞识别脆弱代码模式,并据此在代码的其他部分发现重复的变体。你将使用 Visual Studio Code (VS Code) 的 CodeQL 插件来提升工作流程效率。最后,你还将尝试跨多个项目的多仓库变种分析。
抽象语法树(AST)
为了比简单的正则匹配替换操作做得更好,现代静态代码分析工具需要理解代码的某些方面,比如函数和变量的区别、面向对象语言中的类继承等。这种理解通常通过抽象语法树(AST)来表达,AST 是程序语法结构的一种表示。AST 的用途比代码分析更为基础:编译器将其作为源代码的中间表示,以便快速进行优化和语法检查,然后再编译成机器码。
你可以使用 Python 内置的 ast 模块来可视化 AST。试试看,把下面代码保存为 ast_example_1.py:
import ast
# Python 源代码转换为 AST
code = """
name = 'World'
print('Hello,' + name)
"""
tree = ast.parse(code)
print(ast.dump(tree, indent=4))
运行脚本会把源代码转换成 AST,输出应如下所示:
Module(
body=[
Assign(
targets=[
Name(id='name', ctx=Store())],
value=Constant(value='World')),
Expr(
value=Call(
func=Name(id='print', ctx=Load()),
args=[
BinOp(
left=Constant(value='Hello,'),
op=Add(),
right=Name(id='name', ctx=Load()))],
keywords=[]))],
type_ignores=[])
输出以树状结构组织,Module 是根节点,分支出 Assign、Expr、Call 等子节点。
假设 print 是一个危险的 sink 函数。你想知道执行以下 Python 代码时是否会调用 print:
def old_greet(name):
print('Hello, ' + name)
yell = print
yell('HELLO, WORLD')
源代码定义了一个简单函数,打印字符串 "Hello," 加上参数。接着代码将内置的 print 函数赋值给变量 yell,然后用该变量调用。
简单的正则表达式如 /print([^)]*)/g
无法准确检测,既会有误报(old_greet 中的 print)也会漏报(yell 调用),因为变量重命名导致正则难以追踪。一个能处理所有边缘情况的正则将非常复杂且难以调试。
你可以遍历 AST 来识别所有实际执行的 Call 节点,并根据父节点的意义确定。使用 ast 模块,将代码转换为 AST,保存为 sample_code.py,然后写一个脚本 ast_example_2.py:
import ast
import os
cur_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(cur_dir, 'sample_code.py')) as f:
tree = ast.parse(f.read())
print(ast.dump(tree, indent=4))
脚本输出大致如下:
Module(
body=[
FunctionDef(
name='old_greet',
args=arguments(
posonlyargs=[],
args=[
arg(arg='name')],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Expr(
value=Call(
func=Name(id='print', ctx=Load()),
args=[
BinOp(
left=Constant(value='Hello, '),
op=Add(),
right=Name(id='name', ctx=Load()))],
keywords=[]))],
decorator_list=[]),
Assign(
targets=[
Name(id='yell', ctx=Store())],
value=Name(id='print', ctx=Load())),
Expr(
value=Call(
func=Name(id='yell', ctx=Load()),
args=[
Constant(value='HELLO, WORLD')],
keywords=[]))],
type_ignores=[])
结合节点的含义,你可以高效遍历 AST,仅深入 Assign 和 Expr 节点,忽略未调用的 FunctionDef 节点。通过追踪 Assign 影响的变量,最终能准确判断 Call 节点的 func 属性是否指向 print。
树状结构支持多种优化算法查询 AST 信息,避免浪费计算资源在无关分支。另一种代码表示是控制流图(CFG),建模程序执行的潜在路径,支持更高级查询如可达性分析,判断代码哪些部分在执行时能被触达。
数据流图(DFG)是另一种表示,关注数据(变量和表达式)的传播和变换,而 CFG 关注执行顺序(if-else 语句、循环等)。两者在自动化分析中均非常有用。
理解这些理论对掌握静态代码分析工具的工作原理非常重要。任何抽象都会失去部分细节,虽然人工代码分析更全面,但对于包含百万行代码的复杂软件,手动审核通常不可行。静态分析工具在这类场景下极具价值,理解其优缺点能帮助你更有效地辅助代码分析策略。
静态代码分析工具
并非所有的源代码分析工具都具有相同的能力。它们在抽象层次和查询方法上的差异,影响了工具搜索代码中特定模式的效率。在本节中,你将通过 CodeQL 和 Semgrep 两个流行的开源静态分析工具,观察这些差异的实际表现。
CodeQL
CodeQL 是一个起源于学术界的代码分析引擎。它由牛津大学研究团队创建,开发出一种面向对象的查询语言(最初称为 .QL),能够查询包含代码模型的关系型数据库。数据库导向是 CodeQL 和 Semgrep 的关键区别之一;CodeQL 需要先构建代码数据库,之后才能执行查询。对于编译型语言,这个过程通常集成在语言的构建系统中,比如 C/C++ 使用 make。对于非编译语言如 Python,CodeQL 使用提取器先解析代码,再存储到数据库。
不出所料,CodeQL 的查询语言和 SQL 等数据库查询语言有许多相似之处。例如,以下是一个查找对 print 函数调用的 CodeQL 查询:
import python
from Call call, Name name
where call.getFunc() = name and name.getId() = "print"
select call, "call to 'print'."
CodeQL 的类(如 Call、Name)与 Python 的 ast 模块中的类型名相同,因为 CodeQL 的 Python 提取器既使用 ast,也扩展了自己的 semmle.python.ast 类来解析 Python 代码库。同理,CodeQL 其他语言的提取器也深度集成目标语言环境,例如 Go 语言提取器使用 Go 标准库的 go/ast 包。针对每种语言高度定制的提取方法,使 CodeQL 能够构建详细的数据流和控制流关系数据库。
借助 CodeQL 的深入分析,你可以编写强大的全局污点跟踪查询,以查找源到汇的漏洞路径。此外,CodeQL 面向对象的查询语言方便复用组件。以下示例展示了 CodeQL 的优势。
多文件污点跟踪示例
考虑一个基于 Express 框架的 Node.js Web API 服务器,包含两个文件 index.js 和 utils.js。该 Web API 有一个 /ping 路由,根据 ip 查询参数执行 ping 操作。
index.js:
const express = require("express");
const { ping } = require("./utils.js");
const app = express();
app.get("/ping", (req, res) => {
const ip = req.query.ip;
res.send(`Result: \n${ping(ip)}`);
});
app.listen(3000);
单凭分析 index.js 代码,难以判断是否存在漏洞。该文件将用户控制的数据 req.query.ip 作为 ip 传入 ping 函数,但你需要进一步检查 utils.js 中的 ping 函数是否将 ip 传入了危险的 sink。
utils.js:
const { execSync } = require("child_process");
exports.ping = (ip) => {
try {
return execSync(`ping -c 5 ${ip}`);
} catch (error) {
return error.message;
}
};
ping 函数调用 execSync 执行 shell 命令,拼接了用户控制的 ip 参数,存在命令注入风险。攻击者可通过 ip 参数传入如 ;whoami
等命令。虽然代码审查任务简单,但正则搜索通常无法跨文件追踪导入函数和数据流。CodeQL 可以做到这一点,因为它构建了数据流图(DFG)并扩展了污点跟踪功能。CodeQL 分离了普通数据流(DataFlow)与污点跟踪(TaintTracking)库以体现这一点。
CodeQL 还内置了常用源和汇的类,如远程用户输入和命令执行函数。因此,针对该漏洞的全局污点跟踪规则可以写成如下形式:
/**
* @id remote-command-injection
* @name Remote Command Injection
* @description Passing user-controlled remote data to a command injection.
* @kind path-problem
* @severity error
*/
import javascript
module RemoteCommandInjectionConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
source instanceof RemoteFlowSource
}
predicate isSink(DataFlow::Node sink) {
sink = any(SystemCommandExecution sys).getACommandArgument()
}
}
module RemoteCommandInjectionFlow =
TaintTracking::Global<RemoteCommandInjectionConfig>;
import RemoteCommandInjectionFlow::PathGraph
from RemoteCommandInjectionFlow::PathNode source,
RemoteCommandInjectionFlow::PathNode sink
where RemoteCommandInjectionFlow::flowPath(source, sink)
select sink.getNode(), source, sink,
"taint from $@ to $@.", source.getNode(), "source", sink, "sink"
无需担心 CodeQL 语法细节,重点关注整体结构。污点跟踪配置定义源为 RemoteFlowSource 实例,汇为任意 SystemCommandExecution 实例的命令参数。查询检查是否存在源到汇的污点流路径,若存在则输出易被利用的路径(部分中间步骤略)。
CodeQL 能精准追踪从 req.query.ip(➊)到 ip 变量(➋),最终到 utils.js 中 execSync 的模板字符串参数(➌)的污点数据流路径。如果将 TaintTracking::Configuration
替换为普通数据流 DataFlow::Configuration
,则不会返回结果,因为模板字符串的使用导致数据流路径中断。若 utils.js 使用 execSync(ip)
,则普通数据流分析也会成功。
全局污点跟踪的强大功能伴随性能代价:分析范围从局部到全局、从普通数据流到污点跟踪都显著增加计算复杂度,且精度有所降低。CodeQL 规则语法较复杂,规则采用 QL 语言编写,这是一种面向对象的查询语言。正因如此,前半部分看起来像普通的面向对象代码,末尾则更像数据库查询语言,使用了 from、where 和 select 等子句。
若想高效使用 CodeQL,必须学习一门新语言并熟悉其标准库。QL 查询语言允许表达复杂关系和谓词,实现强大的全局污点跟踪查询,是学习的价值所在。
CodeQL 开发者为常用框架(如 Express、Spring、Ruby on Rails)提供了很多便捷类。例如,上述示例中替换 RemoteFlowSource 为 Express::RequestSource 可专门跟踪 Express 框架的 Web 请求输入。另一方面,分析代码和编写查询时,频繁切换上下文也是一大挑战。
VS Code 扩展
为减少 CodeQL 查询开发的摩擦,可使用 Visual Studio Code 的 CodeQL 扩展。它在编辑器中集成了多种 UI 元素和功能,配合 CodeQL CLI 形成完整的查询开发环境(IDE)。
虽然扩展自带 CodeQL CLI,但文档指出:
扩展管理的 CodeQL CLI 无法在终端访问。若想在扩展外使用 CLI(如创建数据库),建议安装自己的 CodeQL CLI。
由于你将本地创建 CodeQL 数据库,需先安装 CodeQL CLI。可从 github.com/github/code… 下载最新版本,解压后加入 PATH。例如在 Kali Linux 下:
wget https://github.com/github/codeql-action/releases/download/codeql-bundle-v2.20.2/codeql-bundle-linux64.tar.gz
tar -xzvf codeql-bundle-linux64.tar.gz
echo "export PATH=$PATH:$(pwd)/codeql" >> ~/.zshrc
source ~/.zshrc
codeql version
安装完 CodeQL CLI 后,下载 VS Code 编辑器(code.visualstudio.com/download),在 Kali Linux 下可直接下载 .deb 包并用 sudo apt install
安装。
打开 VS Code 后,使用快捷键 Ctrl+P 输入 ext install GitHub.vscode-codeql
安装 CodeQL 扩展,或者通过侧边栏“扩展”市场搜索安装。
安装完成后,从 github.com/github/vsco… 克隆 CodeQL 入门工作区,确保递归克隆子模块:
git clone --recursive https://github.com/github/vscode-codeql-starter
在 VS Code 中通过菜单 File ► Open Workspace 打开 vscode-codeql-starter.code-workspace
,即可进入 CodeQL 开发环境,用于编写和测试查询。
创建和加载 CodeQL 数据库
在运行查询前,需要指定 CodeQL 数据库——即从目标代码生成的数据库。虽然扩展支持下载他人创建的数据库,但这里我们用之前多文件污点跟踪示例代码自己创建数据库。
先在 CodeQL 工作区外创建项目目录,并将 index.js 和 utils.js 移入该目录(示例项目位于书中代码仓库的 chapter-03/command-injection-example/app
)。
进入项目目录的父目录,执行:
cd chapter-03/command-injection-example
codeql database create --language=javascript --source-root=app example-database
运行结束后会生成数据库:
Finished writing database (relations: 13.30 MiB; string pool: 4.78 MiB).
TRAP import complete (2.1s).
Finished zipping source archive (243.70 KiB).
Successfully created database at /home/kali/Desktop/from-day-zero-to-zero-day/chapter-03/command-injection-example/example-database.
运行查询和查看结果
返回 VS Code 工作区,点击左侧 CodeQL 图标打开侧边栏,包含数据库、变种分析仓库、查询历史、AST 查看器等视图。
在数据库视图中,点击“From a folder”添加刚创建的 example-database
目录。
加载后,右键点击该数据库,选择“Add Database Source to Workspace”,这样可以在文件资源管理器中看到源代码副本。
右键点击 index.js
,选择“CodeQL: View AST”打开 AST 查看器,观察 CodeQL 如何表示代码结构。
点击 AST 查看器中的任意节点,会定位并高亮源代码对应位置。反之,在代码中选择片段,也会自动定位 AST 中的节点。例如选择 res.send(
Result: \n${ping(ip)});
,AST 显示它是一个 ExprStmt 节点,子节点是 MethodCallExpr,这有助于选择合适的查询类。
回到资源管理器,创建(或复制书中代码仓库的) RemoteCommandInjection.ql
查询文件到 codeql-custom-queries-javascript
目录。注意,QL 查询文件需与 qlpack.yml
同目录以决定依赖的 CodeQL 库,如 codeql/javascript-all
。
右键查询文件,选择“CodeQL: Run Queries in Selected Files”,扩展会调用 CLI 运行查询并解析结果。
如果一切正常,编辑器右侧会显示格式化良好的结果视图。
展开结果行,可以查看从源到汇的每个污点传播步骤。点击步骤可直接跳转到对应源码,方便分析和调试查询。
随着后续章节跨多仓库变种分析的展开,请提前准备好 CodeQL 环境。在此之前,你将用 Semgrep 进行单仓库变种分析。
Semgrep
Semgrep 是另一个流行的代码分析工具,它采用基于模式的规则语法,这与 CodeQL 的基于查询的语法形成对比。这种差异影响了 Semgrep 的易用性和功能,从而影响其在漏洞研究中的适用场景,相较于 CodeQL,Semgrep 在某些情况下表现更优。
注意
想了解 Semgrep 的有趣起源故事,可以阅读 Semgrep 之前版本 sgrep 的作者 Yoann Padioleau 在 Semgrep 官网博客 的文章《Semgrep: A Static Analysis Journey》。
下面是一个 Semgrep 规则示例(express-injection.yml
),用于识别与前述 CodeQL RemoteCommandInjection.ql
查询相同的命令注入漏洞:
rules:
- id: express-injection
mode: taint # ➊ 开启污点分析模式
pattern-sources:
- pattern: req.query.$PARAMETER # ➋ 匹配 Express 查询参数
pattern-sinks:
- pattern: execSync(...) # ➌ 匹配 execSync 函数调用
message: Passing user-controlled Express query parameter to a command injection.
languages:
- javascript
severity: ERROR
metadata:
interfile: true
尽管本章不要求深入理解 Semgrep 规则语法,但你应关注几个关键部分:
- 规则采用 YAML 格式,这是一种常见的配置文件数据序列化语言。要留意 YAML 语法细节,比如多行字符串(以
|
开头)、布尔值和转义字符。 - 除了标准的模式匹配模式外,Semgrep 支持通过
mode
字段启用高级或实验性模式,如taint
(污点分析)、join
和extract
。本书中将主要使用普通模式和污点模式。 - 最常用的模式功能是元变量(metavariables),其名称以美元符号
$
开头,只允许大写字母、下划线_
和数字。例如$PARAMETER
。元变量用于匹配代码中的任意元素(变量名、函数名等),匹配内容会存储在对应元变量中,方便后续校验或条件判断。 - 其次是省略号操作符
...
,它匹配零个或多个元素(语句、字符串字符、函数参数等),允许你忽略不关心的代码部分,简化匹配规则。
注意
曾经有用户试图编写 Semgrep 规则匹配 XML 配置<setting name="sanitizeInputs">off</setting>
,却报错 “False is not of type 'string'”,原因是 YAML 1.1 版本会把on
和off
识别为布尔值,而非字符串!
元变量和省略号通常结合使用。例如你想匹配以 SECRET_
开头的变量赋值:
patterns:
- pattern: var $VARIABLE_NAME = "..."
- metavariable-regex:
metavariable: $VARIABLE_NAME
regex: SECRET_.*
此外,patterns
操作符对其子模式执行逻辑与(AND)操作,只有全部匹配才算成功。逻辑或(OR)则使用 pattern-either
,可嵌套多层,但规则会随之复杂。
Semgrep 和 CodeQL 最大的语法区别是:Semgrep 是基于模式匹配,而 CodeQL 是基于编写查询。Semgrep 专注于识别代码中特定结构或片段,而 CodeQL 则是编写能够从代码数据库中筛选满足条件变量的查询。由于 CodeQL 需要更多的抽象和上下文切换,Semgrep 的规则语法学习曲线通常更低。
除了语法,Semgrep 与 CodeQL 在表示代码用于数据流分析的方式也不同。Semgrep 先将多种语言代码解析成通用抽象语法树(AST),然后转换为中间语言(IL)执行模式匹配;而 CodeQL 会为每种语言建立特定的数据库模型和类。
这使得 Semgrep 的数据流分析在语言上更加通用,而 CodeQL 则依赖语言特定查询。例如,CodeQL 无法在 Python 代码库中查询 JavaScript 的 CallExpr
类,而 Semgrep 能同时匹配 JavaScript 和 Python 中的 function_name(...)
调用,且不需要数据库构建步骤。
然而,这种通用性带来分析能力的折中:AST 无法直接表达程序执行流程(如控制流图 CFG 或数据流图 DFG),这对准确污点追踪至关重要。此外,面向特定语言的继承和重写等特性在 Semgrep 中丢失,且每个文件单独生成 AST,无法进行跨文件污点分析。
为弥补这些基础引擎的限制,Semgrep 开发团队提供付费版本 Semgrep Pro,支持跨文件和跨函数分析,涵盖更广泛语言。你可通过 Semgrep Playground 在线体验该功能。
在 Semgrep Playground 中,切换到“advanced”标签页,将前文示例的 express-injection.yml
规则粘贴进去。右侧测试代码编辑器一次只支持一个文本输入,你可将 utils.js
和 index.js
代码依次粘贴。确保右上角 Pro 模式开启后点击 Run,你会看到在 execSync(
ping -c 5 ${ip})
处高亮匹配。
虽然 Playground 可用 Semgrep Pro 测试,但实际应用中使用大量代码时需要订阅。这里我们使用 Semgrep OSS(开源基础版)进行分析,适合大规模单仓库扫描。安装方法为运行:
pip install semgrep
Semgrep OSS 支持部分分析功能,如常量传播和污点追踪(详情见 Semgrep 文档),但仅限于单函数内分析,且在指针数据传递上的污点分析有限。设计上,Semgrep OSS 牺牲了分析的全面性以提升速度和响应能力。
因此,Semgrep OSS 运行速度远快于 CodeQL,无需担心写规则时正确指定类或类型,也不需编译查询或构建数据库,能快速迭代规则。它适合在海量代码库中搜索相对简单但罕见的漏洞模式,比如我曾用它筛选大规模浏览器插件数据库中的潜在安全问题。
在漏洞研究中,合理分配时间和精力,选择最适合目标的工具至关重要。
变种分析(Variant Analysis)
随着开发者实施系统级缓解措施并编写更安全的代码,漏洞研究的难度不断提升。虽然新漏洞不断被发现,但远不如过去的“荒野时代”那样混乱。如今,要在主流软件中发现高影响力的漏洞,需要投入更多时间和专业知识。例如,像 LibreOffice 这样的大型开源项目,代码量轻易达到数百万行。自动化的源代码分析工具可以大幅减少代码审查所需时间,但你仍需对所有结果进行筛选,并理解每个发现的上下文。例如,一个文件中的不安全 memcpy 可能已经被其他地方的大小检查规避。你可以通过调整规则来减少误报,但这也可能增加漏报,导致真实漏洞被遗漏。
幸运的是,许多研究者曾走过相似路径。虽然他们未必会发布所有细节,但开源软件中有两个关键证据可供参考:修补代码的差异(patch diff)和公开的漏洞通告,通常以 CVE(Common Vulnerabilities and Exposures)记录形式发布(详见第0章)。通过分析这些信息,你可以从已知漏洞出发,发现更多类似漏洞。在本节中,我们将介绍如何在单一代码库内及跨多个代码库执行变种分析。
单仓库变种分析
漏洞通常不会孤立存在。如果开发者在代码中某处犯了导致漏洞的错误,其他位置很可能也存在相同错误。此外,漏洞研究者往往不追求枚举漏洞的所有变种,而是专注于特定利用路径,并满足于发现某一变种。最后,开发者在匆忙修补漏洞时,可能未深入分析根本原因,也未建立完善的防护措施,导致未来依旧可能被绕过。这些因素使变种分析成为一个高效且节省资源的漏洞研究途径。主要可从以下几方面入手:
- 变种
引发漏洞的特定代码模式在其他代码位置复现,产生更多漏洞。 - 修补不足
漏洞修补未解决根本原因,留有绕过空间,漏洞仍可被利用。 - 回归
漏洞虽被修补,但缺乏回归测试或安全防护,未来代码变更可能使漏洞“死灰复燃”。
借助前述的漏洞通告和补丁差异,你能准确了解漏洞的成因和触发方式。经过根因分析后,可以针对相似的漏洞模式快速扫描代码,再基于是否重复原始漏洞对结果进行筛选,而非每次都从头开始。这种方法允许你编写更精确且针对特定模式的规则,而非泛用规则。
实践示例:Expat 库中的整数溢出漏洞变种
Expat 是一个用于解析 XML 文件的 C 语言库,广泛应用于 Firefox、Python 等软件(Expat 文档)。XML 文件的普遍性使得 Expat 的漏洞具有显著的下游影响。Expat 曾多次遭遇整数溢出漏洞,包括 CVE-2022-22822 至 CVE-2022-22827 等(CVE 列表)。相关漏洞补丁通常通过 GitHub 合并请求发布,例如针对上述多个 CVE 的共享补丁 “[CVE-2022-22822 to CVE-2022-22827] lib: Prevent more integer overflows” 指向了合并请求 #534 和 #538,而它们又关联修复了早期的 CVE-2021-46143 和 CVE-2021-45960。
根因分析
以 CVE-2021-46143 的补丁为例,查看合并请求 PR #538 ,标题为“[CVE-2021-46143] lib: Prevent integer overflow on m_groupSize in function doProlog”,仅更新了两个文件。变更日志指出:
pgsql
复制
+ #532 #538 CVE-2021-46143 (ZDI-CAN-16157) -- Fix integer overflow
+ on variable m_groupSize in function doProlog leading
+ to realloc acting as free.
+ Impact is denial of service or more.
这表明漏洞源于 m_groupSize
的整数溢出,导致调用 realloc
时行为异常(realloc 如果 size
为零则相当于 free
),可能引发拒绝服务或更严重影响。
代码变更节选(expat/lib/xmlparse.c
):
if (parser->m_prologState.level >= parser->m_groupSize) {
if (parser->m_groupSize) {
{
+ /* Detect and prevent integer overflow */
+ ➊ if (parser->m_groupSize > (unsigned int)(-1) / 2u) {
+ return XML_ERROR_NO_MEMORY;
+ }
+
char *const new_connector = (char *)REALLOC(
parser, parser->m_groupConnector, parser->m_groupSize *= 2);
if (new_connector == NULL) {
parser->m_groupSize /= 2;
return XML_ERROR_NO_MEMORY;
}
parser->m_groupConnector = new_connector;
}
}
和
+#if UINT_MAX >= SIZE_MAX
+ ➋ if (parser->m_groupSize > (size_t)(-1) / sizeof(int)) {
+ return XML_ERROR_NO_MEMORY;
+ }
+#endif
补丁增加了对 m_groupSize
的两个溢出检测,防止其扩大时超过可表示的最大整数,避免非法的内存分配。
REALLOC 宏分析
通过搜索代码,发现 REALLOC
宏定义如下:
#define REALLOC(parser, p, s) (parser->m_mem.realloc_fcn((p), (s)))
进一步追踪 realloc_fcn
初始化(简化示意):
if (memsuite) {
parser->m_mem.realloc_fcn = memsuite->realloc_fcn;
} else {
parser->m_mem.realloc_fcn = realloc; // 标准库 realloc
}
若无自定义内存管理,则调用标准 realloc
,确认补丁确实修复了真实问题。
整数溢出示例说明
为理解补丁原理,可编译并运行以下 C 代码:
#include <stdio.h>
int main() {
printf("SIZE_MAX: %zu\n", ((size_t)(-1)));
printf("no overflow: %zu\n", ((size_t)(-1) / sizeof(int)) * sizeof(int));
printf("overflow: %zu\n", ((size_t)(-1) / sizeof(int) + 1) * sizeof(int));
return 0;
}
输出示例:
SIZE_MAX: 18446744073709551615
no overflow: 18446744073709551612
overflow: 0
说明整数类型最大值为 2^N-1
,溢出时高位截断导致结果回绕至 0,引发程序行为异常。
漏洞触发路径理解
PR 注释还链接对应 Issue “[CVE-2021-46143] Crafted XML file can cause integer overflow...” (github.com/libexpat/li…),指出漏洞通过构造恶意 XML 文件触发,令 m_groupSize
达到极大值,导致溢出。
根因分析不仅帮助你理解漏洞发生机理,还能指导你编写针对性的规则执行变种扫描。尝试复现 CVE-2021-46143,制作恶意 XML 触发漏洞(可参考相关 PR 和 Issue 中提供的 PoC 脚本)。
实际研究中,虽然像 Expat 这样项目的漏洞修复文档较为详尽,但多数情况下你只能获得公开漏洞通告中的零星信息。部分补丁可能隐藏在大型更新中,或描述模糊以防攻击者利用。对已披露漏洞进行根因分析是一项能带来丰厚回报的关键技能。
多仓库变体分析
在单个仓库中寻找漏洞变体时,可以编写较为宽泛的代码扫描规则,因为大多数仓库都会遵循项目维护者制定的一套编码规范。不幸的是,一旦你试图编写跨多个仓库识别漏洞的规则,就会遇到各种挑战。开发者调用函数(例如 realloc
)的方式多种多样,可能是宏调用,也可能是函数指针调用。仅仅搜索 REALLOC
在 Expat 代码库外是行不通的。
虽然不存在“一条规则匹配所有情况”的万能模式,但研究人员可以将规则调整到低误报、高置信度的区间。通过一次性扫描成千上万个仓库,可以用规模来弥补较高的漏报率(即可能遗漏部分漏洞);即使命中率只有 1%,也可能带来至少 10 个新漏洞。但需要注意,每个漏洞的具体特征不同,规则的通用性有限。例如,针对特定框架配置错误的规则适用范围会小得多。
降低误报率的一种方法是采用数据流分析和污点追踪,而非纯粹的模式匹配。为此,可以使用 CodeQL 强大的数据流功能。幸运的是,你无需直接应对 CodeQL 复杂的语法,可以基于已有处理整数溢出导致内存分配大小异常的标准库查询(如 cpp/integer-overflow-tainted
和 cpp/uncontrolled-allocation-size
)进行改造和整合,示例规则如下:
/**
* @id integer-overflow-allocation-size
* @name Integer Overflow in Allocation Size
* @description Potential integer overflow passed to allocation size.
* @kind path-problem
* @severity error
*/
import cpp
import semmle.code.cpp.rangeanalysis.SimpleRangeAnalysis
import semmle.code.cpp.dataflow.new.TaintTracking
module IntegerOverflowConfig implements DataFlow::ConfigSig {
predicate isSource(DataFlow::Node source) {
exists(Expr e | e = source.asExpr() |
(
e instanceof UnaryArithmeticOperation or
e instanceof BinaryArithmeticOperation or
e instanceof AssignArithmeticOperation
) and
convertedExprMightOverflow(e)
)
}
predicate isSink(DataFlow::Node sink) {
exists(Expr e, HeuristicAllocationExpr alloc | e = sink.asConvertedExpr() |
e = alloc.getAChild() and
e.getUnspecifiedType() instanceof IntegralType and
not e instanceof Conversion
)
}
}
module IntegerOverflowFlow =
TaintTracking::Global<IntegerOverflowConfig>;
import IntegerOverflowFlow::PathGraph
from IntegerOverflowFlow::PathNode source,
IntegerOverflowFlow::PathNode sink
where IntegerOverflowFlow::flowPath(source, sink)
select sink.getNode(), source, sink,
"Potential integer overflow $@ passed to allocation size $@.",
source.getNode(), "source",
sink, "sink"
在 Expat 项目根目录,通过以下命令构建 CodeQL 数据库:
codeql database create --language cpp --source-root expat expat-codeql-database
然后将数据库添加到你的 CodeQL VS Code 工作区,像之前在书中“CodeQL”章节一样运行查询。
不过,这个规则不会返回预期结果,因为它只检测标准库内存分配函数,并未追踪宏调用。要支持宏调用,需要修改 isSink
谓词为:
predicate isSink(DataFlow::Node sink) {
exists(Expr e, ExprCall ec, MacroInvocation mi | e = sink.asExpr() |
ec = mi.getExpr() and
mi.getMacroName() = "REALLOC" and
e = ec.getAnArgument() and
e.getUnspecifiedType() instanceof IntegralType
)
}
这样才能检测出一些漏洞变体,类似 Semgrep 的效果。
既然你用 CodeQL 做多仓库而非单仓库变体分析,执行完毕后请务必将规则还原为更通用的标准库内存分配函数,而不是 Expat 特定的 REALLOC
宏。
使用 Semgrep 扫描成千上万个仓库相对简单,因为它无需先构建数据库,只需克隆所有仓库,直接运行 Semgrep 即可。相比之下,CodeQL 需要为每个仓库单独构建数据库,若仓库有非标准构建流程或第三方依赖,可能构建失败。幸运的是,GitHub 的 CodeQL 团队提供了热门仓库的预构建数据库,你可以通过分布式持续集成/持续交付(CI/CD)流程(GitHub Actions,运行在云端)扫描多达 1000 个仓库。
要在 VS Code 中使用 CodeQL 和 GitHub 进行多仓库变体分析,请按照 CodeQL 官方文档的指引操作(链接:codeql.github.com/docs/codeql…)。设置控制仓库时,确保工作流权限设置为“读写权限”。初次设置完成后,回到 VS Code,点击左侧活动栏的 CodeQL,找到“Variant Analysis Repositories”,选择 Top 100 仓库。然后右键点击你的自定义整数溢出查询,选择 “CodeQL: Run Variant Analysis”。系统会开始多仓库变体分析。
等待几分钟后,你将开始收到结果。每个仓库名称旁会显示结果数量,你可以展开查看源码中的数据流路径。
即使只有 100 个仓库,也可能收到数万个结果!在有限时间内全面筛查几乎不可能。但仔细观察结果,会发现不同仓库结果数量差别巨大——有的数千条,有的不到 10 条。考虑到结果数千的仓库更可能是误报,建议先从结果较少的仓库开始,优化和细化规则,过滤常见校验模式和误报,规则越准确后再处理较大仓库。只要样本足够大,就能找到真实存在的漏洞变体。
你也可以利用 GitHub 自定义代码搜索缩小要分析的仓库范围,而非仅用热门仓库。例如,针对 XML 相关漏洞,你可以优先分析 XML 相关仓库。
总结
自动化代码分析工具为大规模源码分析提供了强大手段。你在使用这些工具时所做的权衡和编写的规则类型,会根据策略的不同而有所差异(单仓库变体分析和多仓库变体分析所需策略截然不同)。但只要合理使用,这些工具能帮助你以有限的资源更高效地发现漏洞。
本章中,你使用了静态代码分析工具 CodeQL 和 Semgrep 实现了变体分析的自动化。你首先分析了已知漏洞 CVE-2021-46143 的补丁说明和代码差异,定位根本原因,随后编写了 Semgrep 规则匹配漏洞代码模式。此外,你还用 CodeQL 编写了污点追踪和数据流查询,实现跨多个文件的源到汇点匹配。最后,你尝试了多仓库变体分析,实现了规模化的漏洞发现。
将你对代码中典型漏洞模式的理解转化为自动化工具的规则,将有助于你在本书后续章节中反向工程二进制时明确寻找目标。代码审查就像是阅读复杂机器的原理图和设计图,帮助你理解其结构;而反向工程则像是拿到一台完整机器,却没有任何图纸,通过观察和分析推断其工作原理。没有对典型设计的基本了解,你会完全迷失。同样,你在代码审查中学到的知识,将为接下来的章节打下坚实基础。