搜索全站

输入至少 2 个字符,查找相关内容。

↑↓ 选择 Enter 打开Esc 关闭

统一日志与启动项

Windows 取证有一条清晰的日志主线:事件查看器、Security.evtx、System.evtx。

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

一、概述

Windows 取证有一条清晰的日志主线:事件查看器、Security.evtx、System.evtx。

macOS 没有这条主线,但统一日志(Unified Logging)承担了同样的角色,而且信息量更大——它把内核、进程、网络、偏好设置的变化统一记进一个子系统化、带毫秒精度、带进程与线程归属的日志库。

麻烦的是它长得不像日志。/var/db/diagnostics/ 下全是 0000000000001ec5.tracev3 这类文件名——十六进制命名、.tracev3 后缀、二进制索引。

取证员打开看到乱码,最常见的反应是「macOS 日志不可用」。

这是对 macOS 最大的误判之一:tracev3 内部是高度结构化的,只是不能 cat。log show 才是它的解析入口。

第二个主题是 launchd。macOS 10.10 之后所有启动项由 launchd(PID 1)统一管理,一个 plist 文件就是一个任务,按目录约定自动加载。

这个设计对取证是双面的。

好处:配置是纯文本 plist,可直接解析、可与命令历史对照,不像 Windows 要在五个 Run 键之间猜。

坏处:用户往 ~/Library/LaunchAgents/ 放一个 plist 就获得开机自启,几乎不产生用户可见提示。这是恶意持久化的高发地。

这两类都是 macOS 上最基础的系统记录,属于分析环节的证据固定阶段。

上游依赖镜像准备与接入。统一日志的完整性高度依赖设备当时的运行状态——运行中的机器有内存态缓存,异常断电的机器只剩磁盘态文件,这个差异直接决定能查到什么,见macOS 取证工件地图。

下游支撑三件事。日志里的启动与加载记录是判断 launchd 任务是否真的执行过的唯一来源(配置存在只说明打算执行);plist 清点是plist 与偏好设置的前置;两者的时间点要并入统一时间线。

动手前要做三件事:确认日志文件存在与覆盖范围、确认 launchd 的加载域(system / gui / user)、确认 plist 的实际生效状态。

痕迹的形态与位置:

形态 位置 关键结构
统一日志数据文件 /var/db/diagnostics/ 下 *.tracev3 二进制索引 + 压缩条目,文件名是十六进制序列号,不可按内容检索
统一日志目录 /var/db/uuidtext/ 日志条目的人类可读文本形式,与 tracev3 配对
日志流签名 logging profile plist(/System/Library/Preferences/Logging、/Library/Preferences/Logging) 决定哪些消息被持久化,默认设置下 Info/Debug 只在内存中,不落盘
系统级启动项 /Library/LaunchDaemons/、/System/Library/LaunchDaemons/ 系统级,开机即加载
用户级启动项 ~/Library/LaunchAgents/ 用户登录后加载,用户可自行放置,是持久化高发地
全局启动项 /Library/LaunchAgents/ 所有用户共享
plist 配置 Label、Program / ProgramArguments、RunAtLoad、KeepAlive、StartCalendarInterval、WatchPaths Label 就是任务身份,判读时优先看它
活动状态 launchctl list(仅在线系统) 加载状态与最近退出码,离线镜像里取不到
崩溃报告 ~/Library/Logs/DiagnosticReports/ 与统一日志时间线可合并

三个判定要点:tracev3 必须用工具读、RunAtLoad 与 KeepAlive 决定了触发方式的不同语义、用户级与系统级目录的信任级别不同。

答得出来的是:某个进程在某个时段做过什么、某个 launchd 任务是否被加载过、以及最近一次退出的退出码、系统启动项的完整清单与各自的自启方式、某个服务是否配置了周期触发或文件监听。

答不出来的,以及几个必须先认清的边界:

被日志保留策略丢弃的消息。 统一日志有默认的保留与丢弃规则,低优先级、突发量大的消息可能被整段丢弃。「日志里没有」不等于「没发生」。

超出覆盖时段的历史。 断电设备的日志通常只剩很短窗口,这部分必须在报告里限定范围,而不是含糊成「未发现异常」。

launchd 任务的实际动作。 配置告诉你「打算做什么」,是否执行、执行结果如何要看日志与退出码。

离线镜像里的加载状态。 launchctl list 只能在线查询,镜像里只能读配置,不能读运行时状态。

launchd 之外的持久化。 登录项、crontab、shell profile、XPC 服务都不在这个体系内。

用户与进程的归属。 日志记录了进程与用户,但**「这个用户当时坐在机器前」需要输入会话侧的材料**。

要读懂这些,得先熟悉 macOS 目录结构,特别是 ~/Library 的分层与 launchd 的三个加载域,见macOS 取证工件地图;理解 plist 的两种格式(XML 与 bplist00 二进制)及其解析工具,否则读不了启动项配置;

了解统一日志的架构(logd 收集、tracev3 存储、log show 查询)这一层就够,不需要内部实现细节;掌握时间戳的纪元与精度概念——统一日志是毫秒精度,与文件系统秒级时间戳合并时要留意,见时间戳换算。

还有一条纪律:清楚只读接入的边界,log show 在某些情况下会触发日志归档动作,操作前应先固定原始文件。

二、核心原理

2.1 统一日志的双层架构

macOS 10.12 之后所有日志走同一套 os_log 接口,后端是 logd。关键在于双层:

调用方 (os_log / NSLog / print)
      ↓
   logd  ← 运行时内存日志(环形缓冲,高速,容量有限)
      ├──────────────┬─────────────
      ↓              ↓
  tracev3 文件    Signpost 性能采样
  (异步落盘)
