机器学习漏洞检测的困境:函数级分类为何是伪命题?
1. 项目概述与核心问题
在软件安全研究领域,利用机器学习进行漏洞检测(Machine Learning for Vulnerability Detection, ML4VD)已经成为一个炙手可热的方向。简单来说,这个领域的梦想是:让AI像一位经验丰富的安全专家一样,扫描成千上万行代码,自动、精准地揪出那些可能导致缓冲区溢出、整数溢出、释放后使用等安全漏洞的“坏代码”。过去五年,顶级软件工程和安全会议上,近九成的相关论文都在朝着这个目标努力,它们不约而同地将问题简化成了一个看似清晰的二元分类任务:给你一段独立的函数代码,模型需要判断这个函数是否包含安全漏洞。
这个设定听起来很直接,也便于构建数据集和评估模型。业界常用的几个基准数据集,比如 BigVul、Devign,都是按照这个思路构建的:从开源项目的漏洞修复提交(patch commit)中提取被修改的函数,标记为“漏洞函数”,并从同一项目的其他部分或不同项目中提取一些函数,标记为“安全函数”。然后,研究者们就拿着这些数据集,训练各种复杂的图神经网络、Transformer模型,比拼谁的准确率、F1分数更高。
但这里存在一个根本性的、被广泛忽视的假设:仅凭一个孤立的函数体,我们真的能断定它是否“漏洞”吗?作为一名长期混迹于代码审计和漏洞挖掘一线的从业者,我每次看到这种“函数级分类”的论文,心里都会打个问号。在实际工作中,当我面对一个可能有问题的函数时,我的第一反应绝不是只看它本身。我会立刻去翻看它的调用链:谁调用了它?传入了什么参数?这些参数在更上游的代码路径中是否经过了充分的校验?这个函数所处的模块状态是怎样的?一个函数本身可能写得“人畜无害”,但如果在某个特定的、未经校验的调用上下文里,它就会变成灾难的源头。反之,一个看起来“危如累卵”的函数(比如内部有一个未经验证的空指针解引用),如果它的所有调用者都确保了传入参数不可能为空,那它在当前程序中就是安全的。
这种漏洞是否显现依赖于外部调用上下文的现象,我们称之为“上下文依赖”。令人震惊的是,我们的实证研究发现,在主流数据集中,超过90%的所谓“漏洞函数”或“安全函数”,其标签的有效性都严重依赖于上下文。这意味着,当前绝大多数ML4VD研究赖以生存的“函数级分类”问题本身,可能就是一个“伪命题”。更糟糕的是,模型在这些有缺陷的基准测试上取得的高分,很可能不是学会了识别漏洞,而是学会了识别数据集中与漏洞标签虚假相关的表面特征,比如某些特定API名称、变量名的出现频率,甚至是代码注释的风格。
这就好比让学生参加一场考试,考题本身就有问题,但评分标准却只看最终答案是否与一个有缺陷的“标准答案”匹配。学生可能通过死记硬背“标准答案”的特征(比如选择题总是选C)得了高分,但这完全不能证明他理解了知识本身。我们的研究,就是要揭示这场“错误考试中的高分”背后的真相,并探讨如何设计一场更靠谱的“考试”。
2. 主流方法剖析:函数级分类为何成为“标准答案”
要理解问题的根源,我们得先看看当前ML4VD领域的研究范式是如何形成的。通过对近五年顶级会议和期刊(如ICSE、FSE、S&P、USENIX Security等)上81篇相关论文的系统性调研,我们发现了一个高度统一的模式。
2.1 问题定义的趋同与数据集垄断
高达88%的论文将ML4VD明确定义为函数级二元分类问题。给定一个函数f的代码(通常是C/C++代码),模型输出一个二值标签:1表示有漏洞,0表示安全。这个定义的吸引力是显而易见的:
- 数据单元明确:函数是编程中天然的逻辑单元,边界清晰,易于从代码仓库中提取。
- 便于建模:无论是基于序列(代码文本)、抽象语法树(AST)还是代码属性图(CPG)的模型,都能以函数为粒度进行特征提取和表示学习。
- 评估简单:可以直接套用机器学习中成熟的分类评估指标,如准确率、精确率、召回率、F1分数、AUC-ROC等,便于论文间横向比较。
这种问题定义的趋同,直接导致了数据集的集中化。如图3所示,BigVul、Devign和ReVeal这三个数据集占据了绝对主导地位,超过65%的论文至少使用了其中之一。这种“基准-方法”的紧密耦合,使得整个领域的研究在很大程度上被这几个数据集所定义和局限。
2.2 一个典型的“漏洞函数”示例及其陷阱
让我们看一个来自DiverseVul数据集的真实例子(对应CVE-2021-29599):
TfLiteStatus ResizeOutputTensors(TfLiteContext* context, TfLiteNode* node, const TfLiteTensor* axis, const TfLiteTensor* input, int num_splits) { int axis_value = GetTensorData<int>(axis)[0]; // [...] const int input_size = SizeOfDimension(input, axis_value); TF_LITE_ENSURE_MSG(context, input_size % num_splits == 0, "Not an even split"); const int slice_size = input_size / num_splits; // 潜在除零错误! for (int i = 0; i < NumOutputs(node); ++i) { TfLiteIntArray* output_dims = TfLiteIntArrayCopy(input->dims); output_dims->data[axis_value] = slice_size; // [...] TF_LITE_ENSURE_STATUS(context->ResizeTensor(context, output, output_dims)); } return kTfLiteOk; }在数据集中,这个函数因为其修复提交(patch)涉及对num_splits参数进行零值检查而被标记为“漏洞函数”。模型的任务是:只看这个函数体,判断它是否有漏洞。
关键问题来了:这个函数在第10行有一个除法input_size / num_splits。如果num_splits为0,会导致除零错误。但是,仅从这个函数内部,我们无法知道num_splits是否可能为0。这个信息存在于所有调用此函数的地方。如果所有调用者都确保传入的num_splits > 0,那么这个函数在当前的程序上下文中就是安全的。反之,如果存在一个调用路径传入了num_splits = 0,那它就是漏洞。
这个简单的例子揭示了函数级分类的根本缺陷:它强行将一个本质上需要上下文信息(调用者行为)才能做出的判断,压缩成了一个仅基于局部信息的猜测。这就像医生只看一张局部皮肤的照片就诊断是否患癌,而不询问病史、不做全身检查。
2.3 与静态分析和软件测试的对比
其实,“上下文依赖”问题在传统的程序分析领域早已被充分认识和讨论。
- 静态分析:会严格区分过程内(intra-procedural)和过程间(inter-procedural)分析。过程内分析只关注单个函数,但会因此产生大量误报(False Positive),因为它必须对未知的调用上下文做最坏的假设。高级的静态分析工具会尝试构建函数摘要(function summary)或要求用户提供前置条件(precondition)来缓解这个问题。
- 软件测试:区分单元测试和系统测试。单元测试针对单个函数,需要精心构造测试用例(包括输入参数)来覆盖各种情况。如果测试用例没有覆盖到
num_splits=0的情况,那么这个除零错误就无法被发现。这正说明了函数行为对输入的依赖。
然而,当前的ML4VD研究在很大程度上“忘记”了这些来自姊妹领域的深刻见解,将漏洞检测过度简化,并沉浸在基于有缺陷基准的高性能报告中。
3. 实证研究:拆解基准测试的“黑箱”
为了量化函数级分类问题的缺陷到底有多严重,我们设计并实施了一项严格的实证研究。我们选取了三个最流行的数据集:BigVul、Devign和DiverseVul。研究核心围绕两个研究问题展开。
3.1 RQ1:函数级分类是一个定义良好的问题吗?
我们首先需要确认,数据集中标记为“漏洞”的函数,是否真的包含漏洞(解决标签噪声问题)。然后,重点分析这些漏洞的上下文依赖性。
3.1.1 数据采样与人工审计流程
我们从每个数据集中随机抽取了100个被标记为“漏洞”的样本,总计300个函数。审计工作由两名具有丰富软件安全研究经验的研究者独立进行,耗时超过150小时。对于每个函数,审计者需要:
- 找到对应的原始Git提交(commit)和CVE报告(如果有)。
- 理解这个提交修复了什么安全问题。
- 判断这个被抽样的函数是否是漏洞的根源(即,修复这个漏洞是否必须修改这个函数)。如果不是,则标记为“安全”(Secure)。如果是,则标记为“真实漏洞”(Vulnerable)。
- 对于“真实漏洞”函数,进一步判断其漏洞是否是上下文依赖的。即,仅看这个函数代码本身,能否确定它一定会导致漏洞?还是说需要额外的调用上下文信息才能做出判断?
注意:这个审计过程极其耗时且需要专业知识,但它对于评估数据质量至关重要。许多后续研究直接使用这些数据集的原始标签,而忽略了其中可能存在的巨大噪声和歧义。
3.1.2 令人震惊的发现:无处不在的上下文依赖
审计结果清晰地揭示了当前基准测试的根本性缺陷:
标签噪声:即使在经过筛选的“漏洞”样本中,仍有相当一部分函数并非漏洞的真正根源。例如,有些提交修复的是文档、配置或构建脚本,其关联的函数代码本身并无安全缺陷。这与其他研究(如Croft等人)的发现一致,即数据集中存在标签不准确的问题。
漏洞的上下文依赖性(Context-dependent Vulnerability):对于被确认为“真实漏洞”的函数,超过90%的漏洞是上下文依赖的。这意味着,仅看这个函数代码,你无法断定它“有漏洞”。它的“漏洞”属性,完全取决于是否存在一个“错误”的调用上下文(比如传入一个非法参数)。图1中的除零错误就是一个典型例子。函数本身只是一个“工具”,工具是否危险,取决于使用者如何用它。
安全的上下文依赖性(Context-dependent Security):更有趣的是反向思考。对于那些被标记为“安全”的函数(通常是从非漏洞提交或不同项目中抽取的),我们能否构造一个调用上下文,使得它变得“有漏洞”?答案是,在绝大多数情况下,可以。许多“安全”函数包含潜在的危险操作(如指针解引用、数组访问、除法运算),它们之所以安全,仅仅是因为在当前程序的所有调用路径中,前置条件都得到了满足。只要稍微改变一下调用上下文(例如,一个调用者忘记进行边界检查),这些函数立刻就会变成漏洞源。
结论:对于数据集中绝大多数的函数样本(无论是标为漏洞还是安全),其标签的有效性都严重依赖于未在样本中提供的上下文信息。因此,要求模型仅根据函数体代码进行分类,相当于让模型去猜一个缺失了关键条件的谜题。这个问题的定义本身是不完整的,基于此得出的任何模型性能评估,其内部效度都值得严重怀疑。
3.2 RQ2:高分数从何而来?虚假相关性的力量
既然问题定义有严重缺陷,为什么那么多论文报告他们的模型在BigVul、Devign等数据集上取得了“优异”的性能(例如超过90%的准确率)?
我们的假设是:模型并没有学会检测漏洞的本质特征,而是学会了利用数据集中与漏洞标签虚假相关的表面统计特征。这些特征与漏洞的因果机制无关,但恰好在这个特定的数据分布中与标签有较强的相关性。
3.2.1 设计一个“荒谬”的实验
为了验证这个假设,我们设计了一个极简的、完全抛弃代码语义和结构的分类器:
- 特征:我们不再使用复杂的AST、CFG或代码嵌入向量。我们只使用最简单的词袋模型,统计每个函数中各个单词(包括变量名、函数名、关键字等)出现的频率。
- 模型:使用一个经典的梯度提升树模型(如XGBoost)。
- 任务:在同样的数据集(BigVul, Devign)上,用同样的训练/测试划分,进行函数级漏洞分类。
这个设置是“荒谬”的,因为单词频率完全无法表达程序的逻辑、数据流或控制流,而这些才是决定是否存在漏洞的关键。
3.2.2 实验结果与启示
令人震惊的是,这个仅基于单词频率的“荒谬”分类器,其性能(以F1分数、AUC等指标衡量)竟然与许多声称利用了高级代码表征的最先进的ML4VD模型不相上下!
这个结果具有颠覆性意义:
- 它提供了强有力的证据,证明当前主流数据集存在严重的虚假相关性。模型可以通过记忆诸如“在漏洞函数中
memcpy、strcpy等‘危险’函数名出现频率更高”、“安全函数中assert、CHECK等检查宏出现更多”之类的表面模式来获得高分,而无需理解这些API为何危险、在什么条件下危险。 - 它挑战了现有评估方法的有效性。如果一个仅靠数单词就能达到SOTA性能的模型被认为是“成功”的,那么整个评估体系就在奖励错误的技能。这就像考试奖励背答案而不是理解原理。
- 它解释了为何模型泛化能力差。一旦部署到新的、代码风格或词汇分布不同的项目上,这些基于虚假特征的模型就会迅速失效,因为其依赖的表面相关性在新环境中不复存在。
实操心得:在评估一个ML4VD模型时,一个非常有效的“嗅觉测试”是:尝试用最简单的特征(如n-gram、关键字频率)和简单的模型(如逻辑回归、随机森林)跑一个基线。如果这个基线模型的性能和你精心设计的复杂神经网络模型差距不大,那就需要高度警惕你的数据集和任务定义是否存在虚假相关性问题。真正的进步应该体现在模型能够超越这些表面特征,捕捉到更深层的、语义上的漏洞模式。
4. 构建更有效的漏洞检测评估体系
既然当前的函数级分类基准存在根本缺陷,我们应该如何前进?完全否定机器学习在漏洞检测中的应用前景是武断的。关键在于,我们需要重新思考问题定义和评估方法,使其更贴近安全分析的现实需求。
4.1 迈向上下文感知的评估
核心思路是将调用上下文纳入评估范围。这并非要完全抛弃现有工作,而是对其进行升级和细化。以下是几个有潜力的方向:
函数对(Function Pair)或上下文片段分类:不再孤立地给一个函数打标签,而是提供一个“函数+其某个调用者”的配对,或者一个小的代码片段(包含该函数及其直接上下文)。任务变为:在此特定上下文中,该函数是否存在漏洞?这更接近代码审计时“点击函数调用,查看调用方”的实际操作。
漏洞触发条件生成:对于给定的一个函数,任务不是二分类,而是生成使其触发漏洞的前置条件或测试输入。例如,对于前面的除零例子,模型应输出“当
num_splits == 0时存在除零漏洞”。这直接评估了模型对漏洞根因的理解。过程间(Inter-procedural)切片级分类:以“漏洞触发点”为核心,构建包含相关数据流和控制流依赖的代码切片,这个切片可能跨多个函数。以此切片作为分类单元,比单个函数更合理,因为一个漏洞的成因和触发可能涉及多个函数协作。
4.2 创建更严谨的数据集
构建新数据集是推动领域发展的基础。新的数据集应:
- 包含上下文信息:至少提供函数的主要调用者信息,或整个模块的代码。
- 精细化的标签:不仅标注“是否有漏洞”,还应标注漏洞类型(CWE ID)、漏洞触发的具体位置、以及关键的漏洞触发条件。
- 平衡与多样性:确保数据来自更多样化的项目,避免编码风格和库使用的单一化,减少数据集特定的偏见。
- 区分“易混淆”样本:主动包含那些在表面特征上相似但漏洞属性不同的样本,以及上下文依赖性的正反例,迫使模型学习更深层的特征。
4.3 改进模型训练与评估策略
即使暂时仍使用现有数据集进行研究,也可以采用一些策略来缓解问题:
- 对抗性评估:使用代码重构、变量重命名、语义保持的代码变换等技术,生成测试集的变体。一个健壮的模型应该在经过合理变换的代码上保持性能,而一个依赖虚假特征的模型则会性能骤降。
- 因果特征学习:探索如何让模型更多地关注与漏洞有因果关系的代码模式(如缺少边界检查的数组访问),而不是与漏洞共现的无关特征(如特定的变量名)。这可以通过解耦学习、因果干预等前沿机器学习技术来尝试。
- 评估指标的扩展:除了传统的分类指标,引入更能反映实用性的指标,如可行动性(模型是否能指出漏洞具体位置和类型?)、可解释性(模型的决策依据是否是人类可理解的代码模式?)。
5. 对研究与实践的启示
这项研究的结果对ML4VD领域的研究者和意图将相关技术应用于实践的工程师都有重要启示。
5.1 给研究者的建议
- 重新审视问题定义:在开始设计下一个复杂的GNN或Transformer模型之前,请先花时间思考你试图解决的“漏洞检测”问题在现实中究竟是什么样的。函数级分类可能是一个方便的起点,但绝非终点。
- 重视基准测试的缺陷:对BigVul、Devign等数据集的高分结果保持批判性态度。在论文中,应主动讨论上下文依赖性和虚假相关性对结果的潜在影响,并使用4.3节提到的策略进行鲁棒性测试。
- 探索新范式:勇于尝试4.1节中提出的更贴近实际的问题定义。虽然构建带上下文的数据集更费力,评估也更复杂,但这可能是推动领域产生实质性突破的关键。
- 跨领域借鉴:多向程序分析、软件测试领域的同行学习。他们数十年来在上下文敏感分析、条件生成、切片技术等方面的积累,能为ML4VD提供宝贵的先验知识和解决方案框架。
5.2 给安全工程师的提醒
- 对现有工保持合理期待:目前市场上或开源社区中,直接宣称能“扫描代码即得漏洞”的AI工具,其实际效果很可能被夸大。在关键系统中,绝不能将其作为唯一或主要的安全审计手段。
- 关注工具的“可解释性”:选择一个工具时,不要只看它报出了多少个漏洞。更要看它是否清晰地解释了为什么这里可能是漏洞(例如,指出了哪条数据流未经验证、哪个条件可能被触发)。一个能提供合理解释的工具,即使召回率稍低,也远比一个乱报高分但无法解释的“黑箱”模型有用。
- 将其作为增强手段,而非替代品:最有效的使用方式可能是将ML4VD工具作为传统静态分析工具(如Coverity, Fortify)和动态分析/模糊测试的补充。用它来对海量代码进行初步筛选,标记出需要人工重点审查的“可疑区域”,可以提升资深安全专家的工作效率。
机器学习为自动化漏洞检测带来了新的希望,但我们必须清醒地认识到,将复杂的软件安全问题强行塞进一个过于简化的机器学习框架中,可能会带来误导性的“进步”。这项研究敲响的警钟是:在追逐更高的准确率数字之前,我们首先需要确保自己正在解决一个正确的问题,并且用正确的方式去评估它。未来的道路在于建立上下文感知的、因果驱动的、贴近真实安全分析场景的评估体系,只有这样,机器学习才能真正成为软件安全工程师手中一件可靠而强大的工具。
