数据库基础体系 · 第 117/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

SQLite 安全边界:文件权限、注入、扩展、加密选择和不可信数据库

SQLite 的安全问题,不能只归结为“SQL 是否使用参数绑定”。SQLite 不是一个独立运行、替用户隔离权限的数据库服务器,而是嵌入应用进程中的库:应用以什么操作系统身份运行,SQLite 就通常以什么身份读取、创建和修改数据库文件;应用启用了哪些扩展、注册了哪些函数,也会改变数据库文件能够触发的行为。

因此,SQLite 的安全边界至少包括五层:

  1. 操作系统对数据库文件及其目录的访问控制;
  2. SQL 语句与应用数据之间的注入边界;
  3. SQLite 扩展和应用定义函数带来的代码执行边界;
  4. 数据静态加密与密钥管理;
  5. 打开、读取和迁移不可信数据库时的解析与资源消耗风险。

下面分别说明这些边界如何形成,以及它们不能解决什么问题。


一、先确定 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_CANTOPENSQLITE_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)

这里有三个不同层次:

  1. ? 防止字符串值改变 SQL 语法;
  2. UNIQUE 由数据库约束防止重复数据;
  3. 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() 配置项:可更细致地区分 SQL load_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 并不默认提供一个通用的、内置的数据库文件加密功能。实际项目中常见选择包括:

  1. 操作系统或存储层加密
    例如全盘加密、加密卷或平台安全存储。部署简单,通常对应用透明,适合主要威胁是设备丢失或离线读取的场景。设备解锁、系统运行或应用账户可用时,数据库通常也会被解密使用。

  2. SQLite 加密扩展
    SQLite 官方生态中存在商业的 SQLite Encryption Extension(SEE);也有 SQLCipher 等第三方方案。它们可能改变页面格式、密钥派生、校验和、兼容性和备份方式,不能把一种方案的数据库文件直接当作另一种方案打开。

  3. 应用层字段加密
    只对特定敏感字段加密,可以降低数据库管理员或备份读取者看到明文的范围,但会失去普通索引、范围查询、排序和模糊检索能力。密钥不应与密文放在同一个可直接读取的位置。

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 和内存;
  • 它不是恶意文件的安全沙箱;
  • 对不可信文件不应直接在生产主进程中运行;
  • 修复前应保留原始副本,并记录哈希;
  • REINDEXVACUUM 或导出重建都可能修改或放大数据,不应直接对原文件操作。

恢复通常应优先采用“隔离副本上检查 → 导出可验证数据 → 新建数据库导入”的方式,而不是盲目执行修复命令。这样可以避免把损坏文件中的异常结构原样带入生产库。


七、事务、并发与安全故障路径

SQLite 的事务主要解决一致性和并发可见性,不是权限控制。一个连接在事务中执行多条语句时,其他连接通常按照 SQLite 的锁和隔离规则看到提交前或提交后的状态,但这不会阻止同一进程中的其他代码读取敏感表。

安全相关的故障路径常见如下:

写入数据
  → 事务提交
  → WAL 暂留明文页面
  → 应用崩溃或检查点延迟
  → 备份程序只复制主库
  → 备份与主库状态不一致

或者:

数据库文件权限收紧
  → 应用仍需创建 -wal/-shm
  → 目录不可写
  → 提交失败
  → 应用进入重试循环
  → 日志中暴露 SQL、路径或敏感数据

所以安全测试不能只测试“能不能 SELECT”,还应测试:

  • WAL 和回滚日志创建、删除及权限;
  • 崩溃恢复;
  • 在线备份;
  • 多进程并发;
  • 磁盘空间耗尽;
  • 目录不可写;
  • 数据库只读;
  • 连接关闭和异常回滚;
  • 错误日志是否泄露绑定参数和密钥。

八、把措施放回正确的边界

可以用下面的因果链检查设计是否完整:

用户输入
  → 参数绑定或白名单
  → 固定 SQL 结构
  → 最小业务权限
  → 事务一致性
  → 文件系统访问控制
  → 备份与副本保护
  → 加密与密钥管理
  → 不可信文件的隔离执行

其中任何一层都不能替代另一层:

  • 参数绑定不能防止窃取 .db 文件;
  • 文件权限不能防止应用中的动态 SQL 注入;
  • 数据库加密不能阻止已控制应用进程的攻击者读取明文;
  • 禁用扩展不能修复错误的动态 SQL;
  • trusted_schema=OFF 不能代替进程沙箱;
  • integrity_check 不能证明文件来自可信来源;
  • 只读模式不能消除解析器和资源消耗风险。

SQLite 安全设计的核心,不是寻找一个“安全开关”,而是明确每个输入和每个权限的边界:文件由谁提供,进程以谁的身份运行,哪些 SQL 结构固定,哪些函数有副作用,密钥在哪里,备份流向何处,以及不可信数据库是否在独立资源边界内处理。只有这些边界彼此闭合,SQLite 才能在嵌入式部署中同时保持轻量性与可控性。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。