对网络协议做模糊测试时,测试器往往在一夜之间产生数千个崩溃转储文件。这些崩溃中真正有价值的漏洞可能只有十几个,其余大部分是同一个根因触发的大量重复样本。如果靠人工用调试器逐个打开分析,效率极低。用一个机器学习模型对崩溃自动分类,先去重再按可疑度排序,是目前 fuzzing 工程里非常主流的做法。本文以 Ruby 为主要开发语言,从特征提取到模型训练再到评估,完整讲一遍流程。

崩溃样本的特征提取与向量化
机器学习模型不能直接吃崩溃转储文件,需要先把每个崩溃样本转换成一个数值向量。对协议模糊测试来说,一个崩溃样本通常包含几类信息:崩溃类型(空指针读写、断言失败、堆损坏等)、崩溃时的调用栈、触发崩溃的输入前缀、以及进程状态(寄存器、内存映射)。
实际工程中最有效的特征往往来自调用栈。可以把调用栈中每一帧的函数名和所属模块抽取出来,构成一个类似“词袋”的特征空间:有多少个不同的函数名,就定义多少个维度,某个崩溃的调用栈里出现了这个函数,对应维度就置 1。再加上一些统计特征,比如栈深、崩溃偏移地址的哈希、输入长度等。下面是一个用 Ruby 提取特征的简单实现:
def extract_features(crash)
# crash 是一个结构体,包含 stack(函数名数组)和 meta(元信息哈希)
features = {}
crash.stack.each do |frame|
key = "fn:" + frame
features[key] = 1.0
end
# 统计特征
features["stack_depth"] = crash.stack.size.to_f
features["input_len"] = crash.meta[:input_len].to_f
# 地址归一化:只保留页内偏移,减少地址随机化的干扰
features["offset"] = (crash.meta[:fault_addr] & 0xFFF).to_f
features
end
def vectorize(crashes)
# 构建全局特征词典
vocab = {}
crashes.each do |c|
extract_features(c).keys.each { |k| vocab[k] ||= vocab.size }
end
crashes.map do |c|
vec = Array.new(vocab.size, 0.0)
extract_features(c).each { |k, v| vec[vocab[k]] = v if vocab[k] }
vec
end
end
这里有一个细节值得注意:现代操作系统普遍开启了地址空间随机化,直接用绝对地址做特征会引入大量噪声,所以示例中对出错地址做了页内偏移的截断处理。如果目标程序每次编译符号都不同,还可以对函数名只取哈希值的前几位做模糊匹配,提高模型在不同版本间的泛化能力。
标签体系设计与训练集构建
崩溃分类本质上是监督学习,需要人工先标注一部分样本作为训练集。推荐的标签体系分两层:第一层是“根因类别”,比如空指针解引用、越界写、UAF、断言失败、栈溢出等;第二层是“是否为重复崩溃”,即该样本是否与某个已知根因属于同一类。两层标签分别对应两个模型:一个多分类模型负责判断崩溃类型,一个聚类或二分类模型负责去重。
标注工作量可以通过“主动学习”的策略压缩:先用无监督聚类(比如对调用栈哈希做前 N 帧匹配的粗去重)把样本分组,每组只需要人工确认一个代表样本,组内其他样本自动继承标签。Ruby 标准库没有内置聚类,可以借助简单的编辑距离或 Jaccard 相似度手写:
def jaccard(a, b)
# a、b 是两个函数名数组,计算集合相似度
inter = (a & b).size.to_f
union = (a | b).size.to_f
union.zero? ? 0.0 : inter / union
end
def coarse_dedup(crashes, threshold = 0.7)
groups = []
crashes.each do |c|
# 只用栈顶前 5 帧,降低深层噪声影响
sig = c.stack.first(5)
found = groups.find { |g| jaccard(g[:sig], sig) >= threshold }
if found
found[:members] << c
else
groups << { sig: sig, members: [c] }
end
end
groups
end
这种粗去重的阈值需要根据目标程序调试。阈值太高会把不同根因的崩溃混在一起,太低则去重不彻底。实践中可以抽样检查几个分组的边界样本,观察调用栈差异再微调。一般来说,取栈顶前 3 到 8 帧做相似度计算是比较稳妥的范围。
用Ruby训练分类模型
Ruby 生态里最省事的 SVM 库是 libsvm 的 Ruby 绑定,安装后可以直接训练多分类模型。SVM 对高维稀疏特征(比如函数名词袋)效果好,训练样本在几千级别时速度也完全够用。
require 'svm'
def train_model(vectors, labels)
problem = Svm::Problem.new
parameter = Svm::SvmParameter.new
parameter.kernel_type = RBF
parameter.gamma = 0.01
parameter.c = 10
parameter.cache_size = 200
problem.set_examples(labels, vectors)
model = Svm::Model.train(problem, parameter)
model
end
# 使用示例
# labels: [0, 1, 2, 0, ...] 对应崩溃类别编号
# vectors: vectorize 输出的特征矩阵
model = train_model(vectors, labels)
prediction = model.predict(vectors.first)
puts "预测类别: #{prediction}"
如果崩溃数量更大、特征更复杂,也可以走神经网络路线,用 Ruby 的 tensorflow 或 torch.rb 绑定搭建一个简单的前馈网络。但对崩溃分类这种表格型数据任务,树模型和 SVM 通常是性价比更高的选择,没必要为了上深度学习而增加部署复杂度。
训练完成后务必做交叉验证。可以留出 20% 的样本作为测试集,统计准确率和混淆矩阵。特别要关注混淆矩阵里“低危类被预测成高危类”和反向的错误比例:前者只是浪费审计时间,后者会导致真正的漏洞被淹没在噪音里,代价更高。针对这种情况,可以在损失里给高危类别加权,或者在输出层之后加一个人工复核的规则:凡是栈中出现内存写操作相关函数且模型置信度低于阈值的样本,一律强制送人工。
把模型接入fuzzing流水线
模型训练好之后,最后一步是把它接到模糊测试的持续运行流程里。典型做法是写一个守护进程,监听崩溃输出目录,一旦有新样本落盘,立即提取特征、调用模型预测、更新去重分组,并把新出现的根因推送到通知渠道。这样第二天上班看到的不是几千个原始 dump,而是一份按可疑度排好序、每个根因只出现一次的清单。
还有一个工程细节:随着模糊测试持续运行,新的崩溃模式会不断出现,模型需要定期用新标注的数据增量再训练。可以把模型版本号与训练数据快照一起存档,一旦发现分类质量下降,就能回溯到某个具体的版本排查问题。整个流水线用 Ruby 写成 rake 任务或者 Sidekiq 后台任务都很方便,代码量通常不超过几百行,却能省下大量人工分析崩溃的时间。