SQLite WAL 取证

移动端几乎所有主流应用都把 SQLite 配成 WAL 模式。

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

关键词:SQLite、WAL、write-ahead log、checkpoint、wal-index、-shm、三文件联合提取 难度:进阶 前置知识:SQLite 基础与结构、十六进制分析、并发写入概念 相关文章:SQLite 基础与结构、即时通讯取证、数据库取证通用方法

一、概述

移动端几乎所有主流应用都把 SQLite 配成 WAL 模式。

这不是可选项,是事实默认值:Android 侧 SQLiteOpenHelper 的 enableWriteAheadLogging()、iOS 侧 Core Data 与大量第三方库、桌面与服务端库,几乎都开了 WAL。

由此产生一个实战中后果很重的事实:应用正在使用的数据,绝大多数不在 .db 主库里,而在同目录的 -wal 伴随文件里。

现象 真实原因
刚导出的库里查不到"最新"记录 最新提交在 -wal 中,尚未 checkpoint
记录数比委托方描述的少一截 同上,缺的是 WAL 尾部
换个工具解析结果就不一样 有的工具读 WAL、有的只读主库
同一份检材两次 SELECT 结果不同 第一次查询触发了 checkpoint,副本被改写

本篇要建立的核心认知只有一条:

⚠️ app.db、app.db-wal、app.db-shm 三个文件必须一起提取

只提主库 = 拿到的是上一次 checkpoint 的时间切片,不是应用当前状态。 漏提 -wal 是本领域出现频率最高、后果最严重的提取失误。

-shm 不含业务数据(它是共享内存索引),但它记录了 WAL 的读取状态,保留它可保证 wal-index 的一致性判断。三者应作为一个不可分割的整体归档、哈希、传输、存储。

在取证流程中的位置:本篇是提取之后、解析之前的必经一步,位置很窄但漏不起。它有两个动作:提取阶段确保三件套一起走(含从应用私有目录与 /data/data/<pkg>/databases/、Library/Application Support/ 等不同落点定位),分析阶段先用只读副本建立基线,不碰原始文件。上游依赖是 SQLite 基础与结构(页、B-tree、sqlite_master 与页大小这些概念要先有),时间戳处理按 数据库取证通用方法 的时区纪律走。"分析即破坏"这条纪律在本领域没有例外:用 sqlite3 直接打开原始 -wal 副本或原始 .db 都可能触发 checkpoint,把要取证的状态改掉,所以流程是"哈希 → 复制 → 只对副本操作"。

本篇处理的证据形态,是三件套加两个关键字段:

证据 关键位置或字段 判读要点
app.db 文件头 SQLite format 3、偏移 18/19 的 write_version / read_version,值为 2 即 WAL 模式;页大小在偏移 16(0x1000 表示 4096) 主库头里的变更计数器要与 -wal 头对照,它决定哪些 WAL 页有效
app.db-wal 32 字节文件头 + 若干 24 字节帧头 + 页数据;文件头 magic 为 0x377f0682(校验和按小端)或 0x377f0683(大端),偏移 16/20 是 salt1/salt2 每次 checkpoint 后 salt 会随机重生成,旧帧因 salt 不匹配失效;帧头的 salt 必须与文件头一致,不一致的帧属于上一个 WAL 周期,必须丢弃
app.db-shm wal-index 共享内存映像 无业务数据,但它的读取状态决定 WAL 会被怎么消费,保留它是为了判断一致性
表结构 sqlite_master(3.33 之后重命名为 sqlite_schema,两者内容一致) 只有它能回答"某张表是否存在"——只提主库时它可能不含被 WAL 覆盖后的最新结构
帧内字段 帧头的页号、提交点、checksum(滚动校验和) 校验和用于发现被静默篡改或比特翻转的帧

本篇能回答什么:应用在关机或提取那一刻真正持有的数据(不只是上次 checkpoint 的快照)、-wal 里最近发生过哪些事务与页面覆盖、被 checkpoint 覆盖掉的历史页内容、只提了主库时"缺失"这一事实本身能说明什么。

本篇不能回答什么:解析不出一段内容不等于那段数据没被写过——帧的 salt 不匹配只能说明它属于已失效的 WAL 周期,说明不了写它的是谁;校验和不通过只能说明字节被改过或损坏,不能直接定性为人为篡改,也可能是正常的比特翻转或采集传输截断。sqlite_master 里没有的表不能断言它从未被创建——drop 掉再 checkpoint 之后元数据就没了。提取时机造成的损失不可弥补:应用在被提取前就已经 checkpoint 或主动删掉 WAL,检材里就不存在那批数据,这时任何分析都给不出结论。阴性结果的表述必须落到范围上:"在 /data/data/<pkg>/databases/ 与 /data/user/0/<pkg>/ 下未发现该应用 SQLite 三件套",未覆盖的是已卸载应用残留、系统级数据库与云备份。最关键的一条:本篇给不出"谁做的"——SQLite 里没有用户身份、没有来源 IP、没有执行轨迹,把聊天记录里的内容对上嫌疑人,需要 即时通讯取证 与应用侧日志来补。

读者前提:需要先理解 SQLite 的页与 B-tree 概念、以及 sqlite_master 存的是模式而非数据(SQLite 基础与结构 是硬前置);需要能读十六进制并手工走一遍 32 字节 WAL 文件头与 24 字节帧头,这一步是本篇的核心技能——不手工校验过一轮,salt 与校验和的判断就只能靠工具报成功;需要分清"字节序"这件事在 WAL 里有两层含义(magic 最低位决定整体字节序,而帧头字段固定按大端读取,混用会算出看似合理的错误值);还需要接受不可在原始文件上操作这条纪律,并知道文件系统层复制 SQLite 文件时必须走 cp 保留大小与时间、不能靠"打开另存"。


二、核心原理

2.1 为什么会有 WAL:先写日志再写库

传统 rollback journal 的写入顺序是"先改数据页 → 再写日志说明改了什么 → 提交",代价是写放大严重、并发读能力弱。WAL 反过来:

  1. 所有修改以「帧(frame)」为单位追加写入 -wal 文件尾部;
  2. 数据页在被覆盖前,事务已完整记录在 WAL 中,崩溃后只需重放 WAL 即可把主库推进到一致状态;
  3. 主库文件在 checkpoint 时才被批量改写,checkpoint 是分批的(默认满 1000 页触发一次),所以主库长期处于"落后"状态。

结论:-wal 的大小反映的是"距上次 checkpoint 以来累积的写入量"。

一个 20 MB 的库配一个 200 MB 的 -wal,说明该库高频写入且长期未 checkpoint——这在取证中是重要信号。

2.2 三个文件的分工

文件 内容 含业务数据 缺失/损坏后果
app.db 主库,含 B-tree 页、schema、freelist 是 无法解析
app.db-wal 预写日志,含尚未落盘的完整页镜像与部分已落盘页的新版本 是(最新) 丢失最新数据;主库仍可解析但内容陈旧
app.db-shm wal-index 共享内存索引,含各事务的读取标记 否(纯索引) SQLite 会尝试重建;极端情况下会忽略整个 WAL

-shm 是共享内存映射文件,内容通常保留在磁盘(未 unlink 时),但它的存在与内容在不同提取路径下差异极大。

有的 tar 打包会带上它,有的按 *.db 通配符只取主库,有的同步工具因为它是「内存文件」而直接跳过。

这正是「三文件一起提取」容易被简化为「两个文件」的原因。

2.3 WAL 文件的字节结构

-wal 由 32 字节文件头 + 若干 24 字节帧头 + 页数据组成。

WAL 文件头(32 字节):

偏移 字段 说明
0 magic 0x377f0682 校验和按小端计算,0x377f0683 大端
4 format_version 目前为 3007000
8 page_size 4 字节,页大小原值(含 65536 本身,不做 1→65536 转换)
12 checkpoint_seq 主库与 WAL 不一致时 WAL 会被整体忽略
16/20 salt1/salt2 每次 checkpoint 后随机重生成
24 checksum 前 24 字节的校验和(起始状态 0,0)

帧头(24 字节):

偏移 字段 说明
0 page_number 该帧承载的页号
4 db_size_after_commit 非 0 表示该帧所属事务的提交点,同时给出提交后库的总页数
8 salt1/salt2 必须与文件头一致,否则该帧属于上一个 WAL 周期,必须丢弃
16 checksum 帧头前 8 字节 + 整页数据的滚动校验和

salt 与校验和是 WAL 取证的两道核心判据:WAL 被 checkpoint 重置后,文件头会被填入新的随机 salt,旧帧因 salt 不匹配而失效;而校验和用于发现被静默篡改或比特翻转的帧。

"WAL 里有很多数据"不等于"这些数据都有效",更不等于"这些数据没被改过"。必须逐帧校验 salt 与校验和。 只查 salt 会漏掉页数据被改写的情形;只查长度与帧边界则两种都查不出来。

