Rust二进制安全分析:基于机器学习的unsafe代码自动识别技术
1. 项目概述与核心价值
如果你是一名安全研究员或者正在对某个用Rust编写的闭源二进制程序进行安全审计,面对动辄几十甚至上百MB的庞大可执行文件,最头疼的问题是什么?是如何在茫茫的机器指令海洋中,快速定位到最可能藏有内存安全漏洞的那几行关键代码。传统的逆向工程方法往往需要人工逐函数分析,或者依赖覆盖率引导的模糊测试进行“盲测”,效率低下且针对性不强。
Rust语言以其编译时的所有权和借用检查机制,理论上能杜绝大部分内存安全问题。但为了与C语言交互、进行系统级编程或追求极致性能,Rust提供了
unsafe
关键字。这块“法外之地”就像安全堡垒中的一个特殊通道,虽然必要,但也成为了潜在风险的聚集区。一个核心的洞察是:
绝大多数Rust中的内存安全漏洞,其根源都位于
unsafe
代码块或
unsafe fn
函数内部
。因此,如果我们能在二进制层面准确识别出哪些函数包含了
unsafe
操作,就等于拿到了一张藏宝图,能将漏洞挖掘的搜索范围从整个程序缩小到这些高风险区域。
rustspot
这个工具正是基于这一思路。它本质上是一个基于机器学习的二进制分析工具,核心任务是:
给定一个剥离了符号和调试信息的Rust编译二进制文件,自动识别出其中哪些函数包含了
unsafe
操作
。这听起来像是一个“不可能的任务”,因为
unsafe
是一个源码级别的概念,在编译优化后,其语义信息几乎消失殆尽。但
rustspot
通过数据驱动的方法,从大量已知的Rust包编译结果中学习
unsafe
模式在汇编指令层面的“蛛丝马迹”,成功地将这个想法变成了现实。
它的技术价值非常直接:
极大地提升逆向工程和自动化安全测试(如定向模糊测试)的效率
。实验表明,使用
rustspot
引导的模糊测试,在找到97.6%漏洞的情况下,能节省超过20%的分析时间。对于安全分析师来说,这意味着可以把宝贵的时间从海量代码审查中解放出来,聚焦于最可能出问题的核心地带。
2. 核心原理:从源码
unsafe
到二进制模式的映射
要理解
rustspot
如何工作,首先得明白Rust的
unsafe
在编译后究竟留下了什么。
unsafe
本身不是一条具体的CPU指令,而是一种编译器许可,允许程序员进行以下几类操作:
-
解引用裸指针 (
*const T,*mut T) -
调用
unsafe函数或方法 - 访问或修改可变静态变量
-
实现
unsafetrait -
使用内联汇编 (
asm!) -
访问
union的字段
这些操作在编译成LLVM IR再到最终的机器码时,会转化为特定的指令序列或模式。例如,解引用一个裸指针最终对应着
mov
等内存访问指令;调用一个
unsafe
函数对应着
call
指令。关键在于,这些模式是否具有足够的区分度,能让机器学习模型从安全的指令序列中将其识别出来。
rustspot
的假设是:
尽管经过编译优化,但
unsafe
操作所对应的底层指令模式,与安全的Rust代码所生成的指令模式,在统计特征上存在可学习的差异
。这种差异可能体现在操作码序列、寄存器使用模式、内存访问模式或对特定外部函数(如
libc
函数)的调用上。
2.1 数据集的构建:CrateU与RustSecU
任何机器学习项目的基础都是高质量的数据。
rustspot
构建了两个核心数据集:
-
CrateU (源数据集)
:从
crates.io(Rust的官方包仓库)抓取了数万个流行的Rust包,使用一个定制化的Rust工具链进行编译。这个工具链的关键修改在于,它在编译过程中不仅生成二进制文件,还同时记录下每一条指令对应的源码位置是否位于unsafe块或unsafe fn内部。通过解析DWARF调试信息,将二进制指令与源码中的unsafe区域进行精确映射,从而为每一个函数打上“安全”或“不安全”的标签,并进一步细分为14种unsafe类型(如DerefOfRawPointer、CallToUnsafeFunction等)。最终,CrateU包含了超过1500万个已标记的函数,构成了模型训练的基石。 -
RustSecU & RustSecB (目标数据集)
:为了评估工具在真实漏洞场景下的有效性,
rustspot团队从Rust安全公告数据库(RustSec Advisory Database)中手工筛选出121个与内存安全相关的漏洞报告(如Use-after-Free, Double-Free等)。他们定位到触发漏洞的代码行,同样使用定制工具链编译对应的包版本,为函数打上“存在漏洞”(bug)的标签,同时也生成unsafe标签。RustSecU是带有unsafe标签的函数集合,RustSecB是带有漏洞标签的函数集合。这个数据集规模较小(约44.7万函数,其中仅240个含漏洞),但价值极高,因为它直接关联真实的安全缺陷。
注意:数据集的挑战与处理 :构建这类数据集并非易事。主要挑战来自编译器优化,特别是函数内联。内联会将
unsafe代码的指令“溶解”到调用者函数中,破坏原有的函数边界和标签映射。rustspot的解决方案是在编译前预处理源码,强制禁用内联优化,但这可能轻微改变程序的性能特征。此外,过程宏(procedural macros)中的unsafe代码目前无法被准确标记,因为DWARF信息在宏展开前生成,这是一个已知的局限性。
2.2 模型架构与训练策略
rustspot
的核心是一个二分类器(判断函数是否
unsafe
),但其内部实现是一个多标签分类模型。它采用经典的Transformer架构(基于RoBERTa-large)作为汇编代码的嵌入(embedding)模型。
1. 汇编代码的表示与嵌入
如何让模型“读懂”汇编?
rustspot
将函数的汇编指令视为一种特殊的文本序列。每个函数被表示为其汇编指令的文本,并且为了捕获调用上下文,它还会以特殊标记(如
|<C>|
)的形式,按调用深度比例嵌入被调用函数(callee)的汇编代码。这种方式让模型不仅能看当前函数的指令,还能感知其调用链的粗略结构。随后,这个文本序列经过Tokenizer处理后,送入Transformer编码器,输出一个能够表征该函数语义的向量(embedding)。
2. 分类头与多标签学习
得到函数向量后,接上一个全连接层构成的分类头。这里的关键在于,模型不是简单地进行“安全/不安全”二分类,而是进行
15分类
(14种
unsafe
类型 + 1种“安全”类型)。对于每个函数,模型会输出一个15维的分数向量,每个分数代表该函数属于对应类别的置信度。训练时使用二元交叉熵损失函数。这种多标签学习方式能让模型更细致地理解不同
unsafe
操作的模式差异,理论上比粗暴的二分类更具鲁棒性和可解释性。
3. 领域自适应(Fine-tuning on Target)
直接从CrateU(源领域)训练出的模型,在RustSecU(目标领域,即真实漏洞相关的二进制)上表现可能会下降,因为两者涉及的包类型、代码风格、
unsafe
使用模式可能存在分布差异(协变量偏移)。为此,
rustspot
采用了迁移学习中的标准策略:在CrateU上预训练模型后,再在RustSecU的训练集上进行微调(fine-tuning),使模型更好地适应目标领域的特征。
2.3 PAC阈值选择:保证召回率的统计方法
模型输出的是分数,我们需要一个阈值来将其转化为“是/否”的判断。如何选择这个阈值?
rustspot
采用了一种基于
可能近似正确(PAC)理论
的严谨方法。
简单来说,安全分析师通常有一个明确的需求:
“我希望找到至少90%的
unsafe
函数”
(即召回率Recall >= 90%)。PAC阈值选择算法就是为了在统计上满足这个需求而设计的。它利用一个校准集(例如RustSecU的验证集),根据模型在该集上的预测分数和真实标签,计算出一个阈值τ。该阈值能以高置信度(例如1-δ=99.9%)保证,在未来的新数据(同分布)上,模型的召回率不低于1-ε(例如90%)。
这个方法的优势在于其
统计保证性
,它不是一个启发式的选择(如最大化F1分数),而是为用户提供了一个可靠的概率承诺。虽然这个保证是针对
unsafe
函数召回率的,但实验表明,以此阈值来寻找漏洞函数,也能达到近似的召回率,因为漏洞高度集中于
unsafe
区域。
3. 实操:构建与运行你自己的
rustspot
理解了原理,我们来看看如何实际使用或借鉴
rustspot
的思路。由于原研究项目可能未完全开源所有组件,这里我将概述其关键步骤和可行的复现路径。
3.1 环境准备与数据收集
核心依赖 :
-
Rust工具链
:你需要一个可修改的Rust编译器(如从源码编译)。
rustspot基于1.57.0-dev版本进行修改,以在编译时注入unsafe区域信息。 -
Python深度学习环境
:PyTorch或TensorFlow,以及Transformer库(如Hugging Face
transformers)。 -
二进制分析工具
:用于解析ELF/DWARF信息,如
pyelftools、capstone(反汇编)或angr(二进制分析框架)。 -
数据集
:你需要编译大量Rust包。可以从
crates.io的索引开始,使用cargo下载并编译。关键在于修改编译流程以捕获标签。
定制化编译工具链(关键步骤)
:
这是最具挑战性的一步。你需要修改Rust编译器(主要是
rustc
),在它的中间表示(如MIR或HIR)层面,在检查
unsafe
块时,不仅进行错误报告,还将这些区域的位置信息(文件、行号、列号、
unsafe
类型)以某种格式(如JSON)输出到额外的元数据文件中。同时,在编译时务必生成完整的DWARF调试信息(
-g
标志)。
数据管道构建 :
-
批量编译
:编写脚本,遍历你选定的crate列表,使用定制工具链进行编译(
cargo build --release)。同时编译其示例(--examples)和测试(--tests)以生成更多样化的二进制。 -
标签提取
:编译完成后,你的定制编译器会为每个二进制生成对应的
unsafe标签文件。 -
二进制函数提取与反汇编
:使用二进制分析工具(如
angr或radare2)加载ELF文件,识别函数边界,将每个函数的机器码反汇编为文本形式的汇编指令。注意,这里需要处理函数内联问题,rustspot的方法是尝试在编译前禁用内联。 -
标签映射
:这是最精巧的部分。利用DWARF信息,将二进制指令的地址映射回源码行号。再根据源码行号去查询
unsafe标签文件,确定该指令是否属于unsafe区域。一个函数只要包含任何一条指令在unsafe区域内,该函数就被标记为unsafe,并继承其包含的所有unsafe类型。 -
数据格式化
:将每个函数表示为一条数据记录:
{“asm_text”: “函数汇编指令字符串”, “unsafe_labels”: [标签ID列表], “is_bug”: 0/1}。将其保存为易于读取的格式,如JSON Lines。
3.2 模型训练与调优
数据预处理 :
- 分词 :将汇编指令文本进行分词。可以基于空格和标点进行简单分词,或构建一个针对x86_64/ARM汇编的专用BPE词表。
- 序列长度 :统计函数指令数的分布,设定一个最大长度(如512或1024)。过长的函数可以截断,过短的可以填充。
- 数据集划分 :务必 按crate进行划分 ,而不是随机打乱函数。这可以防止模型通过记忆特定crate的特征来“作弊”,确保其泛化能力。通常按70%/15%/15%划分训练、验证、测试集。
模型实现 :
# 伪代码示例,基于PyTorch和Hugging Face Transformers
from transformers import RobertaModel, RobertaTokenizer
import torch.nn as nn
class UnsafeClassifier(nn.Module):
def __init__(self, num_labels=15):
super().__init__()
self.roberta = RobertaModel.from_pretrained('roberta-large')
# 替换tokenizer以适应汇编词汇
self.tokenizer = ... # 自定义汇编tokenizer
self.classifier = nn.Linear(self.roberta.config.hidden_size, num_labels)
def forward(self, asm_texts):
# 分词
inputs = self.tokenizer(asm_texts, padding=True, truncation=True, return_tensors='pt')
# 获取Transformer编码
outputs = self.roberta(**inputs)
# 取[CLS]位置的输出作为整个函数的表示
pooled_output = outputs.last_hidden_state[:, 0, :]
# 分类头
logits = self.classifier(pooled_output)
return torch.sigmoid(logits) # 多标签分类,使用sigmoid
训练循环
:
使用二元交叉熵损失(
BCEWithLogitsLoss
),在CrateU训练集上进行训练。监控在验证集上的损失和按标签计算的F1分数。使用早停(early stopping)防止过拟合。
领域自适应 : 在CrateU上训练收敛后,加载模型权重,在RustSecU的训练集上继续训练(微调)。此时应使用较小的学习率(例如初始学习率的1/10),并可能只训练分类头或最后几层,以避免灾难性遗忘。
3.3 阈值校准与评估
实现PAC阈值选择 :
import numpy as np
from scipy.stats import beta
def pac_threshold_selection(val_scores, val_labels, epsilon=0.1, delta=1e-3):
"""
val_scores: 模型在验证集上对‘安全’类别的预测分数 (s(x,0))
val_labels: 验证集标签,0表示安全,1表示不安全
epsilon: 期望的误差上界 (1 - 期望召回率)
delta: 置信度参数
返回: 阈值 tau
"""
# 1. 获取所有不安全样本的“不安全分数”:1 - s(x,0)
unsafe_scores = 1 - val_scores[val_labels == 1]
n_cal = len(unsafe_scores)
# 2. 对不安全分数排序
sorted_scores = np.sort(unsafe_scores)
# 3. 寻找最大的tau,使得在tau处被误判为安全的比例不超过epsilon (以高概率)
# 根据PAC预测集理论,找到最小的k,使得上置信界 <= epsilon
for k in range(n_cal + 1):
# 如果阈值设为第k+1小的分数,则会有k个不安全样本被误判(分数低于阈值)
# 计算误判率的上置信界 (Clopper-Pearson上界)
# 使用Beta分布的分位数
upper_bound = beta.ppf(1 - delta, k + 1, n_cal - k) if n_cal > k else 1.0
if upper_bound <= epsilon:
# 阈值设为第k+1小的分数。如果k==n_cal,则设为无穷大(即全部判为不安全)
tau = sorted_scores[k] if k < n_cal else np.inf
return tau
return np.inf # 如果没有找到,返回一个保守值
评估指标 :
-
对于
unsafe分类任务 :计算精确率-召回率曲线(PR-AUC)和F1分数。在测试集上评估ˆu_CrateU和ˆu_RustSecU的性能。 -
对于漏洞发现任务
:这是终极检验。在RustSecB测试集上,计算
覆盖率-召回率曲线
。覆盖率定义为:为了达到某个召回率(找到X%的漏洞),需要人工审查或分析工具扫描的指令数占总指令数的百分比。理想情况下,曲线越靠近左上角(高召回、低覆盖率)越好。将
rustspot与随机选择函数、仅选择包含外部调用的函数等基线方法进行比较。
4. 实战应用:集成到安全分析工作流
rustspot
不仅仅是一个评估指标漂亮的学术模型,它的真正价值在于赋能实际的安全分析。
4.1 逆向工程辅助
当你拿到一个陌生的Rust二进制时:
-
使用
rustspot加载模型并对二进制进行反汇编、函数提取。 - 模型会为每个函数输出一个“不安全分数”或二分类结果。
- 将函数按不安全分数从高到低排序。
- 逆向工程师可以优先审查排名前10%-20%的函数。实验数据显示,这能覆盖超过90%的潜在内存安全漏洞所在区域,将需要重点分析的代码量减少80%以上。
实操心得:处理大型二进制 :对于像Servo浏览器引擎这样的大型项目,直接处理整个二进制可能内存消耗巨大。可以分模块(按ELF section或通过调用图粗略划分)进行分析。另外,模型的推断速度是关键,可以考虑将模型转换为ONNX格式并用更快的运行时(如ONNX Runtime)加载,或者对模型进行量化(Quantization)以加速。
4.2 引导模糊测试(Fuzzing)
这是
rustspot
论文中展示的最具说服力的应用。以
cargo fuzz
(基于libFuzzer)为例:
- 正常模糊测试 :对目标程序进行一段时间的初始模糊测试,记录发现的崩溃和代码覆盖率。
-
二进制分析与函数排序
:使用
rustspot分析目标程序(编译为Release模式,不带插桩),得到高风险函数列表。 - 定向模糊测试 :将高风险函数列表作为“重点函数”(focus functions)提供给libFuzzer。libFuzzer的覆盖率引导引擎会倾向于生成能触发这些函数的测试用例。
- 对比结果 :运行相同时间的定向模糊测试,比较其与初始模糊测试在发现独特崩溃数量、到达高风险函数速度上的差异。
论文中的实验表明,这种方法在118个crate的277个fuzz目标上,
跳过了50个被模型判定为“安全”的二进制
,总运行时间减少了20.5%,同时仍发现了基线测试中97.6%的漏洞。这意味着
rustspot
能有效识别出低风险目标,避免不必要的测试资源消耗。
4.3 在CI/CD管道中作为安全门禁
对于内部使用Rust开发关键组件的团队,可以将
rustspot
集成到CI/CD管道:
-
在每次构建Release版本时,自动运行
rustspot对生成的二进制进行分析。 - 设定一个阈值,例如“不允许出现不安全分数高于0.9且未经审核的函数”。
-
如果检测到此类函数,则中断构建,并生成报告,要求开发者对对应源码区域的
unsafe使用进行说明和复审。 -
这可以将安全左移,在构建阶段就捕获可能引入风险的
unsafe代码变更。
5. 局限性、挑战与未来方向
尽管
rustspot
效果显著,但我们必须清醒地认识到它的边界和当前面临的挑战。
1. 分布外泛化与对抗样本
模型在训练过的crate类型上表现良好,但对于使用了全新编程模式、大量内联汇编或特殊编译器优化的二进制,其性能可能下降。这本质上是机器学习模型的通病——对分布外(OOD)数据泛化能力不足。更棘手的是,攻击者可能针对模型进行
对抗性混淆
。例如,通过插入无意义的指令序列、改变指令顺序(但保持语义)、或利用编译器优化将
unsafe
模式“打散”,从而欺骗分类器,使其将
unsafe
函数误判为安全。这引发了一场攻防博弈。
2. 过程宏与复杂元编程
Rust强大的过程宏可以在编译时生成代码。目前
rustspot
无法准确标记在过程宏内部定义的
unsafe
代码,因为DWARF调试信息在宏展开之前生成。解决这个问题需要更深入地与编译器内部交互,在宏展开后的中间表示上进行分析,技术复杂度很高。
3. 阈值选择的实际意义
PAC阈值保证了
unsafe
函数的召回率,但安全研究员更关心的是
漏洞的召回率
。两者高度相关但并非绝对等同。存在一种可能:一个漏洞由多个安全函数协同触发,或者一个
unsafe
函数本身是正确且必要的。因此,高
unsafe
函数召回率是高漏洞召回率的必要不充分条件。在实际使用中,需要结合其他静态分析或动态验证手段进行交叉确认。
4. 多架构与优化等级支持
当前工作主要针对x86-64架构和特定的优化等级(如Release)。不同的CPU架构(ARM, RISC-V)和编译器优化等级(
-O1
,
-O2
,
-O3
,
-Oz
)会显著改变生成的汇编代码,可能影响模型识别模式的稳定性。构建一个跨架构、跨优化等级的鲁棒模型需要更庞大和多样化的训练数据。
未来可能的演进方向 :
- 多模态学习 :不仅分析汇编文本,同时结合二进制代码的控制流图(CFG)、数据流图(DFG)等结构信息,提供更丰富的特征。
-
与符号执行/污点分析结合
:
rustspot作为初筛工具,快速定位高风险区域;随后启动更重量级、更精确的符号执行或污点分析工具对这些区域进行深度验证,形成分层分析管道。 - 解释性与归因 :开发模型可解释性技术,例如通过注意力机制或梯度归因,告诉分析师模型是根据函数中的哪些特定指令或模式做出“不安全”判断的,增加分析结果的可信度。
-
集成到现有工具链
:开发
rustspot的IDA Pro/Ghidra插件,让逆向工程师能在熟悉的图形化界面中直接看到模型的分析结果,无缝融入现有工作流。
rustspot
为我们展示了一条将机器学习应用于二进制程序分析的务实路径。它不追求完全替代传统的程序分析或模糊测试,而是作为一个强大的
力倍增器
,通过智能化的初筛,将安全专家的注意力引导至最值得关注的战场。在软件供应链安全日益重要的今天,这类能够提升漏洞挖掘效率的工具,对于守护用Rust构建的关键基础设施,具有不可忽视的价值。
更多推荐
所有评论(0)