Spotlight 索引与元数据

Windows 上排查「哪些文件是从网上下载的」,要查 Zone.Identifier 备用数据流、查浏览器下载列表、查 Prefetch 哈希。macOS 上有一条比这些都直接的路:Spotlight 索引。

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

关键词: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 索引不可用与未索引文件

离线镜像的三个现实:

  1. .Spotlight-V100 是二进制数据库,Linux 上无可靠解析器;strings 得到的 URL 片段缺路径与时间归属。
  2. mdfind / mdls 查的是运行系统的索引服务,在分析机上对镜像路径执行会返回空或 (null)。
  3. 索引是易失数据,会被 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