近似匹配与模糊哈希

哈希与完整性校验 里的 MD5 / SHA-256 有一个前提:它是对完整字节序列的一次性摘要。这带来两个后果,第二个才是近似匹配存在的理由。

最后更新 2026-10-09版本 v1.0维护 DigiForensics查看历史 0

定位:精确哈希回答「这两份文件是不是同一份」,近似匹配回答「这两份文件是不是同一份东西的两个版本」。它是从「已知的坏样本」追到「未知变体」的唯一可行路径,也是 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 提出,最初是为反垃圾邮件设计的,后来成了事实上的通用标准。

工作流程:

  1. 以 3 个字节为步长滚动窗口,每个窗口算一个弱哈希(hash)
  2. 维护一个「哈希基线窗口」,用 HASH_PRIME 构造的 7 个候选哈希字符查表
  3. 当滚动哈希命中 7 字符表中的任一字符时,判定为块边界
  4. 记录该块的摘要:校验和(2 字节) + 前一块滚动哈希(1 字节) + 长度
  5. 重复直到文件结束

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

  1. 文件按滑动窗口(默认 5 字节,字节数取 2 的幂)计算差分值
  2. 对差分值序列做 Tukey transform(四分位距归一化),把分布压到可比较的范围
  3. 量化成 4 字节桶号序列
  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 等):

主流做法是归一化后分词:

  1. 统一编码、统一换行、去 HTML 标签
  2. 分词(中文用分词器,英文按词边界)
  3. 去除停用词、可选:词干化
  4. 计算 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

③ 提取结果必须做交叉验证

从内存雕刻出的对象,不能直接作为「检材中存在该恶意软件」的结论。理由:

  • 雕刻有误报(相邻内容被误判为文件头)
  • 内存中的片段可能是解码过程中的中间态,不是落盘内容

验证手段:

验证对象是否结构完整

file /Volumes/DigiForensics/work/mem-carve/xxxxx.exe python3 -c " import pefile, sys pe = pefile.PE('/Volumes/DigiForensics/work/mem-carve/xxxxx.exe') print('节区数:', len(pe.sections)) print('入口点:', hex(pe.OPTIONAL_HEADER.AddressOfEntryPoint)) print('导入 DLL:', [e.dll.decode() for e in pe.DIRECTORY_ENTRY_IMPORT]) " 2>&1 | head -20

与已知样本做精确哈希比对,确认是否完全一致

sha256sum /Volumes/DigiForensics/work/mem-carve/xxxxx.exe grep -i 'xxxxx.exe' Dig

安全验证 当前请求需要先完成一次滑块验证。