数据库基础体系 · 第 117/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
SQLite 安全边界:文件权限、注入、扩展、加密选择和不可信数据库
SQLite 的安全问题,不能只归结为“SQL 是否使用参数绑定”。SQLite 不是一个独立运行、替用户隔离权限的数据库服务器,而是嵌入应用进程中的库:应用以什么操作系统身份运行,SQLite 就通常以什么身份读取、创建和修改数据库文件;应用启用了哪些扩展、注册了哪些函数,也会改变数据库文件能够触发的行为。
因此,SQLite 的安全边界至少包括五层:
- 操作系统对数据库文件及其目录的访问控制;
- SQL 语句与应用数据之间的注入边界;
- SQLite 扩展和应用定义函数带来的代码执行边界;
- 数据静态加密与密钥管理;
- 打开、读取和迁移不可信数据库时的解析与资源消耗风险。
下面分别说明这些边界如何形成,以及它们不能解决什么问题。
一、先确定 SQLite 的安全模型
1. SQLite 是库,不是数据库服务器
典型 SQLite 使用方式如下:
应用进程
└── SQLite 库
├── 解析 SQL
├── 访问数据库文件
├── 创建日志、WAL 和临时文件
└── 执行已加载的扩展或应用定义函数
数据库服务器通常有独立进程、认证协议、连接账户和服务端权限模型。SQLite 默认没有这些隔离层。调用方已经拥有启动应用进程的权限,而 SQLite 通常继承该进程的文件权限。
这意味着:
- SQLite 文件权限不能限制已经攻陷应用进程的攻击者;
- SQL 授权模型不能替代操作系统文件权限;
- 数据库加密不能阻止拿到运行中密钥的进程读取数据;
sqlite3_open()不只是“打开一个数据结构”,还可能访问路径对应的文件、目录和伴随文件。
2. 一个安全结论的前提
讨论“是否安全”前,必须明确攻击者能力。例如:
| 攻击者能力 | 主要应对机制 |
|---|---|
| 能读取应用目录,但不能执行应用 | 文件权限、磁盘加密、数据库加密 |
| 能提交普通业务字段 | 参数绑定、输入校验、授权 |
| 能控制 SQL 片段 | 标识符白名单、避免动态 SQL |
| 能替换数据库文件 | 文件权限、目录权限、完整性校验、签名 |
| 能修改应用目录中的扩展 | 目录权限、代码签名、禁用扩展加载 |
| 已控制应用进程 | 不能仅靠 SQLite 内部机制防护 |
| 提供恶意数据库供应用打开 | 只读、沙箱、资源限制、版本更新、关闭危险能力 |
同一种措施只能覆盖其中一部分能力。把一种措施误认为全局防护,是 SQLite 安全设计中最常见的错误。
二、文件权限:保护的不是“一个 .db 文件”
1. 数据库文件由多个文件组成
SQLite 在不同日志模式下会产生不同的伴随文件:
- 主数据库文件,例如
app.db; - 回滚日志,例如
app.db-journal; - WAL 文件,例如
app.db-wal; - 共享内存文件,例如
app.db-shm; - 临时文件和临时数据库;
- 备份、导出文件以及应用自行生成的副本。
在 WAL 模式下,已提交但尚未检查点合并回主数据库的页面可能仍然位于 app.db-wal 中。因此,只保护 app.db 而让同目录中的 app.db-wal 可读,不能认为数据已经受到保护。
SQLite 还需要访问数据库所在目录,以创建、删除或重命名日志文件。只给数据库文件读写权限、不给目录必要权限,可能造成:
- 无法创建 WAL 或回滚日志;
- 无法完成提交;
- 启动时出现
SQLITE_CANTOPEN、SQLITE_READONLY或 I/O 错误; - 管理员误以为“文件权限很严格”,实际程序根本没有按预期工作。
2. Unix 示例:权限、属主与目录
下面的示例假定应用使用专用账户 myapp,数据库目录为 /var/lib/myapp:
sudo install -d -o myapp -g myapp -m 700 /var/lib/myapp
sudo install -o myapp -g myapp -m 600 app.db /var/lib/myapp/app.db
含义是:
- 目录只有
myapp可以进入、列出和修改; - 数据库文件只有
myapp可以读写; - 其他用户不能通过目录遍历直接读取或替换数据库及其伴随文件。
应用首次创建文件时,还应使用合适的 umask,例如:
umask 077
umask 077 会使新建普通文件默认不向组用户和其他用户授予权限,但它不是对已有文件的修复,也不是对父目录权限的替代。实际部署还需要检查:
namei -l /var/lib/myapp/app.db
ls -la /var/lib/myapp
namei -l 可以逐级显示路径中各目录的权限。因为只要某一级父目录允许不可信用户替换路径、创建符号链接或重命名文件,就可能出现路径替换问题。
3. 权限设计中的几个边界
文件可读,不等于数据库可安全使用
攻击者若只能读取 .db 文件,可能已经获得全部明文数据。若只能写目录或替换数据库,攻击者则可能:
- 让应用打开攻击者准备的数据库;
- 破坏业务数据;
- 在应用启用了扩展或危险应用函数时影响后续执行;
- 利用解析器、虚拟表或复杂查询消耗资源。
只读数据库也可能需要目录访问
SQLite 的只读打开并不意味着“完全不写任何文件”。某些打开方式、临时文件、共享内存和日志模式会影响实际行为。若部署目标是严格只读,应在打开时使用只读 URI 参数,并在操作系统层面确认目录和文件权限,同时测试 WAL、临时目录和检查点行为,而不是只调用 SQL:
file:/path/data.db?mode=ro
URI 是否被启用取决于具体 API 和打开选项;在 C API 中应使用支持 URI 的打开标志。只读模式阻止 SQLite 写入数据库,但不能自动阻止应用读取文件,也不能阻止应用对查询结果进行导出。
备份是另一个数据副本
sqlite3_backup()、.dump、复制文件、在线备份工具以及应用导出的 JSON,都可能产生新的敏感数据副本。备份副本必须拥有独立的:
- 文件权限;
- 保留期限;
- 加密策略;
- 删除和恢复流程;
- 审计记录。
在 WAL 模式下,直接复制主数据库文件也可能不是一个一致的在线备份方案。应使用 SQLite 的备份 API、经过正确协调的检查点与复制流程,或在合适的停机状态下复制完整文件集合。
三、SQL 注入:参数绑定解决“值”,不解决“语法结构”
1. 注入的本质
SQL 注入发生在应用把不可信字符串拼接进 SQL 文本时。例如:
username = input()
sql = "SELECT id FROM users WHERE name = '" + username + "'"
输入:
' OR 1=1 --
可能形成:
SELECT id FROM users WHERE name = '' OR 1=1 --'
这里的输入不再只是一个字符串值,而是改变了 SQL 的词法结构。
正确方式是让 SQL 文本和参数值分离:
import sqlite3
conn = sqlite3.connect("app.db")
name = input("name: ")
row = conn.execute(
"SELECT id FROM users WHERE name = ?",
(name,)
).fetchone()
print(row)
? 是参数占位符,name 作为绑定值传给 SQLite。即使 name 中包含引号、注释符号或 SQL 关键字,也会被当作字符串值,而不是重新解析成 SQL 语法。
2. 一个完整的插入示例
import sqlite3
conn = sqlite3.connect("app.db")
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
)
""")
try:
with conn:
conn.execute(
"INSERT INTO users(name) VALUES (?)",
("O'Reilly",)
)
except sqlite3.IntegrityError as exc:
print("业务约束失败:", exc)
这里有三个不同层次:
?防止字符串值改变 SQL 语法;UNIQUE由数据库约束防止重复数据;with conn:使事务在成功时提交、异常时回滚。
参数绑定不负责权限判断、不负责业务校验,也不负责防止资源消耗。例如,用户可以提供合法但极大的字符串,应用仍需设置业务长度限制。
3. 参数不能替代标识符
以下写法通常是错误的:
column = input()
conn.execute("SELECT ? FROM users", (column,))
这里的 ? 表示一个字符串值,不表示列名。查询结果通常是每行返回字面量 column 的内容,而不是动态列。
表名、列名、排序方向等 SQL 结构不能通过普通值参数绑定。正确做法是固定白名单:
allowed_columns = {
"name": "name",
"created": "created_at",
}
requested = input()
column = allowed_columns.get(requested)
if column is None:
raise ValueError("unsupported sort column")
sql = f"SELECT id, name FROM users ORDER BY {column} DESC"
rows = conn.execute(sql).fetchall()
这里的 f-string 之所以可接受,是因为插入 SQL 的 column 只能来自程序写死的映射,而不是直接来自用户输入。排序方向也应使用同样的白名单:
allowed_direction = {"asc": "ASC", "desc": "DESC"}
direction = allowed_direction.get(input().lower())
if direction is None:
raise ValueError("unsupported direction")
反例是:
sql = f"SELECT * FROM users ORDER BY {user_column} {user_direction}"
即使用户输入来自“看似受控”的前端,也不能把它当作安全边界。前端校验可以改善体验,不能替代服务端或应用内部的白名单。
4. executescript 与多语句边界
某些接口,例如 Python 的 executescript(),专门用于执行一段包含多个语句的脚本。它不应接收用户可控文本:
# 仅适用于受信任的迁移脚本
conn.executescript("""
CREATE TABLE ...
INSERT INTO ...
""")
迁移文件本身应作为代码审查、版本控制和发布流程的一部分管理。把用户输入拼接进脚本,会绕过普通单语句接口所提供的边界。
5. SQL 注入之外:应用定义函数
参数绑定只能保护 SQL 值的语法边界。如果应用注册了危险函数,例如把 SQL 参数传给 shell、文件系统或网络操作,那么合法 SQL 也可能产生危险副作用:
SELECT application_function(user_input) FROM ...
因此,安全审查必须继续追踪:
- 函数能否访问文件、网络或进程;
- 函数是否在触发器、视图或生成列中执行;
- 函数是否允许修改外部状态;
- 数据库文件中的模式对象是否能调用该函数。
这正是“不可信数据库”与 trusted_schema 需要结合考虑的原因。
四、扩展:从“执行 SQL”跨越到“加载代码”
1. SQLite 扩展是什么
SQLite 扩展可以提供:
- 新的 SQL 函数;
- 虚拟表模块;
- 排序规则;
- 全文搜索或其他数据访问能力。
扩展可能以内置方式编译进程序,也可能作为动态库在运行时加载。动态扩展本质上是本地代码,不是普通 SQL 数据。加载一个扩展通常等价于允许该动态库在应用进程内执行,并拥有该进程可拥有的系统权限。
因此,扩展加载是代码执行边界,而不是查询功能开关。
2. 默认行为与显式开启
SQLite 的运行时扩展加载需要显式启用;具体控制方式取决于使用的 API。C 接口中常见的相关能力包括:
sqlite3_load_extension():由应用代码指定扩展路径并加载;sqlite3_enable_load_extension():控制扩展加载能力;sqlite3_db_config()配置项:可更细致地区分 SQLload_extension()与应用通过 C API 加载的能力。
生产程序不应仅依赖默认值,而应在连接初始化阶段明确设置策略:不需要扩展时关闭;需要扩展时固定路径、固定文件、固定版本,并验证其来源。
扩展名或路径不能直接来自用户输入:
错误:load_extension(user_supplied_path)
正确:从程序内置白名单选择固定绝对路径
路径白名单也不是完整的供应链验证。还应控制动态库文件权限,避免运行账户或低权限用户可以替换该文件。
3. 数据库文件可以间接影响扩展执行
恶意数据库可能包含视图、触发器、表达式索引、生成列等模式对象。若应用查询这些对象,而它们引用了应用注册的函数,就可能触发函数执行。
对于打开不可信数据库的连接,可以考虑:
PRAGMA trusted_schema = OFF;
或者通过 C API 使用相应的数据库配置项。该设置的目的,是减少模式对象调用非内置、应用定义函数的能力。它不是沙箱,也不能阻止所有资源消耗或解析器漏洞;同时,启用后某些依赖应用函数的合法模式可能无法工作,应用必须测试并处理失败。
还可以在适用场景使用防御模式配置,例如 SQLITE_DBCONFIG_DEFENSIVE,限制修改模式、关闭日志等危险操作。该模式同样不是权限系统:它不会把一个已被攻陷的进程变成安全进程。
4. 虚拟表和文件访问
某些扩展或虚拟表可以访问文件、网络或外部数据源。即使 SQL 本身没有明显注入,以下路径也可能构成风险:
不可信 SQL/数据库
→ 调用虚拟表或函数
→ 读取本地文件、访问网络或消耗大量资源
因此,处理不可信数据时,最稳妥的边界通常是:
- 在独立进程中打开;
- 使用低权限账户;
- 放入操作系统沙箱;
- 禁用动态扩展;
- 不注册危险应用函数;
- 使用只读连接;
- 设置查询超时、进程级资源限制和输出大小限制。
五、加密选择:先区分机密性、完整性和运行时防护
1. “SQLite 加密”不是单一能力
需要区分三个目标:
- 静态机密性:攻击者拿到磁盘文件后不能直接读出内容;
- 完整性:攻击者修改文件后能够被检测,或修改无法生效;
- 运行时机密性:应用运行期间,其他进程或内存攻击者不能读取明文。
常见措施的覆盖范围如下:
| 措施 | 磁盘文件机密性 | 文件篡改检测 | 防运行中进程读取 |
|---|---|---|---|
| Unix 文件权限 | 有限 | 否 | 否 |
| 全盘加密 | 通常有 | 取决于方案 | 否 |
| 文件系统加密目录 | 通常有 | 取决于方案 | 否 |
| SQLite 加密扩展 | 有 | 取决于实现 | 否 |
| 应用层字段加密 | 指定字段有 | 需额外设计 | 否 |
| 进程隔离与硬件密钥 | 间接增强 | 可增强 | 不能绝对保证 |
加密解决的是数据暴露问题,不会自动解决 SQL 注入、越权查询或恶意扩展。
2. 官方 SQLite 与第三方加密
SQLite 官方核心发行版提供数据库引擎,但核心 SQLite 并不默认提供一个通用的、内置的数据库文件加密功能。实际项目中常见选择包括:
-
操作系统或存储层加密
例如全盘加密、加密卷或平台安全存储。部署简单,通常对应用透明,适合主要威胁是设备丢失或离线读取的场景。设备解锁、系统运行或应用账户可用时,数据库通常也会被解密使用。 -
SQLite 加密扩展
SQLite 官方生态中存在商业的 SQLite Encryption Extension(SEE);也有 SQLCipher 等第三方方案。它们可能改变页面格式、密钥派生、校验和、兼容性和备份方式,不能把一种方案的数据库文件直接当作另一种方案打开。 -
应用层字段加密
只对特定敏感字段加密,可以降低数据库管理员或备份读取者看到明文的范围,但会失去普通索引、范围查询、排序和模糊检索能力。密钥不应与密文放在同一个可直接读取的位置。
3. 加密方案的工程检查点
选择加密实现时,应确认:
- 加密覆盖主数据库、WAL、回滚日志和临时/备份副本的方式;
- 密钥是否由合适的密钥管理系统提供;
- 是否使用密码派生函数,而不是把用户密码直接当作密钥;
- 是否有认证加密或独立的篡改检测;
- 备份、导出、迁移和恢复工具是否支持该格式;
- 升级 SQLite 或更换平台后的兼容性;
- 密钥轮换是否需要重写整个数据库;
- 错误时是否会泄露“密钥正确但数据损坏”等敏感信息。
一个常见失败方案是:
数据库文件加密密钥 = 固定字符串写在应用源码中
这只能防止偶然查看文件,不能防止逆向应用、提取包体或读取运行时内存的攻击者。若应用必须自动解密,密钥最终必须以某种形式到达应用进程;安全目标应明确为“提高离线窃取成本”,而不是宣称“应用无法读取的数据”。
六、不可信数据库:数据库文件也是输入
1. 为什么“只读打开”仍然不够
把外部提供的 .db 文件以只读方式打开,可以减少覆盖原文件和写入日志的风险,但它不能保证:
- 查询一定快速结束;
- 文件不会占用大量内存;
- 结果集不会极大;
- 数据库模式不会包含复杂或异常结构;
- 解析器不会遇到漏洞;
- 应用定义函数不会被模式对象调用;
- 触发器、视图或虚拟表不会改变应用行为。
因此,“不可信数据库”应按不可信文件和不可信程序输入处理。
2. 推荐的数据流边界
一个较稳妥的处理流程是:
外部文件
→ 文件大小、来源、签名/哈希检查
→ 独立低权限进程打开
→ 只读连接
→ 禁用扩展
→ trusted_schema=OFF
→ 仅执行固定查询
→ 限制时间、内存、输出行数
→ 导出经过校验的数据
→ 主应用使用导出结果
如果必须在同一进程处理,至少应做到:
- 不让文件路径由不可信输入直接控制;
- 使用只读 URI;
- 不启用动态扩展;
- 不注册具有副作用的应用函数;
- 不执行数据库中取出的 SQL 文本;
- 不把数据库中的表名、列名直接拼入下一条 SQL;
- 对查询结果的行数、字段长度和总输出大小设上限。
3. PRAGMA 与错误处理示例
以下 Python 示例展示一个只读检查器的基本边界:
import sqlite3
from pathlib import Path
path = Path("/srv/import/incoming.db").resolve()
if path.stat().st_size > 200 * 1024 * 1024:
raise ValueError("database is too large")
uri = f"file:{path}?mode=ro"
try:
conn = sqlite3.connect(uri, uri=True, timeout=1.0)
# 不让数据库模式中的应用定义函数默认获得信任。
conn.execute("PRAGMA trusted_schema = OFF")
# 只执行程序固定的查询,不执行文件中的 SQL 文本。
count = conn.execute(
"SELECT count(*) FROM sqlite_master WHERE type = ?",
("table",)
).fetchone()[0]
if count > 1000:
raise ValueError("too many tables")
except sqlite3.DatabaseError as exc:
# 记录错误类型和文件标识,不把完整内部路径或敏感 SQL 返回给外部。
raise RuntimeError("invalid or unreadable SQLite database") from exc
finally:
try:
conn.close()
except UnboundLocalError:
pass
这里的 mode=ro 表示只读打开;trusted_schema=OFF 用于降低模式对象调用应用定义函数的风险;count(*) 仍然可能扫描大量结构,因此生产代码还应配合进程级超时和资源限制。Python 的 timeout 主要针对锁等待,不是通用的查询执行时间限制,不能把它误认为 CPU 超时沙箱。
4. 不能把数据库内容当作迁移脚本
下面这种流程十分危险:
读取外部数据库中的某个字段
→ 拼接为 SQL
→ executescript()
数据库中的文本即使看起来像“配置”或“规则”,仍然是数据。若确实需要支持规则,应设计受限的数据格式,并由应用解析为固定操作,而不是把它重新交给 SQL 解析器。
5. 文件损坏、恶意构造和恢复
面对打开失败,应区分:
SQLITE_NOTADB:文件不是可识别的数据库或结构已严重损坏;SQLITE_CORRUPT:数据库结构或页面存在损坏;SQLITE_BUSY/SQLITE_LOCKED:锁竞争,不一定是损坏;SQLITE_CANTOPEN:路径、权限或目录问题;SQLITE_READONLY:打开或日志策略与写入需求冲突。
诊断时可以对副本执行:
sqlite3 suspect.db "PRAGMA integrity_check;"
但要注意:
integrity_check读取数据库,可能消耗大量 CPU 和内存;- 它不是恶意文件的安全沙箱;
- 对不可信文件不应直接在生产主进程中运行;
- 修复前应保留原始副本,并记录哈希;
REINDEX、VACUUM或导出重建都可能修改或放大数据,不应直接对原文件操作。
恢复通常应优先采用“隔离副本上检查 → 导出可验证数据 → 新建数据库导入”的方式,而不是盲目执行修复命令。这样可以避免把损坏文件中的异常结构原样带入生产库。
七、事务、并发与安全故障路径
SQLite 的事务主要解决一致性和并发可见性,不是权限控制。一个连接在事务中执行多条语句时,其他连接通常按照 SQLite 的锁和隔离规则看到提交前或提交后的状态,但这不会阻止同一进程中的其他代码读取敏感表。
安全相关的故障路径常见如下:
写入数据
→ 事务提交
→ WAL 暂留明文页面
→ 应用崩溃或检查点延迟
→ 备份程序只复制主库
→ 备份与主库状态不一致
或者:
数据库文件权限收紧
→ 应用仍需创建 -wal/-shm
→ 目录不可写
→ 提交失败
→ 应用进入重试循环
→ 日志中暴露 SQL、路径或敏感数据
所以安全测试不能只测试“能不能 SELECT”,还应测试:
- WAL 和回滚日志创建、删除及权限;
- 崩溃恢复;
- 在线备份;
- 多进程并发;
- 磁盘空间耗尽;
- 目录不可写;
- 数据库只读;
- 连接关闭和异常回滚;
- 错误日志是否泄露绑定参数和密钥。
八、把措施放回正确的边界
可以用下面的因果链检查设计是否完整:
用户输入
→ 参数绑定或白名单
→ 固定 SQL 结构
→ 最小业务权限
→ 事务一致性
→ 文件系统访问控制
→ 备份与副本保护
→ 加密与密钥管理
→ 不可信文件的隔离执行
其中任何一层都不能替代另一层:
- 参数绑定不能防止窃取
.db文件; - 文件权限不能防止应用中的动态 SQL 注入;
- 数据库加密不能阻止已控制应用进程的攻击者读取明文;
- 禁用扩展不能修复错误的动态 SQL;
trusted_schema=OFF不能代替进程沙箱;integrity_check不能证明文件来自可信来源;- 只读模式不能消除解析器和资源消耗风险。
SQLite 安全设计的核心,不是寻找一个“安全开关”,而是明确每个输入和每个权限的边界:文件由谁提供,进程以谁的身份运行,哪些 SQL 结构固定,哪些函数有副作用,密钥在哪里,备份流向何处,以及不可信数据库是否在独立资源边界内处理。只有这些边界彼此闭合,SQLite 才能在嵌入式部署中同时保持轻量性与可控性。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:SQLite Backup API 与在线复制:快照、一致性、锁和恢复验证
- 下一篇:Redis 内核与事件循环:命令执行、IO 线程、阻塞点和延迟
- 延伸:数据库安全治理:最小权限、加密、审计、脱敏与注入防护
- 延伸:SQLite 工程实践:嵌入应用、备份、迁移、损坏恢复与安全
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论