关键词:SQLite、文件头、B-tree、freelist、溢出页、WAL、sqlite3 CLI
难度:进阶
前置知识:十六进制分析、B-tree 概念、关系数据库基础
相关文章:SQLite-WAL 取证、数据库取证通用方法
一、概述
SQLite 在取证里的位置无可替代。
手机端最常见的数据库——几乎每个应用都有。嵌入式用得最广——浏览器缓存、桌面应用、浏览器书签都是它。也是取证工具的首选解析对象。
不理解它的文件结构,"为什么工具报错了"就解释不清,"我明明拉了数据库却看不到数据"更解释不清。
本篇要解决的问题只有一句:凭什么判断"这个库是什么状态、数据在哪里、有没有可恢复的残留"。
多数人只用到 sqlite3 db ".tables" 这一层。工具报错、file 报乱码、记录数与预期不符、删掉的消息还能不能找回——答案全在文件头的前 100 字节和 B-tree 页的组织方式里。
没有这层理解会发生什么
最典型的失败是把工具的报错当成数据的属性。
file 输出 data、sqlite3 报 file is not a database,于是写进报告"该库已损坏或已加密"。实际可能只是文件头被改了(加密库会改写魔数)、页大小字段读成了 1 而没做换算、或者只是 strings 默认按 ASCII 解码而库是 UTF-16。
判断"损坏 / 加密 / 读法错"这三件事,依据不同,处置方式完全不同——而在工具输出上,它们几乎长得一样。
在取证流程中的位置
本篇属于分析环的最底层。它不是某个案件环节的专题,而是几乎所有移动取证与桌面取证都要经过的格式识别与状态判读。
上游是取证工具与镜像准备(结构化导出见 证据格式与转换);下游是四类具体工作:应用数据提取后的库验证(见 应用私有数据)、聊天记录解析(见 即时通讯取证)、浏览器缓存分析、以及桌面应用与配置残留。
它自己不产出案件结论。但它决定了那些结论能不能被采信。
三个必须建立的认知:
| 认知 |
含义 |
为什么重要 |
| SQLite 是单文件数据库 |
没有独立的数据文件 / 索引文件,整个库就是一个文件(另有 -wal/-shm/-journal 伴随) |
决定了提取时"一个文件"是否等于"全部数据" |
| 删除不是擦除 |
DELETE 只是把记录写进 freelist 空闲页链,数据字节通常还在文件里 |
这是已删除记录恢复的全部基础 |
| SQLite 会自证完整性 |
文件头、页、cell 中有多处校验,结构损坏可被检测 |
既是分析工具,也是判断"是否被人为破坏"的依据 |
涉及的证据形态(具体到偏移与文件):
| 形态 |
具体位置 |
结构特征 |
| 文件头 |
偏移 0–99 |
SQLite format 3\0 魔数、页大小、编码、schema_cookie、freelist 头页 |
| B-tree 页 |
每页首字节 |
0x0D = 叶表页,用户可见表数据都在这里;0x05/0x02/0x0A 为各级内部与索引页 |
| 溢出页 |
单元中指向的溢出链 |
大字段被拆到溢出页,只看库文件正文会漏 |
| freelist |
first_freelist_trunk_page 起的链 |
已删除记录仍在字节里,但需按 record 格式重建 |
| WAL |
同名 -wal、-shm |
写版本字节为 2 表示 WAL;最近写入在 -wal 里 |
| record |
每个 cell 的变长记录 |
serial types 决定字段类型与长度 |
能回答什么
这是不是 SQLite 库、页大小与编码、库是否完整、有多少空闲页与残留、结构版本变更过几次、上次写入用的 SQLite 版本。
以及**"打不开"的原因属于哪一类**。
不能回答什么
库里存了什么业务语义(要读表结构)、记录为什么被删(是应用主动删还是容量回收)、integrity_check 通过是否意味着数据没被改(它只校验结构一致性,不校验内容真实性)、已删除记录恢复出来的一定是原文(可能是部分记录、可能是覆盖后的残留)。
读者前提
需要十六进制查看与基础二进制结构知识、SQL 的基本读写能力。工具只需 sqlite3 命令行与 xxd。
如果还不能熟练使用 PRAGMA 系列,建议先按 第 1–4 步 做一遍再看第二章——本篇的价值在于把工具输出与文件字节对应起来,跳过这一步就只剩命令记忆。
二、核心原理
2.1 文件头(偏移 0–99):第一行 hex 就要看的 100 字节
SQLite 3 数据库文件的前 100 字节是固定的数据库头。这是实战中打开任何一个 .db 文件后第一个要看的区域:
| 偏移 |
大小 |
字段 |
说明 |
| 0 |
16 |
magic |
** SQLite format 3\0** |
| 16 |
2 |
page_size |
页大小,大端。值 1 表示 65536(65536 无法用 2 字节表示) |
| 18 / 19 |
1 / 1 |
write_version / read_version |
1 = legacy journal,2 = WAL |
| 20 |
1 |
reserved_size |
每页末尾保留字节数(常见 0) |
| 21–23 |
3 |
payload 比例 |
固定 64 / 32 / 32 |
| 24 |
4 |
file_change_counter |
每次事务提交 +1 |
| 28 |
4 |
database_size_in_pages |
库的总页数(= 文件大小 / 页大小) |
| 32 |
4 |
first_freelist_trunk_page |
freelist 链的起始页 |
| 36 |
4 |
number_of_freelist_pages |
freelist 中的总页数 |
| 40 |
4 |
schema_cookie |
结构版本号,DDL 变更时 +1 |
| 44 |
4 |
schema_format |
建表 SQL 的格式号(1–4) |
| 52 |
4 |
largest_root_page |
主库根页(VACUUM 后会变) |
| 56 |
4 |
text_encoding |
1 = UTF-8,2 = UTF-16le,3 = UTF-16be |
| 60 / 68 |
4 / 4 |
user_version / application_id |
应用自定义值 |
| 96 |
4 |
sqlite_version_number |
最后写入该文件的 SQLite 版本号 |
三条必须记住的判定:
- 偏移 16 读出 1 → 实际页大小是 65536。这是硬指标,读错会让所有页偏移全错。
file_change_counter 连续递增说明库在被正常使用;若它不变但文件 mtime 变了,可能存在异常。
text_encoding 决定字符串怎么解码——UTF-16 时用 strings -e l(小端)而不是默认的 strings。
xxd -l 16 app.db
# 00000000: 5351 4c69 7465 2066 6f72 6d61 7420 3300 SQLite format 3.
xxd -s 16 -l 2 -p app.db # 例:0400 → 0x0400 = 1024;0001 → 值 1 → 实际 65536 ★
sqlite3 app.db "PRAGMA page_size; PRAGMA page_count;"
sqlite3 app.db "PRAGMA encoding; PRAGMA schema_version; PRAGMA user_version;"
2.2 B-tree 页:数据库的物理组织
SQLite 的表与索引都存成 B-tree。每个"页"(默认 4 KB 或 1024 字节)是一个 B-tree 节点,页首 8 或 12 字节是页头,其后是一个 cell 指针数组,再后面是 cell 内容区。
| 页类型字节 |
名称 |
含义 |
0x02 |
内部索引页 |
只存索引键 + 子页号 |
0x05 |
内部表页 |
只存行 ID + 子页号 |
0x0A |
叶索引页 |
索引键 + 数据 |
0x0D |
叶表页 |
★ 真正的表数据在这里 |
0x0D 是实战中最该认识的字节:所有用户可见的表数据都存储在叶表页中。做数据恢复时,第一个判断就是"这个区域有没有 0x0D 开头的页"。
┌───────────── 第 N 页(页大小 1024/4096)─────────────┐
│ 页类型 0x0D │ 首空区 │ 单元数 │ 内容起点 │ 碎片数 │ ← 页头
│ cell[0] cell[1] cell[2] ... │ ← 指针数组
│ (未使用的空隙) │
│ ... cell[2] 内容 cell[1] 内容 cell[0] 内容 ... │ ← 内容区(从后往前长)
└──────────────────────────────────────────────────────┘
页类型扫描的用法要点:页大小必须用文件头读出的真实值,不能默认 4096。页大小猜错,整个统计就是错的。
2.3 freelist 与"已删除记录"——取证的核心价值
DELETE FROM ... 在 SQLite 中不会把数据字节擦除。它做的是:把该 cell 从页的 cell 指针数组中移除;把整个页挂进 freelist(若页内还有其他记录,则只把空闲空间记入空闲空间链);字节内容原地保留,直到该页被新数据复用。
| 时间点 |
状态 |
| DELETE 之前 |
记录在叶表页的 cell 数组中,可正常 SELECT |
| DELETE 之后、页被复用之前 |
表里查不到,但字节还在文件里 |
| 页被新数据覆盖后 |
真正灭失 |
freelist 的组织(头部字段 32/36):trunk 页的偏移 32 字节处 = 下一个 trunk 页号;偏移 36 起 = 本 trunk 页所管理的空闲页号数组。
sqlite3 app.db "PRAGMA freelist_count;" # freelist 页数
# 从空闲空间提取残留(低成本、高收益)
strings -n 8 app.db | head -100
strings -n 8 -e l app.db | head -100 # UTF-16le(text_encoding=2 时)
strings -n 8 app.db | grep -E '身份证|银行卡|130[0-9]{9}|转账'
foremost -t all -o carved/ app.db # 按文件签名雕刻
freelist_count 很大是有意义的信号:可能发生过大量删除、可能有表被 DROP、也可能是有人清理过痕迹。它本身不证明任何事,但值得追查。
2.4 溢出页:大字段怎么存
当一条记录太长、一页放不下时,SQLite 把超出部分放到溢出页链上,页内只留一个指向首个溢出页的指针(4 字节)加长度:
叶表页 cell:
[变长字段长度][局部载荷 N 字节][4 字节溢出页号] → 溢出页 1 → 溢出页 2 → …
每个溢出页:[4 字节下一溢出页号][最多 (页大小-4) 字节数据]
取证意义:一条被截断的记录,其完整内容可能散布在溢出页链上。只看 cell 头部会漏掉大量内容——这是长文本(聊天记录正文、日志行、JSON 字段)恢复时的关键点。
2.5 WAL:三文件协作机制
SQLite 默认使用 journal mode = delete(回滚日志),也可配置为 WAL(预写日志)。WAL 模式下的三个文件是本领域最重要的机制:
| 文件 |
内容 |
缺失的后果 |
app.db |
已 checkpoint 的数据 |
— |
app.db-wal |
自上次 checkpoint 以来所有已提交事务的页副本 |
★ 最近的数据全部"消失",看起来像被删了 |
app.db-shm |
WAL 索引(通常是 32768 字节) |
sqlite3 可能无法正确定位 WAL 中的帧 |
- 价值:WAL 里的页是已提交的完整页镜像——即使主库中记录被
DELETE 过,WAL 中可能还留着删除前的页镜像。
- 风险:不带
-wal 打开,主库会"退回"到上次 checkpoint 状态。 这是本领域最高频的误判来源。
详细的取证流程、checkpoint 处理、.recover 恢复见 09-SQLite-WAL取证。
sqlite3 app.db "PRAGMA journal_mode;" # delete / wal / truncate / persist / memory / off
ls -l app.db*
# -rw-r--r-- 1 root root 2457600 app.db
# -rw-r--r-- 1 root root 524288 app.db-wal ← 有内容 = 有未回写数据
# -rw-r--r-- 1 root root 32768 app.db-shm
2.6 记录格式(serial types)
每条记录的值用变长整数编码的类型号描述类型与长度(serial type):
| serial type |
含义 |
占用字节 |
| 0 |
NULL |
0 |
| 1–6 |
整数(1/2/3/4/6/8 字节) |
对应长度 |
| 7 |
浮点(IEEE 754) |
8 |
| 8 / 9 |
整数 0 / 整数 1 |
0 |
| 12+2N |
BLOB,长度 N |
N |
| 13+2N |
文本,长度 N |
N |
为什么记这个:解析已删除记录或未知格式的页时,先读 serial type 才知道往后跳多少字节。不按 serial type 逐字段推进,会在第一个变长字段后全部错位。
浏览器把大量状态存在 SQLite 库里,Login Data、places.sqlite 的定位与解析是这套方法最常见的落点,见浏览器取证。
三、操作步骤
第 1 步:验证文件身份
ls -l app.db*
sha256sum app.db app.db-wal app.db-shm 2>/dev/null | tee app-hashes.txt
file app.db # SQLite 3.x database
xxd -l 16 app.db # 期望 SQLite format 3.
第 2 步:读取文件头字段
cat > sqlite_hdr.py <<'PY'
import struct, sys
d = open(sys.argv[1], 'rb').read(100)
if d[:16] != b"SQLite format 3\x00":
print("!! 非明文 SQLite(可能已加密)"); sys.exit(1)
ps = struct.unpack('>H', d[16:18])[0]
ps = 65536 if ps == 1 else ps # ★ 值为 1 实际是 65536
enc = {1: 'UTF-8', 2: 'UTF-16le', 3: 'UTF-16be'}
u = lambda o: struct.unpack('>I', d[o:o+4])[0]
print(f"页大小={ps} 写/读版本={d[18]}/{d[19]} (2=WAL) 变更计数器={u(24)}")
print(f"总页数={u(28)} freelist 链页={u(32)} freelist 页数={u(36)}")
print(f"schema_cookie={u(40)} 文本编码={enc.get(u(56))} 写入版本号={u(96)}")
PY
python3 sqlite_hdr.py app.db
第 3 步:完整性检查
sqlite3 app.db "PRAGMA integrity_check;"
# ok → 结构完好
# 大量 "row N missing from index ..." → 逻辑不一致但可读
# "database disk image is malformed" → 结构损坏(详见 09 篇 .recover)
sqlite3 app.db "PRAGMA quick_check;"
sqlite3 app.db "SELECT name, type FROM sqlite_master ORDER BY type, name;"
第 4 步:表结构分析
sqlite3 app.db ".tables"
sqlite3 app.db ".schema" # 全部建表语句
sqlite3 app.db ".schema message" # 单表
sqlite3 app.db "SELECT type,name,tbl_name FROM sqlite_master;"
第 5 步:让 WAL 生效(关键)
# ★ 确认三文件在同一目录后再操作,且永远在可写副本上操作
cp app.db work.db && cp app.db-wal work.db-wal && cp app.db-shm work.db-shm
sqlite3 work.db "PRAGMA wal_checkpoint(TRUNCATE);" # 回写并清空 WAL
sqlite3 work.db "SELECT count(*) FROM message;"
第 6 步:提取残留数据
sqlite3 app.db "PRAGMA freelist_count;"
# 编码感知的字符串提取
ENC=$(python3 sqlite_hdr.py app.db | awk '/文本编码/{print $3}')
case "$ENC" in "UTF-16le") FLAGS="-e l" ;; *) FLAGS="" ;; esac
strings -n 8 $FLAGS app.db > strings-all.txt
# 关键词定向搜索(用实际案件关键词替换)
grep -E '身份证|银行卡|手机号|转账|地址' strings-all.txt | head -40
foremost -t all -i -o carved/ app.db # 按签名雕刻
第 7 步:导出与固化
sqlite3 -header -csv app.db "SELECT * FROM message ORDER BY createTime;" > message.csv
sqlite3 app.db ".dump" > schema-and-data.sql # 完整 SQL 转储(可复现)
sha256sum app.db app.db-wal app.db-shm message.csv | tee final-hashes.txt
四、常见陷阱
SQLite 的取证陷阱集中在**「读不出来」与「读出来是错的」之间**:文件头几个字节的特殊编码、页大小这个全局参数、以及 DELETE 之后「数据还在」这个反直觉的事实——它们导致的误判往往不产生任何报错,只产生一个看起来合理的错误答案。 这一章按「读文件头 → 读结构 → 判残留 → 表述结论」的顺序展开。
4.1 文件头解析
陷阱 1:把页大小字段的值 1 当成 1,导致后续全部页偏移错位
现象:拿到一个 .db 文件,用脚本按偏移 16 读出页大小字段,值是 1,于是按 1 字节一页去计算页边界,整个解析结果全是乱码;改按 4096 处理,页类型字节统计也不对。
为什么会误判:文件头偏移 16 的两字节大端字段是页大小,但值 1 是一个特殊编码,它实际表示 65536——因为 65536 无法用 2 字节直接表示,SQLite 用了哨兵值。这是一个「读出值」与「实际值」不一致的字段,而其它所有字段都是所见即所得的。 分析者按「所见即所得」的默认预期处理,就会把这个字段当成 1。更麻烦的是它不会立刻报错——按 1 字节一页会立刻遇到荒谬的页计数而暴露,而按「猜的」4096 处理则会得到一个自洽但错误的结果:页头能解析、页类型字节看着合理、0x0D 叶表页能扫到一些,只是页边界全部错位,于是同一份数据被切成错误的结构。
误判代价:页大小是全局参数,它一错,整个文件级的分析全部作废。 具体表现是:页类型统计失真(0x0D 页数量对不上表的规模)、freelist 页数与 freelist_count 不符、cell 解析越界、WAL 帧定位错误。这些错误不会集中报错,而是分散表现为「数据读不全」「数量对不上」「某些表读不出」——每一项都可以被独立地解释为「文件损坏」或「数据不存在」,于是错误的根因被完全掩盖,而结论照常产出。
正确做法:页大小必须从文件头读,且必须处理 1 这个哨兵值:
# 读出原始值
xxd -s 16 -l 2 -p app.db
# 0400 → 0x0400 = 1024
# 0001 → 值 1 → ★ 实际页大小 65536
# 脚本里必须做哨兵值转换
python3 -c "
import struct
d=open('app.db','rb').read(100)
ps=struct.unpack('>H', d[16:18])[0]
ps = 65536 if ps == 1 else ps # ★ 值为 1 实际是 65536
print('页大小 =', ps)
"
# 交叉验证:总页数 × 页大小 必须等于文件大小
sqlite3 app.db "PRAGMA page_size; PRAGMA page_count;"
stat -c%s app.db
# 1024 × 2400 = 2,457,600 = 实际文件大小 ✓
# 若不等,说明页大小读错了(先排除文件被截断的可能)
最后那句交叉验证是判定页大小读对的最简判据——「总页数 × 页大小 = 文件大小」这个等式成立,页大小就是对的。这一条验证成本几乎为零,但能挡住这一整类错误。报告里应把页大小的实测值写明,而不是只写「已解析文件头」——因为它决定了后续所有分析的可信度基础。
陷阱 2:不从文件头读页大小,直接假设 4096
现象:写了个页类型扫描脚本,页大小参数直接写死 4096,扫出 32 个 0x0D 叶表页,报告写「该库有 32 个数据页,表规模较小」。
为什么会误判:SQLite 的页大小由创建时的配置决定,取值范围从 512 到 65536(512 / 1024 / 2048 / 4096 / 8192 / … / 65536,另有 512/65536 的 2 的幂特例),现代应用更倾向 4096,但移动端遗留库用 1024 的极多,而嵌入式与浏览器场景下 512 与 8192 都不罕见。猜错的形态是「看起来能跑」:按 4096 去切,文件前部是主库与索引页,切出来的页头大多能对上(因为 B-tree 页的页头格式一致),只有当页大小不是 4096 的约数关系时才会明显出错。而「扫出的页数偏少」这个现象很容易被解释为「表确实小」——它不像报错那样引人警觉。
误判代价:与陷阱 1 同一个病根,但表现更隐蔽:它不会产生任何异常输出,只会系统性少算。 具体后果:已删除数据的残留扫描覆盖不全(只扫了文件的一部分)、freelist 页的字节被漏掉、表规模被低估而这可能影响「该应用使用程度」的行为判断。最严重的是残留数据恢复——freelist 恢复依赖「整文件按真实页边界扫描」,页大小猜错就等于只扫描了文件的一小部分。
正确做法:任何页级操作的第一步是从文件头读页大小,脚本里不允许出现字面量:
# 反模式:页大小写死
# python3 for page in range(0, len(data), 4096): ... ← 绝对不要
# 正解:参数来自文件头
python3 -c "
import struct, sys, collections
data = open('app.db','rb').read()
if data[:16] != b'SQLite format 3\x00':
sys.exit('非明文 SQLite')
ps = struct.unpack('>H', data[16:18])[0]
ps = 65536 if ps == 1 else ps # 哨兵值
print(f'页大小={ps} 文件可容纳页数={len(data)//ps} 余字节={len(data)%ps}')
types = collections.Counter()
for off in range(0, len(data)//ps*ps, ps):
b = data[off]
if b in (0x02,0x05,0x0a,0x0d):
types[hex(b)] += 1
print('页类型分布:', dict(types))
print('0x0d 叶表页数:', types.get('0xd', 0))
"
注意最后那行 余字节——文件大小不是页大小的整数倍,说明文件被截断了,这是一个独立于页大小的重要发现。两个信息一起看,脚本才具备自诊断能力。