存储位置 特性 取证意义
/var/db/diagnostics/Persist/*.tracev3 持久化,跨重启保留 ★★★★★ 离线镜像的主要目标
/var/db/diagnostics/Special/*.tracev3 特殊/高优先级事件 ★★★
/var/db/diagnostics/Signpost/*.tracev3 性能采样点 ★★ 偶有应用行为
logd.0.log、logdata.statistics.*.jsonl 日志系统自身的元数据 ★★★★ 体量与进程分布记录

「持久化」是相对于内存日志而言的——tracev3 会落盘,但会滚动清理,空间不足时最早的文件被淘汰。所以「日志缺失」通常不是没记录,而是被保留策略淘汰。

.tracev3 是二进制索引格式,不是纯文本。strings 或 grep 只能得到零散碎片,时间戳与消息字段是二进制编码的。必须用 log show 解析。 这是本篇最重要的一条认知。

2.2 log show 谓词语法

能力全在 --predicate 上。谓词是布尔表达式,支持 ==、!=、<、>、AND、OR、NOT、CONTAINS[c]、BEGINSWITH。

维度 字段 示例
进程 process 'process == "Safari"'
调用者镜像 senderImagePath 'senderImagePath CONTAINS "securityd"'
子系统 subsystem 'subsystem == "com.apple.securityd"'
分类 category 'category == "GATEKEEPER"'
消息内容 eventMessage 'eventMessage CONTAINS "password"'
线程 / 消息类型 threadID、messageType 'messageType == "Error"'

subsystem 是最有效的过滤维度:Apple 自家组件用反向域名命名(com.apple.*),第三方应用也鼓励这样做。查某个系统行为时先猜子系统名,比猜进程名效率高一个数量级。

2.3 三个必须知道的默认行为

行为 默认 影响
日志级别 只显示 default 与 error --info/--debug 的记录默认不输出,必须显式加
时间范围 不加 --last 时输出全部历史 扫全库耗时数分钟,高速率下会丢行
输出样式 --style default(含颜色图形) 解析时必须改 json 或 ndjson

log 在 zsh 里是内建数学函数,直接敲 log show 会报 "too many arguments"。用绝对路径 /usr/bin/log,或先 which -a log 确认。

log show 是查询,log collect 是导出——把日志打成 .logarchive 包(内部是目录,含 Persist/、LogData.sqlite 等)。

选型逻辑:目标明确 → log show + 谓词;需要完整日志或需交付委托方复核 → log collect 导出。两者不能互相替代。

2.4 launchd 的加载顺序

launchd 取代了传统的 init/rc 与 System Events 的 AppleScript 自动登录项。

# 路径 时机 权限
1 /System/Library/LaunchDaemons/ 开机即加载(最早,系统卷只读) root
2 /Library/LaunchDaemons/ 开机即加载 root
3 /System/Library/LaunchAgents/ 登录后 root
4 /Library/LaunchAgents/ 登录后 root
5 ~/Library/LaunchAgents/ 该用户登录时 该用户
6 /Applications/X.app/Contents/Library/LaunchAgents/ 随应用安装 安装时指定

LaunchDaemons 与 LaunchAgents 的区别不只是权限:前者开机就跑,无需任何用户登录,因此也是隐蔽性最好的位置。

2.5 plist 结构与关键字段

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>Label</key>            <string>com.vendor.helper</string>
    <key>ProgramArguments</key> <array>
        <string>/usr/local/bin/helper</string>
        <string>--mode</string>
    </array>
    <key>RunAtLoad</key>        <true/>          <!-- 加载即启动一次 -->
    <key>KeepAlive</key>        <true/>          <!-- 退出即重启,持续驻留 -->
    <key>StartInterval</key>    <integer>3600</integer>
</dict>
</plist>
字段 含义 检查点
Label 任务唯一 ID 是否伪装成系统组件
ProgramArguments 执行的命令行(数组) 绝对路径?指向哪?
Program 单个可执行文件(ProgramArguments 的简写) 二者并存时 ProgramArguments 优先
RunAtLoad / KeepAlive 加载即启动 / 退出即重启 后者代表持续驻留,最值得警惕
StartInterval / StartCalendarInterval 周期 / 定时(类 cron) 周期过短值得关注
WatchPaths 指定路径变化时启动 触发器式
StandardOutPath / StandardErrorPath 输出重定向 日志落在哪,可交叉验证
UserName 以指定用户运行 降权属正常
Disabled true 则不加载 被禁用仍是历史证据

Label 伪装是最需警惕的一点。位于 ~/Library/LaunchAgents/ 的 plist 若 Label 写成 com.apple.securityd 之类,它不是 Apple 自带的——系统组件只存在于 /System/Library/LaunchDaemons/。

"第三方目录 + 系统风格 Label" 是明确的伪造信号。

2.6 判断异常的六条规则

只看现象,不写实现。

# 规则 判定依据
1 位置与 Label 不符 Label 声称 com.apple.* 却不在系统目录
2 KeepAlive = true + 可疑路径 持续重启的陌生程序
3 可执行文件在非常规位置 /tmp/、~/Downloads/、/var/tmp/、/Users/Shared/
4 StartInterval 过短 60~300 秒的外联轮询
5 无有效签名 codesign 无 TeamIdentifier、spctl rejected
6 文件名与 Label 不一致 文件名 update.plist 但 Label 无关

判定为"异常"后必须做的事:记录 plist 的创建/修改时间(stat)确定植入时间点;记录目标可执行文件的哈希固定证据;在统一日志中查该 Label 的加载历史确认何时开始运行;查 /private/var/log/install.log 确认是否有对应安装记录;查该路径下是否还有其他关联文件以判断是单点还是一组。

"有异常启动项"不等于"已定性为恶意"。 报告应写事实(Label、路径、修改时间、签名状态、运行历史),定性交给调查方结合上下文判断。

未经签名验证就写"木马"是越权结论。

2.7 launchctl 查询(仅在线系统)

launchctl list 输出三列:PID(进程号,- 表示当前未运行)、Status(最近一次退出状态码,0 正常、非 0 说明反复崩溃)、Label(与 plist 的 Label 对应)。

launchctl print system/com.apple.cupsd 可取单任务详细状态。

离线镜像上 launchctl 不可用(它查的是运行中的 launchd)。离线靠 plist 文件本身 + 统一日志里 launchd 域的加载记录。

2.8 落盘与留存是两个独立开关

一条日志消息要出现在 Persist/ 里的 .tracev3 中,得同时过两道关,而这两道关由不同键控制:

键 作用 取值
Level → Enable 决定这类消息是否进入日志系统 Inherit / Default / Info / Debug / Off
Level → Persist 决定进入系统的消息是否从内存写进 Persist/ 文件 同上

两级都是层级的:设成 Debug 时 Default、Info、Debug 全部命中;设成 Info 时 Debug 一条都不进来。

这套开关由 logging profile plist 承载,位置是 /System/Library/Preferences/Logging(系统卷,只读)与 /Library/Preferences/Logging(管理员可写,后者覆盖前者)。profile 按进程名或子系统名命名,例如 com.apple.iohid.plist;顶层键是 DEFAULT-OPTIONS 或各 category 名,Level 字典下面是 Enable 与 Persist 两个字符串键,另有 TTL 控制各类消息的留存天数。

Enable 命中但 Persist 没命中的消息,在 log stream 里能看到,内存环形缓冲区里也有,但永远不落盘——采集镜像时 Persist/ 里就是没有,log show 在离线镜像上也读不出来。这就是"某行为在实时监控里看得见、镜像里查无此事"的成因,它不是采集遗漏,是配置使然。

sudo log config --status --subsystem <子系统> 读当前档位,--mode "level:debug" 或 --mode "persist:debug" 改档。--mode 立即生效并写回 profile,直接改 profile 文件可能要重启才生效。

取证上的意义有两点:一是某类消息在镜像里缺失时,先确认它是被 Persist 挡掉还是压根没产生,"没找到"和"不留存"是两件事,结论表述不能混;二是自定义 profile 会一起进入 logarchive 和 sysdiagnose 输出,把它们交出去等于同时交出"这台机器当时在记录什么"。


三、操作步骤

3.1 第 1 步:确认日志存在性与覆盖范围

DIAG=/mnt/mac-data/var/db/diagnostics ls -1 $DIAG/Persist/.tracev3 2>/dev/null | wc -l # 文件数 du -sh $DIAG/Persist $DIAG/Special # 体量 ls -1 $DIAG/Persist/.tracev3 | head -1 # 最早 ls -1 $DIAG/Persist/*.tracev3 | tail -1 # 最新 stat -f '%Sm' -t '%Y-%m-%d %H:%M' $DIAG/Persist/0000000000000a1f.tracev3

文件名是十六进制递增的,差值反映覆盖的写入序号范围;结合 mtime 确定时间跨度

日志体量随时间的变化(jsonl 每行一条,字段 unixTime / totalBytes / processList)

tail -3 $DIAG/logdata.statistics.0.jsonl | jq -r '[.unixTime, .totalBytes] | @tsv' 2>/dev/null

实测说明:logdata.statistics.*.jsonl 的字段是 unixTime、totalBytes、processList,不包含 dropped 字段。

判断"是否因配额丢弃"应看 totalBytes 是否贴近存储上限,以及 Persist/ 目录的实际文件时间跨度。不要去 grep 一个不存在的字段。

这一步决定后续查询的 --last 取值。

3.2 第 2 步:查询与导出

离线镜像:--directory 直接读镜像内的 tracev3,无需挂载到系统目录(关键)

/usr/bin/log show --directory $DIAG --last 24h --info --style json
--predicate 'process == "Safari"' > /evidence/safari-24h.jsonl

JSON 字段提取

/usr/bin