interplains/Zero-Breach-Toolkit:3,784 stars 的离线间谍软件痕迹扫描器,只读、断网、给每条证据打分

凌晨一点,一个做调查报道的朋友在电话里问我:” 我这台电脑,怎么知道自己有没有被装东西?”

他不是那种会乱点链接的人。真正让他睡不着的是另一件事:过去半年,他采访过的两个线人先后失联,其中一个在最后一条消息里说” 我的手机好像不太对”。他搜了一圈,看到的建议是” 下载某某检测工具”” 上传样本到某某平台分析”。然后他停住了 —— 因为你没法确认,那个让你下载检测工具的人,是不是正等着你下载。

这就是商业雇佣间谍软件带来的一个很别扭的困境:你想检查自己有没有被监控,但检查这个动作本身就可能暴露你,或者让你信任一个不该信任的二进制文件。

我后来在 GitHub 上翻到这个项目,第一反应是” 这作者懂行”。它没有漂亮的官网首屏,没有”AI 驱动的一键检测”,README 里很长一段在讲怎么给一个不会用命令行的记者装 Python。但在这些啰嗦的步骤背后,是三条被写成代码、写成测试、写进 JSON 契约的硬规则。

项目叫 interplains/Zero-Breach-Toolkit。

一、它到底是什么:一个不装依赖、不联网的扫描器

先摆事实。

项目 数值
仓库 interplains/Zero-Breach-Toolkit
Stars / Forks 3,784 / 409
建仓时间 2026-10-25(对,五天前)
提交数 56
许可证 Apache-2.0(代码)/ CC0 1.0(IOC 数据)
语言 Python,3.8+,默认只用标准库
代码量 164 个 .py,约 39,498 行(核心包 scan/zbtriage 约 10,248 行)
测试 109 个测试文件,约 18,176 行
当前版本 v0.1.13(2026-10-29)
作者 匿名,署名 archivegenocide

它做的事,一句话:在你自己机器的本地记录里,找已知商业间谍软件留下的、已经被公开过的痕迹。

支持的输入面很宽:

  • Windows 桌面(浏览器、桌面应用的 Chromium 内核、下载来源、Win+R 粘贴记录、Defender 历史、PowerShell 日志、Prefetch、Amcache、启动项、DNS 缓存)
  • macOS / Linux 桌面(同类浏览器记录、Safari、LaunchAgent / LaunchDaemon、systemd、cron、shell 历史)
  • Android:读取 AndroidQF 采出来的取证包,自己不碰手机
  • iPhone:读取 Finder / Apple Devices /iTunes 的本地备份、已提取的文件系统、sysdiagnose 归档

注意最后两条的措辞。它不” 连接手机”,它读别人采好的包。这是很克制的设计,后面会讲为什么。

还有一点值得先说:这个项目不是杀毒软件。它不做启发式查杀,不做行为监控,不驻留后台。它是一次性的、离线的、事后取证的字符串比对器,外加一堆” 我知道自己看不到什么” 的自我声明。

二、三条铁律:D1、D2、D3

scan/CONTRACT.md 里写死了三条规则,代码里到处引用它们的编号。

D2:绝不接触指标(never contact an indicator)。

这是最重要的一条。你想想,如果扫描器为了” 验证” 一个 C&C 域名,去 getaddrinfo 解析一下,会发生什么?DNS 查询会到达对方控制的解析器,等于举着牌子告诉运营者:” 这台机器上有人正在查你的域名,而且他知道这个名字。”

对记者、异见者、人权工作者来说,这一下就可能坐实” 目标已察觉”,接下来对方可能换 infrastructure、加速利用、或者干脆动手清痕迹。

D1 / D3 是同一套认识论的两面:

  • 同一串字符串出现在不同位置,证据强度完全不一样。磁盘上一个和已公布样本同 SHA-256 的文件,是” 东西在”;浏览器历史里一条访问记录,是” 这台机器去过”;一篇收藏的新闻文章里提到这个域名,只是” 被提到过”。证据要分级,等级决定建议,而不是命中数量。
  • 检查不了的东西,必须明确写成” 没查到”,绝不能悄悄合并进” 干净”。SDK 里的原话是:A check that could not run is a gap, never a clean result.

第二条听起来像常识,但它是绝大多数安全工具做不到的地方。大部分扫描器最后给你一个绿色的勾,你根本不知道那个勾背后有多少个” 我读不了这个文件所以跳过了”。

三、把 socket 焊死:D2 是怎么落地的

只写一句” 我们保证不联网” 没有意义。这个项目的做法是:在扫描期间,把 Python 的 socket 模块改成一颗地雷。

scan/zbtriage/netguard.py 的注释写得很直白:

我们不只是” 打算” 不解析任何东西 —— 我们让它变得不可能。扫描在 no_network() 里运行时,socket 模块里每一个域名解析和出站连接的入口都会抛 NetworkContactAttempt。一个想解析 IOC 的 bug 会大声崩掉,而不是悄悄泄露出一个信号。

实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
_BLOCKED = (
"getaddrinfo",
"gethostbyname",
"gethostbyname_ex",
"gethostbyaddr",
"getnameinfo",
"create_connection",
)

@contextlib.contextmanager
def no_network():
"""Context manager that disables all socket-level resolution and egress."""
recorder = _Recorder()
saved = {}

for name in _BLOCKED:
if hasattr(socket, name):
saved[name] = getattr(socket, name)

def make(fn_name):
def blocked(*_a, **_k):
recorder.trip("socket." + fn_name)
return blocked
setattr(socket, name, make(name))

# Methods on the socket OBJECT that open a connection or send egress.
_SOCK_METHODS = ("connect", "connect_ex", "sendto", "sendmsg", "sendall", "send")
# ... 逐个替换成抛异常的桩

注意它连 sendto 和 sendmsg 都堵了。为什么?因为 UDP 不需要先 connect()。如果只堵 connect,一个 bug 仍然可以往解析器或者 C&C 发一个 UDP 包出去。这种细节不是靠” 我很小心” 能覆盖的,只能靠把攻击面一个个数出来。

更妙的是它的边界说明。注释里明确写了:这个守卫不拦 subprocess,因为 host 模块需要用 PowerShell 读本地 DNS 解析器缓存 —— 读一个” 已经解析过的缓存条目” 不会发起任何解析,所以是安全的。同时 docs/PATTERNS.md 里也老实承认:这不是操作系统级网络沙箱,在 POSIX 上如果一个目录是网络挂载的,它看起来就是个普通绝对路径,可能真的会被读到,” 需要严格离线请用操作系统层面的网络隔离”。

这种” 我知道我的保证到哪儿为止” 的写法,比任何营销文案都可信。

除了 netguard,配套还有几道闸:

  • 拒绝重定向路径:符号链接、Windows 的 UNC 路径、远程驱动器、云占位文件(OneDrive 之类没下载到本地的” 云端唤起” 文件)都不读。
  • 只读:模块不得创建、修改、删除宿主上的任何东西。唯一允许的写操作,是把被锁住的数据库复制到系统临时目录再读,读完清理。
  • 测试钉死:tests/test_netguard.py 专门检查” 任何扫描模块都不许提网络工具”,tests/test_heuristics.py 钉死 heuristics.py 只能 import re、不许调用任何东西。

四、指标从哪来:12,400 条 IOC 的流水线

扫描器本体只是” 比对器”,真正决定它能看见什么的是指标集。

README 给的数字是:约 12,400 条已公开指标,来自 29 个来源,包括 Amnesty International Security Lab、MVT 项目的 mvt-indicators、Citizen Lab、Echap 的 stalkerware 研究,外加一份手工整理、每一条都带出处的 Candiru(DevilsTongue)与 Paragon Graphite 指标。

手工整理那份在 iocs/curated_mercenary.txt,格式是 TSV,文件头写得非常” 取证”:

1
2
3
4
5
6
7
8
# Curated mercenary-spyware indicators not available in any machine-readable feed
# as-of: 2026-10-25
# ^ Curated date; the staleness reference for this file.
# Licence: CC0 1.0 (as iocs/LICENSE.md). Indicators are facts; the citations below
# name the researchers who published them.
# Method: every value was read out of the raw text of the cited report (not from a
# summary), and its ROLE is recorded from the report's own description.
# Format: indicator <TAB> tier <TAB> confidence <TAB> source-tag <TAB> family

里面长这样:

1
2
3
4
# Tier 1: victim-facing Graphite servers (devices are expected to contact these)
46.183.184.91 infrastructure-ip primary citizenlab-paragon-2025-06 Paragon Graphite
84.110.122.27 infrastructure-ip primary citizenlab-paragon-2025-03 Paragon Graphite
awake-wood.io infrastructure primary citizenlab-paragon-2025-03 Paragon Graphite

它甚至写了” 故意不收录什么”:

  • 报告只公布了部分网段的客户部署 IP(例如 67.69.21.xxx);
  • 证书名 forti.external-Sht_prd_2.com(不是合法主机名,含下划线);
  • BIGPRETZEL / SMALLPRETZEL:被点名的取证指标,但值没有公开。

