☰
代码级对抗攻击:漏洞检测模型的脆弱性与攻防实践
2026/10/26 10:14:55 网站建设 项目流程

1. 代码级对抗攻击到底在解决什么问题

如果有人告诉我,给一段源代码改两个变量名,就能让训练好的漏洞检测模型把明显有问题的代码判成“安全”,我第一反应是不信。但当我真的复现出第一例代码级对抗攻击时,我意识到:代码模型在离散输入上的脆弱性,远比我们想象的普遍。

先给还不熟悉这块的读者打个底。所谓代码级对抗攻击,就是把对抗样本的思想从图像、文本搬到源代码上:给定一个训练好的深度模型(比如漏洞检测、代码分类、克隆检测),攻击者在原始代码上做尽可能小的高限制修改,比如重命名变量、插入等价语句、调整控制流,让模型的输出被翻转到目标类别,比如把“存在漏洞”判成“干净”,同时这段代码仍然可以正常编译,并且行为表现和原始代码保持一致。

对人类来说,这可能只是“代码风格不同”;对模型来说,这却是致命的认知偏差。要解决这些偏差,先得知道攻击是怎么落地的。这篇总结性质的文章,围绕代码级对抗攻击的工作脉络,梳理几种主流攻击路线,并结合实际复现经验给出评估指标、常见坑和后续建议。如果你是做 AI 代码安全、漏洞检测模型鲁棒性,或者只好奇“机器到底看到了什么”,都可以往下看。

1.1 先理解图像对抗样本是怎么迁移过来的

图像对抗攻击给大众的印象是“贴一张肉眼难辨的噪声贴纸,熊猫变长臂猿”。它的核心是:在高维连续输入空间找到一个小扰动 δ,让模型对 x+δ 的输出偏离正确答案。研究人员很快发现,深度学习模型普遍存在这样的漏洞,于是开始系统研究攻击方法、防御策略,以及鲁棒性的理论性质。

代码层面的对抗攻击,天然走的是另一条路。代码不是像素数组,而是离散 token 序列、AST 节点、符号表、数据流和控制流关系的组合。你没法在“第 3 个 token”上加一个 0.001 的实数扰动,改了就得改彻底。所以,代码攻击更像是在一个受语法和语义双重限制的编辑空间里搜索最优改动。简单说,对抗目标一样,但约束集完全不同。

这也是为什么很多人把代码攻击叫做“程序变换攻击”,它在形式上更接近软件混淆和代码改写,而不是经典梯度噪声。

1.2 核心约束:改完还得是同一段程序

做代码级攻击,最麻烦的不是让模型出错,而是让代码不出错。你改了一个变量名,得保证所有引用点都同步更新;你插了一段看似无关的代码,得保证不踩到未定义行为、不改变执行结果;你交换了两个分支,得保证逻辑等价。

我把这个约束总结成三条:

  • 可编译性:攻击后的代码必须能通过编译器,不能产生语法错误、类型错误或未定义符号。
  • 语义保持:在给定输入范围内,程序的可观测行为不能改变。严格来说要通过测试用例、差分执行甚至形式化等价性证明来确认,但工程上通常用“关键输入输出一致”来近似。
  • 扰动最小化:攻击者的改动面积要尽量小,否则很容易被人眼或静态扫描发现。

这三条之间其实是冲突的。改动越小,搜索难度越高;语义验证越严格,耗时越不可控。后续所有方法本质上都是在三角关系里做取舍。

1.3 这项工作到底值不值得投入

我的答案是:值得,而且相当值得。代码级对抗攻击并不只是“打比赛”或者炫技,它直接关系到 AI 辅助代码分析的产品化落地。现在很多公司已经把深度模型放在漏洞扫描、代码评审、恶意脚本检测的前置链路里,如果攻击者知道模型内部结构或至少知道决策边界,那么通过几个不显眼的等价代码变换就能绕过模型,后续的人工审计压力会剧增。