校验和算法的两个易错点(第 3.3 节的实现已在真实 WAL 上验证):

  • 字节序由 magic 最低位决定,但帧头字段本身固定按大端读取——这是两个独立的字节序,不要混用。
  • 帧校验和的输入范围是「帧头前 8 字节 + 页数据」,不含 salt 与校验和字段本身。写错范围会算出看似合理的错误值。

2.4 checkpoint 的四种模式与"分析即破坏"

模式 行为 风险
PASSIVE 尽力而为地搬运,遇到读事务就停 低;但它仍然会改写主库
FULL 阻塞等待所有读者,之后搬运 会改写主库
RESTART 搬运 + 重置 WAL 起始点 改写主库与 WAL
TRUNCATE 搬运并把 -wal 截断为 0 字节 不可逆地销毁 WAL 残留

取证铁律:sqlite3 app.db "SELECT ..." 是一次读写操作。 只要同目录存在有效的 -wal,SQLite 打开数据库时就会自动执行隐式 checkpoint,把 WAL 内容写回主库并改写主库文件的字节。 这意味着:你分析"只提主库"的那份副本,查询一次后主库就变了,原始哈希不再可复现。

正确做法有两条:

① 归档副本永远只读(chmod 444 或只读挂载); ② 需要保留 WAL 原始状态时用 immutable 模式 sqlite3 'file:app.db?immutable=1'——此时 SQLite 明确声明「该文件不变」,不会写、也不会 checkpoint(代价是不读 WAL)。

2.5 "-wal 里有数据但查询不出来"的三种原因

原因 判据 处置
WAL 被忽略:checkpoint 序列或 salt 不匹配 主库头 write_version=2,但读取后行数不变 检查 -wal 头 12 字节与主库头变更计数器
WAL 已损坏:帧校验和断裂 越靠后的帧越不可用 用 3.3 节脚本逐帧判定可用边界
本来就只有这些数据:应用最后一次写入就在更早时间 有效帧的 db_size_after_commit 均不晚于主库状态 属正常情况,需回到时间线交叉验证

若库文件本身是 SQLCipher 之类加密的,三文件联合提取这套流程要先过密钥这一关,判定与获取路径见数据库与配置文件加密。


三、操作步骤

3.1 提取:三文件作为一个整体

首选方案:整目录 tar(保证同刻、原子、完整)

PKG=com.digiforensics.sample
DEV=/data/user/0/$PKG/databases

# ① 设备端先看全貌 —— 先 ls,确认哪些文件真实存在
adb shell su -c "ls -la $DEV"
# app.db 2457600 / app.db-wal 196608 ★必须 / app.db-shm 32768 ★必须 / app.db-journal 8192

# ② 一次性流式导出(不落地到设备,不受 /data 空间限制)
adb shell su -c "tar -cf - -C $DEV ." > dbset.tar
sha256sum dbset.tar | tee dbset.sha256      # tar 落盘即证据载体

# ③ 只在副本上工作
mkdir -p work && tar -xf dbset.tar -C work && chmod -R a-w work

无法 root 时的降级路径(run-as 仅适用于 debuggable 应用):

adb shell run-as com.digiforensics.sample ls -la databases/
adb exec-out run-as com.digiforensics.sample tar -cf - -C databases . > dbset-runas.tar

无论用哪种路径,交付物必须是同一时刻的 dbset.tar,而不是三个分别导出的文件。 分别导出可能出现"主库来自 23:14、WAL 来自 23:12"的不一致组合,这种组合会被 SQLite 判为不一致而忽略 WAL,且这种错误不会报错,只会静默少数据。

3.2 建立基线:不触碰地观察

sqlite3 "file:work/app.db?immutable=1" "PRAGMA integrity_check;"
sqlite3 "file:work/app.db?immutable=1" "PRAGMA page_size;"
sqlite3 "file:work/app.db?immutable=1" "PRAGMA freelist_count;"
xxd -s 18 -l 2 work/app.db          # 0202 = WAL 模式;0101 = legacy journal

3.3 逐帧校验 WAL 的有效性

wal_scan.py —— 校验文件头与每一帧的 salt 与滚动校验和,输出有效帧的提交点与页覆盖情况:

cat > wal_scan.py <<'PY'
import struct, sys
d = open(sys.argv[1], 'rb').read()
if len(d) < 32:
    print("!! 文件过小,不是合法 WAL"); sys.exit(1)
