关键词:MySQL、InnoDB、ibdata1、.ibd、.frm、redo log、undo、binlog、mysqlbinlog
难度:高级
前置知识:关系数据库、事务与日志两阶段提交、十六进制分析
相关文章:数据库取证通用方法、SQLite 基础与结构
一、概述
MySQL 取证的最大特征是:它不是"一个文件",而是一整套文件协同描述数据库状态。只看 .ibd 或只看 .frm,都会得出错误结论。
更重要的是它的日志体系给取证提供了普通关系库没有的能力:
⭐ binlog 里保存着已经执行过的删除操作
DELETE FROM users WHERE id=42; 之后,表里没有 42 号用户了。
但如果 binlog 开启且格式为 ROW,日志里永久保存着 42 号用户删除前的完整行镜像——所有列的旧值、事务 ID、执行时间、服务器 ID。
这是 MySQL 取证中证据价值最高、也最容易被忽略的一类证据。 它不需要恢复物理页、不需要解析 freelist,只要 binlog 还在,删除前的事实就是可读、可校验、可复现的。
需要先建立的核心认知:
| 认知 |
含义 |
| 数据不在一个文件里 |
系统表空间 + 独立表空间 + undo + redo + binlog 共同构成数据库状态 |
| 版本决定文件布局 |
5.6 / 5.7 / 8.0 的文件组成完全不同,不先定版本就会找错文件 |
| binlog 是逻辑日志 |
它记录"发生了什么操作",而非"数据在哪个页"——因此它对页损坏免疫 |
| 时间戳无时区 |
MySQL 的 datetime 不带时区,binlog 时间戳用服务器本地时间(见 12) |
在取证流程中的位置:本篇属于分析环节,而且路径是分叉的:binlog 在就直取 binlog,binlog 缺失或已过期才回到物理层(ibdata1、.ibd、undo 里的 MVCC 旧版本)。这个顺序不能反——先花时间啃物理页格式,结果发现 binlog 里明明白白写着删除前的行,是本领域最常见的时间浪费。上游依赖是 数据库取证通用方法(时区与阴性表述纪律、SHOW VARIABLES 与配置文件的读取顺序)以及 只读挂载与安全接入(数据库目录必须整体归档,不能只拷一个 .ibd)。下游是把行镜像与业务事实对齐,这一步靠 系统日志深度分析 里的 shell 与登录痕迹、以及应用侧日志交叉。表结构或内容在磁盘上被加密时,判定与密钥材料位置走 数据库与配置文件加密。
本篇处理的证据形态,先按版本定文件组成,再按文件取内容:
| 文件 |
5.6 / 5.7 |
8.0 |
取证价值 |
| binlog |
binlog.000001…、binlog.index |
同,8.0 默认就是 ROW |
最高——含删除前旧值与执行时间 |
| 系统表空间 |
ibdata1(含数据、undo、字典) |
ibdata1 缩为只含数据字典,undo 独立 |
中——字典与残留数据 |
| 表数据 |
.ibd(innodb_file_per_table=ON) |
同 |
高——单表独立文件 |
| 表定义元数据 |
.frm |
已废弃移除,改为事务数据字典(存于 mysql.ibd) |
5.x 用来还原列名与类型 |
| undo 表空间 |
强制在 ibdata1 内 |
undo_001、undo_002 独立 |
中高——MVCC 未清理版本可能含旧值 |
| redo log |
ib_logfile0 / ib_logfile1 |
#innodb_redo/ 目录(8.0.30+) |
低——物理页镜像,格式随版本剧变 |
| 二进制日志格式 |
binlog_format 三选一:STATEMENT / ROW / MIXED |
同 |
决定删除前值能否读出,判定入口在 binlog.index 与文件头 |
binlog 内部的判读字段也固定:事件类型 Query / Write_rows / Update_rows / Delete_rows,ROW 事件里带 table_id、server_id、transaction_id、时间戳,以及删除事件的 before_image(旧值整行)。用 mysqlbinlog 读不动时,可以按事件头的长度字段手工跳着扫,这是本篇第三章第 3 步的做法。
本篇能回答什么:某个库有哪些表与列(5.x 靠 .frm,8.0 靠数据字典)、binlog 覆盖的时间窗内发生过哪些 DDL 与 DML、被删除或被更新前的完整行镜像、删除操作来自哪个 server_id 与哪个事务、undo 里尚未被清理的旧版本内容、以及配置层面 binlog 是否开启、格式是什么、有没有过期。
本篇不能回答什么:binlog 里没有的东西就是没有——binlog 通常有 expire_logs_days 或按容量滚动,最老的文件被自动删除,这个动作本身不留痕,"没找到"和"已被过期清理"从盘上看不出区别,所以阴性结论必须写成"在 binlog.00000X~binlog.0000Y 覆盖的时间窗内未发现……",并把这个窗口写进报告。ROW 事件里的时间戳用服务器本地时间且不带时区,直接与应用日志比对必然出偏差;server_id 是实例标识,不等于操作者身份,binlog 证明不了"是谁"。binlog 是逻辑日志,不含页号与偏移,所以它回答不了数据物理位置,也无法证明某段数据当前还在表里——UPDATE 与随后的 DELETE 都会留在日志里,只能读到历史。undo 里的 MVCC 旧版本能不能读到取决于长事务是否还开着、长事务一结束就可能被 purge,这是有时效窗口的证据,不是稳定来源。最后,8.0 上 .frm 不存在,用 5.x 的方法找列名会直接扑空,而 binlog 的列名依赖当时的表定义,表结构改过之后旧事件的列名映射会断裂。
读者前提:需要先定 MySQL 大版本再动手——本篇所有偏移与文件清单都按版本给,版本判断错了后面全错(判据是 mysql.ibd 是否存在、my.cnf 的默认值、@@version 的残留痕迹);需要能读十六进制并理解"页"这个单位,以及 binlog 事件头里长度字段的作用(手工跳扫时靠它定位下一个事件);需要分清 redo(物理)与 binlog(逻辑)两类日志为什么取证价值差一个量级;还需要掌握 MySQL 配置的读取顺序(配置文件 → 环境变量 → 命令行参数,后面覆盖前面),否则改了 binlog_format 的场景会被漏掉。
二、核心原理
2.1 三类日志的分工
| 日志 |
内容 |
所在 |
取证价值 |
| redo log |
物理日志:哪些页被怎么改的 |
ib_logfile*(5.6/5.7)、#innodb_redo/(8.0.30+) |
低(崩溃恢复用,格式复杂) |
| undo log |
回滚信息与历史版本 |
系统表空间(5.6/5.7)、独立 undo 表空间(8.0 默认) |
中高——MVCC 未清理版本可能含旧值 |
| binlog |
逻辑日志:DDL/DML 的语句或行事件 |
binlog.00000X、binlog.index |
最高——含删除前旧值 |
取证应当优先攻 binlog,而不是 redo。 原因是:redo 是物理页镜像,解析需要精确匹配 InnoDB 内部格式且随版本剧变;binlog 是行级文本/行事件,人可读、工具成熟、结论可复现。
2.2 版本差异:必须先定版本
| 项目 |
5.6 |
5.7 |
8.0 |
| 表定义元数据 |
.frm |
** .frm** |
事务数据字典(存于 mysql.ibd),.frm 已废弃移除 |
| 表数据 |
.ibd(innodb_file_per_table=ON 时) |
同 5.6 |
同 5.6 |
| 系统表空间 |
ibdata1(含数据、undo、字典) |
ibdata1(含数据、undo、字典) |
ibdata1 缩为只含字典,undo 独立 |
| undo 表空间 |
强制在 ibdata1 内 |
可用独立 undo |
默认 undo_001/undo_002 独立 |
| redo log |
ib_logfile0、ib_logfile1 |
同 |
#innodb_redo/ 目录(8.0.30+);此前仍是 ib_logfile |
| 临时表空间 |
ibtmp1 |
ibtmp1 |
ibtmp1 |
| binlog 默认 |
binlog.0000X |
同 |
同,且 8.0 默认 ROW |
| MySQL 系统库 |
mysql/*.frm |
mysql/*.frm |
mysql.ibd + mysql.ibd 内的字典 |
取证第一步永远是 SELECT VERSION(); 或从文件特征判断版本,然后再决定"该去找哪些文件"。跳过这一步,5.7 上找 mysql.ibd、8.0 上找 .frm 都会白费时间。
版本识别线索(无服务可用时):
ls data/mysql/ # 出现 mysql.ibd → 8.0;出现 *.frm → 5.6/5.7
ls data/ # 出现 #innodb_redo/ → 8.0.30+
ls data/ # 出现 undo_001/ → 8.0
strings data/ibdata1 | grep -m3 '5\.[67]\|8\.0'
cat data/*.cnf 2>/dev/null; cat /etc/my.cnf 2>/dev/null
2.3 InnoDB 数据页与删除行为
InnoDB 的数据以 16 KB 页为单位组织在表空间中。删除一行时:
- 行被标记为 delete-marked,其撤销信息写入 undo;
- 页进入 purge 队列,后台线程异步执行 purge;
- purge 完成后,该页被放进该表空间的 free list,字节通常仍然存在。
这与 SQLite freelist 概念相似,但有两个关键差异:
| 差异 |
SQLite |
InnoDB |
| 旧值存放位置 |
主库文件空闲区 |
undo 表空间(可被 purge 清理) |
| 清理时机 |
直到该页被复用 |
purge 可很快清理,undo 也有 purge |
| 行版本 |
无 MVCC |
MVCC 保留多版本,事务未结束前旧版可读 |
结论:InnoDB 的"已删除数据"窗口比 SQLite 更短、更不可靠。 而 binlog 不受 purge 影响——这正是 binlog 优先的原因。
2.4 binlog 的三种格式(决定删除前值能否读出)
| 格式 |
记录内容 |
能否还原删除前值 |
STATEMENT |
原始 SQL 语句 |
不能——只知道"删了 id=42",不知道原值 |
MIXED |
自动在两者间选择 |
视该条语句实际用的方式而定 |
ROW |
每一行的变更前后镜像 |
能,且是唯一可靠方式 |
ROW 格式下的行事件类型(重点):
| 事件 |
含义 |
取证价值 |
Write_rows |
INSERT |
插入时的原始值 |
Update_rows |
UPDATE |
旧版本值 + 新版本值(被覆盖的旧值可读) |
Delete_rows |
DELETE |
★ 删除前的完整行镜像 |
Table_map |
表结构映射(列类型、NULL 位图) |
解读行事件的前提 |
判断 binlog 格式(无需连接数据库):
strings binlog.000003 | grep -o 'Delete_rows\|Update_rows\|Write_rows\|Query' | sort | uniq -c
# 1200 Delete_rows ← ROW 格式的标志
# 340 Update_rows
# 5600 Write_rows
若只看到 Query,则是 STATEMENT 格式,删除前值不可得,应立即转向 undo/物理页方向。
2.5 事务与时间
每个行事件都带:
- 事件时间戳(
timestamp,秒级,使用服务器本地时区)
- 事务 ID(
XID)
- 可能的
server_id、GTID
同一事务的多条事件共享 XID,恢复一条被删记录时,应把同一 XID 的所有事件一起提取,这样能同时得到该行在事务前后的完整状态。
没有事务日志的嵌入式库走的是另一条路——靠页结构与残留数据直接读,文件头与 serial type 的判读见第 08 篇。
三、操作步骤
3.1 固定检材、版本判定与整体归档
# 1) 原始镜像哈希先行
sha256sum DigiForensics.dd | tee image.sha256
# 2) 只读挂载副本,识别数据目录与版本
mkdir -p /mnt/case
mount -o ro,loop,offset=$((2048*2048)) DigiForensics.dd /mnt/case # 分区偏移以实际分区表为准
ls /mnt/case/var/lib/mysql/mysql/ | head -30
ls -d /mnt/case/var/lib/mysql/#innodb_redo 2>/dev/null && echo "→ 8.0.30+"
ls /mnt/case/var/lib/mysql/undo_00* 2>/dev/null && echo "→ 8.0"
ls /mnt/case/var/lib/mysql/ib_logfile* 2>/dev/null && echo "→ 5.6/5.7 或 8.0.30 之前"
# 3) 一次性打包,避免遗漏与版本不一致
cd /mnt/case/var/lib/mysql
tar -cf /work/mysql-data.tar ibdata1 ibtmp1 mysql binlog.* mysql-bin.* 2>/dev/null
find . -name '*.ibd' -o -name '*.frm' | tar -cf /work/mysql-tables.tar -T -
tar -tf /work/mysql-data.tar | sort | head -40
sha256sum /work/mysql-data.tar /work/mysql-tables.tar
binlog.index 与 binlog.00000X 必须一起取。 若 binlog.index 中记录的最后一个 binlog 已被删除而未清理,说明有人动过日志——这是一条独立的可疑线索。
3.2 配置解析:先看服务器怎么说
grep -Ei 'log[-_]?bin|binlog_format|server_id|expire_logs_days|datadir|innodb_file_per_table' my.cnf
# 若有运行中的实例,配置以实际为准
mysql -uroot -p -e "SELECT VERSION(), @@log_bin, @@binlog_format, @@server_id, @@global.time_zone, @@system_time_zone;"
@@server_id 是取证锚点:binlog 事件头里带 server_id,如果同一批 binlog 中出现多个 server_id,说明存在主从切换或曾被其他实例写入。这在"数据是谁写的、什么时候换了写入端"这类问题上是直接证据。
3.3 读取 binlog —— 本篇核心
ls -l binlog.00000*; cat binlog.index
# --base64-output=DECODE-ROWS 关键,否则行数据是 Base64 乱码
mysqlbinlog --base64-output=DECODE-ROWS -vv binlog.000003 > binlog003.txt
grep -n 'Table_map:.*app\..*users' binlog003.txt | head
# 限定表 + 只看行事件
mysqlbinlog --base64-output=DECODE-ROWS -vv \
--tables='appdb.users' binlog.000003 | grep -A6 'Delete_rows' | head -60
输出形态(### DELETE FROM 之后即为删除前的完整行):
### DELETE FROM `appdb`.`users`
### WHERE
### @1=42 /*INT meta=0 nullable=0 is_null=0*/
### @2='wangsong' /*VAR_STRING meta=0 nullable=0 is_null=0*/
### @3=1397819423 /*INT meta=0 nullable=0 is_null=0*/
# at 4183722
#240815 10:33:19 server id 1 end_log_pos 4184120 CRC32 0x1f0e2a1b Query thread_id=88
| 字段 |
含义 |
### WHERE 段 |
★ 变更前的行值——删除前事实就在这里 |
### SET @n=… |
仅 INSERT / UPDATE 事件的变更后行镜像;DELETE 事件只输出 WHERE 段 |
0x77616e677377 |
十六进制字符串,wangsong(含结尾 \0) |
1397819423 |
Unix 时间戳,无时区标记 |
#240815 10:33:19 |
#YYMMDD 年月日 + 服务器本地时间 |
CRC32 0x… |
事件校验,用于证明转储未失真 |
十六进制字段还原(ROW 格式常见坑):
cat > hexdec.py <<'PY'
import sys
for line in sys.stdin:
line = line.strip()
if not line.startswith('### SET @'):
continue
val = line.split('=', 2)[2].split('/*')[0].strip()
name = line.split('= ')[0]
try:
if val.startswith('0x'):
s = bytes.fromhex(val[2:]).split(b'\x00')[0].decode('utf-8', 'replace')
print(f"{name} hex-> str: {s!r}")
else:
print(f"{name} {val}")
except ValueError as e:
print(f"{val} <解析失败: {e}>")
PY
mysqlbinlog --base64-output=DECODE-ROWS -vv --tables='appdb.users' binlog.000003 \
| grep '### SET @' | python3 hexdec.py
按时间范围裁剪、跨文件按序恢复(binlog.index 是权威顺序):
mysqlbinlog --start-datetime='2024-08-01 00:00:00' \
--stop-datetime='2024-09-01 00:00:00' \
--base64-output=DECODE-ROWS -vv binlog.000004 | head -200
while read -r f; do
mysqlbinlog --base64-output=DECODE-ROWS -vv "$f" >> all-binlog.txt
done < binlog.index
3.4 用 binlog 做时间线重建
# 抽取某行 id 的全部历史事件
grep -B12 -A4 '@1=1042 ' all-binlog.txt | grep -E 'SET @|WHERE|### |#24'
| binlog 中出现 |
可得出的结论 |
Insert 后 Delete |
该记录被创建后删除,删除前值可完整读出 |
Update 多条 |
被覆盖的历次旧值全部可读,可还原"改之前是什么样" |
Query: ALTER TABLE |
表结构变更时点,变更前的字段含义需按当时 schema 解读 |
Query: DROP TABLE |
表被删除,此后数据只能从 binlog 与备份恢复 |
server id 变化 |
写入端变更,时间点即为切换时点 |
| 序号跳变 |
有 binlog 被删除或轮转,日志不完整,须在报告中声明 |
3.5 回到物理层(binlog 缺失时)与报告级导出
# 1) 找表空间
find . -name 'users.ibd' -o -name 'users.frm'
# 2) 解析表定义:5.6/5.7 可用专用工具(可用性以实际环境为准)
mysqlfrm --diagnostic users.frm 2>/dev/null | head -40
# 8.0:从 mysql.ibd 的事务数据字典中读(需同版本实例或专用解析器)
# 3) 隔离实例恢复(务必在只读副本上做)
mysqld --initialize-insecure --datadir=/work/isolated --skip-networking
mysqld_safe --datadir=/work/isolated --skip-networking &
mysql -uroot -e "SELECT VERSION();"
# 将 tables 目录复制进来,按恢复文档挂载(具体步骤以实际版本文档为准)
# 4) 报告级导出(binlog 转储与哈希一起入卷)
mysqldump --single-transaction --hex-blob --default-character-set=utf8mb4 appdb users > users.sql
sha256sum binlog.000003 binlog003.txt all-binlog.txt users.sql | tee binlog.sha256
在隔离实例中恢复 innodb_force_recovery=1..6 时,每升一级都会修改数据文件。 必须使用二次副本,且原始 .ibd/.frm 保持只读。
四、常见陷阱
4.1 版本与文件布局
陷阱 1:未判定版本就按另一代的文件布局找证据
现象:接手一个 MySQL 数据目录,直接去找 mysql.ibd,没找到,就报告"该实例缺少系统库文件,表定义可能已丢失";或者在另一份检材里只找 *.frm,找了半天一无所获。
为什么会误判:MySQL 的文件布局是随版本换代整体重排的,不存在"兼容两种布局"的中间态。
5.6 与 5.7 的表定义在 .frm 里;8.0 把表定义搬进了事务数据字典,.frm 被彻底移除,系统库也从散落的 mysql/*.frm 变成单个 mysql.ibd。
undo 表空间在 5.6 强制寄生于 ibdata1,8.0 变成默认独立的 undo_001/undo_002;redo 在 8.0.30+ 从 ib_logfile0/1 变成 #innodb_redo/ 目录下的一组文件。
同一句"找表定义",在