另一方面,攻击工作是鲁棒性研究的“磨刀石”。一个攻击方法如果能稳定骗过现有模型,那它同时也在告诉我们:模型的决策依据存在什么问题,哪些特征是表面相关、哪些才是决定性证据。把这个信息反向利用起来,可以做对抗训练、数据增强、鲁棒性评测。所以我在实际工作中,通常把攻击实验放在“建立信任链”的第二步,先把模型打穿,再想办法补墙。

2. 主流攻击路线的横向拆解

在整理相关工作的时候,我倾向于不按论文发布时间排,而是按“扰动是怎么找出来的”来归类。这样更容易看出技术演进和各自的适用场景。大体上,可以分成四类。

2.1 白盒梯度思路:理论上优雅,落地时磕碰

经典图像对抗攻击依赖梯度,因为损失函数对输入是连续可导的。如果把同样的逻辑直接套到代码上,第一反应是:先用模型对某个嵌入输出的结果算梯度,再把梯度信息映射回离散编辑空间。听起来顺理成章,做起来有几个坎。

首先是离散问题。代码 token 被映射成 embedding 向量后,你可以在连续空间朝梯度方向挪动,但挪动后必须重新量化为某个合法 token。这个量化过程会引入大量噪声,经常出现“梯度指了个方向,但实际 token 替换后模型根本没反应”的情况。另一个问题是代码修改的合法性:模型未必按语法约束来做量化,它可能推荐一个在语法上完全不合理的 token 组合。

所以白盒方法在代码领域,通常不是端到端直接用的,而是作为“重要度计算引擎”嵌入到搜索框架里。比如用梯度或注意力给 token 排序,然后从这个候选优先级列表里做启发式替换。这样做既保留了梯度信息的指导性,又避开了“连续到离散”的致命误差。我自己的体会是:别指望纯白盒一次到位,把它当“指点方向的前置筛选器”效果更好。

2.2 基于搜索与采样的黑盒方法:更容易落地的路子

黑盒攻击不碰模型内部,只通过大量查询“改完这段代码后模型输出变成什么”,从而判断哪些改动有效。这个思路跟模糊测试很像,随机或者用一定规则产生候选,保留那些能提高攻击成功率的改动,迭代累积。

把采样推进到证据层面的是 Metropolis-Hastings 这类马尔可夫链蒙特卡洛方法。具体到代码上,可以这样理解:设置一个能量函数,能量高代表“模型置信度偏向目标类别”,然后以一定概率接受或者拒绝一次代码变换。这种接受/拒绝机制让搜索不会只掉进局部最优,在初期能稳定跑出不错的结果。我也用过遗传算法做变体,粗暴但有效,缺点是迭代轮次多、查询量非常大。

这类工作的共同价值点是“不需要模型梯度”,因此可以用于那些只给出置信度甚至只给出类别标签的封闭服务,更接近真实世界攻击场景。代价也很明显:每次改动都要重新编译、重新跑测试,执行时间分分钟飙到不可控。

2.3 改写式攻击:把方言换成另一种方言

如果把“攻击”看成“改写成不同但仍等价的表达”,就会自然想到利用预训练模型。代码BERT、CodeT5 这类模型对代码结构有极强的上下文建模能力,把它们用作生成器,针对某个位置做 mask 填空,或者做整句 paraphrase,可以生成一批更自然、更拟人的等价代码。

我印象最深刻的路径是把代码片段抠出来,用 MLM 模型预测候选替换 token,再用一个打分模型做排序。好处是替换结果看起来非常自然,不是那种一眼假的“插入一堆垃圾变量”;坏处是生成式的搜索空间同样很大,而且预训练模型本身也可能被带到沟里,生成出的“等价代码”并不等价。所以这条路线要配严格的语义校验和语法检查,否则实验数据会很虚。

2.4 图结构建模带来新的攻击视角

