简介这是一份面向IT安全学习者与代码审计研究者的CWE经典漏洞代码集配套神经网络训练实践素材旨在用真实缺陷样本帮助理解常见弱点的成因与识别方法。压缩包共365个文件、约7.33MB以290个Python源码为核心辅以pyd、dll、exe等运行依赖和txt、xml等配置说明便于直接搭建实验环境。内容围绕CWE分类选取代表性漏洞编写可触发缺陷的代码片段并标注CWE编号整理成结构化数据集再用于词嵌入、LSTM或Transformer建模、训练调参以及准确率、召回率、F1评估等环节。已有252人学习下载适合课程实验、安全教学与漏洞检测原型验证也能为代码审查工具和IDE插件中的实时安全检查提供可复用的样本与排错思路。1. 从 CWE 目录到训练语料先别急着选模型拿到「生成 CWE 经典漏洞代码集 神经网络训练」这个题目多数人第一反应是挑模型用序列模型还是图网络、要不要上预训练编码器。我在某公司的内部检测项目里踩过的反例正好相反——第一版语料用模板硬套两万条样本、正负比 1:1训练损失掉得漂亮验证集 F1 到 0.96拿去扫真实业务仓库前 50 个告警里能对上人工确认的不到 3 个。问题不在网络容量在语料模板生成器和验证器共用了同一套变量命名习惯模型学的是「看到 execute 后面跟加号就报警」。下面把这条链路拆开讲从 CWE 目录里挑哪些弱点、一个弱点拆几种触发形态、生成器怎么写变异算子、代码怎么变成 token 和 AST 张量、类别不均衡怎么处理、上线前必须做的跨弱点留一验证。适合已经在做代码审计工具、想自己造训练集的同行也适合手里只有公开数据集、想搞清楚标签为什么不可信的初学者。2. CWE 弱点选型与模板族设计哪些能合成哪些合成了也是噪声选哪些 CWE 进语料直接决定这个数据集最后能训出东西还是只能发个榜。我的判断标准只有一条这个弱点能不能在一个函数或一段切片范围内靠语法结构和有限的数据流信息判定出来。判定不了的弱点模型学到的只能是模板的外观。这条标准听起来粗暴但它能把「越界写」「命令注入」这类边界清晰的弱点留下把「竞态条件」这类必须靠运行时交错才能确认的弱点挡在门外——后者的正样本在静态视角下和正常代码几乎同构硬塞进去只会给标签加噪。2.1 用「函数内可判定性」筛弱点一张取舍表CWE 目录里有几百条能进训练集的其实就那么十来个。我筛的时候看三个维度判定信号是否落在函数体内、合成成本高不高、正负样本能不能在语法层面拉开差距。判定信号落在函数体外的比如需要跨多个模块追踪配置加载顺序的函数级模型天然看不见合成成本高的弱点意味着模板族写起来费劲几百条样本撑不起一个类别正负样本语法上拉不开差距的模型只能靠极细微的模式记忆泛化必然崩。CWE 编号弱点函数内可判定的信号合成成本是否纳入CWE-89SQL 注入外部输入到执行接口的路径上缺少参数化低是CWE-79跨站脚本未转义变量进入 HTML 拼接或模板渲染低是CWE-78命令注入外部输入进入 shell 调用低是CWE-22路径遍历用户输入拼进文件路径且未做规范化低是CWE-798硬编码凭证字面量直接赋给凭证语义的变量极低是CWE-327弱加密算法调用固定常量指定的弱算法极低是CWE-330弱随机数安全场景使用非密码学随机源低是CWE-476空指针解引用可能返回空值的调用未判空即解引用中是CWE-125 / CWE-787越界读 / 越界写索引或长度缺少边界检查中是CWE-190整数溢出算术表达式缺少范围约束中是CWE-502不安全反序列化不可信数据进入反序列化接口低是CWE-416释放后重用释放后仍存在别名引用高视情况CWE-362竞态条件共享状态缺少同步高否表里「合成成本」这一列不是拍脑袋来的成本低意味着一个模板配几个变量槽就能覆盖大部分形态成本高意味着要写状态机或者多线程时序才能造出可信样本。CWE-416 我标了「视情况」原因是纯静态的函数级模型很难确认别名关系一旦模板写歪正样本里就会混进「释放后没有任何引用」的假漏洞如果一定要做建议单独建一个小规模、高置信的子集别和主训练集混在一起算总指标。CWE-362 我直接排除。竞态条件的判定依赖两个线程的交错顺序静态代码里两个线程函数长得几乎一样正负样本的 token 分布高度重叠模型最后只能靠变量名碰运气。把这类弱点塞进语料指标会好看业务侧一用就露馅。提示选型阶段就砍掉不可判定的弱点比训练完发现某一类召回率长期在 0.1 附近再回头删数据成本低一个数量级。2.2 一个 CWE 拆几种触发形态模板族与难度分层只给每个弱点写一个模板生成出来的样本会高度同质模型三分钟就记住了。我的做法是每个弱点至少拆成五个触发形态形成所谓的「模板族」。以 CWE-89 的 Python 版本为例五种形态分别是直接字符串拼接、格式化插值f-string 与 format 各算一种写法、拼好后再赋给中间变量传给执行接口、由辅助函数返回拼好的语句、以及带不完整过滤的变体——比如只对单引号做替换、但没处理注释符注入依然成立。第五种是我最看重的形态也是整个数据集里最难的部分。它同时逼着模型去看「有没有过滤」和「过滤够不够」两件事而不是简单地看「有没有加号拼接」。真实业务代码里绝大多数漏报都发生在这一层开发者加了个 replace 就觉得安全了扫描器也这么认为。除了形态还要做难度分层我一般分三级。L1 是单函数内 source 到 sink 一条直线中间没有任何干扰。L2 是中间隔了一次赋值、一次函数调用或者一层条件分支模型必须顺着数据流走两步。L3 是跨函数传播加部分过滤sink 和 source 不在同一个函数里靠参数传递连起来。三个难度在训练集里的比例大致按 4:4:2 分配验证集和测试集里 L3 的占比要提到 4 成——不然测出来的分数跟真实场景差得远。模板的变量槽也要提前设计好别等到生成阶段现编。我常用的槽位有六个sink_api执行接口名、source_var外部输入变量名、wrap外层包裹结构函数、循环、try 块、filter_kind过滤方式无、黑名单、白名单、编码、depth传播层数、lang语言变体。这六个槽做笛卡尔积会爆炸实际生成时用随机采样加约束比如filter_kind白名单时depth强制设为 0因为白名单校验过的路径本来就不该判成漏洞。变量命名也得随机化。第一版语料翻车就翻在这里所有模板都用user_id、sql、cursor这几个词模型学到的判别边界其实是「变量名叫不叫 user_id」。重命名算子后面会讲但模板阶段就该把命名空间打散让每个模板族内部的标识符风格互不相同。2.3 用静态分析器反过来校验标签而不是靠人眼数行数合成语料最大的风险是标签噪声模板里本来就有过滤函数注入的「漏洞」根本走不到 sink或者两个形态几乎一样一个标了正一个标了负。靠人眼一行行看几千条样本不现实我的做法是写一组针对性的静态规则拿分析器反过来给合成样本打一遍分做三分类处理。规则不用追求完备召回优先宁可多报。以 SQL 注入为例一条能覆盖三种拼接写法的规则大致长这样rules: - id: py-sql-concat-sink languages: [python] severity: ERROR message: SQL 语句由外部输入拼接后直接执行 patterns: - pattern-either: - pattern: $CUR.execute(... $X) - pattern: $CUR.execute(....format(...)) - pattern: $CUR.execute(f...{$X}...) - pattern: $CUR.execute($SQL)$CUR匹配任意接收者对象...是分析器里的省略号元变量表示中间可以夹任意参数。第四条$CUR.execute($SQL)看上去太宽会把参数化的写法也报出来这正是想要的效果——让规则偏向召回误报留给后面的抽检环节收拾。扫描结果按三档处理。第一档是规则命中、抽检也确认为漏洞的直接进正样本池。第二档是规则没命中这类样本最值得看抽 5% 人工过一遍如果确实是漏洞说明规则写漏了补规则如果不是漏洞说明模板写错了回炉改模板。第三档是规则命中但人工判断不可达的——比如过滤函数在异常分支里抛了错sink 实际上执行不到——这类直接从正样本里剔除不要心疼。这套流程跑完正样本池通常会缩水一到两成。看上去亏了实际上把噪声最大的那部分清掉了。我在一个跨语言项目里做过对比同样两万条样本清理前后用同一个模型训练清理后的版本在真实代码上的误报量下降了一半多验证集指标反而没掉——说明之前那部分「高分」本来就是靠噪声撑起来的。3. 批量生成与特征化从模板到可训练张量到这一步弱点选完了、模板族设计完了接下来是把它们变成几万条能喂给网络的记录。这一步的工程量大但技术含量集中在两个地方变异算子写得对不对以及特征抽取时有没有把语义信息丢掉。前者决定样本的多样性上限后者决定模型的能力上限。3.1 生成器骨架与目录约定目录结构我固定成下面这样好处是后面的划分、去重、复现都能靠路径反查来源vulnset/ ├── templates/ │ └── cwe89/ │ └── python/ │ ├── t01_direct_concat.j2 │ └── t02_format_string.j2 ├── generators/ │ ├── render.py │ ├── mutate.py │ ├── featurize.py │ └── split.py └── data/ ├── raw/ ├── verified/ └── splits/模板用 Jinja2 写一个文件对应一个触发形态文件名前缀就是模板编号。下面是一个 CWE-89 的直接拼接模板带过滤开关def get_user_{{ uid }}({{ conn }}, {{ param }}): {{ cur }} {{ conn }}.cursor() {% if filter_kind blacklist %} {{ param }} {{ param }}.replace(, ) {% elif filter_kind whitelist %} if not {{ param }}.isalnum(): return [] {% endif %} {{ cur }}.execute(SELECT * FROM users WHERE name {{ param }} ) return {{ cur }}.fetchall()filter_kind取none时是标准正样本取blacklist时是难正样本过滤不完整注入仍然成立取whitelist时自动翻转成负样本。这样一个模板能同时产出正负两类省掉一半模板量。渲染脚本的核心逻辑不长但要处理几件事从路径推断 CWE 编号和语言、按约束采样变量槽、给每条样本打上模板编号和难度等级、写成一行业务无关的 JSONL。import json import random from pathlib import Path from jinja2 import Environment, FileSystemLoader env Environment(loaderFileSystemLoader(templates), trim_blocksTrue) def slots(rng, filter_kind): # depth 与 filter_kind 存在约束白名单过滤时漏洞不可达depth 归零 depth 0 if filter_kind whitelist else rng.choice([1, 1, 2, 2, 3]) return { uid: rng.randint(1000, 9999), conn: rng.choice([conn, db, session, pool]), cur: rng.choice([cur, cursor, stmt, c]), param: rng.choice([name, user_input, kw, q]), filter_kind: filter_kind, depth: depth, } def render_all(roottemplates, per_template40, seed7, outdata/raw/samples.jsonl): rng random.Random(seed) rows [] for tpl_path in sorted(Path(root).rglob(*.j2)): rel tpl_path.relative_to(root).parts # (cwe89, python, t01_direct_concat.j2) cwe, lang rel[0].upper(), rel[1] tpl_id tpl_path.stem tpl env.get_template(/.join(rel)) for i in range(per_template): fk rng.choices([none, blacklist, whitelist], weights[5, 3, 2])[0] s slots(rng, fk) code tpl.render(**s) label 0 if fk whitelist else 1 rows.append({ code: code, cwe: cwe, lang: lang, template_id: tpl_id, variant_id: f{tpl_id}-{i:03d}, difficulty: L1 if s[depth] 1 else (L2 if s[depth] 2 else L3), label: label, }) Path(out).parent.mkdir(parentsTrue, exist_okTrue) with open(out, w, encodingutf-8) as f: for r in rows: f.write(json.dumps(r, ensure_asciiFalse) \n) return len(rows)per_template控制每个模板的产出量40 是一个折中值太少模板族内部的多样性不够太多会把同一模板的变体灌满训练集模型容易过拟合到模板本身。seed必须固定并写进日志否则后面复现不出同一份数据集排查问题时连对照组都拉不出来。weights[5, 3, 2]是我常用的正负配比倾向正样本略多因为后续还有真实的负样本要补进来。3.2 变异算子制造难负样本而不是简单替换变量名很多人对「变异」的理解停留在把变量名换一换、加两行注释。这类变异对模型几乎没有挑战因为 AST 结构完全没变模型看到的是同一张图。真正有用的变异改动的是控制流和数据流让同一类漏洞在不同的上下文里出现。我常用的算子有六个。重命名算子打散标识符风格语句包裹算子把目标语句塞进 if 块、for 循环或者 try 块里改变缩进和上下文死代码插入算子在不影响语义的位置插几条无用语句制造噪音等价重构算子把a b拆成先赋值再拼接改变数据流的层数sink 替换算子在语义等价的不同执行接口之间切换比如execute换成executemany过滤插入算子往路径上塞一个校验函数如果校验是完整的标签翻转为负。重命名和等价重构用 AST 做最省事正则替换容易把字符串字面量里的词也改掉import ast class Renamer(ast.NodeTransformer): def __init__(self, mapping): self.mapping mapping def visit_Name(self, node): if node.id in self.mapping: node.id self.mapping[node.id] return node def visit_arg(self, node): if node.arg in self.mapping: node.arg self.mapping[node.arg] return node def rename(src, mapping): tree ast.parse(src) tree Renamer(mapping).visit(tree) ast.fix_missing_locations(tree) return ast.unparse(tree)visit_Name处理变量引用visit_arg处理函数参数两个都要覆盖否则改完变量名和参数名对不上代码直接语法错误。ast.fix_missing_locations不能省节点被替换后坐标信息会丢不加这行后续做切片或者报错定位时会崩。ast.unparse在较新的解释器上是标准库老版本需要装第三方反解析库替代。注意每次变异完必须重跑静态分析校验。语句包裹算子可能让漏洞变得不可达比如塞进了永远为假的分支过滤插入算子可能让标签翻转这两类变化不校验就会变成标签噪声。变异还有个容易被忽略的作用生成难负样本。同一段业务逻辑把 sink 从拼接改成参数化查询其余部分一字不动这就是一个高质量的负样本——模型必须精确地看到执行接口的参数形态变了才能判对。这类样本比随便找个正常函数当负样本有价值得多我一般会让它占到负样本总量的三到四成。3.3 token 通道与 AST 通道两条腿走路代码进模型之前要变成张量最直接的路是 token 序列第二条路是 AST 节点序列或者图。两条路我都保留后面做通道对比的时候有用。token 通道的关键是切分策略。用正则粗切的好处是可解释、不依赖外部词表调试的时候能直接把 token 序列打出来看import re TOKEN_RE re.compile(r[A-Za-z_]\w*|\d|[^\s\w]) def tokenize(src): return TOKEN_RE.findall(src)这个正则会把手写标识符保留成完整 tokenuser_input不会被切成user和input对识别命名风格有帮助。代价是词表会膨胀同一个语义的驼峰和下划线写法算两个 token。实际训练时更常见的做法是在这批语料上自己训一个 BPE 词表把出现频次低的标识符拆成子词词表规模压到两三万未登录词的问题能缓解不少。AST 通道我一般不用整棵树而是抽一条带深度的遍历序列import ast def ast_seq(src, max_nodes512, max_depth8): tree ast.parse(src) seq [] def walk(node, depth): if len(seq) max_nodes: return seq.append((type(node).__name__, min(depth, max_depth))) for child in ast.iter_child_nodes(node): walk(child, depth 1) walk(tree, 0) return seqmax_nodes控制序列上限超长函数直接截断短函数补零。max_depth截到 8 是为了把深层嵌套压平——真实代码里嵌套十层以上的结构不少深度不封顶的话同一类语法结构在不同深度的表示会分散得很厉害。遍历用前序 DFS不用 BFS因为前序序列保留了语法结构的嵌套关系BFS 出来的序列更像是节点类型的统计直方图。这条序列可以直接喂给序列模型也可以转成图AST 节点当顶点父子边加兄弟边再叠一条基于变量定义的边就成了一个简易的图神经网络输入。图通道能显式表达「这个变量在哪定义、在哪使用」对数据流类弱点SQL 注入、路径遍历的帮助比纯序列明显代价是构图和批处理麻烦不少。3.4 数据集划分按模板族切分堵住泄漏划分这一步比模型选择更能影响最终指标的可信度。最常见的翻车是随机划分同一个模板生成的 40 个变体随机打散后训练集里有 32 个、测试集里有 8 个。模型在训练时已经把模板背下来了测试集等于开卷考。我的做法是按模板编号做哈希分桶保证同一模板的所有变体只会落在同一个集合里import hashlib def bucket(key, mod10): h hashlib.sha1(key.encode()).hexdigest() return int(h[:8], 16) % mod def assign(template_id): b bucket(template_id) if b 7: return train if b 7: return val return testmod10切成 7:1:2比例可以根据样本量调样本上万之后 8:1:1 也够用。用 SHA1 而不是 Python 内置 hash是因为内置 hash 在不同进程间带随机盐跑两次结果不一样数据集划分就对不上了。光按模板切还不够。变异算子产出的样本可能和源样本高度相似比如只做了重命名的那些token 层面几乎一样。所以切完之后还要跑一次跨集合的指纹比对把相似度超阈值的样本从训练集里剔掉def shingles(code, k5): toks tokenize(code) return {tuple(toks[i:i k]) for i in range(max(1, len(toks) - k 1))} def jaccard(a, b): inter len(a b) return inter / max(1, len(a) len(b) - inter) def dedup(train_rows, holdout_rows, k5, thr0.8): holdout [shingles(r[code], k) for r in holdout_rows] kept [] for r in train_rows: s shingles(r[code], k) if all(jaccard(s, h) thr for h in holdout): kept.append(r) return keptk5是 5-gramthr0.8是我常用的相似度阈值。这两个值调低会让去重变激进训练集缩水严重调高会漏掉一些近似重复。经验上先把阈值设成 0.9 跑一遍看剔掉多少如果剔除比例超过两成回头检查变异算子是不是太保守了——产出全是一个模子说明多样性本来就不够。4. 训练类别不均衡、分项评估与阈值选取语料准备好之后训练本身反而是最快的一步。真正花时间的是调不均衡策略和定评估口径。这两件事做错模型指标再漂亮也没法用。4.1 基线模型与最小可跑训练循环基线我一般先用双向循环网络加注意力池化结构简单、训练快、好调试。等基线跑通、评估口径定下来之后再换更重的编码器做对比这样每次换模型都能确定收益来自结构还是来自调参。import torch import torch.nn as nn class SeqClassifier(nn.Module): def __init__(self, vocab_size, emb128, hidden256, layers2, dropout0.3): super().__init__() self.emb nn.Embedding(vocab_size, emb, padding_idx0) self.encoder nn.GRU(emb, hidden, num_layerslayers, batch_firstTrue, bidirectionalTrue, dropoutdropout) self.head nn.Sequential( nn.Linear(hidden * 2, hidden), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden, 1), ) def forward(self, x, mask): h, _ self.encoder(self.emb(x)) h (h * mask.unsqueeze(-1)).sum(1) / mask.sum(1, keepdimTrue).clamp(min1) return self.head(h).squeeze(-1)emb128是词表规模两三万时的常用起点词表更大可以加到 256。hidden256配两层参数量在千万以内单卡跑几万条样本大概几十分钟一轮。dropout0.3对合成语料来说偏保守因为合成样本本身就同质正则太弱容易记住模板。mask的处理要注意池化时必须按有效长度做平均不能直接对补零位置求平均否则长函数和短函数会被拉到同一个尺度损失掉长度信息。训练循环里我固定几个设置优化器用 AdamW、学习率 2e-4、batch 32、最多 20 轮、验证集指标连续 3 轮不涨就停。学习率上到 1e-3 时损失经常在前两轮震荡下到 5e-5 又收敛太慢2e-4 是个覆盖面比较广的值换模型之后微调一下就行。4.2 类别不均衡重采样、Focal Loss 与按 CWE 加权合成语料的正负比可以精确控制但真实场景里漏洞函数占比很低训练集如果按 1:1 配模型上线后阈值会严重偏斜。我的做法是训练集按 1:3 到 1:5 配负样本同时用 Focal Loss 压制易分样本的梯度。class FocalLoss(nn.Module): def __init__(self, alpha0.75, gamma2.0): super().__init__() self.alpha, self.gamma alpha, gamma def forward(self, logits, targets): ce p a hrefhttps://download.csdn.net/download/m0_74824254/88674356 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
阅读完成 · 觉得有帮助?