
1. 为什么“代码级对抗攻击”值得单独拎出来讲第一次接触“Code-Level Adversarial Attacks”这个词很多人会下意识把它和图像对抗样本、文本对抗样本归到一类觉得无非是把攻击面从像素、token 换成了代码而已。真上手做过一轮之后你会发现代码这个模态和图像、文本有本质区别图像是连续的、文本是离散但语义松散的而代码是离散、强语法约束、强语义约束、且执行结果可验证的。这三点决定了代码级对抗攻击既不能照搬 FGSM、PGD 那套梯度方法也不能简单套用同义词替换那套文本攻击思路。我最初关注这个方向是因为在做代码大模型的鲁棒性评估。当时用一批开源模型跑代码补全和代码摘要任务发现一个很反直觉的现象把变量名count改成cnt模型输出质量几乎不掉但把for循环改成等价的while循环或者把if (x 0)改成if (0 x)模型的补全准确率能掉十几个点。这个现象背后其实就是代码级对抗攻击要研究的核心问题——什么样的代码扰动对人类程序员来说语义完全不变但对模型来说却是致命的。所以这篇内容我想聊的不是某一篇论文的复现而是把“代码级对抗攻击”这个方向拆开讲清楚它的攻击面在哪、主流方法怎么分类、每种方法的实现细节和坑在哪、以及如果你要自己动手做一套评估流水线应该怎么搭。适合已经对对抗攻击有基本概念、想切入代码模态的读者也适合做代码大模型评测、代码安全审计的同行参考。下面所有内容都是基于公开研究和我在实际项目里的复现经验整理的涉及具体工具和参数的地方我会说明是常见实践还是我的个人选择。2. 代码级对抗攻击的核心分类与攻击面拆解2.1 从“扰动作用的对象”划分三大类代码级对抗攻击最直观的分类维度是看扰动作用在代码的哪个层面。我把它分成三类这个分法在复现时特别好用因为它直接决定了你用什么工具去生成扰动。第一类是标识符级扰动作用对象是变量名、函数名、类名、参数名这些标识符。典型操作包括重命名userName→u_name、大小写变换、加前后缀、同义替换。这类扰动的特点是生成成本极低一个正则加一个词典就能批量产出而且几乎不会破坏语法。但它的攻击效果也最不稳定因为现代代码模型对标识符的语义依赖没有想象中那么强很多时候重命名之后模型照样能补全。第二类是结构级扰动作用对象是控制流和语句结构。比如循环类型互换for/while/do-while、条件表达式等价变形a b→b ax y→y x、语句顺序调整在不改变数据依赖的前提下、插入等价的无操作语句如x x 0。这类扰动的生成难度中等需要做一定的程序分析来保证语义等价但攻击效果通常比标识符级强得多因为它改变的是模型看到的“代码形状”。第三类是语义级扰动作用对象是代码的实际行为但通过精心构造让行为在特定输入下等价、在其他输入下偏离。这类最接近传统对抗攻击的精神也最难做。典型手法包括插入死代码分支、构造浮点精度陷阱、利用未定义行为、在边界条件上做手脚。语义级扰动的生成需要求解器或者符号执行支持成本高但一旦成功往往能同时骗过模型和部分静态分析工具。提示新手建议从结构级扰动入手因为它的“投入产出比”最高——程序分析难度可控攻击效果明显而且生成的对抗样本在论文里也最好讲故事。2.2 攻击面的三个层次token、AST、执行轨迹如果换个角度从模型“看到”的表示来划分攻击面可以分成 token 层、AST 层和执行轨迹层。这个视角对理解为什么某些扰动有效特别关键。Token 层就是模型输入的原始文本序列。标识符级扰动主要作用在这一层因为重命名直接改变 token 序列。但要注意很多代码模型用的是 BPE 或者 WordPiece 分词一个标识符可能被切成多个 subword所以重命名的时候要考虑分词后的边界否则你以为改了一个 token实际上改了三个。AST 层是抽象语法树。结构级扰动主要作用在这一层因为循环互换、条件变形改变的是 AST 的节点类型和子树结构。这里有个坑不同解析器对同一段代码生成的 AST 可能不一样比如 Python 的ast模块和tree-sitter在某些边缘语法上的处理就有差异。如果你做的是跨语言攻击一定要固定解析器版本。执行轨迹层是代码运行时的行为序列。语义级扰动作用在这一层因为它保证的是“在特定输入下执行轨迹一致”。这一层的攻击最难评估因为你需要一个可靠的执行环境来验证等价性而很多代码片段本身就不是自包含的依赖外部库和运行时状态。2.3 为什么“语义等价”是这类攻击的生命线所有代码级对抗攻击都建立在一个前提上扰动后的代码对人类来说语义不变。如果语义变了那就不叫对抗攻击叫 bug 注入或者恶意代码篡改了。这个前提听起来简单做起来极难因为“语义等价”本身就是一个需要严格定义的概念。在实际操作中我一般用三个层次的等价性来约束语法等价扰动前后都能通过解析器不报语法错误。这是最低要求标识符级和结构级扰动都必须满足。行为等价在给定测试用例集上扰动前后输出完全一致。这是最常用的验证手段但要注意测试用例的覆盖率覆盖不到的路径上可能已经不等价了。语义等价在所有合法输入上行为一致。这是理论上的目标实际中几乎无法完全验证只能用形式化方法或者符号执行去逼近。我踩过的一个坑是早期做循环互换的时候只验证了正常输入下的输出没考虑异常路径。结果有一段代码在for循环里用了continue换成while之后continue的跳转目标变了异常输入下行为直接偏离。所以后来我养成了一个习惯任何结构级扰动都要跑一遍异常路径测试哪怕覆盖率低一点。3. 主流方法的技术细节与实现要点3.1 标识符重命名看起来最简单坑最多标识符重命名是入门级方法但真正做到“攻击有效且语义安全”并不容易。核心难点在于你怎么知道两个标识符是等价的。最朴素的做法是用同义词词典比如count/cnt/num、get/fetch/retrieve。但代码里的标识符往往有领域含义count在计数器场景和统计场景下可替换的候选词完全不同。我试过直接用通用同义词库结果生成了一批语义漂移的样本比如把balance改成equilibrium语法没问题但读起来极其别扭人类评审一眼就能看出是机器生成的。更靠谱的做法是基于代码语料训练一个标识符替换模型或者用代码搜索的方式找上下文相似的标识符。实际操作中我用的折中方案是先从目标代码库里统计标识符的共现模式构建一个领域相关的替换候选表再用规则过滤掉长度差异过大、命名风格不一致的候选。具体规则包括替换前后标识符的字符长度差不超过原长度的 50%命名风格保持一致camelCase 换 camelCasesnake_case 换 snake_case替换后不能与作用域内已有标识符冲突替换后不能是语言关键字或内置函数名这套规则跑下来生成的样本质量比纯词典方法高不少但代价是需要针对每个目标代码库做一次预处理。如果你要做的是通用评估可以预先在几个主流开源代码库上构建候选表然后复用。注意重命名的时候一定要做作用域分析。我见过有人直接把整个文件里的i全替换成idx结果把字符串字面量里的i也改了语义直接崩掉。用 AST 做精确替换是必须的正则替换只能用于快速原型。3.2 结构级扰动的等价变换规则库结构级扰动是我认为最值得投入的方向因为它有一套相对成熟的等价变换规则可以复用。下面这张表是我整理的核心规则库覆盖了大多数常见场景。变换类型原始形式变换后形式等价条件实现难度循环互换for (init; cond; step)init; while (cond) { ...; step; }循环体内无continue或continue跳转目标一致中条件交换a bb a两侧均无副作用低比较反转x yy x类型支持全序关系低语句重排stmt1; stmt2;stmt2; stmt1;两者无数据依赖且无副作用高无操作插入x f();x f(); x x;无低分支等价if (c) A else Bif (!c) B else A无低实现这套规则库的关键是依赖分析。语句重排和循环互换都需要知道哪些语句之间有数据依赖否则重排之后语义就变了。我用的是基于 def-use 链的轻量级分析对于单函数内的代码足够用跨函数的场景需要更重的分析一般评估任务里可以跳过。具体实现上我推荐用tree-sitter做解析因为它对多语言支持好而且能拿到精确的节点位置。流程是解析成 AST → 遍历找到可变换的节点 → 检查等价条件 → 生成变换后的代码 → 用测试用例验证行为等价。这个流程里最容易出错的是等价条件检查尤其是副作用判断。比如a b交换成b a如果b是一个函数调用且有副作用交换后执行顺序变了语义就不等价了。3.3 语义级攻击从符号执行到约束求解语义级攻击的门槛明显高一个档次因为它需要保证“在特定条件下行为等价”。我实际做过的两类是死代码分支注入和边界条件扰动。死代码分支注入的思路是插入一个永远不会执行的分支但分支条件写得足够复杂让模型在理解代码时被误导。比如def process(data): if len(data) 0 and data[0] data[0]: # 恒真条件 result compute(data) else: result None # 死代码 return result这个else分支永远不会执行但模型在生成补全时可能会被这个分支干扰。生成这类扰动的关键是构造恒真或恒假条件同时保证条件表达式本身看起来“合理”。我用过的方法包括利用类型系统的性质如x x对非 NaN 浮点数恒真、利用集合运算的性质如set(s) | set(s) set(s)、利用位运算的性质如x | 0 x。边界条件扰动更接近传统对抗攻击思路是在边界值上做文章。比如把if x 0改成if x 1对整数等价把if len(s) 0改成if not s对字符串等价。这类变换的等价性依赖类型信息所以需要类型推断支持。我用的是mypy的类型推断结果作为输入对于动态类型语言类型推断的准确率是个瓶颈实际中只能覆盖一部分场景。提示语义级攻击的验证成本很高建议先用符号执行工具如angr、KLEE做小规模验证确认等价性之后再批量生成。不要一上来就大规模跑否则验证阶段会把你拖死。3.4 基于梯度的攻击在代码模态的适配传统对抗攻击里的梯度方法能不能用到代码上答案是能但需要改造。代码是离散的没法直接求梯度所以要么用 Gumbel-Softmax 做连续松弛要么用强化学习做离散搜索。我试过 Gumbel-Softmax 的路线效果一般因为代码的语法约束太强连续松弛之后生成的样本大量不合法需要额外的投影步骤而投影本身又会破坏攻击效果。相比之下基于替换的贪心搜索更实用。思路是定义一个可替换位置集合比如所有标识符、所有比较运算符、所有循环关键字然后逐个位置尝试替换用模型输出的损失作为评分选损失最大的替换。这个方法计算量大但实现简单而且生成的样本一定合法。我一般会限制搜索步数比如最多替换 5 个位置否则时间成本不可控。还有一种折中方案是基于遗传算法的搜索把代码扰动编码成基因序列用模型损失作为适应度函数。这个方法在样本多样性上比贪心搜索好但收敛速度慢适合离线批量生成不适合在线评估。4. 实操流程从零搭一套代码对抗样本生成流水线4.1 环境准备与工具选型先说工具选型这块我踩过不少坑直接给结论解析器tree-sitter首选多语言支持好增量解析快。备选是各语言自带的解析器Python 的ast、Java 的javac但跨语言统一性差。程序分析轻量级用tree-sitter的查询功能自己写重量级用Joern或者CodeQL。评估任务里轻量级够用。等价性验证单元测试用pytest符号执行用angr二进制或KLEELLVM IR形式化验证用Z3。模型评估transformers加载代码模型vllm做批量推理加速。环境上我建议用 Docker 把每个语言的工具链隔离开因为不同语言的解析器和运行时依赖经常冲突。Python 环境用conda管理避免和系统 Python 混在一起。4.2 样本生成的核心代码骨架下面是我常用的生成流水线骨架以 Python 为例其他语言把解析和变换部分替换掉即可。import tree_sitter_python as tspython from tree_sitter import Language, Parser PY_LANGUAGE Language(tspython.language()) parser Parser(PY_LANGUAGE) def parse_code(source: str): tree parser.parse(bytes(source, utf8)) return tree def collect_rename_candidates(tree, source: str): 收集所有可重命名的标识符及其作用域 candidates [] query PY_LANGUAGE.query( (identifier) id ) captures query.captures(tree.root_node) for node, _ in captures: name source[node.start_byte:node.end_byte] if is_reserved(name): continue candidates.append({ node: node, name: name, start: node.start_byte, end: node.end_byte, }) return candidates def apply_rename(source: str, candidate: dict, new_name: str): 精确替换避免误伤字符串字面量 return source[:candidate[start]] new_name source[candidate[end]:] def verify_equivalence(original: str, mutated: str, test_cases: list): 用测试用例验证行为等价 orig_result run_with_tests(original, test_cases) mut_result run_with_tests(mutated, test_cases) return orig_result mut_result这段骨架的关键点是用字节偏移做精确替换而不是用字符串替换。字符串替换在标识符出现在字符串字面量或者注释里的时候会误伤用 AST 节点的字节范围就能避免这个问题。4.3 等价性验证的测试用例构造等价性验证是整条流水线里最容易被忽视、但最影响样本质量的环节。我的经验是测试用例的质量比数量重要得多。构造测试用例的时候我一般分三层第一层是正常路径用例覆盖函数的主要逻辑分支。这部分可以从代码库自带的单元测试里抽取或者用模型生成一批输入输出对。第二层是边界用例覆盖空输入、极值、类型边界。这部分需要人工设计因为自动生成很难覆盖到所有边界。第三层是异常路径用例覆盖异常抛出、错误返回。这部分最容易被忽略但恰恰是结构级扰动最容易出问题的地方。三层用例跑下来如果扰动前后输出完全一致我就认为这个样本通过了等价性验证。注意这里说的是“通过验证”不是“证明等价”因为测试用例永远不可能覆盖所有输入。对于评估任务来说这个强度足够了如果要做形式化保证需要上符号执行。4.4 批量生成与去重策略批量生成的时候会遇到两个问题样本重复和样本质量参差。样本重复的根源是同一个位置可能被多次替换成同一个候选或者不同位置的替换产生了相同的代码。去重我一般做两层第一层是代码文本的精确去重用哈希第二层是 AST 结构的去重把 AST 序列化之后做哈希。第二层能抓到文本不同但结构相同的样本比如两个不同变量名的重命名。样本质量参差的问题更麻烦。我的做法是给每个样本打一个“攻击强度”分数用模型在扰动前后的损失差来衡量。损失差大的样本优先保留损失差小的样本直接丢弃。这样能保证最终样本集里的每个样本都是“有效攻击”而不是凑数的。注意批量生成的时候一定要控制并发。代码解析和模型推理都是 CPU/GPU 密集型操作并发太高会导致内存溢出。我一般把并发控制在 CPU 核数的 70% 左右留出余量给系统。5. 常见问题与排查技巧实录5.1 扰动后代码无法解析怎么办这是最常见的问题尤其是结构级扰动。原因通常是变换规则没有考虑语法细节比如循环互换的时候忘了处理break语句导致生成的while循环里break跳转目标不对。排查思路是先用解析器定位报错位置然后对照变换规则检查该位置是否满足等价条件。我整理了一个速查表报错现象可能原因排查方法语法错误变换后缺少分号/括号对比变换前后的 AST 节点数缩进错误Python 类语言的缩进未调整检查变换后代码块的缩进层级作用域错误重命名后变量冲突跑一遍作用域分析类型错误比较反转后类型不匹配检查操作数类型5.2 等价性验证通过但模型攻击无效这个问题的本质是你的扰动没有触及模型的敏感区域。模型对某些扰动不敏感比如把count改成cnt模型可能根本不在乎。这时候需要换扰动类型或者加大扰动强度。我的经验是模型对结构变化的敏感度远高于标识符变化。如果你做的是标识符级扰动攻击无效是正常的换结构级扰动通常能立刻看到效果。另外扰动位置也很关键模型对函数签名、循环条件、返回语句这些位置的扰动更敏感对局部变量声明的扰动不敏感。5.3 生成速度太慢怎么优化生成速度的瓶颈通常在两个地方解析和验证。解析优化用tree-sitter的增量解析只重新解析被修改的子树而不是整个文件。这个优化能把解析时间降低一个数量级。验证优化测试用例的执行是串行的可以改成并行。但要注意有些测试用例之间有状态依赖不能无脑并行。我的做法是把无状态用例并行跑有状态用例串行跑。还有一个容易被忽视的点是缓存。同一个代码片段可能被多次解析和验证把结果缓存起来能省不少时间。我用的是functools.lru_cache加文件哈希做键简单有效。5.4 跨语言攻击的适配问题如果你要做多语言的代码对抗攻击最大的坑是不同语言的等价变换规则不一样。比如 Python 的for循环和 C 的for循环语义差异很大Python 的for是迭代器协议C 的for是计数器循环互换规则不能通用。我的做法是给每种语言维护一套独立的规则库共享底层的解析和验证框架。规则库的接口统一但实现分开。这样虽然前期工作量大但后期维护和扩展都方便。6. 评估指标与实验设计的一点经验做代码对抗攻击的评估指标设计比方法本身还重要。我见过不少工作只报“攻击成功率”但攻击成功率的定义五花八门有的用模型输出完全错误算成功有的用输出偏离参考结果一定阈值算成功横向对比根本没法比。我一般用三个指标组合攻击成功率ASR扰动后模型输出错误的比例。定义要明确我一般用“与原始输出的编辑距离超过阈值”作为错误判据。语义保持率SPR扰动后通过等价性验证的比例。这个指标衡量的是攻击的“合法性”SPR 太低说明你的扰动大量破坏了语义攻击本身就不成立。扰动幅度PM扰动涉及的 token 数或 AST 节点数。这个指标衡量攻击的“隐蔽性”幅度越小越隐蔽。实验设计上我建议至少做两组对照一组是不同扰动类型的对比标识符 vs 结构 vs 语义一组是不同模型的对比不同规模、不同训练数据的代码模型。这样能看出扰动类型和模型特性之间的交互关系比单一指标有信息量得多。还有一个容易被忽视的点是随机性控制。代码模型的输出有随机性同一个样本跑两次结果可能不一样。我一般固定随机种子并且每个样本跑多次取平均减少方差。如果算力允许跑 5 次取平均是比较稳妥的。7. 我个人在实际操作中的几点体会做这个方向一年多最大的体会是代码级对抗攻击的难点不在攻击算法而在等价性保证。攻击算法本身可以很简单贪心搜索、随机替换都能出效果但如果你不能保证扰动后的代码语义不变整个工作就失去了意义。我见过太多工作在这上面翻车生成的样本人类一看就知道语义变了评审直接质疑攻击的合法性。第二个体会是工具链的稳定性比先进性重要。tree-sitter不是最强大的解析器但它的稳定性和多语言一致性是最好的。我早期用过一些研究型的程序分析工具功能很全但版本一升级接口就变维护成本极高。后来全部换成tree-sitter加自研的轻量分析虽然功能少一点但胜在可控。第三个体会是评估要趁早。不要等样本生成完了才开始想怎么评估应该在设计扰动规则的时候就同步设计评估方案。因为不同的扰动类型适合不同的评估指标事后补评估往往发现指标不匹配返工成本很高。最后分享一个小技巧如果你要快速验证一个扰动想法是否有效不用搭完整流水线直接手写几个样本用模型跑一遍看损失变化就行。我很多想法都是先用这种“土办法”验证确认有效之后再工程化。这样能省下大量在无效方向上的工程投入。
拿不准这条消息跟你有没有关系?
工种不同、批次不同,要求可能差很多。打电话把你的情况说清楚,我们按信阳、平顶山本地的口径给你捋一遍。