代码可以建模成抽象语法树、控制流图、数据流图、程序依赖图等结构化形式。当模型用 GNN 在这些图上做推理时,攻击者的操作对象就从“一串 token”变成了“一个图”。这带来一个很有意思的视角:能不能改动一个 AST 节点,让它在图传播时产生连锁影响,从而改变 GNN 对整段代码的判断?

这方面的工作通常和代码混淆技术高度耦合。比如对控制流做扁平化、对布尔表达式做等价变形、对循环做展开或者合并。这些变换在代码混淆领域已经积累了非常丰富的经验,现在相当于把它们从“增加阅读难度”重新定义为“扰乱深度模型的表征”。我自己的观点是,图结构攻击和 token 级攻击并不互斥,实际攻击往往是两层同时做:既改 token 序列,又改图结构,才能得到稳定有效的对抗样本。

3. 从零开始复现一个代码级攻击实验

纸上谈兵的分类讲再多,不如自己亲手跑一轮实验。这一部分我会从头到尾展示一个可复现的工作流。为了方便对齐,我给实验设定一个具体任务场景:二分类漏洞检测模型,输入是 C 语言风格的函数源码,输出是“安全”或“含漏洞”。攻击目标很简单:给定一个已经被模型判定为“含漏洞”的正样本,在尽量少改代码的前提下,让它被模型判成“安全”。

这个设定比较直观,也正好覆盖了代码级对抗攻击中最常见的应用场景。如果你手头的任务是代码克隆检测、算法分类,方法框架基本可以平移,只是候选变换列表要换血。

3.1 准备模型、数据集与验证环境

第一步是确认攻击目标长什么样。我建议至少选两类模型来对着打:一类是 token 级编码器,比如基于 BERT 架构的代码模型,输入是 token id 序列;另一类是图结构模型,输入是 AST/CFG 转换出的图。为什么要两个模型?因为代码攻击的可迁移性常常不理想,在 token 级模型上成立的方法,换到图模型上可能直接失效,反过来也一样。把这个差异放在一个实验矩阵里,你会看到很多有趣的现象。

数据集选择上,我常用的组合是公开的 CWE 漏洞类数据集和开源仓库切出来的函数级样本。注意正负样本比例要控制好,否则模型很容易靠“行数多就是漏洞”这种东西偷懒,后续攻击实验也会失真。验证环境必须包含两部分:

  • 编译检查:用 gcc/clang 做语法和链接验证,至少保证攻击样本能被编译成可执行文件。
  • 行为验证:准备一组代表性测试用例,记录原始程序和攻击后程序在这些用例上的输出差异。我的做法是做一个差分执行脚本,把两边的 stdout、返回值、特定变量的最终值全部拉出来对比。

3.2 定义变换、约束和攻击入口

所谓变换集合,就是攻击者允许使用的“武器库”。我不会一上来就上控制流扁平化这种狠活,而是从一个基础集合开始:

# 候选变换:基于 Python ast 做源码级别的修改 import ast import copy class ProgramTransform: def __init__(self, source): self.source = source self.tree = ast.parse(source) def rename_variable(self, old_name, new_name): # 重命名需要收集所有 Name 节点,同步替换,不能只改一处 for node in ast.walk(self.tree): if isinstance(node, ast.Name) and node.id == old_name: node.id = new_name def insert_dead_code(self, side_effect_free_stmt): # 在函数末尾插入一段不可达或语义无关的语句 # 注意不能引入未定义变量,不能改变作用域 pass def swap_branches(self): # 把 if 的两个分支对调,并把条件取反 # 必须确保两个分支都具备执行条件 pass

这段代码只是骨架,但你注意看,每个变换函数的第一步都是“保证语法树可被重新拼接”。我遇到过太多新手直接做字符串替换,结果把变量名换到字符串字面量里面去了,程序行为变得一团糟。所有变换都要基于语法树,不要基于纯文本,这是第一条铁律。

约束检查器是另一个关键入口。我通常会写一个函数verify_program(orig, new, test_cases),它负责做编译、执行和输出对比。只有通过了这一步,这次修改才算“合法候选”。

3.3 攻击循环与评估指标

有了武器库和检查器,攻击主体就是一个循环。下面这份伪代码是目前我跑得最顺的一个框架,适合查询量受限的黑盒场景:

def attack_loop(orig_src, model, verified_cases, max_iter=1000): current = orig_src for i in range(max_iter): cands = transform(current) for cand in cands: if not verify_program(orig_src, cand, verified_cases): continue if model.predict(cand) == target_label: return cand, i # 从合法候选中选出置信度偏离最大的一个继续迭代 current = select_best(cands) return None, max_iter

这里select_best负责维持一个“当前最优样本”,比如让模型对目标类别的置信度最高,或者让改动距离最小。所谓改动距离,我用的是 AST 编辑距离加上 token 级的替换数量,两个指标同时参考,避免某一种失真。

评估指标方面,强烈建议把下面这些全部记录下来,不要只报一个“攻击成功率”:

指标含义我为什么要看它
ASR攻击成功的样本比例代表方法是否有效
平均修改 token 数每个样本改了多少个 token代表扰动是否足够隐蔽
平均查询次数成功攻击需要多少次模型调用代表方法是否可落地
语义保持率通过差分执行的样本比例代表攻击样本是否合格
可迁移率在模型 A 上生成的样本,能否攻击模型 B代表攻击的泛化能力

不要只看第一个指标。因为构造一个“能骗过模型但代码已经跑不起来”的对抗样本,技术含量和实用价值都很低。真正有价值的攻击,要像语法正确的方言一样,说得地道、让人能听懂,才能骗过另一个“读者”。

3.4 实验记录参考

我把一次典型实验跑完后,表格大概是这样的:

  • 模型 A(token 级 BERT):ASR 82%,平均修改 4.6 个 token,平均查询 130 次,语义保持率 96%。
  • 模型 B(图结构 GNN):ASR 66%,平均修改 3.1 个 AST 节点,平均查询 510 次,语义保持率 91%。
  • 模型 A 攻击样本迁移到模型 B:ASR 只有 23%。

这个结果出来之后,我的判断是:这次攻击方案过度依赖 token 级特征,没有真正抓到本质。下一步就得调整变换策略,比如加重控制流变换、加入更多语义等价的表达式变形,再重新跑。慢是慢,但每一轮都能把问题看得更清楚。

4. 实操中踩过的坑与排查思路

这部分我要写的是我在反复复现和扩展攻击方法时真实遇到的坑。每一条都不只碰到过一次,可以说是代码级对抗攻击的“泥泞地带”。

4.1 编译能过,但程序行为变了

这是我最早也是最痛苦的坑。一段代码经过变量重命名、死代码插入之后,编译顺利通过,测试用例却变得输出错误。原因往往特别隐蔽:

  • 变量名遮蔽了内建函数或库函数;
  • 插入的死代码里有一个函数调用,恰好产生了副作用;
  • 交换分支时只改了条件,没有同步改分支里的依赖变量。

排查手段只有一个:构造更严格的差分测试。把输入覆盖到正常值、边界值、异常值,至少保证每个分支都被走到。如果业务场景敏感,甚至要跑符号执行来验证某些路径等价。经验之谈:不要为了跑得快就只测一组输入,不然你发布的对抗样本很容易被人拆穿,不满足“语义保持”最基本的承诺。

4.2 模型扰动“不为所动”:为什么改了半天不生效

另一种让人抓狂的情况是:代码明明变了,模型输出却纹丝不动。这时候多半不是攻击方法没用,而是你根本没打中模型的“决策关键点”。

  • 如果模型是卷积式的序列模型,可能会对局部 token 模式敏感,而不在意变量名。
  • 如果模型是基于图结构的,那么改动一个普通赋值节点的影响,可能远小于改动控制流节点。
  • 如果模型在训练时做过数据增强,普通的死代码插入可能已经被“免疫”了。