# 文件头字段固定大端;magic 最低位单独决定校验和的字节序
magic, fmt, ps, cseq, s1, s2, ck1, ck2 = struct.unpack('>IIIIIIII', d[0:32])
CE = '<' if not (magic & 1) else '>'
M = 0xFFFFFFFF
def ck(data, s0, s1_):
    """SQLite 32 位滚动校验和:按 CE 字节序取 32 位字,成对迭代"""
    n = len(data) // 8
    v = struct.unpack(CE + 'I' * (n * 2), data[:n * 8])
    for i in range(0, len(v), 2):
        s0 = (s0 + v[i] + s1_) & M
        s1_ = (s1_ + v[i + 1] + s0) & M
    return s0, s1_
h0, h1 = ck(d[0:24], 0, 0)                 # 头校验和覆盖前 24 字节,起始状态 0,0
print(f"magic=0x{magic:08x} ({'大端' if magic&1 else '小端'}校验和)  format={fmt}")
print(f"page_size={ps}  checkpoint_seq={cseq}  salt=0x{s1:08x}/0x{s2:08x}")
print(f"头校验和 计算=0x{h0:08x}/0x{h1:08x} 存储=0x{ck1:08x}/0x{ck2:08x} "
      f"{'OK' if (h0,h1)==(ck1,ck2) else '!! 不一致'}")
off, valid, bad, pages = 32, 0, 0, set()
while off + 24 + ps <= len(d):
    pno, dbsz, fs1, fs2, f1, f2 = struct.unpack('>IIIIII', d[off:off+24])
    if fs1 != s1 or fs2 != s2:             # salt 不匹配 → 上个周期的残留帧
        print(f"帧@{off}: salt 不匹配 → 该帧及之后失效,停止"); break
    # 校验和输入 = 帧头前 8 字节 + 整页数据(不含 salt 与校验和字段)
    c1, c2 = ck(d[off:off+8], h0, h1)
    c1, c2 = ck(d[off+24:off+24+ps], c1, c2)
    if (c1, c2) != (f1, f2):
        bad += 1
        print(f"帧@{off} page={pno}: 校验和不符 计算=0x{c1:08x}/0x{c2:08x} "
              f"存储=0x{f1:08x}/0x{f2:08x} → 该帧及之后不可用,停止"); break
    valid += 1; pages.add(pno); h0, h1 = c1, c2
    if dbsz:
        print(f"  提交帧@{off}: page={pno} 提交后总页数={dbsz} → "
              f"累计 {valid} 有效帧,覆盖 {len(pages)} 个页号")
    off += 24 + ps

print(f"\
有效帧={valid} 校验失败={bad} 覆盖页号={len(pages)}")
print("结论:", "WAL 有效,应与主库联合解析" if valid else "WAL 无有效帧,主库即最新状态")
PY

python3 wal_scan.py work/app.db-wal

该实现已用真实 SQLite 生成的 WAL 验证:158 帧全部通过(含 2 个提交帧),人为翻转页数据中的 1 个 bit 后立即报出校验和不符。在真实案件中不要照抄后就直接采信输出,仍需用 sqlite3 实际打开并 PRAGMA integrity_check 交叉验证。

3.4 联合解析:让 SQLite 自己合并

# 三文件齐备时普通打开即自动读入 WAL —— 但会 checkpoint 并改写副本,先另存分析用副本
cp work/app.db analysis.db && cp work/app.db-wal analysis.db-wal && cp work/app.db-shm analysis.db-shm
sqlite3 analysis.db "PRAGMA journal_mode;"
sqlite3 analysis.db "SELECT count(*) FROM pay_record;"
sqlite3 analysis.db "PRAGMA wal_checkpoint(PASSIVE);"     # 0 | 38 | 0 ← (busy, 已搬运, 仍留在 WAL)
sha256sum work/app.db                                      # 归档副本哈希此时必须不变

3.5 只保留主库的情况:把"缺失"变成证据

若确实只有主库,不要沉默地继续分析,而要把缺失转化为可陈述的事实:

xxd -s 18 -l 2 app.db                 # 0202 → 声明 WAL 模式却无配套 WAL,必然不完整
ls app.db-wal 2>/dev/null || echo "!! 缺 -wal,检材不完整"
stat -c '%n  mtime=%y  size=%s' app.db
sqlite3 app.db "SELECT max(datetime(createTime/1000,'unixepoch','localtime')) FROM pay_record;"

若"内容最大时间"显著早于"文件 mtime",就是数据未落盘到主库的客观证据,应写入报告限制条款。

3.6 单独挖掘 WAL 内的历史页

WAL 帧里可能保存着已在主库中被覆盖的旧页镜像——即"覆盖前的上一版本"。这在删除类案件中有独立价值: