关键词:Spotlight、mdfind、mdls、kMDItem、kMDItemWhereFroms、排除规则、索引局限
难度:进阶
前置知识:macOS 目录结构(见01)、基础元数据概念
一、概述
Windows 上排查「哪些文件是从网上下载的」,要查 Zone.Identifier 备用数据流、查浏览器下载列表、查 Prefetch 哈希。macOS 上有一条比这些都直接的路:Spotlight 索引。
Spotlight 是 macOS 的系统级文件索引,在后台为文件维护一套结构化元数据。这套元数据里有个字段直接记录「这个文件从哪些 URL 获得」——kMDItemWhereFroms。它是 macOS 上信息密度最高的单条工件:一条记录同时给出文件名、多个来源 URL、以及时间,而这三样在别的系统上通常分散在三处。
但这条路有三道绕不过去的限制:
| 限制 |
后果 |
| 索引是数据库,不是文件系统的一部分 |
离线镜像里无可靠解析路径,只能现场查询 |
| 排除规则大量存在 |
加密卷、网络卷、隐私目录、部分系统目录根本不入索引 |
| 索引存在滞后 |
刚创建的文件可能未入索引,未索引不等于不存在 |
kMDItemWhereFroms 因此是 macOS 上证明文件来源的最强单条证据,但它必须现场采集,且必须与 Quarantine 属性配对解读。
这个议题的特殊之处在于:主要工作发生在现场,不在实验室。
- 上游是现场判断,不是镜像。采集
mdfind 输出需要目标系统在线,这一步如果没在现场做,实验室里无法补做——这是它和其它 macOS 工件最大的不同。
- 它自己要先记录三样东西:命令原文、
mdutil -s 的索引状态输出、sw_vers 的系统版本。这三样是后续复核的前提,不带回来的输出在报告里没有证明力。
- 下游支撑文件来源的定性。找到来源 URL 之后,还要靠Quarantine 与 Gatekeeper确认这个 URL 是被系统判定过的下载来源、靠浏览器取证(Windows 侧)确认是否有对应的访问记录、以及靠文件系统确认文件当前是否还存在。
- 它的证据位阶要摆正:Spotlight 是索引,不是原始记录。优先级低于浏览器历史、下载目录、Quarantine 属性——索引可以被重建、被覆盖、被同步,原始记录不会。
落到具体物件上,形态是这样的:
| 形态 |
位置 |
关键结构 |
| 索引数据库 |
<卷>/.Spotlight-V100/ |
二进制数据库,在 Linux 上无可靠解析器;strings 得到的 URL 片段缺路径与时间归属 |
| 索引服务状态 |
mdutil -s 输出 |
决定索引是否启用、是否被 .metadata_never_index 关闭 |
| 查询工具 |
mdfind(中央元数据存储)、mdls(单路径全部属性) |
mdfind 查的是运行系统的索引服务,对镜像路径执行会返回空或 (null) |
| 排除标记 |
.metadata_never_index(目录内空文件,整棵子树排除)、.noindex 后缀 |
★ 发现这个标记本身就是重要结论——绝大多数用户不知道这个机制 |
| 隐私排除 |
系统与应用的隐私清单 |
属正常设计,不是人为规避 |
| 系统卷 |
Big Sur+ 的 SSV(/System)签名只读卷 |
不入索引,属预期行为,不构成异常 |
| 核心字段 |
kMDItemWhereFroms(来源 URL 数组)、kMDItemFSContentChangeDate、kMDItemFSCreationDate |
URL 数组可含多个值,判读时不能只取第一个 |
| 配对证据 |
com.apple.quarantine 扩展属性 |
必须与 kMDItemWhereFroms 配对,见Quarantine 与 Gatekeeper |
据此能回答的是这些:某个文件的下载来源 URL、文件的内容修改与创建时间、某个关键词在全盘出现过、以及某个目录内是否有可被系统索引的文件。
回答不了的是这些,其中第一条是最贵的误判。
- 「搜不到」不等于「不存在」。
.metadata_never_index 能让整棵子树不入索引,.noindex 后缀改个名就能绕过,卷级排除让外接盘上的文件全部搜不到。把索引结果当文件存在性证明,会直接漏掉整类证据。
- 离线镜像里的索引内容。
.Spotlight-V100 在 Linux 上无可靠解析器,strings 得到的片段缺路径与时间归属;mdfind / mdls 在分析机上对镜像路径执行会返回空。这一条决定了它必须在现场采集。
- 文件被删除后的存在性。 索引有滞后与重建机制,删掉的文件可能仍在索引里,也可能已被移除——两个方向都可能。
- 来源 URL 的可达性与内容。 索引记录的是元数据,不是访问行为。要看实际访问过程得靠浏览器侧材料。
- 网络卷与加密卷上的文件。 FileVault 未解锁的卷不入索引,这类文件即使存在也搜不到。
- URL 的语义。
kMDItemWhereFroms 里的一串 URL 可能是下载源,也可能是重定向链条的中间环节,判读需要结合 Quarantine 属性的时间戳。
读到这里你应该已经知道:macOS 目录结构与卷布局,理解索引是卷级而非全局的;元数据查询的基本概念(mdfind 的谓词语法、mdls 的属性名),至少要知道哪些属性存在、哪些属性常为空;com.apple.quarantine 扩展属性的结构与它记录的时间戳,否则无法做配对解读,见Quarantine 与 Gatekeeper;时间戳的精度与时区处理,索引时间与文件系统时间的基准必须统一。
还有一条纪律要立住:阴性结论必须限定范围。涉及索引查询的否定性结论,必须写明检查了哪些卷、索引状态如何、哪些范围未覆盖。
二、核心原理
2.1 Spotlight 索引的位置
| 路径 |
版本 |
说明 |
/.Volume275901/ds_store |
10.5–10.9 时代 |
早期实现,卷号随机,路径不可预测 |
/.Spotlight-V100 |
10.10–10.15 |
非系统卷上的用户卷索引 |
/System/Volumes/Data/.Spotlight-V100 |
Big Sur+(11 起) |
现代位置,随数据卷一起镜像 |
.Spotlight-V100 目录普通用户无读取权限(实测属组为 root:_mds_stores,权限 drwx--x---,普通用户 ls 报 Operation not permitted)。这正是它作为易失数据必须在现场采集的原因。
Big Sur+ 上它位于数据卷内,会随数据卷一起被 dd 进镜像。
但索引文件是二进制数据库(Store 文件),没有公开可靠的解析工具链——Linux 分析机上直接 strings 只能得到碎片化 URL,缺少归属路径与时间,不能作为证据。
正确做法是现场用 mdfind 把结果导出为文本,带回实验室与磁盘遍历结果交叉验证。
2.2 mdfind:查询中央元数据存储
mdfind 查的是"中央元数据存储"(central metadata store),不是文件系统。
mdfind [-live] [-count] [-onlyin dir] [-name fileName] [-literal] [-interpret] query
| 参数 |
作用 |
取证用法 |
(无 -onlyin) |
全机搜索 |
范围太广,容易带出无关用户数据 |
-onlyin <dir> |
限定搜索目录 |
★ 取证首选,锁定到检材的用户目录 |
-name <fileName> |
只按文件名匹配 |
找特定文件名,不查内容 |
-count |
只输出匹配总数 |
先看数量,再决定是否列出路径 |
-literal |
强制按字面量处理 |
文件名含空格/括号时必须加 |
-interpret |
按 Spotlight 菜单规则解析 |
模拟"用户在搜索框里打了这句话" |
-interpret 的实际行为(man mdfind 原文举例):查询串 search 会被解释为 (* = search* cdw || kMDItemTextContent = search* cdw),即同时匹配文件名与文件内容。
它是"用户视角"检索,可印证"用户当时在搜索框里搜这个词会看到什么",但依赖索引完整性,不能作为唯一检索手段。
mdfind 支持的谓词远比普通用户以为的多:
mdfind "kMDItemFSName == '*.docx' && kMDItemFSCreationDate >= \$time.today-7"
mdfind "kMDItemTextContent == '*invoice*' && kMDItemFSOwnerUserID == 501"
mdfind "kMDItemContentType == 'com.microsoft.excel'"
mdfind -count "kMDItemWhereFroms == '*example.com*'" # ★ 黄金查询
2.3 mdls:读取单个路径的全部属性
mdls 直接向索引询问某一个路径的所有 kMDItem* 属性:
mdls <path> # 全部属性
mdls -name kMDItemWhereFroms <path> # 只取一个
mdls -name kMDItemTextContent <path> | head -c 500
mdls 输出一批 = (null) 是正常现象。
实测在新建文件上,文件系统类属性(kMDItemFSCreationDate、kMDItemFSName、kMDItemFSSize)会返回 (null),因为该文件还没进入索引。
(null) 只表示"索引里没有这条记录",不表示文件没有时间戳。取真实时间戳必须用 stat -f 'btime=%SB mtime=%Sm'。
把 mdls 的 (null) 写成"时间戳被清除"是严重错误,会导致整个时间线重建失效。
2.4 kMDItemWhereFroms:macOS 最实用的单条元数据
这是本篇重点。它是一个数组,记录文件是从哪些 URL 获得的:
kMDItemWhereFroms = (
"https://cdn.vendor-portal.example/assets/price-2024Q3.xlsx",
"https://partner.vendor-portal.example/login"
)
| 特性 |
说明 |
| 第一个元素 |
通常是文件的直接下载地址(含完整路径与查询串) |
| 第二个元素 |
常是下载页/会话来源——证明"用户从哪个页面点的" |
| 数量 |
多数为 1–2 条;多次下载/多来源会有更多元素 |
| 时间戳 |
★ 该字段本身不含时间,是纯 URL 数组 |
必须讲清的技术边界:kMDItemWhereFroms 不携带下载时间。下载时间记录在** com.apple.quarantine 扩展属性的第 2 个字段**(Unix 秒)。这两个工件必须配对解读——WhereFroms 给 URL,Quarantine 给时间。 把 kMDItemWhereFroms 描述成"含下载时间的字段"是技术上不准确的。详见05。
报告中应引用完整数组,不应只取第一个——第二个元素常常是证明"用户从哪个页面点的"的关键,也常常是唯一能指向具体客户/订单页的元素。
2.5 排除规则:"搜不到"往往不是"不存在"
不正确理解排除规则,就会把"没搜到"错当成"没有"——本篇最贵的误判就是这么来的。
| 排除类型 |
机制 |
取证影响 |
| 卷级排除 |
FileVault 未解锁的卷、网络卷(/Volumes/* 多数)不入索引 |
移动硬盘、外接磁盘上的文件全部搜不到 |
.metadata_never_index |
目录内放这个空文件,整棵子树不入索引 |
★ 必须逐卷查找这个文件 |
.noindex 后缀 |
目录名或文件名以 .noindex 结尾即排除 |
改名即可绕过索引 |
| 隐私排除 |
系统与应用的隐私清单排除某些目录 |
属正常设计,非人为 |
| 系统卷 |
Big Sur+ 的 SSV(/System)签名只读卷不入索引 |
/System 下无个人痕迹,属预期 |
find /mnt/mac-data -name '.metadata_never_index' 2>/dev/null
find /mnt/mac-data -name '*.noindex' 2>/dev/null
发现 .metadata_never_index 本身就是重要结论。 绝大多数用户根本不知道这个机制。报告写法应是"该目录因放置 .metadata_never_index 标记而未纳入系统索引",并同时说明"目录内文件已通过直接遍历完整提取"——**"系统搜不到"绝不等于"文件不存在"。
2.6 索引不可用与未索引文件
离线镜像的三个现实:
.Spotlight-V100 是二进制数据库,Linux 上无可靠解析器;strings 得到的 URL 片段缺路径与时间归属。
mdfind / mdls 查的是运行系统的索引服务,在分析机上对镜像路径执行会返回空或 (null)。
- 索引是易失数据,会被
mdsync、卷挂载、Spotlight 重建覆盖。
结论:Spotlight 的正确采集时机是现场。
现场把 mdfind 结果导出为文本,同时记录命令原文、mdutil -s 状态输出、sw_vers 版本,带回实验室交叉验证。
优先级低于浏览器历史、下载目录、Quarantine 属性——因为它是索引,不是原始记录。
三、操作步骤
3.1 现场:先记录索引状态
sw_vers # 版本决定索引格式
mdutil -s / # 索引是否启用
mdutil -as / # 各卷索引状态
ls -ld /System/Volumes/Data/.Spotlight-V100 # 确认索引目录存在
mdutil -s 的输出必须原样记入证据记录。
若输出 Index is disabled 或 Index is read-only.,mdfind 的结果必须降级为线索而非证据。
3.2 现场:mdfind 分层查询
# 第 1 层:先看数量,避免结果淹没终端
mdfind -count -onlyin /Users/liwei "kMDItemFSName == '*.xlsx'"
# 第 2 层:★ 黄金查询——按来源域名筛文件
mdfind -onlyin /Users/liwei "kMDItemWhereFroms == '*vendor-portal.example*'" \
> /evidence/sp-wherefroms.txt
# 第 3 层:按时间窗筛近期文件
mdfind -onlyin /Users/liwei "kMDItemFSName == '*.xlsx' && kMDItemFSCreationDate >= \$time.today-30"
# 第 4 层:模拟用户搜索(用户视角)
mdfind -interpret -onlyin /Users/liwei "客户 报价"
# 特殊字符必须用 -literal
mdfind -literal -name '2024 报价单.xlsx' -onlyin /Users/liwei
3.3 现场:mdls 导出元数据
# 从 mdfind 结果批量导出(★ 现场必做,索引不带走)
while read -r f; do
echo "=== $f"
mdls -name kMDItemWhereFroms "$f"
mdls -name kMDItemFSCreationDate "$f"
mdls -name kMDItemContentType "$f"
done < /evidence/sp-wherefroms.txt > /evidence/sp-metadata.txt 2>&1
每条结果必须与 stat 的真实时间戳并列记录:
stat -f 'btime=%SB mtime=%Sm ctime=%Sc %N' -t '%Y-%m-%d %H:%M:%S' "$f"
配对表(★ 本篇交付物模板):
来源 URL(kMDItemWhereFroms) |
下载时间(Quarantine 第 2 字段) |
文件 btime |
路径 |
结论强度 |
https://cdn.vendor-portal.example/…/price-2024Q3.xlsx |
2024-09-05 14:22:07 |
2024-09-05 14:22:07 |
~/Downloads/… |
强(双源一致) |
https://example.com/dl/invoice.pdf |
(无 Quarantine) |
2024-11-14 22:10:03 |
~/Documents/… |
中(只有 URL 无下载时间) |
3.4 现场:Quarantine 时间回填(与第 5 篇配对)
while read -r f; do
q=$(xattr -p com.apple.quarantine "$f" 2>/dev/null) || continue
[ -n "$q" ] && printf '%s\ %s\
' "$(printf '%d' "0x${q#*;}")" "$(printf '%d' "0x${q#*;*;}")" "$f"
done < /evidence/sp-wherefroms.txt
${q#*;} 取第 2 段(旗标之后的 Unix 秒),printf '%d' 0x… 把它从十六进制解码为十进制 Unix 时间;${q#*;*;} 取第 3 段(下载应用名)。这个解码是第 5 篇的核心,本篇不重复。
3.5 验证
| 验证对象 |
方法 |
通过标准 |
| 索引可信度 |
mdutil -s 输出 |
Index is enabled 且未被禁用 |
| 结果非空集 |
mdfind -count |
计数与后续列表条数一致 |
| URL 归属明确 |
每条 URL 对应到唯一文件 |
无孤儿 U |