关键词: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 反过来:
- 所有修改以「帧(frame)」为单位追加写入
-wal 文件尾部;
- 数据页在被覆盖前,事务已完整记录在 WAL 中,崩溃后只需重放 WAL 即可把主库推进到一致状态;
- 主库文件在 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 帧里可能保存着已在主库中被覆盖的旧页镜像——即"覆盖前的上一版本"。这在删除类案件中有独立价值: