定位:精确哈希回答「这两份文件是不是同一份」,近似匹配回答「这两份文件是不是同一份东西的两个版本」。它是从「已知的坏样本」追到「未知变体」的唯一可行路径,也是 malware family 归并、文档抄袭检测、已知样本库比对的技术底座。
与本板块其他文章的关系:哈希与完整性校验 讲的是精确匹配的单向性与碰撞;本文讲的是相似性度量的构造、参数选择与阈值判定。两者共用「哈希」这个词,但解决的是相反的问题:前者要证明两个对象相同,后者要证明两个对象相似。
读者前提:已理解 取证概念与分类 中的证据类型划分,并能使用命令行做基本的文件提取。
关键词:模糊哈希 · ssdeep · sdhash · TLSH · 相似度阈值 · 抗碰撞 · 归并簇 · 变体识别
一、概述
哈希与完整性校验 里的 MD5 / SHA-256 有一个前提:它是对完整字节序列的一次性摘要。这带来两个后果,第二个才是近似匹配存在的理由。
第一个后果是任何一个字节不同,结果就完全不同。这不是概率问题,是构造问题——SHA-256 的设计目标就是让任何微小差异引发雪崩效应。真实案件里,一份可疑程序几乎不可能与样本库里的已知样本字节级一致:PE 头 TimeDateStamp 自动填入编译时刻,开发机不同则嵌入 .rdata 的 PDB 路径不同,VS_VERSION_INFO 里的资源时间戳每次打包都变,嵌入 .rdata 的 C2 地址字符串换一次即不同,字符串逐字符 XOR 且密钥每次随机会让主体代码全变,在指令间插入 nop / jmp 让长度与偏移全变,UPX、VMProtect 或自定义壳会把整个 .text 段重写,符号剥离则改变调试目录。
用一个字节的不同去比对两份 8 MB 的可执行文件,得到的两个摘要没有任何可用的相似度信息。结论是:只要对方重新构建过一次,精确哈希就完全失效,哪怕改动只有几十字节、只占总体积的 0.01%。
第二个后果是精确哈希无法回答「这份文件像不像我见过的某份」。它是集合成员判定(set membership),不是相似性判定,根本没有「部分匹配」这个概念。
近似匹配(approximate matching,也称 similarity / fuzzy hashing / 模糊哈希)要回答的问题可以分成三类,而它们对算法的要求不同——用同一个阈值处理这三类问题,是实践中最常见的错误来源。
第一类是嵌入检测(is it embedded?),判断目标对象内部是否嵌入了 master 对象的一个片段,典型场景包括在一个 4 GB 的磁盘镜像里找已知样本库中某个 DLL 的任何片段、在一个可疑程序的 .data 段里找某个解密 key 或某段 shellcode、在一个 PCAP 的重组流里找已知恶意 payload。
它的特征是「长对象找短片段」:master 对象短、target 对象长,二者的相似度天然偏低,因为相似度函数通常用「共同片段数 / master 片段数」做分母。
第二类是变体识别(are these the same family?),判断两个同量级的对象是不是同一源的不同构建,典型场景是样本库里存着 3 个已知样本而检材里有 40 个可疑文件要归并成变体簇,或者判断一批勒索软件的加密器组件是不是同一个家族。它的特征是「长度相近的完整对象」,这是最主流的用法,也是 ssdeep 表现最好的场景。
第三类是内容定位(where does it appear?),不判断整体相似,而是定位「已知内容的哪个部分出现在目标的哪个位置」,用于恢复被切分的证据:从一个巨型 dump 里切出完整的 PE 文件、从一段 HTTP 响应体里剥出原始文件、从内存里的碎片中重建上传的文件。它的特征是「需要偏移信息,不只要布尔结论」。
必须放在开头讲清楚的是:相似不等于同源。两个文件相似只有三种可能。第一,确实同源,即同一份代码的不同构建,或同一份文档的不同版本。第二,共用的模板或库,即都调用了同一个库、用了同一个 SDK 模板、都是同一份协议实现的变体。第三,算法伪影,即两段内容都很短(比如 64 字节),或者都很稀疏(比如大段零字节),使得哈希块天然大量重合。
后两类是系统性的、可预期的,不是异常现象。Python 生成的 Flask 项目、Qt Designer 生成的界面、AutoIt 编译的脚本、.NET 的 Assembly 头部这些共用模板,相似度天然在 80 以上,但彼此之间没有源码继承关系;两个 100 字节的配置文件即使内容完全不同,只要分块策略在这么小的对象上退化成「整块一个哈希」,相似度也可能很高;
一个 PE 文件里的 .bss 段(全是零字节)、未初始化的堆块、被清零的密码学缓冲区,这些区域对任何分段哈希算法都会产生大量重复块。
所以近似匹配的输出是「相似度分值」,不是「同源判定」。把分值写成结论,是这里唯一会造成冤错的方向性错误,4.14 专门讨论这个问题。
二、核心原理
2.1 相似度函数的三个基本要求
一个可用于取证的相似度函数必须同时满足三条。缺任何一条,它就只适合实验室,不适合办案。
要求一:对局部修改鲁棒(robustness)
文件中间插入一段无用字节后,相似度应当基本不变。插入点附近的一两个哈希块会变,其余块不受影响。
反过来,如果算法对全局变化敏感(像精确哈希那样),就没有意义。
要求二:具备缩放不变性(scalability)
必须能在数 TB 的镜像上跑完。含义是:
- 分块与哈希过程必须是 O(n) 时间
- 匹配阶段不能是 O(n×m) 的全量两两比较
- 索引结构必须能按分值剪枝,而不是把 1 亿个摘要全部载入内存
要求三:可复核(verifiability)
- 分值必须能由任何第三方用公开算法独立算出
- 算法不能有隐藏参数影响输出
- 相同输入必须得到相同输出(跨版本、跨平台)
第三条是本主题与一般相似性度量最大的区别。
一个用机器学习训练出来的向量相似度模型,即使效果很好,也不能用于取证——法庭上无法向对方解释「为什么这两个文件是 87% 相似」。
2.2 分段哈希:把文件切成块再逐块哈希
所有主流近似匹配算法都建立在同一个骨架上:分段哈希 + 块序列比较。
文件字节流
↓ ① 按固定步长(rolling window)切出候选块
↓ ② 对每个块算校验和 + 弱哈希
↓ ③ 相邻块弱哈希满足条件时,记录「边界哈希」= 校验和 + 前一块弱哈希 + 长度
④ 得到一个定长摘要序列:H1, H2, H3, ... Hn
↓ ⑤ 比较两个摘要序列,算相似度分值
关键设计在第 ③ 步:不是每个滚动窗口都记录,而是只在满足条件时记录。这个条件由两个参数控制:
- 块大小(block size / spamsum 参数) —— 滚动窗口的字节数
- 触发阈值(hash trigger / piece length) —— 何时认为「块发生变化了」
三款主流算法的参数默认值不同,这是它们分值不可直接比较的根本原因(见 4.2)。
2.3 ssdeep:上下文触发分段哈希
ssdeep(Context Triggered Piecewise Hashing, CTPH)由 Jesse Kornblum 在 2006 年 DFRWS 提出,最初是为反垃圾邮件设计的,后来成了事实上的通用标准。
工作流程:
- 以 3 个字节为步长滚动窗口,每个窗口算一个弱哈希(hash)
- 维护一个「哈希基线窗口」,用
HASH_PRIME 构造的 7 个候选哈希字符查表
- 当滚动哈希命中 7 字符表中的任一字符时,判定为块边界
- 记录该块的摘要:
校验和(2 字节) + 前一块滚动哈希(1 字节) + 长度
- 重复直到文件结束
7 字符触发表是 ssdeep 的核心设计。它利用 64 位素数 HASH_PRIME = 0x01000193 的性质:插入一字节后,需要滚动约 7 字节才能恢复原有哈希值。
这就让块边界的定位不依赖字节对齐,插入操作最多影响相邻一个块。
输出格式:
3:<块摘要序列>:<文件名>
3: 是版本号,后面是逐块摘要,每块 2 个十六进制 + 1 个有效载荷字符。
用不上的场景也有两类:
- 要求最小文件大小 —— 默认块大小 3 字节(对应 2 字节摘要),理论最小 64 字节。小于 64 字节的文件无法生成 ssdeep
- 对加密载荷完全失效 —— 密文的字节分布是均匀随机的,滚动哈希分布也是均匀的,块边界退化为随机切分,两份不同的密文会产生完全不同的块序列。压缩包同理,除存储模式(store)外的压缩流是伪随机的
块大小是自适应的:每块独立记录长度,文件任意位置插入不改变后续块的内容,只改变插入点附近。
ssdeep 有 3 组参数(3: / 96: / 48: 前缀),默认只用 3::
| 前缀 |
块大小 |
适用 |
分值范围 |
3: |
3 字节 |
默认,最鲁棒 |
0–100 |
48: |
48 字节 |
中等大小文件 |
0–100 |
96: |
96 字节 |
大文件(>1 MB),块数少、速度快 |
0–100 |
跨组比较分值没有意义。3:abc:file 与 48:abc:file 的 abc 不是同一个东西。
2.4 sdhash:归一化分段与相似度估计
sdhash 由 Vassil Roussev 开发,设计目标是修正 ssdeep 的两个问题:块大小固定(不随文件长度调整)与相似度计算不对称。
块大小的归一化:
sdhash 的块大小随文件长度按 2 的幂次缩放:
块大小 = 2^ceil(log2(文件长度 / 目标块数))
目标块数默认 2048。这意味着:
- 小文件 → 块小 → 分辨率高
- 大文件 → 块大 → 速度快
- 块数大致恒定,因此分值在不同大小的文件之间可比
相似度计算:
sdhash 不是简单统计「共同的块」,而是估计两个文件的最长共同子序列的相似度。它定义:
相似度 = 2 × (共同片段估计值) / (文件 A 的片段数 + 文件 B 的片段数)
用调和平均式的分母,使得相似度天然对称 —— sim(A,B) == sim(B,A)。这与 ssdeep 的非对称定义形成对比(见 2.6)。
ddde 扩展:
sdhash 家族还有一个变体 ddde(differentially-differentially-differentially...),它额外输出差分摘要——对文件做一阶差分(相邻字节作差)后再算摘要。
这个技巧对固定间距的字节插入/删除特别鲁棒:插入等长内容后差分结构基本不变。
怎么挑:
| 目标变异方式 |
推荐算法 |
| 无害字节插入 / 编译差异 / 资源变化 |
ssdeep(3:)或 sdhash |
| 固定间距的插入或等长替换 |
sdhash 的 ddde 变体 |
| 需要对称分值、跨文件大小可比 |
sdhash |
| 大批量初筛、追求速度 |
TLSH 或 ssdeep 的 96: 组 |
2.5 TLSH:面向文件系统的多线程友好设计
TLSH(Trend Micro Locality Sensitive Hash)设计目标与前两者不同:它不是「最准」,而是「在大文件系统上够快且够用」。
核心机制:Tukey transform + 多桶 LSH
- 文件按滑动窗口(默认 5 字节,字节数取 2 的幂)计算差分值
- 对差分值序列做 Tukey transform(四分位距归一化),把分布压到可比较的范围
- 量化成 4 字节桶号序列
- 对桶号序列做局部敏感哈希(LSH),得到两个 128 位值
两个 128 位值分别是:
- L value(左侧桶号质心) —— 整个文件的分布特征
- R value(右侧桶号质心) —— 文件局部的分布特征
TLSH 最重要的特性是分值方向与前两者相反:
0 → 完全相同(差异距离 0)
100 → 完全不同(差异距离 100)
这是 TLSH 最容易踩的坑。网上大量 TLSH 使用示例会给出这样的代码:
# 错误:把 TLSH 距离当成 ssdeep 相似度
if tlsh_diff < 30:
print("高度相似")
正确逻辑应该是距离越小越相似:
# 正确:距离越小越相似
if tlsh_diff <= 30:
print("相似度高")
把两个工具的输出放进同一个「相似度 ≥ 60 就算同源」的规则里,会得到完全相反的结论。详见 4.1 与 4.3。
TLSH 的其他特性:
- 多线程友好 —— 桶号计算可并行,这是它在大文件系统上比纯 C 实现更快的关键
- 存在最小长度限制 —— 默认要求输入至少 50 字节,且内容需有一定随机性;全零或纯重复字节无法生成指纹。另有
-conservative 选项把下限提到 256 字节
- 版本前缀
T1 —— TLSH 5.x 起输出以 T1 开头,总长 72 字符(T1 + 70 位十六进制);旧版输出为 70 位十六进制。用 -old 可强制输出旧格式。新旧指纹可混合读取,但跨版本的距离计算结果不可直接对比
- 对加密内容同样失效 —— 密文的差分分布是均匀的,Tukey transform 后没有可提取的结构
2.6 相似度分值的计算:popcount 与非对称性
ssdeep 的分值计算:
ssdeep 的分值是 0–100 的整数,核心是 popcount(二进制中 1 的个数):
对于两个块序列 A 和 B:
1. 取公共的块 → 得到简化序列 A' 和 B'(保持相对顺序)
2. 匹配字符:相同则 ++match,不同则 match = 0(连续不匹配长度 * 2)
3. 得分 = 2 × (2 × match + 非匹配字符的贡献) / (A'.len + B'.len)
关键在第 2 步的非匹配惩罚:match 每遇到不同字符就归零,并且连续不匹配会让惩罚线性增长。这样设计是为了避免「两个文件只是开头和结尾相似、中间完全无关」却拿到高分。
非对称性:
ssdeep 的定义中,分母是 len(A') + len(B'),而匹配是按位置对齐的。因此:
sim(A, B) 可能 ≠ sim(B, A)
具体来说,把长文件放在第一个参数位置会系统性压低分值,因为长文件贡献了更多分母却通常匹配不上。
# 这两条命令的分值可能不同
ssdeep -A long-file.bin short-file.bin
ssdeep -A short-file.bin long-file.bin
因此 ssdeep -A 输出的第三列不是对称的,不能当作「相似度百分比」使用。ssdeep 的 man page 也明确说明它的输出「不是相似度」。
做归并时固定一个约定(例如「长文件在前」或「按文件名字母序」),并把这个约定写进报告。
2.7 文本相似与代码相似
前述都是二进制相似。文本与代码的相似度有专门的方法,结论特征完全不同。
文本相似(sdtext 等):
主流做法是归一化后分词:
- 统一编码、统一换行、去 HTML 标签
- 分词(中文用分词器,英文按词边界)
- 去除停用词、可选:词干化
- 计算 Jaccard 相似度(两个词集合的交集 / 并集)或余弦相似度(词频向量夹角)
Jaccard 相似度的对称性天然成立,且与 ssdeep 不同——它对词序完全不敏感,一篇把所有词打乱重排的文章仍会得到 100 分。
对取证来说这是优点(更能穿透格式变化),对「抄袭检测」场景是缺点(改写但保留词句的文本仍会高分)。
代码相似:
代码的相似度有专用工具链,处理的是结构而非文本:
- token 序列比对 —— 去掉空白与注释后做序列比对
- AST 指纹 —— 把语法树规范化后序列化,对变量名不敏感
- 函数级相似度 —— 用 winnowing 等算法在 token 序列上取指纹,抵抗变量重命名与行号变动
取证场景的关键差异:
一个攻击脚本从 Python 2 迁移到 Python 3,或者把 requests 换成 urllib3,文本相似度会大幅下降,但函数级代码相似度几乎不变。
反过来说,两个功能完全相同的脚本如果由不同作者用不同风格实现,代码相似度会很低,而功能层面它们确实相关。所以代码相似度也不能单独作为同源判据。
2.8 参数选择:块大小与块数的相互作用
这是实践中最需要理解、也最少被解释的技术细节。
块大小的核心权衡:
| 块越小 |
块越大 |
| 抗局部修改强(插入几字节只影响一个块) |
抗局部修改弱(插入的字节可能落在块内部,整个块变了) |
| 块数多,摘要长 |
块数少,摘要短 |
| 速度快不了(块数多,哈希次数多) |
速度快 |
| 文件太小会退化(块数不足 7 个时算法无意义) |
文件变大时分辨率下降(大文件里一个小改动只占一个块,分值变化不显著) |
块数与相似度的数学关系:
设文件有 n 个块,插入 k 字节的无关内容,大约影响 2k/b 个块(b 为块大小),相似度下降约:
Δsim ≈ 100 × (2k/b) / n
代入 n ≈ 文件长度 / b:
Δsim ≈ 100 × 2k / 文件长度
关键结论:相似度下降量与块大小无关,只取决于插入字节数占文件总长度的比例。
这个推导解释了实际观察到的现象:
- 往一个 4 MB 的文件里插入 4 KB 无用字节 → 相似度几乎不变(约 0.2% 变化)
- 往一个 8 KB 的文件里插入 4 KB 无用字节 → 相似度掉到接近 0(50% 变化)
所以用小样本调出来的阈值,不能用于大文件。一批小样本上验证的「80 分算同源」,在一批大文件上会漏掉真正的同源对。
实践中的参数选择建议:
| 场景 |
推荐配置 |
| 未知大小的一般样本库 |
ssdeep 3:(默认),全量生成,不分组 |
| 已知文件 > 1 MB |
ssdeep 96: 提速,或 TLSH |
| 需要跨大小文件比分值 |
sdhash(块数归一化) |
| 对抗等长插入 |
sdhash ddde |
| 内存 dump / 大型文件 |
TLSH(多线程)或 sdhash |
关键原则:同一批匹配中,所有对象必须用同一组参数生成指纹。
三、操作步骤
3.1 建立基准库并记录生成参数
基准库(master/reference corpus)是近似的「已知样本库」。建库的质量决定了后续所有结论的上限。
步骤:
① 确定范围
明确基准库包含什么、不包含什么。范围声明必须写进报告,因为它直接决定阴性结论的效力。
② 收集样本并记录元数据
每个样本记录:来源、获取时间、是否官方发布、是否有配套的分析报告。没有元数据的样本库价值有限 —— 无法判断某次命中是「已知威胁」还是「误报」。
③ 生成指纹并记录参数
# 约定:统一用默认的 3: 参数组,不做分组
mkdir -p DigiForensics-corpus/ssdeep-3
cd DigiForensics-corpus/ssdeep-3
# 批量生成 ssdeep 指纹
find . -type f -exec ssdeep -b {} + > ../corpus.ssdeep 2>/dev/null
wc -l ../corpus.ssdeep
④ 同时记录精确哈希
# 近似匹配不能替代精确匹配,基准库要两套哈希并存
cd DigiForensics-corpus
find . -type f -exec sha256sum {} + > corpus.sha256 2>/dev/null
wc -l corpus.sha256
⑤ 保存参数与工具版本
{
echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "tool: $(ssdeep -V 2>&1 | head -1)"
echo "params: block=3 (default group)"
echo "samples: $(find . -type f | wc -l)"
echo "hash: $(sha256sum corpus.ssdeep | cut -d' ' -f1)"
} > corpus.ssdeep.meta
cat corpus.ssdeep.meta
corpus.ssdeep 自身的 SHA-256 必须在报告里出现 —— 它是整个基准库的身份标识。基准库更新后这个值会变,复核者据此判断「比对用的是哪一版样本库」。
★ 破坏性提示: 更新基准库时不要直接覆盖旧文件。每次更新新建带日期或版本号的目录,保留旧版本,否则历史报告的「未发现」结论无法复现。
3.2 批量生成目标侧指纹
从检材中提取出待比对的对象。提取本身是独立的技术步骤(见 文件系统取证总览),这里只强调与近似匹配相关的部分。
① 提取时保留原始路径信息
# 保留相对路径,便于回溯到检材中的位置
cd /Volumes/DigiForensics/mnt/evidence
find ./suspect -type f -size +0 -print0 |
while IFS= read -r -d '' f; do
# 路径中的空格与中文原样保留
printf '%s\0' "$f"
done > /Volumes/DigiForensics/work/targets.nul
wc -l /Volumes/DigiForensics/work/targets.nul
② 生成指纹
cd /Volumes/DigiForensics/work
mkdir -p ssdeep-3
ssdeep -b $(cat targets.nul | tr '\0' '\
' | tr '\
' ' ') ssdeep-3/targets.ssdeep 2>/dev/null
更稳的做法是用 -r 递归或直接对目录操作,避免 shell 的参数长度限制与空格处理问题:
# 递归处理整个提取目录
ssdeep -br /Volumes/DigiForensics/mnt/evidence/suspect \
> /Volumes/DigiForensics/work/ssdeep-3/targets.ssdeep 2>/dev/null
# 统计生成了多少条
wc -l /Volumes/DigiForensics/work/ssdeep-3/targets.ssdeep
③ 统计失败对象
这一步不能跳过。 ssdeep 对小于 64 字节的文件会静默失败:
# 对比实际文件数与生成的指纹数
echo "文件数: $(find /Volumes/DigiForensics/mnt/evidence/suspect -type f -size +0 | wc -l)"
echo "指纹数: $(wc -l < /Volumes/DigiForensics/work/ssdeep-3/targets.ssdeep)"
两者差额就是无法生成指纹的对象(多为小于 64 字节的配置文件、图标、零字节文件)。这个差额必须写进报告,因为它决定了阴性结论的覆盖范围。
3.3 匹配并人工复核阈值边界
① 双向匹配
# A: 基准库在前
ssdeep -A DigiForensics-corpus/ssdeep-3/corpus.ssdeep \
/Volumes/DigiForensics/work/ssdeep-3/targets.ssdeep \
> /Volumes/DigiForensics/work/match-A.txt
# B: 目标库在前(分母不同,结果可能不同)
ssdeep -A /Volumes/DigiForensics/work/ssdeep-3/targets.ssdeep \
DigiForensics-corpus/ssdeep-3/corpus.ssdeep \
> /Volumes/DigiForensics/work/match-B.txt
wc -l /Volumes/DigiForensics/work/match-A.txt /Volumes/DigiForensics/work/match-B.txt
两次结果的差异本身是信息(见 3.4)。
② 按分值分档,不设单一阈值
# 按分值区间分档输出,而不是只取 >60 的行
awk -F'\ ' '{
s = $3 + 0
if (s >= 90) print "极强 " s "\ " $2 " <=> " $1
else if (s >= 70) print "强 " s "\ " $2 " <=> " $1
else if (s >= 40) print "中等 " s "\ " $2 " <=> " $1
else if (s > 0) print "弱 " s "\ " $2 " <=> " $1
}' /Volumes/DigiForensics/work/match-A.txt > /Volumes/DigiForensics/work/match-banded.txt
echo "--- 各档数量 ---"
awk -F' ' '{print $1}' /Volumes/DigiForensics/work/match-banded.txt | sort | uniq -c
③ 人工复核阈值边界附近的样本
分值在阈值上下 10 分以内的匹配,必须逐个人工看。 理由:
- 算法分不出「同源变体」和「共用模板」
- 分值高但两者属于不同厂商的同类工具,是常见的误报源
- 分值低但确实是同源的情况也存在(如只改了导入表的样本)
复核方法:
# 提取两个文件的头部特征做对照
for f in target.bin corpus.bin; do
echo "=== $f ==="
file "$f"
sha256sum "$f"
done
# 看 PE 的节区与导入表差异
objdump -x target.bin 2>/dev/null | head -40
objdump -x corpus.bin 2>/dev/null | head -40
④ 记录复核结论
不要只保留「是/否」,要记录判断依据:
| 匹配对 |
分值 |
复核发现 |
结论 |
suspicious-a.bin ↔ known-cve-2024-xxxx.bin |
87 |
导入表结构一致,编译时间戳相差 3 天,C2 域名同注册人 |
同源变体 |
installer-x.exe ↔ nsis-installer-template.bin |
74 |
均为 NSIS 打包,无业务逻辑重叠 |
共用工具,非同源 |
macro-x.docm ↔ macro-y.docm |
81 |
宏代码结构一致,变量名与注释不同 |
同源变体(人工确认) |
3.4 归并成变体簇并做交叉验证
单个匹配没有意义,真正要交付的是「哪些对象属于同一变体簇」。
① 初始归并(基于与基准库的直连匹配)
# 对每个目标对象,取它与基准库的最高分值命中
awk -F'\ ' '{
key = $2
if ($3+0 > best[key]) { best[key] = $3+0; src[key] = $1 }
}
END { for (k in best) print k "\ " best[k] "\ " src[k] }' \
/Volumes/DigiForensics/work/match-A.txt |
sort -t$'\ ' -k2,2nr > /Volumes/DigiForensics/work/best-hit.txt
head -20 /Volumes/DigiForensics/work/best-hit.txt
② 目标侧内部两两比较(发现基准库里没有的新变体)
这一步是发现「全新变体簇」的唯一途径。ssdeep -A 只在两个文件集之间做比较,不做集内自比。
cd /Volumes/DigiForensics/work
# 对目标库自身做两两比较(小批量可行,大批量需分块)
ssdeep -A ssdeep-3/targets.ssdeep ssdeep-3/targets.ssdeep \
> self-match.txt 2>/dev/null
# 提取高分的对,排除自比
awk -F'\ ' '$3+0 >= 60 && $1 != $2 {print $3 "\ " $2 " <=> " $1}' self-match.txt \
| sort -rn > internal-pairs.txt
wc -l internal-pairs.txt
★ 大批量警告: 目标库有 n 个对象时,两两比较是 O(n²) 次比对。1000 个对象是 50 万次,10 万个对象是 50 亿次 —— 后者不可行。超过数千个对象时必须用 LSH 索引(如 tlsh 的库模式)先做候选筛选,再对候选做精确比对。
③ 传递闭包归并
关系相似不满足传递性,这是本主题最容易被忽略的算法性质:
sim(A, C) = 85
sim(A, B) = 40
sim(B, C) = 90
sim(A,B) 很低,但 A 与 C、C 与 B 都强相关。如果只按「与基准库的直接命中」归并,B 会成为一个孤立簇,但实际上 A、B、C 是同一个家族。
正确的做法是构建相似图后求连通分量:
import collections
# 输入:所有 >= 阈值的配对
pairs = []
with open('/Volumes/DigiForensics/work/internal-pairs.txt', encoding='utf-8') as f:
for line in f:
score, rest = line.split('\ ', 1)
a, b = rest.split(' <=> ')
pairs.append((a.strip(), b.strip(), int(score)))
THRESHOLD = 60
parent = {}
def find(x):
parent.setdefault(x, x)
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
for a, b, s in pairs:
if s >= THRESHOLD:
union(a, b)
clusters = collections.defaultdict(list)
for x in list(parent):
clusters[find(x)].append(x)
# 输出簇,只保留有 2 个及以上成员的
for root, members in sorted(clusters.items(), key=lambda kv: -len(kv[1])):
if len(members) < 2:
continue
print(f'簇({len(members)} 个成员):')
for m in sorted(members):
print(f' {m}')
print()
但要记录一个重要事实:连通分量不等于「所有成员都与中心高相似」。一个 50 个对象的簇可能是「25 个强相关对象 + 25 个通过单条弱边挂上去的对象」。报告里应当给出簇内的分值分布(最大、最小、中位数),而不只是簇的大小。
3.5 加密压缩样本的预处理
近似匹配对加密与压缩内容完全无效,这是原理决定的,不是工具缺陷。
原因很直接:密文与压缩流的字节分布是均匀随机的,其滚动哈希的分布也是均匀的。块边界退化为随机切分,两段不同的密文会产生完全不同的块序列。
# 验证:同一个文件的两次压缩,分值接近 0
cp target.bin target-copy.bin
gzip -k -9 target-copy.bin
ssdeep -A target.bin target-copy.bin.gz
# 典型输出:0
正确做法是解压 / 解密后再比对:
# ZIP / RAR
mkdir -p unpacked
unzip -o -d unpacked target.zip 2>/dev/null
unrar x -o+ target.rar unpacked/ 2>/dev/null
7z x -o unpacked -y target.7z 2>/dev/null
# gzip
gunzip -k target.gz
# 7z(注意:某些 7z 格式的密码可从命令行传入,但应避免在 shell 历史里留下密码)
# 密码用交互式输入,不要写进命令行
# 生成解压后内容的指纹
ssdeep -br unpacked/ > unpacked.ssdeep 2>/dev/null
wc -l unpacked.ssdeep
★ 关键提醒:解压出的内容不能直接回写检材。 全部在分析工作目录下操作,且记录解压清单(哪些文件、来自哪个压缩包、解压时间)。解压动作本身要在保管链中留痕。
同名不同内容的情况要特别注意:
# 一个压缩包里有多层同名文件
find unpacked -name '*.doc' -exec sha256sum {} \; | sort
同名不同哈希的文档是解压包分析的常规发现,必须在报告中区分,不能按文件名归并。
3.6 内存与磁盘中的模糊搜索
在内存 dump 和原始磁盘镜像中做近似匹配,是近似匹配对取证最有价值的方向 —— 因为这类证据里的对象通常没有被文件系统正常管理,无法用文件名定向提取。
① 在内存 dump 中搜已知样本的片段
# 先算出已知样本的指纹
ssdeep -b DigiForensics-corpus/known-malware.bin
# 在 dump 中递归搜索(-r 递归,自动跳过小文件)
ssdeep -br -t 2048 /Volumes/DigiForensics/mem/DigiForensics-mem.raw \
> mem-hits.ssdeep 2>/dev/null
head -20 mem-hits.ssdeep
-t 参数指定最小文件大小,内存 dump 中大量区域小于这个值,指定它可以大幅加速并减少噪声。
② 从内存中先提取再比对(更可控)
直接对 raw 扫描产生的结果难以复核。更稳的做法是先提取候选对象:
# 用 foremost 或 bulk_extractor 从内存中提取可识别格式的对象
bulk_extractor -o /Volumes/DigiForensics/work/mem-carve \
-S none /Volumes/DigiForensics/mem/DigiForensics-mem.raw 2>/dev/null
ls /Volumes/DigiForensics/work/mem-carve/ | head
# 对提取出的对象做近似匹配
ssdeep -br /Volumes/DigiForensics/work/mem-carve/ \
> mem-carve.ssdeep 2>/dev/null
③ 提取结果必须做交叉验证
从内存雕刻出的对象,不能直接作为「检材中存在该恶意软件」的结论。理由:
- 雕刻有误报(相邻内容被误判为文件头)
- 内存中的片段可能是解码过程中的中间态,不是落盘内容
验证手段: