MySQL 数据库取证

MySQL 取证的最大特征是:它不是"一个文件",而是一整套文件协同描述数据库状态。只看 .ibd 或只看 .frm,都会得出错误结论。

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

关键词: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 页为单位组织在表空间中。删除一行时:

  1. 行被标记为 delete-marked,其撤销信息写入 undo;
  2. 页进入 purge 队列,后台线程异步执行 purge;
  3. 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/ 目录下的一组文件。

同一句"找表定义",在