“我没有收录什么,为什么” —— 这一段比收录清单本身更能说明作者做过取证工作。

在线更新走 --update,是唯一主动联网的普通操作(另一个是单独调用的 Android 采集)。它的约束在 feeds.py 里:

  • 只允许 https://raw.githubusercontent.com/<白名单 owner>/...;
  • 重定向被拒;
  • 默认按本 release 里钉死的 commit 下载,并要求匹配 release 记录的 SHA-256;
  • 下载后把每个文件的 SHA-256 记进缓存,用来发现意外改动;
  • 明确写了” 发布者没有对这些文件签名,所以没有签名可验”。

这句自曝很重要 —— 很多工具会把” 我们校验了哈希” 说成” 我们验证了真实性”,那是两码事。

还有一个我很喜欢的细节:指标撤回(withdrawn)的处理。

同一个指标可能被多个发布者列出,某个发布者后来撤回了。如果简单地” 任何一条记录还在就算有效”,那个被撤回的指标会永远活着,制造假警报。evidence.py 里的 _withdrawn_only() 做了分组:按(发布者,STIX object id)分组,组内取 modified 最新的那版说话;STIX 要求撤回版本的 modified 更新,所以最新版就是发布者的真实意图。不同发布者之间允许” 诚实地分歧”—— 只要还有一家在现行清单里列着,这个命中就仍然可行动。

注释里还留了一句:Different publishers still disagree honestly. 这种把” 数据源互相矛盾” 当成正常状态处理,而不是强行归一,是成熟的做法。

五、十个模块,一张注册表

scan/zbtriage/modules/ 下有 10 个模块:

1
2
3
4
5
6
7
8
9
10
android.py      # AndroidQF 采集包
browser.py # Chromium 系 / Firefox 系 / Safari
execution.py # Prefetch、Amcache 等执行痕迹
hashscan.py # 文件指纹(SHA-256)
host.py # Windows 主机记录:DNS、COM、Win+R、Defender、PowerShell
intrusion.py # Android 入侵日志(DNS / TCP / 进程启动)
ios.py # iPhone 备份
patternscan.py # 可选的 YARA-X 规则扫描(Candiru)
posix.py # macOS / Linux 启动项、shell 历史、日志
traces.py # Windows 痕迹(ShimCache、PCA 等)

调度不靠 if platform == 'win32' 这种散落各处的判断,而是一张源码里的注册表。readers.py 开头就写清楚了设计意图:

A reader implements NAME and run(ScanContext) -> ModuleResult. Registration is source-controlled: scan inputs cannot load Python plugins or change this list.

1
2
3
4
5
6
7
8
9
10
@dataclass(frozen=True)
class ReaderSpec:
name: str
module: str
suffix: str
platforms: tuple
modes: tuple
catalogue: dict
helpers: tuple = ()
optional: str = ""

每个 ReaderSpec 除了” 叫什么、在哪个平台跑”,还带一份 catalogue:reads(读什么)、check_kinds(每种检查代表什么证据)、blind_spots(看不到什么)。这份 catalogue 有三个用途 —— 平台选择、报告里的覆盖说明、以及单文件打包时的代码收集。也就是说,” 我能看见什么、看不见什么” 不是文档里的一篇文章,而是代码里的数据,最后原样出现在你的报告里。

模块之间通过 ScanContext 解耦,CLI 不直接碰任何模块:

1
2
3
4
5
6
7
8
9
10
11
12
@dataclass
class ScanContext:
iocs: IocSet
deep: bool = False
quick: bool = False # 跳过最慢、信号最弱的文件指纹
full: bool = False # 连弱证据的原始缓存也读
browser_only: bool = False
phone_path: str = ""
targets: tuple = ()
extended_windows: bool = False
partial: object = None # 模块跑到一半崩了,已找到的命中要活下来
# ... 其余字段略

partial 这个字段的理由写得很实在:一个模块可能在读到第 300 个 cookie 时崩掉,但前面已经匹配到的东西是真实证据,不能因为后面的异常把整份结果丢掉。

模块内部则必须记录每个子检查的结局:

1
2
r.ran("dns-cache")                                  # 跑了,没找到也算跑过
r.could_not_run("dns-log", "operational log disabled")

只要一个模块里有任何一个 could_not_run,它的状态就自动变成 PARTIAL / UNKNOWN。“不许把没跑过的子检查报成干净的” 这条,是写在 CONTRACT.md 的 Hard rules 里的。

六、证据分级:命中不等于中招

这是整个项目里我最想推荐给其他安全工具作者看的部分。

报告不会给你一个” 危险 / 安全” 的红绿灯,而是给每条命中标一个证据等级。README 里的表从强到弱:

等级 含义
on-disk 磁盘上有文件的指纹和已公布样本一致
on-device 手机上存在研究者点名的应用、签名证书、文件或设置
file-pattern 命中了公开检测规则(线索,不是证明)
persistence 这个名字被注册为开机自启
ran-command 本机执行过一条提到它的命令或脚本(也可能是研究者自己敲的)
execution-record 历史执行记录里有这个名字(内容和真实路径未确认)
inventory-record 历史文件清单里有它(不能证明执行过,也不能证明现在还在)
contacted 设备确实和它通信过(它设的 cookie、安全连接记录、从它那里下载)
device-log 手机系统日志提到了它
received 收到的短信 /iMessage/WhatsApp 消息里包含它(不能证明打开过)
dns-record 有域名查询记录(通信未验证)
attempted 尝试连接但被拦住了
configured 出现在本地配置里
referenced 出现在保存下来的内容里,通常意味着” 这是一篇讲它的文章”

14 级。而且报告里明确写:“A saved web page mentions the address” is weak, and is usually an article about the spyware. The grade, not the number of matches, decides what to do.

翻译成人话:你在浏览器收藏夹里存了一篇 Citizen Lab 的报告,里面提到了 awake-wood.io,扫描器会命中,但等级是 referenced,建议是” 别慌”。而如果磁盘上某个 DLL 的 SHA-256 和一 published sample 一致,等级是 on-disk,那就是另一回事了。

比” 找到什么” 更重要的是” 找到但不算数” 的那一栏,叫 Worth a look。里面放的是工具自己判断不了的东西:

  • 一个侧载(sideload)安装的 App;
  • 一条下载并执行了代码的命令;
  • 一个被杀毒软件排除的目录。

每一条都附上” 最常见的无害解释”。它们在 JSON 里跟 findings 是分开的字段,永远不影响结论。heuristics.py 的实现也很有意思 —— 它是个纯数据文件,只有正则,不执行任何东西:

1
2
3
4
5
6
RUNNER = re.compile(r"\b(powershell|pwsh|mshta|curl|wget|bitsadmin|certutil|msiexec|"
r"rundll32|regsvr32|wscript|cscript|cmd)\b", re.I)
REMOTE = re.compile(r"(https?://|ftp://|\\\\[^\\\s]|\b\d{1,3}(?:\.\d{1,3}){3}\b)", re.I)
CRADLE_RUN = re.compile(r"\b(iex|invoke-expression)\b", re.I)
CRADLE_GET = re.compile(r"(downloadstring|downloaddata|invoke-webrequest|\biwr\b|"
r"invoke-restmethod|\birm\b|net\.webclient|start-bitstransfer)", re.I)

文件头的注释是:”These patterns DESCRIBE commands an attacker gets people to run. They are never executed.” —— 描述,不执行。然后测试专门钉死这个文件只 import re。

这个” 把危险知识当成数据而不是代码” 的习惯,值得抄。

给 AI 助手准备的契约

2026 年的安全工具基本都会遇到同一个场景:用户把报告丢给 ChatGPT/Claude,问” 我中毒了吗”。而这个项目的 JSON 里带了一整套防止 AI 乱说话的字段(contract.py,schema 1.6):

  • must_relay:AI 必须原样转达的免责声明;
  • next_steps:从固定目录里选的建议,不是模型生成的;
  • content_trust:哪些字段是被扫描机器上的文本(不可信);
  • instructions_for_assistants:转述规则;
  • sanitize_untrusted():把控制字符、双向覆盖字符(bidi override)、不可见格式字符替换掉,防止一个恶意文件名在终端里” 重绘” 你的屏幕,或者骗过 AI。
1
2
3
4
5
6
7
8
9
def sanitize_untrusted(value: Any) -> Any:
"""Make scanned text safe to print and to hand to an AI: replace control,
bidirectional-override and invisible formatting characters ... and cap the length."""
if not isinstance(value, str):
return value
clean = _CONTROL.sub("�", _INVISIBLE.sub("", value))
if len(clean) > MAX_UNTRUSTED_LEN:
clean = clean[:MAX_UNTRUSTED_LEN] + "…[truncated]"
return clean

contract.py 的模块注释里有一句我读了两遍的话:

Everything in this module is fixed text. Nothing here is built from scanned content, so a hostile file name or page title cannot reach the caveats or the advice.

“恶意文件名不可能污染免责声明和建议” —— 这是 prompt injection 防御在传统工具里的一个非常干净的落点。不是让模型” 小心一点”,而是让不可信文本根本进不了那个字段。

七、亲手跑一遍

我在这台 Linux 机器上按 README 跑了一遍。先 clone:

1
2
3
git clone https://github.com/interplains/Zero-Breach-Toolkit.git
cd Zero-Breach-Toolkit
python3 -X utf8 scan/zero-breach-toolkit.py --update

--update 是唯一联网的普通步骤,跑完会缓存到 ~/.cache/zero-breach-triage/indicators。然后是标准检查:

1
python3 -X utf8 scan/zero-breach-toolkit.py

菜单会出来:回车 = 标准检查,--quick 跳过最慢的文件指纹,--scan-type browser 只查浏览器,--scan-type targeted --target PATH 只查指定目录。

我用 targeted 模式扫了一个临时目录,记录下真实输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[*] running check: browser
[*] browser finished in 0s
[*] running check: hash
[hash] looking for 2 known sample hash(es)
[hash] scanning /tmp/zbtest ...
[hash] fingerprinted 0 / 1 candidates
[*] hash finished in 0s
[*] running check: windows-execution
[*] windows-execution finished in 0s
[wrote JSON report to /tmp/zb_report.json]
[wrote readable report to /tmp/zb_report.txt]
========================================================================
Zero Breach Toolkit 0.1.13 - spyware indicator check
Scope: targeted
Only selected targets were checked; system and other-user records are outside scope.
Checks for TRACES of known mercenary spyware. A clean result is NOT a
clean bill of health - see what could not be checked, below.
2026-10-30T15:44:08 Linux 5.10.134-18.0.12.lifsea8.x86_64
========================================================================

RESULT: no known indicator was found in anything that could be
checked.

This is NOT a clean bill of health. See the two sections below for
what was searched and, more importantly, what could not be.

WHAT COULD NOT BE CHECKED (29 gaps, grouped)
- Indicator sources not downloaded — run --update once, then scan
again.

因为我没有跑 --update(不想在这个环境里主动访问那些源),本地只有随仓库带的两份清单:

1
2
3
Indicator set : 428 indicators, 5 day(s) old
from adfection_iocs.txt : 405 for computer checks
from curated_mercenary.txt : 23 for computer checks

进程退出码是 3,不是 0。这个区别就是整个项目的态度:

退出码 含义
0 没有可行动的命中,且范围内所有检查都跑完了
1 安装、参数或输出错误
2 有超出” 仅供参考” 级别的命中
3 没有可行动的命中,但检查或指标源不完整
4 用户关了菜单没扫(没有结论、没有报告)

“没找到任何东西” 和” 有 29 个地方我没能检查” 必须是两种不同的结果。 我在别的工具里很少见到敢这么设计退出码的 —— 因为 CI 会红,用户会烦,但正确性优先。

配套的命令行选项也很完整:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 快速模式:跳过文件指纹
python3 -X utf8 scan/zero-breach-toolkit.py --quick

# 只查浏览器
python3 -X utf8 scan/zero-breach-toolkit.py --scan-type browser

# 只查指定目录/文件,可重复 --target
python3 -X utf8 scan/zero-breach-toolkit.py --scan-type targeted --target /path/a --target /path/b

# 手机采集包 / iPhone 备份
python3 -X utf8 scan/zero-breach-toolkit.py --phone "PATH/TO/COLLECTION" --backup-password

# 机器可读报告 + 可分享的最小摘要
python3 -X utf8 scan/zero-breach-toolkit.py --json report.json --share-json summary.json

# 打印全部检查项、能看见/看不见什么、退出码、给 AI 的转述规则
python3 -X utf8 scan/zero-breach-toolkit.py --describe

# 生成证据包(未加密 ZIP + 完整性清单)
python3 -X utf8 scan/zero-breach-toolkit.py --scan-type targeted --target SUSPICIOUS_FILE \
--report report.txt --json report.json --evidence-bundle evidence.zip --evidence-file SUSPICIOUS_FILE

--describe 打出来的是一份 1.6 版本的 JSON,包含 modules / exit_codes / check_kinds / blind_spots。我拿它当” 这工具到底能做到什么” 的权威来源,比读 README 快。

还有一个容易被忽略的隐私设计:--share-json 只写计数、范围标签、结论和固定建议,丢掉所有 artifact 文本、指标值和时间。因为完整报告里可能有你的用户名、浏览器 profile 名、文件路径、浏览记录、短信发件人和消息片段。README 里那句提醒写得很克制:”Do not post your report there (the issue tracker); it contains private details.”

八、手机端:读采集包,不碰设备

手机这部分的设计比桌面更保守,原因很实际:iOS 和 iPadOS 不允许一个 App 读别的 App 的数据,所以在手机上装个” 检测 App” 这件事,本身就很难成立。

Android:委托给 AndroidQF

它不自己实现 ADB 采集,而是读 AndroidQF 产出的包。检查的内容包括:

  • 已安装 / 运行中的应用、指纹、签名证书;
  • 安装历史(装过又删掉的 App 仍然会出现);
  • 系统记录、日志、崩溃记录;
  • 已解密的入侵日志:DNS 查询、TCP 连接尝试、进程启动事件(连接尝试不等于成功);
  • 短信 / 彩信里的链接(来自 AndroidQF 可选的 SMS 备份);
  • 非应用商店安装的 App,以及它们的权限(读短信、用麦克风、读通知、抵抗卸载)和到达时间;
  • 手机被改动的迹象:解锁的 bootloader、debug 构建、安全保护被关闭。

还有个很细的点:它能识别采集行为自己留下的痕迹。开 USB 调试、调试 shell 的权限,这些都是” 因为要取证才出现的”,不会被当成可疑项报出来。README 里说,MVT 的启发式规则在普通手机上经常误报,其中一部分就是采集过程自己产生的。

Android 端的进程名检查也做了平台区分:那些” 间谍软件伪装成苹果服务名” 的进程名清单来自 iPhone 调查,其中有些名字(比如 gatekeeperd)在 Android 上是正常的锁屏服务。所以 Android 侧干脆不比进程名,只有 iPhone reader 比。这个决定背后是对数据源语境的尊重,而不是” 多查一点总没错”。

实验性的 --collect-android 会下载官方 AndroidQF v1.8.3 并校验钉死的 SHA-256,然后跑它。这是全项目唯一运行一个不是自己发布的程序的入口,限制写得非常细:

  • 资源名和摘要值硬编码在源码里;
  • HTTPS 下载,重定向只允许白名单;
  • 边下边算哈希,不匹配就拒绝,什么都不执行;
  • 写到临时目录,直接执行(不经 shell),清掉 ADB_、ANDROID_ADB_、LD_、DYLD_ 环境变量,PATH 重置为系统目录,跑完删除;
  • 不显式调用并确认,什么都不会下载。

还是要说清楚:钉死的摘要只证明” 你拿到的是发布者发布的那串字节”,不等于对采集器做了独立审计。

iPhone:备份、WAL 与上限

iPhone 检查读本地备份(Finder / Apple Devices /iTunes),加密或未加密都行,也支持已提取的文件系统与 sysdiagnose 归档。

覆盖的记录很杂:短信、iMessage、WhatsApp、Safari 和其他浏览器、数据用量里的进程名、应用记录、权限、通话、日历、交互、快捷指令、配置描述文件、分析诊断(关机日志、崩溃与内存报告、运行进程、安装日志)。本机浏览和同步 / 导入的浏览记录会分开,来源不明的只算” 参考” 并记一个 gap。

技术上最值得一提的是 SQLite WAL 的处理:提交在 WAL 里但还没 checkpoint 的记录,会在临时副本上校验并重放。这一点很关键 —— 很多取证工具只读主数据库文件,恰好漏掉最新写入的那几条,而那几条往往就是最关键的。JSON 里保留 wal_sha256 和 record_id,可追溯。

v0.1.12 的更新日志讲了一个很真实的故事:

一台 512 GB 手机、照片库很大,Manifest.db 超过 300 MB,Info.plist 超过 16 MiB,超了检查上限,报告只说” 超过读取限制”,手机上什么都没查。现在上限按当代手机重新设定(单文件 1 GiB、100 万条 manifest 记录、总计 8 GiB、属性列表 64 MiB),同时仍然限制一个精心构造的备份能把检查拖垮到什么程度。

这种” 上限是为了防 DoS,但上限设错了会静默地什么都不查” 的权衡,只有真的收到过用户报告才会写出来。

测试覆盖 iOS 15、16、17、18 的表结构。不支持的编码、损坏的 WAL、读不到的行,全部记为 gap;同一张表里某一行读不了,会跳过并继续读后面的行,重复的 gap 会被合并。

九、取舍:为什么不签名、不用 YARA、不装依赖

这个项目的每一个” 不做”,背后都有理由,而且理由都写在仓库里。

1)默认只用标准库,不 pip install。

代价是解析能力受限(不能随便用第三方 SQLite、plist 增强库),收益是一个记者照着 README 装完 Python 就能跑,不需要理解虚拟环境、依赖冲突,也不会在执行 pip install 时把自己暴露给供应链攻击。对一个” 给高风险人群用的工具” 来说,这个交换是对的。

2)发布一个可复现的单文件,而不是 wheel。

build/bundle.py 把同一份源码打成一个 dist/zero-breach-toolkit.py。v0.1.13 是 973,579 字节,SHA-256 是 bb0e6a7a36ee659c0494d02e9f7495d5fd4701f1ab7e57529e79925cb581ae15。README 教你从 release tag 重新构建再比对哈希 —— 构建是确定性的,哈希一致就意味着” 发布出来的文件里只有这份源码,没有别的东西”。发布还带 GPG 签名。

3)不提供签名的 Windows exe。

理由写在 README 里,而且很坦白:项目是匿名的,代码签名证书需要具名的法律身份。自己用 PyInstaller 打的 exe 没有签名、没有下载信誉、也不是字节可复现的,SmartScreen 会警告、部分杀软会报毒,而上面那套” 重建比对哈希” 的验证流程对它不适用。

所以你只能从源码跑。这确实劝退了一部分人,但它把” 可验证” 放在了” 好装” 前面。

4)默认不跑 YARA。

--patterns 是可选的,需要 Python 3.9+ 和钉死哈希的 YARA-X 1.21.0 wheel(拒绝源码构建)。默认扫描不加载它,因为:

  • 公开的 Candiru 规则只有两条(微软 2021 年的 DevilsTongue 劫持 DLL 规则、Malpedia 自动生成的规则),覆盖一个具体家族;
  • “规则日期更新” 不等于” 覆盖了新战役”;
  • 没有引擎时它报 UNKNOWN 并记 gap,而不是假装跑过了。

file-pattern 这个证据等级也单独存在,和精确哈希的 on-disk 分开,报告里会保留规则的作者、来源和许可证。

5)域名和 IP 用大小写不敏感的子串匹配。

这是有意为之的粗糙:快、不依赖解析库、能匹配到日志里嵌在长字符串中的域名。代价是误报,用证据分级来吸收,而不是用更复杂的匹配来消除。

6)证据包是明文 ZIP。

--evidence-bundle 生成的是未加密的 ZIP,带完整性清单(SHA-256、工具版本、范围、时间、指标集指纹),限制 128 个原始文件、单个 64 MiB、总计 256 MiB。作者没有做一个” 看起来安全” 的加密层,而是明确说:哈希只能证明之后的完整性,不能证明它和原始设备一致;第一次接触请用最小摘要,完整包请通过和对方约定的安全通道传。

不假装自己解决了它没解决的问题,这一点贯穿全项目。

十、和同类工具比、它不适合谁,以及我自己的判断

对比 MVT(mvt-project/mvt)

MVT 是这个领域最知名的开源工具,同样出自 Amnesty 生态,Python 写,支持 iOS 备份和 Android(配 AndroidQF),强项是时间线(timeline)和 IOC 检查。Zero-Breach-Toolkit 的指标集里就包含 MVT 的 mvt-indicators,属于站在巨人肩膀上。

区别在定位:MVT 是取证框架,命令行分 mvt-ios / mvt-android,输出时间线,需要使用者自己判断;Zero-Breach-Toolkit 是面向非技术用户的一键体检,把” 能看见什么 / 看不见什么 / 这条命中算几级证据” 直接编进结果,并且把桌面端(Windows/macOS/Linux 的浏览器、Prefetch、Amcache、Defender 历史)也纳进来了。MVT 不做桌面。

对比 Loki / THOR

Loki 是开源的 IOC 扫描器(文件名、哈希、YARA),THOR 是它的商业版。它们的目标是” 在一堆文件里找恶意样本”,面向事件响应;Zero-Breach-Toolkit 找的是人身上的痕迹 —— 浏览器历史、HSTS、cookie 主机名、短信、安装历史。前者回答” 这台机器上有没有这个文件”,后者回答” 这台机器有没有和这个基础设施发生过关系”。

对比 Sysinternals Autoruns / Objective-See KnockKnock

Autoruns 列 Windows 的所有自启位置,KnockKnock 列 macOS 的持久化软件。它们是” 列出事实”,不比对 IOC,也不给证据等级。Zero-Breach-Toolkit 的启动项检查更窄(只比对已知指标),但会区分” 配置存在”(persistence / configured)和” 实际执行过”(execution-record),这个区分在 Autoruns 里得靠你自己做。

对比商业取证套件(Magnet AXIOM 之类)

商业套件能做的远多于此:完整文件系统解析、内存取证、加密容器、法庭可采信的报告链。Zero-Breach-Toolkit 明确说了它不替代专家取证。它的位置是” 在联系专家之前,先花 20 分钟看看有没有明显痕迹”。

不适合谁

  • 想要” 证明我干净” 的人。它给不了,任何工具都给不了。
  • 企业批量巡检。没有 agent、没有中心管理、没有并发调度,一次一台。
  • 恶意软件分析师。不做内存、不解密 payload、不做动态分析。
  • 需要法庭采信报告的场景。它的证据包是明文 ZIP 加哈希清单,不是 chain-of-custody。
  • 不愿意读” 这个检查没跑成” 那一段的人。这一段才是报告里最重要的部分,跳过它等于白跑。

适合谁

  • 记者、律师、人权工作者、异见者 ——README 开头就是给这群人写的,连” 点黄按钮下载 Python” 都写了。
  • 企业的安全 / IT 人员,做一次性的疑似入侵初筛。
  • 想把检测能力接进 AI 助手的人:它自带 skills/zero-breach-toolkit/SKILL.md 和一套 --json 契约,是目前少见的” 为被 agent 调用而设计” 的安全工具。
  • 想学取证工具该怎么设计的人。就算你一行都不用,CONTRACT.md 和 docs/METHODOLOGY.md 也值得读一遍。

我自己的判断

这个项目有三个地方让我觉得” 作者是真做过一线”:

第一,它把” 没查到” 和” 查不了” 彻底分开了。 退出码 3 这个设计会让人不舒服,但它诚实地表达了取证的本质:你只能说你查过什么,不能说你没查到就等于没有。绝大多数安全产品在这件事上是含混的,因为含混更好卖。

第二,它对” 证据强度” 的处理是可执行的,不是口号。 14 级证据等级不是文档里的表格,而是 result.py 里的常量、evidence.py 里的传播逻辑、报告里的排序规则。同一串域名,出现在 referenced 和 contacted 两个位置,得到的建议完全不同。

第三,它把不可信输入当成了真正的敌人。 恶意文件名可以带 ANSI 转义序列重绘终端,可以带 bidi 覆盖字符伪装成别的名字,可以被喂给 AI 让它说出” 你被感染了”。sanitize_untrusted() 和” 契约里全是固定文本” 这两个设计,比大多数号称”AI 安全” 的项目做得扎实。

但它也有一个绕不过去的问题:它太新了。

2026-10-25 建仓,五天 3,784 stars,56 个提交,作者匿名。这种项目最需要警惕的就是”star 数涨得太快” 本身 —— 它可能是一个真正有价值的工具,也可能是一次精心包装的传播。README 对这一点的回应是唯一正确的回应:别信我,去验证。 从源码跑、自己重建单文件、比对哈希、看测试。它甚至专门写了一节 “Verify what you run”。

我按它的方法验证了:源码在 scan/ 下没有 src 目录的包装,164 个 Python 文件、3.9 万行,109 个测试文件、约 1.8 万行 —— 也就是说全部 Python 代码里差不多一半是测试。--describe 的输出和 README 一致。我在 Linux 上跑通了 targeted 扫描,拿到了预期中的 exit 3 和 29 个 gap。这些都是我能自己复现的。

至于它能不能真的在你的机器上找到什么 —— 那取决于你的机器上有没有留下痕迹,以及那些痕迹有没有被公开过。这两个条件缺一个,它就什么都找不到,而这不代表你安全。

如果真要给一句建议:把它当成” 体检前的自检问卷”,不是” 体检报告”。查完发现问题,别自己删东西、别去访问它报出来的地址、别把完整报告发到群里,去找 Access Now 的数字安全热线或者 Amnesty Security Lab。README 开头就是这么写的,而且写在最前面,在教你怎么装 Python 之前。


项目地址:https://github.com/interplains/Zero-Breach-Toolkit
文档:docs/COVERAGE.md(每个检查读什么、上限在哪)、docs/METHODOLOGY.md(证据分级与从其他工具学到的东西)、scan/CONTRACT.md(机器契约)
许可证:代码 Apache-2.0;iocs/ 与生成的指标导出 CC0 1.0;third_party/candiru 保留各自许可证