搜索全站

输入至少 2 个字符,查找相关内容。

↑↓ 选择 Enter 打开Esc 关闭

SQLite 基础与结构

手机端最常见的数据库——几乎每个应用都有。嵌入式用得最广——浏览器缓存、桌面应用、浏览器书签都是它。也是取证工具的首选解析对象。

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

一、概述

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 版本号

三条必须记住的判定:

  1. 偏移 16 读出 1 → 实际页大小是 65536。这是硬指标,读错会让所有页偏移全错。
  2. file_change_counter 连续递增说明库在被正常使用;若它不变但文件 mtime 变了,可能存在异常。
  3. 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))
"

注意最后那行 余字节——文件大小不是页大小的整数倍,说明文件被截断了,这是一个独立于页大小的重要发现。两个信息一起看,脚本才具备自诊断能力。

4.2 结构解析与数据可见性

陷阱 3:只提 app.db 不带 -wal,把「没回写」读成「已删除」

现象:adb pull 单独拉了 app.db,PRAGMA integrity_check 返回 ok,.tables 正常列出四张表,`pay_re