解决办法是做一个“特征归因”步骤。拿一小批样本出来,用注意力权重或梯度信息看模型到底关注哪些位置。你会在实践中发现,模型经常盯着某些固定的关键词,比如漏洞模式里的memcpy、strcpy,或者盯着某几行特殊算式的文本结构。攻击的时候先把火力集中到这些位置上,改动效率会高很多。

4.3 可迁移性与评测口径的偏差

代码级对抗攻击一个特别容易出的问题,是“评测指标好看,实际迁移性差”。你在一个开源模型上做实验,攻击成功率动不动就 90%,你以为局势大好,结果换到另一个同任务模型上,立刻掉到 20% 以内。

这里面的核心原因是决策边界不一致。不同模型的学习到特征可能差距很大,尤其在代码数据集比较小、任务比较简单时,很多模型都有“撞题”式学习的嫌疑,就是根本没理解程序语义,只是记住了某些局部模式。

所以,做攻击工作的评估矩阵里,至少得有三个维度:目标模型内部效果、跨模型迁移效果、跨数据集迁移效果。我给自己定的最低要求是:至少要在两个不同架构的模型上测出可迁移率,再谈方法有效。否则你的突破只能算是“针对某个模型的过拟合攻击”。

4.4 等价性验证的“成本陷阱”

最后这个坑比较现实,最磨耐心——等价性验证太贵了。一个几千行的真实函数,每次变换都要重新编译、跑差分执行,很多攻击方法一天只能处理几十个样本。如果再把对抗训练意图考虑进去,训练集就是十万级,这个速度根本跑不动。

我在这块的经验是分层次控制验证成本:

  • 第一层是静态检查:语法、类型、作用域,发现明显错误直接淘汰;
  • 第二层是轻量差分:只跑几个关键正例和边界输入;
  • 第三层是完整差分:所有测试用例、异常输入都覆盖,只在最后一轮候选样本上做。

没有哪个领域能完全绕开计算成本,但至少可以用这种分层策略,把有限算力花在真正有价值的候选上。

5. 后续工作方向与个人体会

这个方向虽然偏研究,但离工程并不远。我自己接下来的计划里,有三类工作是马上要推进的。

5.1 从攻击走向防御与鲁棒评测

攻击方法最自然的后续就是对抗训练。把生成的对抗样本混进训练集,让模型在“代码语义保持但表达发生了变化”的情况下也能做出正确判断。这样训练出来的模型,不只是在攻击对抗上更抗打,在真实的代码风格迁移、混淆代码扫描等场景下,鲁棒性也会明显提升。

另外,我认为代码级对抗攻击应该被纳入更标准的模型评测流程。现在很多代码大模型发布时只报 pass@k 或者准确率,但完全不测“给一段代码做小改动后输出是否稳定”。这个空白要是能被补齐,对整个代码智能都是好事。

5.2 我在代码级对抗攻击实验里的一些心得

如果你准备上手做这个方向,我的建议是:别一上来就追新方法,先把最简单的启发式变换跑通,把验证链路做扎实。很多看起来朴素的操作——变量重命名、死代码插入、分支交换——已经能构建出很强攻击性,真正限制效果的不是技巧不够高级,而是“你能不能在每次修改后快速验证代码是否真的等价”。

我自己实测下来非常稳的一条路径是:先把一个漏洞检测模型打到 ASR 70% 以上,然后停下来,认真分析那些“改不成功”的样本,你会发现它们往往比成功的样本更有价值。它们暴露了模型真正鲁棒的那部分特征,而这正是防御和加固需要的信息。攻击实验到这里,就不只是找漏洞,而是在给模型画一张“认知边界图”。

最后分享一个小技巧:每次生成对抗样本后,把原始代码和攻击代码并排放在一起做一次人工盲审,让不熟悉这个模型的人去猜哪个是恶意代码。如果对方能一眼看出来,说明扰动还不够自然。这个“人工盲审”成本不高,但对攻击质量的说服力,比很多指标都强。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询