一、概述
前面几篇都是具体引擎的具体做法。本篇讲骨架。
不管你拿到的是手机里的 .db、服务器上的 ibdata1,还是缓存里的 dump.rdb,第一小时该怎么走、每一步产出什么、结论在什么情况下才算成立——这些跟引擎无关。
有三个问题最容易漏,而且漏了会一路错到结论。
⭐ 1. 时间线陷阱:datetime 没有时区
MySQL 的 DATETIME、PostgreSQL 的 timestamp without time zone、MongoDB 的 BSON Date、JSON 里的字符串时间——它们存储时都不带时区信息。
它们的含义完全依赖服务器/数据库当时的 time_zone 设置,而这个设置可以被随时改、且不记录在数据里。
一旦跨系统比对(应用日志、Nginx 访问日志、Syslog、内网其他服务器),8 小时甚至更多时差的错位几乎必然发生。
⭐ 2. 容器里的 TZ=UTC 陷阱
越来越多的服务跑在容器里。容器镜像默认时区就是 UTC,且经常与宿主机时区不一致。
运维看到的日志时间是 UTC,取证人员按本地时区解读,同一行日志会有两种"正确"读法。
必须确认数据库所在运行环境(宿主 or 容器)的实际时区,不能看"服务器在哪"来判断。
⭐ 3. 缺失 ≠ 未发生
数据库层面的"查不到"至少有六种完全不同的原因,任何一种都不能写成"没有这个行为":
已删除 / 未落盘 / 已过期清理 / 从未同步到该节点 / 归档窗口已过 / 检材提取不完整。
在取证流程中的位置:本篇是本板块所有具体引擎文章的前置方法论,读它应该在读 SQLite WAL 或 MySQL 之前。它给出一条固定的六个阶段:检材固定与环境记录 → 引擎识别 → 整体归档 → 时间基准归一 → 一致性与完整性校验 → 报告输出。每个阶段都有明确产出,跳过任何一个都会在后面某个环节以更贵的形式还回来——尤其是时间基准归一,它必须在任何查询执行之前完成,因为 mysqldump 一次就足以在检材副本上留下访问痕迹。上游依赖是 只读挂载与安全接入(归档动作必须只读、哈希先于分析)与 取证流程与原则(证据链与结论标准)。下游是本板块的三个具体篇目:SQLite 基础与结构、SQLite WAL 取证、MySQL 数据库取证、NoSQL 与 Redis 取证——它们各自的具体做法都建立在本篇的阶段划分之上,尤其时间基准这一段,四篇共用同一套纪律。
本篇处理的证据形态,横跨三类对象,判据各不相同:
| 类别 |
具体对象 |
判据 |
| 数据库文件 |
app.db、app.db-wal、app.db-shm(SQLite 三件套);ibdata1、各 .ibd、binlog.00000X、undo_001(InnoDB 家族);WiredTiger 的 collection-*.wt 与 WiredTiger.turtle;dump.rdb 与 AOF 分片 |
看目录构成与文件头,不看扩展名;三件套必须整体哈希,单独哈希主库没有意义 |
| 配置 |
my.cnf 与 ~/.my.cnf、postgresql.conf、redis.conf、/etc/mongod.conf;容器的 Dockerfile / compose / K8s manifest |
读取顺序是配置文件 → 环境变量 → 命令行参数,后者覆盖前者,所以只看配置文件会得出错误配置 |
| 时区证据 |
三处独立来源:进程环境变量 TZ、数据库参数 @@time_zone / @@system_time_zone、日志里已渲染出来的时间;物证是 /etc/localtime 的符号链接指向与 /usr/share/zoneinfo/ 下的文件 |
/etc/localtime → Etc/UTC 是容器 UTC 时区最直接的物证;环境变量在内存镜像里也能捞到 |
| 完整性证据 |
归档包的 SHA-256、页级校验(如 SQLite 的 WAL 帧滚动校验和)、解析时的 PRAGMA integrity_check 一类只读校验 |
哈希要在分析前、分析后各算一次,两次一致才说明分析过程没有写坏副本 |
检材在什么状态下被接收、它包含哪些引擎的文件、时间基准是什么(依据哪三处证据得出)、文件与归档包是否完整一致、每个结论的覆盖范围到哪里。
"数据库里没有记录"永远不等于"这件事没有发生"——time_zone 一旦被改过且无记录,你根本判断不出那条时间戳当初是按哪个时区写的,这时任何时间换算都是猜测,报告里必须把不确定性写出来而不是给一个看似精确的时间。查不到记录的六种成因彼此无法区分,需要靠同批次的其他工件(日志、备份、binlog 过期窗口)缩小范围,而不能靠数据库本身下结论。归档完整不等于内容完整:整体哈希一致只能证明文件没变,证明不了提取时就没漏。阴性结果必须限定到具体范围与时间窗,并列出未覆盖对象(未提取的应用目录、未纳入归档的日志、已过期的 binlog);而且"查不到"永远无法排除它从未被写入数据库这个可能之外的路径——比如数据只存在于客户端缓存或另一个副本库里。
读者前提:需要已经能识别检材属于哪种引擎(否则"阶段二:引擎识别"就是天书),至少读过本板块的 SQLite 或 MySQL 两篇之一;需要能读十六进制并理解偏移、页、大小端这类基本概念,因为完整性校验和时区物证的判断都要落到具体字节上;需要接受"证据链记录"本身就是交付物的一部分,而不是事后补写;还要理解 mysqldump 这类工具是有副作用的——它能触发 checkpoint、能写查询历史、能在文件系统上留下访问痕迹,所以任何导出动作必须在副本上做,并在采集记录里写明执行时间。
二、核心原理
2.1 数据库状态是文件集合,不是文件
任何数据库在某个时刻的状态,都是好几个文件一起定下来的。少一个,那个时刻的状态就不完整。
| 组成 |
作用 |
缺失后果 |
| 数据文件 |
表/集合/键的实际内容 |
无法读取 |
| 结构元数据 |
表定义、列类型、集合映射 |
读得出字节,读不出含义 |
| 日志(WAL / journal / binlog) |
未落盘增量、历史值 |
丢失最新数据与删除前值 |
| 索引文件 |
加速与完整性辅助 |
部分查询退化,索引存在性无法验证 |
| 内存状态 |
未刷盘缓冲、活跃会话 |
关键数据完全不可见 |
| 配置 |
引擎、时区、字符集、加密 |
解释错误、时区错位、解密失败 |
通用规则就一条:能整体打包就整体打包,别做"选择性拷贝"。
选择性拷贝的错误是静默的。命令成功,包也出来了,一个警告都没有——只是内容少了。
2.2 时间的三种存储形态
| 形态 |
示例 |
带时区? |
判读要求 |
| Unix 时间戳 |
1724297599、MySQL INT、Redis AOF 中的 SETEX |
是(UTC 基准) |
转成 UTC 后再按目标时区呈现 |
| 带时区时间 |
2024-08-22T11:33:19+08:00、Java Instant |
是 |
直接换算,但注意可能是客户端伪造的 |
| 无时区时间 |
MySQL DATETIME、MySQL binlog 事件时间、PostgreSQL timestamp without time zone、本地文件 mtime 呈现 |
否 |
必须声明所采用的时区假设 |
出错最多的就是第三类。DATETIME '2024-08-22 11:33:19' 写进去的时候按哪个时区理解,取决于连接会话的 time_zone;写完之后这个信息就永久没了。
取证能做的有限:把当时的配置和系统时区找出来当证据,声明假设,再在报告里标清这个假设影响到哪些结论。
2.3 容器化环境的时间链路
服务跑在容器里,时间基准得一层层往回追:
| 层级 |
可能的状态 |
后果 |
| 应用代码 |
可能显式设置 TZ、time.Local、X-TIMEZONE |
应用层时间已被转换 |
| 运行时镜像 |
基础镜像几乎都是 UTC |
容器内 date 是 UTC |
| 编排平台 |
K8s Pod 可注入 TZ 环境变量;未注入则继承镜像设置 |
多数集群不设 |
| 数据库连接 |
JDBC/驱动可能把会话时区设为服务器时区 |
决定 DATETIME 如何解释 |
| 宿主机 |
可能为 UTC+8 |
宿主机时区≠容器时区 |
判读规则:
别看"服务器物理位置在哪",看"数据库进程实际读到的是什么"。
这个值有三个地方能拿到:进程环境变量 TZ、数据库参数 @@time_zone / @@system_time_zone、日志里已经渲染出来的时间。
宿主机时区
timedatectl 2>/dev/null | grep -E 'Time zone|RTC in'
cat /etc/timezone 2>/dev/null
进程环境(内存镜像中)
strings -n 4 DigiForensics-mem.raw | grep -E '^TZ=|^TZ=' | sort | uniq -c
容器内(若有 shell 权限)
cat /etc/timezone 2>/dev/null
readlink -f /etc/localtime
常见容器值:/usr/share/zoneinfo/Etc/UTC ← 关键线索
数据库参数
mysql -uroot -p -e "SELECT @@global.time_zone, @@session.time_zone, @@system_time_zone;"
/usr/share/zoneinfo/Etc/UTC 或 /etc/localtime → Etc/UTC 这种指向,就是容器 TZ=UTC 最直接的物证。
2.4 "查不到"的六种成因
| 成因 |
判据 |
报告应写 |
| 已删除 |
主库无记录,但 binlog/undo/空闲区有痕迹 |
"已被删除,删除前值可从 X 恢复" |
| 未落盘 |
WAL/AOF/内存中有,磁盘文件中无 |
"存在于 X,尚未持久化" |
| 已过期清理 |
日志窗口外,无任何痕迹 |
"在已获取的日志窗口(A 至 B)内未见,窗口外无法判断" |
| 未同步到该节点 |
从库/副本缺少主库有的事件 |
"本节点未同步,不代表未发生" |
| 已归档轮转 |
文件序号跳变、目录被压缩移走 |
"存在日志缺口,该时段不可判断" |
| 检材提取不完整 |
只提了主库、漏了伴随文件 |
"检材不完整,已回源补提" |
这一张表应当直接印在报告模板里。 每一条"未发现"的结论旁边,必须标注它属于上表哪一类。不标注的"未发现"在质证环节无法成立。
2.5 完整性的三个层级
委托方嘴里的"数据库已恢复",可能是三件完全不同的事。
| 层级 |
含义 |
验证方式 |
| 设备已解锁 |
手机/主机已能正常进入系统 |
能否开机、能否登录 |
| 存储已解密 |
全盘/FBE/BitLocker 已解开,文件可读 |
文件系统能否挂载、目录能否列出 |
| 数据库已解密 |
数据库自身的表空间加密/字段加密已解开 |
能否 SELECT 出明文 |
报告里这三层必须分开说。
"手机已解锁、磁盘镜像已解密,但目标数据库为 SQLCipher 加密且密钥未获取"——这是一个完整、准确、对委托方有实质价值的结论,不能写成"数据无法获取"。
归档副本的哈希怎么算、用 MD5 还是 SHA-256、要不要登记进哈希数据库,见哈希与完整性校验。
这几个数字后面的每一处时间、每一个结论,都要靠上面的记录兜底。
三、操作步骤
3.1 阶段一:检材固定、环境与时区链路记录
1) 哈希先行,之后一切修改只在副本上
sha256sum DigiForensics.dd DigiForensics-mem.raw | tee hashes.txt
2) 记录时区链路(★ 这一步的产出必须写进报告)
timedatectl 2>/dev/null | grep -E 'Time zone'
readlink -f /etc/localtime # 常见容器值:/usr/share/zoneinfo/Etc/UTC
cat /etc/timezone 2>/dev/null
strings -n 4 DigiForensics-mem.raw | grep -x 'TZ=[A-Za-z/_+-]*' | sort -u
3) 判断是否容器
ls /.dockerenv 2>/dev/null && echo "容器"
cat /proc/1/cgroup 2>/dev/null | head -3
systemd-detect-virt --container 2>/dev/null
4) 数据库进程与配置
ps aux | grep -E '[m]ysqld|[m]ongod|[r]edis-server|[p]ostgres'
ls /etc/my.cnf /etc/mysql/ /etc/mongod.conf /etc/redis/redis.conf 2>/dev/null
mysql -uroot -p -e "SELECT @@global.time_zone, @@session.time_zone, @@system_time_zone;"
时区链路的结论格式(写入报告):
宿主机时区:Asia/Shanghai (UTC+8);容器内 /etc/localtime → Etc/UTC;数据库 @@system_time_zone = UTC。据此判断:本机数据库写入的 DATETIME 与 binlog 事件时间均按 UTC 解释。
/usr/share/zoneinfo/Etc/UTC 或 /etc/localtime → Etc/UTC 这类指向,是容器 TZ=UTC 最直接的物证。
3.2 阶段二:引擎识别
通用特征扫描
find /mnt/case -maxdepth 4 ( -name '.db' -o -name '.ibd' -o -name 'ibdata*'
-o -name '.frm' -o -name '.wt' -o -name 'dump.rdb' -o -name 'appendonly*'
-o -name 'PG_VERSION' -o -name 'ib_logfile*' -o -name '-journal' -o -name '-wal' )
2>/dev/null | head -40
魔术字节
xxd -l 16 app.db | grep -q '5351 4c69 7465' && echo "SQLite 明文"
若首字节不是 'S' → 可能是加密库,判定加密扩展(以实际库为准)
xxd -l 9 dump.rdb # 5245 4449 5300 0006 02 → REDIS + 版本号
cat base/PG_VERSION # PostgreSQL 纯文本版本号
| 命中 |
引擎 |
首要动作 |
SQLite format 3 文件头 |
SQLite |
确认并提取 -wal/-shm(见 09) |
ibdata1 / *.ibd |
MySQL InnoDB |
先定版本(见 10) |
binlog.0000* |
MySQL |
优先攻 binlog |
WiredTiger / collection-*.wt |
MongoDB |
必须取 catalog 文件(见 11) |
dump.rdb / appendonly* |
Redis |
必须做内存取证 |
PG_VERSION / pg_wal/ |
PostgreSQL |
取 pg_wal 与 pg_xact |
3.3 阶段三:整体归档
原则:宁可多不可少;不要跨时间点分别拷贝
cd /mnt/case/var/lib/mysql
tar -cf mysql.tar ibdata1 ibtmp1 mysql binlog.* mysql-bin.* 2>/dev/null
find . -name '.ibd' -o -name '.frm' | tar -cf mysql-tables.tar -T -
cd /mnt/case/var/lib