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指令,而是一种编译器许可,允许程序员进行以下几类操作:

  1. 解引用裸指针 ( *const T , *mut T )
  2. 调用 unsafe 函数或方法
  3. 访问或修改可变静态变量
  4. 实现 unsafe trait
  5. 使用内联汇编 ( asm! )
  6. 访问 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 标志)。

数据管道构建

  1. 批量编译 :编写脚本,遍历你选定的crate列表,使用定制工具链进行编译( cargo build --release )。同时编译其示例( --examples )和测试( --tests )以生成更多样化的二进制。
  2. 标签提取 :编译完成后,你的定制编译器会为每个二进制生成对应的 unsafe 标签文件。
  3. 二进制函数提取与反汇编 :使用二进制分析工具(如 angr radare2 )加载ELF文件,识别函数边界,将每个函数的机器码反汇编为文本形式的汇编指令。注意,这里需要处理函数内联问题, rustspot 的方法是尝试在编译前禁用内联。
  4. 标签映射 :这是最精巧的部分。利用DWARF信息,将二进制指令的地址映射回源码行号。再根据源码行号去查询 unsafe 标签文件,确定该指令是否属于 unsafe 区域。一个函数只要包含任何一条指令在 unsafe 区域内,该函数就被标记为 unsafe ,并继承其包含的所有 unsafe 类型。
  5. 数据格式化 :将每个函数表示为一条数据记录: {“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二进制时:

  1. 使用 rustspot 加载模型并对二进制进行反汇编、函数提取。
  2. 模型会为每个函数输出一个“不安全分数”或二分类结果。
  3. 将函数按不安全分数从高到低排序。
  4. 逆向工程师可以优先审查排名前10%-20%的函数。实验数据显示,这能覆盖超过90%的潜在内存安全漏洞所在区域,将需要重点分析的代码量减少80%以上。

实操心得:处理大型二进制 :对于像Servo浏览器引擎这样的大型项目,直接处理整个二进制可能内存消耗巨大。可以分模块(按ELF section或通过调用图粗略划分)进行分析。另外,模型的推断速度是关键,可以考虑将模型转换为ONNX格式并用更快的运行时(如ONNX Runtime)加载,或者对模型进行量化(Quantization)以加速。

4.2 引导模糊测试(Fuzzing)

这是 rustspot 论文中展示的最具说服力的应用。以 cargo fuzz (基于libFuzzer)为例:

  1. 正常模糊测试 :对目标程序进行一段时间的初始模糊测试,记录发现的崩溃和代码覆盖率。
  2. 二进制分析与函数排序 :使用 rustspot 分析目标程序(编译为Release模式,不带插桩),得到高风险函数列表。
  3. 定向模糊测试 :将高风险函数列表作为“重点函数”(focus functions)提供给libFuzzer。libFuzzer的覆盖率引导引擎会倾向于生成能触发这些函数的测试用例。
  4. 对比结果 :运行相同时间的定向模糊测试,比较其与初始模糊测试在发现独特崩溃数量、到达高风险函数速度上的差异。

论文中的实验表明,这种方法在118个crate的277个fuzz目标上, 跳过了50个被模型判定为“安全”的二进制 ,总运行时间减少了20.5%,同时仍发现了基线测试中97.6%的漏洞。这意味着 rustspot 能有效识别出低风险目标,避免不必要的测试资源消耗。

4.3 在CI/CD管道中作为安全门禁

对于内部使用Rust开发关键组件的团队,可以将 rustspot 集成到CI/CD管道:

  1. 在每次构建Release版本时,自动运行 rustspot 对生成的二进制进行分析。
  2. 设定一个阈值,例如“不允许出现不安全分数高于0.9且未经审核的函数”。
  3. 如果检测到此类函数,则中断构建,并生成报告,要求开发者对对应源码区域的 unsafe 使用进行说明和复审。
  4. 这可以将安全左移,在构建阶段就捕获可能引入风险的 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构建的关键基础设施,具有不可忽视的价值。

更多推荐