数据库基础体系 · 第 13/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
数据库安全治理:最小权限、加密、审计、脱敏与注入防护
数据库安全不是给数据库“加一个安全开关”,而是围绕数据生命周期建立一组可验证的控制:
- 最小权限:身份只能执行完成业务所需的最小操作。
- 加密:在传输、存储、备份和日志等边界保护数据机密性。
- 审计:记录谁在什么时间、通过什么入口、对什么对象执行了什么操作,以及结果如何。
- 脱敏:让不应看到原始值的调用方获得受控的替代表达。
- 注入防护:让用户输入只作为数据参与执行,而不能改变 SQL 语法和执行结构。
这些控制相互关联,但不能互相替代。例如,数据库连接使用 TLS 不能阻止拥有合法权限的账号读取全部数据;审计记录了越权查询,也不会自动阻止查询;脱敏视图不能修复拼接 SQL 导致的注入。
下文以 PostgreSQL 当前稳定版公开 SQL 语义和 MySQL 8.4 公开语义为边界。具体审计插件、透明加密能力和密钥管理能力可能受发行版、商业版本、操作系统或云服务影响。
一、先建立安全边界:保护什么、抵御谁
1.1 数据库安全中的几个边界
一个数据库系统至少包含以下边界:
客户端
│
│ TLS
▼
连接池 / 应用服务
│
│ 数据库身份与权限
▼
数据库服务器
├── 表、索引、视图、函数
├── 数据文件
├── 日志
└── 备份、复制、导出文件
每个边界面对的威胁不同:
| 边界 | 典型威胁 | 主要控制 |
|---|---|---|
| 客户端到数据库 | 窃听、连接劫持、错误证书 | TLS、证书校验 |
| 应用到数据库 | 账号泄露、越权查询、注入 | 最小权限、参数化查询 |
| 数据库数据文件 | 磁盘被复制、快照泄露 | 存储加密、密钥管理 |
| 备份与复制 | 备份桶暴露、复制链路泄露 | 备份加密、访问控制、传输加密 |
| 日志与审计 | SQL 中带出敏感值、审计被删除 | 日志治理、集中存储、不可篡改 |
| 管理员和内部人员 | 超出职责读取数据 | 分权、审计、脱敏、审批 |
“加密”只解决某些情况下的机密性,不自动解决完整性、可用性、权限和业务滥用问题。数据库遭受勒索、误删或逻辑破坏时,仍然需要备份、恢复和恢复演练。
1.2 保护对象要先分类
同一列数据在不同场景的保护目标不同:
- 用户密码:通常应使用专门的密码哈希方案,而不是可逆加密。
- 身份证号、银行卡号:可能需要受控解密,也可能只需展示后四位。
- 手机号、邮箱:业务检索可能需要确定性匹配,但确定性保护会泄露相等关系。
- 订单金额:通常不能随意脱敏,否则会影响计算和对账。
- 审计日志:既要保留足够证据,也不能把完整身份证号、访问令牌写入日志。
因此,安全设计不是“所有字段都加密”或“所有字段都打星号”,而是先说明:
谁(主体)在什么场景下
通过什么接口
需要看到哪些数据
需要执行哪些操作
数据泄露后需要防御哪一种攻击
二、最小权限:把“能访问数据库”拆成具体能力
2.1 最小权限的形式化定义
设:
- 是一个用户或服务身份;
- 是数据库对象,例如表、列、函数、序列;
- 是动作,例如
SELECT、INSERT、UPDATE、DELETE、执行函数; - 是业务请求集合;
- 是身份 为完成正常业务所必需的权限集合。
理想状态是:
实际系统通常允许一定余量:
但应尽量使差集:
保持最小。差集越大,账号泄露后的潜在影响越大。
这不是要求每次请求都临时创建数据库账号,而是要求把权限按职责建模。例如:
订单写入服务:
可以读取订单状态
可以创建订单
可以更新订单状态
不能读取用户身份证号
不能删除订单
不能读取数据库系统表中的全部信息
报表服务:
可以读取已脱敏视图
不能写入业务表
不能执行任意函数
2.2 权限不是只有表级权限
数据库权限至少可能涉及:
- 数据库或实例级连接能力;
- Schema 或数据库命名空间;
- 表、视图、序列;
- 列级读写;
- 函数、存储过程;
- 临时表;
- 角色继承;
- 预定义管理员角色;
- 行级安全策略;
- 对象所有者和超级用户特权。
只给业务账号 SELECT 表权限,仍可能遗漏以下风险:
- 账号拥有
UPDATE或DELETE; - 账号可执行一个权限过大的
SECURITY DEFINER函数; - 账号可读取含原始数据的另一张表;
- 账号可使用默认权限自动获得未来新表权限;
- 账号虽然没有表权限,但可连接到不应连接的数据库;
- 账号实际上是超级用户、
root或拥有绕过检查的能力。
2.3 PostgreSQL:角色、对象所有者与默认权限
下面示例针对 PostgreSQL,执行前需要由管理员或对象所有者执行。
CREATE ROLE app_order LOGIN PASSWORD 'use-a-secret-manager';
CREATE SCHEMA app;
CREATE TABLE app.orders (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric(12, 2) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE app.users (
id bigint PRIMARY KEY,
full_name text NOT NULL,
id_number text NOT NULL
);
REVOKE ALL ON SCHEMA app FROM PUBLIC;
REVOKE ALL ON ALL TABLES IN SCHEMA app FROM PUBLIC;
GRANT USAGE ON SCHEMA app TO app_order;
GRANT SELECT, INSERT ON app.orders TO app_order;
GRANT USAGE, SELECT ON SEQUENCE app.orders_id_seq TO app_order;
这里有几个容易被忽略的细节。
USAGE 与表权限不是一回事
在 PostgreSQL 中,访问 Schema 中的对象通常需要对 Schema 具有 USAGE 权限;SELECT 表并不会自动授予 Schema 使用权。
序列权限不是表权限
如果主键使用序列生成值,插入时可能还需要序列权限。使用 GENERATED ... AS IDENTITY 时,具体权限表现与序列对象关联,但不能简单假设“有表的 INSERT 就一定能使用所有序列”。
PUBLIC 是所有角色
PostgreSQL 中的 PUBLIC 表示所有角色。若初始化脚本没有撤销默认权限,后续单独给应用账号授权可能仍然无法形成清晰的边界。
对象所有者通常具有特殊能力
对象所有者可以修改或删除对象,并且不受普通对象权限限制。因此不应让业务登录角色同时成为生产表的所有者。常见分工是:
schema_owner:只用于变更,禁止业务登录
app_order:运行时登录,只获得业务权限
migration_role:部署时使用,权限受控并有审计
PostgreSQL 的超级用户、拥有 BYPASSRLS 属性的角色会绕过许多安全检查;这类身份不能被当作普通业务账号使用。
默认权限只影响未来对象
ALTER DEFAULT PRIVILEGES FOR ROLE schema_owner IN SCHEMA app
REVOKE ALL ON TABLES FROM PUBLIC;
ALTER DEFAULT PRIVILEGES FOR ROLE schema_owner IN SCHEMA app
GRANT SELECT, INSERT ON TABLES TO app_order;
这只影响以后由 schema_owner 创建的对象,不会自动修复已经存在的表,也不影响其他创建者创建的对象。因此上线时应同时检查存量权限和未来对象的默认权限。
2.4 MySQL:角色、账户与授权范围
MySQL 8.4 支持角色。可以将权限授予角色,再将角色授予登录账户:
CREATE DATABASE shop;
CREATE ROLE 'order_writer';
GRANT SELECT, INSERT, UPDATE
ON shop.orders
TO 'order_writer';
CREATE USER 'order_app'@'10.0.%'
IDENTIFIED BY 'use-a-secret-manager';
GRANT 'order_writer' TO 'order_app'@'10.0.%';
SET DEFAULT ROLE 'order_writer' TO 'order_app'@'10.0.%';
权限范围可以是:
*.* 全实例
shop.* 某数据库
shop.orders 某表
shop.orders(col) 某些列
生产中应避免直接使用 *.*,尤其不要把管理权限授予应用账户。MySQL 的账户还包含主机匹配部分,例如:
'app'@'%'
'app'@'10.0.%'
'app'@'10.0.12.7'
同一个用户名配合不同 Host 可能是不同账户记录,实际匹配结果取决于 MySQL 的账户匹配规则。不能只看用户名判断权限。
MySQL 还存在以下边界:
root或拥有高等级管理权限的账户不是普通应用账号;- 存储程序可能使用
SQL SECURITY DEFINER,调用者的实际能力可能通过定义者权限扩大; - 触发器、事件、视图和存储程序会形成间接访问路径;
GRANT OPTION允许继续授予权限,通常不应给业务账号;- 未来表的权限管理需要配合部署流程检查,不能仅依赖一次性
GRANT。
2.5 权限验证必须从“实际身份”出发
不要只阅读初始化 SQL,要用目标身份验证:
PostgreSQL:
SELECT current_user, session_user;
SELECT current_database();
SELECT has_table_privilege(current_user, 'app.orders', 'SELECT');
SELECT has_table_privilege(current_user, 'app.orders', 'DELETE');
然后执行正向和反向测试:
SELECT id, amount FROM app.orders LIMIT 1; -- 预期成功
DELETE FROM app.orders WHERE id = 1; -- 预期失败
SELECT id_number FROM app.users LIMIT 1; -- 预期失败
MySQL:
SELECT CURRENT_USER(), USER();
SHOW GRANTS;
同样要验证:
SELECT id, amount FROM shop.orders LIMIT 1; -- 预期成功
DELETE FROM shop.orders WHERE id = 1; -- 预期失败
“预期失败”不是测试失败,而是安全控制成立的证据。还应在 CI 或部署后自动执行权限回归测试,防止后来增加的角色继承或默认授权扩大范围。
三、行级安全与业务边界:表级授权还不够
3.1 为什么表级 SELECT 仍可能泄露数据
假设订单服务可以读取整张订单表:
SELECT * FROM app.orders;
即使它只能处理自己的请求,也可能通过修改查询条件读取其他用户订单。此时需要在数据库层约束“哪些行可以被当前身份看到”,这就是行级安全(Row-Level Security,RLS)。
3.2 PostgreSQL RLS 示例
先为表增加租户或用户归属字段:
ALTER TABLE app.orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY order_isolation
ON app.orders
USING (user_id = current_setting('app.user_id')::bigint)
WITH CHECK (user_id = current_setting('app.user_id')::bigint);
GRANT SELECT, INSERT, UPDATE ON app.orders TO app_order;
应用在事务中设置当前请求用户:
BEGIN;
SET LOCAL app.user_id = '42';
SELECT id, amount, status
FROM app.orders
ORDER BY id;
COMMIT;
状态变化如下:
BEGIN开始一个事务。SET LOCAL只在当前事务内设置参数。SELECT触发行策略,只有user_id = 42的行可见。COMMIT后参数自动失效,减少连接池复用时的串请求风险。
USING 控制已有行是否可被读取或更新目标命中;WITH CHECK 控制新插入或更新后的行是否满足条件。只写 USING 而不写 WITH CHECK,可能导致读取隔离存在、写入却能制造不属于自己的行。
生产中还要注意:
- 表所有者通常可以绕过 RLS;需要时使用
FORCE ROW LEVEL SECURITY; - 超级用户和
BYPASSRLS角色仍可能绕过; - 连接池复用连接时,不能用永久
SET保存上一个请求的用户身份; - 应用设置的会话变量必须来自经过认证的服务端上下文,不能直接信任客户端传入的任意值;
- RLS 是数据库层防线,不代替业务层授权检查。
MySQL 没有与 PostgreSQL RLS 完全等价的通用内核语义。常见替代方式是:
- 使用视图,把租户条件固化在视图中;
- 由存储程序执行受控访问;
- 在每条查询中显式加入租户条件;
- 通过应用层和数据库权限共同控制。
但应用层条件容易因漏写而失效,因此应配合代码审查、测试和数据库对象设计,而不是把它称为数据库自动行级安全。
四、加密:按数据流和数据状态分别保护
“数据库加密”至少要拆成四类:
- 传输加密:客户端与数据库之间、复制链路之间使用 TLS。
- 静态数据加密:数据库数据文件、临时文件、表空间等落盘数据加密。
- 备份加密:全量备份、增量备份、WAL/binlog、快照和导出文件加密。
- 字段级加密:应用或数据库函数对某些字段单独加密。
四者的密钥位置、攻击面和性能特征不同。
4.1 传输加密:防止链路窃听,但必须校验证书
TLS 握手大致经过:
客户端发起连接
→ 服务端提供证书
→ 客户端验证证书链、主机名和有效期
→ 协商会话密钥
→ 后续 SQL 和结果使用会话密钥加密
只开启“加密但不验证服务端身份”是不完整的。攻击者可以伪装成数据库服务器,使客户端把账号密码发送给错误端点。
PostgreSQL 客户端连接示例:
postgresql://app_order:密码@db.example.com:5432/shop?sslmode=verify-full
verify-full 的目标是同时验证证书和主机名。还需要正确配置受信任 CA;只写 sslmode=require 通常只要求使用 SSL,不等价于严格的服务端身份验证。
MySQL 命令行示例:
mysql \
--host=db.example.com \
--user=order_app \
--password \
--ssl-mode=VERIFY_IDENTITY \
--ssl-ca=/etc/ssl/certs/db-ca.pem \
shop
VERIFY_IDENTITY 的关键是验证证书与主机身份。生产连接池也必须传递同等强度的 TLS 配置,不能只在人工命令行测试时开启。
TLS 的边界包括:
- 数据库服务器已经被入侵时,TLS 不能保护服务器内存中的明文;
- 拥有合法数据库权限的调用者仍能读取其有权读取的数据;
- 日志、备份和导出的文件不一定自动继承在线连接的 TLS 保护;
- 连接池中的连接参数必须统一,否则会出现部分连接加密、部分连接未加密的配置漂移。
4.2 静态加密:防磁盘或快照泄露,不防在线越权
静态加密通常由以下层完成:
数据库层或表空间加密
操作系统文件系统加密
云盘、卷或存储服务加密
PostgreSQL 核心数据库并没有一个可在所有部署中统一使用的“开启后自动透明加密全部数据文件”的通用 SQL 开关。实践中常依赖文件系统、磁盘、云存储或外部加密方案。WAL、临时文件、备份文件和日志是否覆盖,必须分别确认。
MySQL 的表空间加密、密钥环组件和相关能力依赖具体版本、发行版和部署配置,不能把“启用了 InnoDB”理解成“所有落盘数据都自动加密”。应核实:
- 数据文件是否加密;
- redo/undo、临时表空间是否加密;
- binlog、relay log 是否加密;
- 备份工具输出是否单独加密;
- 密钥环是否与数据库主机分离;
- 重启、恢复、主从切换时能否取回密钥。
静态加密的威胁模型是:
攻击者拿到磁盘、快照或部分文件
→ 没有密钥时难以还原明文
它不防:
攻击者使用正常数据库账号执行 SELECT
攻击者控制已解锁的数据库主机
攻击者从应用日志中取得明文
4.3 字段级加密:保护特定字段,但改变查询能力
字段级加密的核心是:
其中:
- 是明文;
- 是密钥;
- 是密文。
读取时:
如果密钥和数据库放在同一台被完全攻破的主机上,字段级加密的额外保护会显著下降。更常见的部署是由应用或独立密钥管理服务持有密钥,数据库只保存密文。
字段级加密会改变数据操作语义:
- 随机化加密通常不能直接用
=查询; - 确定性加密可以按密文匹配,但会暴露相等关系;
- 加密后无法直接做范围查询、排序和聚合;
- 对密文建立索引可能泄露模式或频率;
- 密钥轮换需要重加密,并处理旧密钥可读窗口;
- 误把可逆加密用于密码存储会导致密钥泄露后所有密码可恢复。
密码校验应使用专门的密码哈希算法和工作因子,例如由应用框架提供的 Argon2id、bcrypt 或 scrypt 实现。数据库不应保存用户原始密码,也不应自行设计“加盐后 SHA-256”作为密码存储方案。
4.4 密钥管理是加密的一部分
必须明确以下对象:
数据密钥 DEK:实际加密数据
密钥加密密钥 KEK:保护 DEK
密钥管理服务 KMS/HSM:生成、封存、轮换和审计密钥
常见的信封加密流程是:
- 生成数据密钥
DEK。 - 用
DEK加密数据。 - 用 KMS 中的
KEK加密DEK。 - 数据库保存密文和被包裹的
DEK。 - 读取时由受控服务向 KMS 请求解包,再在可信边界内解密。
密钥轮换不一定要求立即重写全部历史数据。可以先用新 KEK 包裹旧 DEK,再按计划重加密数据。但必须保留旧密钥的受控解密能力,直到所有旧数据、备份和副本完成迁移。
五、审计:记录可归因的证据,而不是盲目记录所有 SQL
5.1 审计记录回答哪些问题
有用的审计事件至少应包含:
主体:哪个数据库身份、哪个应用用户、哪个租户
时间:服务器时间,必要时统一时区
来源:客户端地址、服务名、请求或会话标识
对象:哪个数据库、Schema、表、列或函数
动作:SELECT、INSERT、UPDATE、DELETE、DDL、授权变更
结果:成功、失败、影响行数或错误类别
上下文:工单、请求 ID、业务原因
数据库看到的身份和业务用户身份可能不同:
数据库身份:app_order
业务身份:user-42
请求 ID:req-abc
如果所有请求都通过连接池使用同一个数据库账号,只审计 app_order,就无法直接回答“具体哪个业务用户读取了数据”。应用需要以受控方式把业务上下文写入审计事件或数据库会话上下文,并避免信任客户端伪造的值。
5.2 PostgreSQL:日志、审计扩展与 SQL 语义
PostgreSQL 核心提供服务器日志能力,例如连接、断开、错误、DDL 或语句日志配置;但“完整安全审计”通常需要结合日志配置、集中采集和审计扩展。pgaudit 是常见扩展,但它不是 PostgreSQL 核心 SQL 的一部分,需确认安装、版本兼容和部署策略。
安全审计应特别关注:
- 登录成功与失败;
- 权限变更;
- 角色和密码变更;
- DDL;
- 对敏感表的读取;
- 修改和删除;
- 批量导出行为;
- 审计配置本身的变更。
不能简单把 log_statement = 'all' 当成完美审计:
- 日志量可能很大;
- SQL 中可能包含敏感字面量;
- 参数是否记录取决于具体日志配置;
- 应用执行的绑定参数可能在日志中以不同形式出现;
- 日志本身可能泄露密码、令牌和个人信息;
- 具备服务器权限的人可能修改或删除本地日志。
5.3 MySQL:普通日志不等于安全审计
MySQL 的 general query log 和 slow query log 主要用于诊断和性能分析:
- general log 可能记录大量连接和语句;
- slow log 重点是慢查询;
- 它们不天然提供完整的“谁访问了哪个敏感对象”的安全事件模型;
- 日志中可能出现敏感 SQL 参数;
- 开启全量查询日志需要评估磁盘、性能和数据暴露风险。
MySQL Enterprise Audit 是 MySQL 生态中的审计能力之一,但其可用性和授权取决于具体发行版与版本。社区部署不能假定一定拥有同样的审计组件。云数据库也可能提供独立审计服务,语义和字段需要以云厂商文档为准。
5.4 审计日志必须独立保护
审计数据和业务数据最好不是同一权限域:
数据库产生审计事件
→ 本地短暂缓冲
→ 采集器传输
→ 集中日志或安全存储
→ 只读分析与告警
如果攻击者拿到业务数据库管理员权限后还能修改审计表,就不能把普通审计表视为不可抵赖证据。可采用:
- 数据库服务器只负责产生事件;
- 审计日志转发到独立账户或独立存储;
- 采用追加写入和访问控制;
- 对日志做完整性校验;
- 记录审计配置变更;
- 对高风险事件实时告警。
审计也有取舍:
- 只记录失败操作,无法分析成功的数据滥用;
- 记录全部
SELECT,可能产生巨大数据量; - 只记录 SQL 文本,无法知道实际返回哪些行;
- 只记录应用用户,不记录数据库身份,无法定位权限路径;
- 只保留短时间日志,可能无法支持事后调查。
因此应按敏感对象和风险选择粒度,并建立日志保留、采样、归档和恢复验证机制。
六、脱敏:控制展示和使用范围,不是简单替换字符串
6.1 脱敏与加密的区别
脱敏是将原始数据转换成受控表示,通常目标是让使用者完成某项业务,同时降低暴露程度。
例如:
13800138000 → 138****8000
alice@example.com → a***@example.com
110101199001011234 → ***************1234
加密通常希望未来在持有密钥时恢复原文;脱敏往往不要求恢复原文,或者只允许在更高权限下恢复。
因此:
- 截断、掩码:通常不可逆,但可能保留过多信息;
- 哈希:一般不可逆,但低熵字段可能被字典枚举;
- 代 token:是否可逆取决于 token 映射表;
- 加密:可逆,但必须保护密钥;
- 泛化:把年龄变成区间,把地址变成地区。
脱敏强度要根据攻击者能掌握的辅助信息判断。手机号只保留后四位,在数据库内部关联场景中可能仍足够识别个人。
6.2 用视图提供最小数据集
假设原始表只有受控服务可以读取:
CREATE VIEW app.user_contact_masked AS
SELECT
id,
CASE
WHEN length(phone) >= 7
THEN left(phone, 3) || '****' || right(phone, 4)
ELSE '****'
END AS phone_masked,
CASE
WHEN position('@' IN email) > 1
THEN left(email, 1) || '***' || substring(email FROM position('@' IN email))
ELSE '***'
END AS email_masked
FROM app.users;
REVOKE ALL ON app.users FROM app_order;
GRANT SELECT ON app.user_contact_masked TO app_order;
这里的安全性质是:应用账号没有原表权限,只有视图权限。若只是给应用同时授予原表和视图权限,再约定“代码只查视图”,这不是数据库层面的脱敏控制。
视图仍需检查:
- 视图定义是否被普通账号修改;
- 视图是否通过其他函数间接泄露原文;
- 是否存在可读取原表的备份、导出或调试接口;
- 错误消息、日志和慢查询日志是否会带出原始值;
- 新增列后是否被
SELECT *意外暴露。
6.3 脱敏数据的查询与统计边界
不同脱敏方式支持的操作不同:
| 方式 | 精确匹配 | 范围查询 | 恢复原文 | 主要泄露 |
|---|---|---|---|---|
| 掩码 | 通常不支持 | 不支持 | 否 | 部分字符、长度 |
| 随机加密 | 不支持 | 不支持 | 有密钥时支持 | 密文长度等元数据 |
| 确定性加密 | 支持 | 通常不支持 | 有密钥时支持 | 相等关系、频率 |
| 哈希 | 支持相同值匹配 | 不支持 | 通常不支持 | 字典攻击、相等关系 |
| 泛化 | 有限 | 有限 | 否 | 区间和分布 |
例如,对邮箱做普通 SHA-256 后,如果攻击者知道邮箱候选集合,就可以逐个计算哈希进行匹配。哈希不是“自动匿名化”。
6.4 测试和生产数据不能只做字符串替换
生产数据复制到测试环境时,需要同时处理:
- 主表;
- 备份;
- 复制文件;
- 关联表;
- 唯一约束和外键;
- 日志中的 SQL 参数;
- 消息队列和缓存;
- 导出的 CSV、Excel 和临时文件。
脱敏后还要验证:
原始手机号是否仍出现在任何表或日志
外键关系是否仍然成立
唯一约束是否因替换冲突
统计分布是否泄露原始业务特征
测试账号是否仍可访问未脱敏副本
如果测试人员可以读取数据库物理备份,单独提供一张脱敏表没有意义。
七、SQL 注入:语法边界被用户输入改变
7.1 注入的本质
SQL 注入不是“用户输入包含特殊字符”这么简单,而是应用把输入拼接进 SQL,使输入同时充当:
数据 + SQL 语法
危险代码:
sql = "SELECT id, amount FROM orders WHERE user_id = " + user_input
cursor.execute(sql)
当 user_input 为:
42 OR 1=1
最终 SQL 变为:
SELECT id, amount
FROM orders
WHERE user_id = 42 OR 1=1;
原本的条件是:
拼接后变成:
由于 1 = 1 恒真,查询可能返回全部订单。
更危险的输入可能尝试结束字符串、追加其他表达式,甚至执行多语句;具体是否成功取决于驱动、协议、数据库配置和账号权限,但应用不应依赖这些差异作为防护。
7.2 参数化查询保持语法与数据分离
安全写法:
sql = """
SELECT id, amount, status
FROM app.orders
WHERE user_id = %s
"""
cursor.execute(sql, (user_id,))
执行过程不是先把参数拼进 SQL 文本,而是:
SQL 模板:WHERE user_id = ?
参数值: "42 OR 1=1"
数据库解析:user_id 与一个字符串值比较
参数值不会被重新解析为 OR 1=1 这样的 SQL 语法。
PHP PDO 示例:
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$stmt = $pdo->prepare(
'SELECT id, amount FROM app.orders WHERE user_id = :user_id'
);
$stmt->execute(['user_id' => $userId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
这里关闭模拟预处理是为了尽可能使用驱动与数据库之间的原生预处理语义;具体行为仍需核对所用驱动版本。参数化解决的是值的位置,不代表所有 SQL 部分都可以参数化。
7.3 表名、列名和排序方向不能直接作为值参数
以下写法通常不成立:
SELECT * FROM %s
因为表名属于 SQL 结构,不是普通值。安全做法是白名单映射:
sort_columns = {
"created": "created_at",
"amount": "amount",
}
sort_directions = {
"asc": "ASC",
"desc": "DESC",
}
column = sort_columns.get(user_sort)
direction = sort_directions.get(user_direction)
if column is None or direction is None:
raise ValueError("invalid sort option")
sql = f"""
SELECT id, amount, created_at
FROM app.orders
ORDER BY {column} {direction}
LIMIT %s
"""
cursor.execute(sql, (limit,))
这里允许进入 SQL 文本的只有程序内部固定映射出的字符串;limit 仍作为参数传递。不能把“经过正则过滤”当成白名单的等价替代,因为 SQL 标识符、编码、注释和不同数据库语法会使过滤规则复杂且容易遗漏。
PostgreSQL 生成动态 SQL 时,应使用驱动提供的标识符转义功能,或在数据库函数中使用 format('%I', identifier) 处理标识符,并继续对值使用 USING 参数。不要把值和标识符都通过字符串拼接处理。
7.4 存储过程不是自动安全
存储过程可以减少应用直接访问表的范围,但如果过程内部拼接动态 SQL,仍然可能注入:
-- 概念上的危险模式
sql := 'SELECT ... WHERE name = ''' || input_name || '''';
EXECUTE sql;
更安全的方向是:
- 尽量使用静态 SQL;
- 动态标识符使用专用标识符引用;
- 动态值使用绑定参数;
- 限制过程的定义者权限;
- 对
SECURITY DEFINER函数设置安全的search_path; - 不让调用者控制函数实际解析到的对象。
SECURITY DEFINER 的含义是函数以定义者权限执行。若函数可被低权限用户调用,又能接收任意表名、表达式或动态 SQL,就可能成为权限提升入口。它应当被视为一个高权限 API,而不是普通工具函数。
7.5 注入防护的错误表现与诊断
常见错误包括:
-
只做字符串转义
转义规则与字符集、SQL 模式和驱动行为相关,容易漏掉上下文。 -
只验证前端输入
攻击者可以绕过前端直接调用接口;服务端必须验证。 -
参数化了值,却拼接了排序字段
仍可能从结构位置注入。 -
账号权限过大
即使注入成功,最小权限可以把影响限制在必要范围内;反之,一个查询接口就可能删除全库。 -
用错误消息回显数据库语句
这会帮助攻击者判断字段、表名和语法。
诊断时应查看:
- 应用是否使用真正的预处理或安全绑定;
- 驱动是否启用了多语句;
- 动态 SQL 的所有拼接点;
- 数据库账号实际授权;
- 注入请求是否出现在应用、数据库和 WAF 日志中;
- 失败查询是否被记录,日志是否包含敏感参数。
代码审查应搜索字符串拼接、格式化 SQL、原生查询 API、动态排序、动态表名和动态 IN 列表构造,而不是只搜索某一个危险函数名。
八、连接池、事务与安全上下文
连接池会复用数据库会话,因此它既影响容量,也影响安全状态。
8.1 会话状态可能跨请求残留
以下状态可能留在连接上:
SET设置的参数;- 角色切换;
- 临时表;
- 未提交事务;
- prepared statement;
- 服务端游标;
- session advisory lock;
- PostgreSQL 的
search_path; - MySQL 的 SQL 模式或用户变量。
如果请求 A 设置了:
SET app.user_id = '42';
请求结束后连接回池,请求 B 复用连接却没有覆盖该值,就可能把 A 的身份上下文带入 B。
优先使用事务范围的状态:
BEGIN;
SET LOCAL app.user_id = '42';
-- 执行本次请求的全部 SQL
COMMIT;
连接归还池前还应确认:
事务已提交或回滚
角色和会话变量已恢复
临时对象不会影响下一请求
错误路径也会执行清理
不同连接池和驱动对归还连接的清理能力不同,不能假设“release”自动回滚所有会话状态。
8.2 连接超时与安全不是独立问题
连接池设置过大可能导致数据库资源耗尽;设置过小会造成请求排队和超时。安全相关查询若长期占用连接,还可能形成拒绝服务。
一个简化容量关系是:
其中:
- 是数据库可接受的并发连接容量;
- 是第 个服务的最大池连接数;
- 是管理、监控和故障处理所需的保留连接。
连接池上限不是越大越好。应把数据库连接容量、事务耗时、排队时间和故障恢复一起测量,并为管理员保留进入数据库的能力。连接泄漏还可能造成业务请求使用错误的残留会话状态,因此连接池治理同时是安全控制的一部分。
九、把控制组合起来:一个受控查询链路
以“订单服务查询当前用户订单”为例,一个较完整的链路如下:
客户端请求
│
├─ 服务端认证用户,得到 user_id
├─ 校验业务参数
├─ 使用固定的数据库服务身份建立 TLS 连接
├─ 从连接池取得连接
├─ 开启事务
├─ SET LOCAL app.user_id
├─ 执行参数化 SQL
├─ RLS 或视图限制可见行和列
├─ 记录审计上下文
├─ 提交事务
└─ 清理连接并归还连接池
示例:
def list_orders(conn, authenticated_user_id, limit):
if not isinstance(limit, int) or not (1 <= limit <= 100):
raise ValueError("invalid limit")
with conn.transaction():
with conn.cursor() as cur:
cur.execute(
"SELECT set_config('app.user_id', %s, true)",
(str(authenticated_user_id),)
)
cur.execute(
"""
SELECT id, amount, status, created_at
FROM app.orders
ORDER BY created_at DESC
LIMIT %s
""",
(limit,)
)
return cur.fetchall()
每一步的安全意义:
authenticated_user_id来自服务端认证结果,而不是直接信任客户端传来的租户字段;limit经过服务端范围验证,并作为值参数传递;set_config(..., true)的true表示事务范围;ORDER BY使用固定列,不接受任意用户输入;- RLS 再次限制用户只能看到自己的订单;
- 连接使用应用账号,而不是迁移或管理员账号;
- 事务结束后,事务范围上下文失效。
若查询失败,事务必须回滚,连接不能带着“failed transaction”或残留会话状态回池。连接池应区分可复用连接和需要销毁重建的连接,例如认证失败、协议错误或数据库连接已损坏的情况。
十、备份、恢复和审计连续性
数据库安全不能只保护在线实例。备份往往包含完整明文,是最容易被忽视的数据副本。
10.1 备份保护面
需要分别确认:
全量备份是否加密
增量或归档日志是否加密
PITR 所需 WAL/binlog 是否加密
备份传输是否加密
备份存储桶是否最小权限
恢复临时环境是否隔离
备份密钥是否与备份存储分离
删除和保留策略是否受控
一个只加密数据库磁盘、但把明文 mysqldump 或 SQL 导出文件上传到公共对象存储的系统,不能称为完整加密方案。
10.2 恢复演练要验证安全属性
恢复演练不应只验证“服务能启动”,还应验证:
- 恢复后的角色和权限是否正确;
- 是否误恢复了测试人员不应看到的生产明文;
- TLS 证书和数据库主机名是否匹配;
- 审计日志是否继续产生并发送;
- KMS 或密钥服务不可用时,系统是否按预期失败;
- PITR 恢复点是否包含或缺少目标审计事件;
- 恢复临时环境是否被公网暴露。
恢复后的数据库如果使用默认管理员账号、关闭 TLS 或跳过审计,就可能在“灾难恢复成功”的同时产生新的数据泄露。
十一、常见误解与验证方法
误解一:使用 HTTPS,所以数据库安全
HTTPS 只保护客户端到应用服务的一段链路。应用服务到数据库仍需独立配置 TLS;数据库文件、备份和日志也有各自的保护边界。
验证:检查数据库连接参数、服务端 TLS 配置、证书校验模式以及连接池实际创建的连接,而不是只查看浏览器地址栏。
误解二:给账号只授予某张表的权限,就实现了最小权限
还要检查 Schema、序列、函数、视图、角色继承、对象所有者、默认权限和管理特权。
验证:使用目标账号执行允许和拒绝操作,并查看实际 SHOW GRANTS 或权限检查函数结果。
误解三:把手机号做哈希就是匿名化
低熵数据可以被字典枚举;确定性哈希还会暴露相等关系。
验证:模拟攻击者掌握候选手机号、邮箱或身份证号集合的情况,评估能否反推出原值。
误解四:打开 general log 就完成审计
普通查询日志可能缺少业务身份、对象级语义、结果信息和可靠的集中留存能力,还可能泄露敏感参数。
验证:构造一次成功读取、一次失败授权、一次权限变更,确认日志是否包含主体、对象、结果和请求上下文。
误解五:使用 ORM 就不会 SQL 注入
ORM 通常会为普通查询提供参数绑定,但原生 SQL、动态排序、动态表名、拼接条件和不安全模板仍可能注入。
验证:审查 ORM 的原生查询 API 和实际生成 SQL;用包含引号、注释符号和逻辑表达式的测试值验证输入是否保持为参数。
误解六:应用层做了权限判断,数据库不需要限制
应用判断可能因接口遗漏、内部调用、脚本或账号泄露而失效。数据库权限、视图和 RLS 可以构成纵深防御,但不能替代正确的业务授权模型。
十二、上线前的可验证结果
安全治理应以结果验收,而不是以“配置文件存在”为验收标准。
权限
- 应用身份不是超级用户或管理员;
- 业务账号没有不必要的
UPDATE、DELETE、DDL 和授权能力; - 原始敏感表与脱敏视图的访问边界已验证;
- RLS 或等效租户隔离策略对读、写、更新都测试过;
- 未来新对象的默认权限已配置并经过验证;
- 迁移身份与运行时身份分离。
加密
- 客户端和复制链路的 TLS 已开启;
- 客户端验证证书链和主机身份;
- 数据文件、临时文件、WAL/binlog、备份的加密范围明确;
- 密钥与数据库主机分离,并有轮换和恢复流程;
- KMS 不可用、数据库重启和备库切换已演练。
审计
- 登录、失败授权、敏感读取、写入、DDL 和授权变更有记录;
- 记录能关联数据库身份、业务身份和请求 ID;
- 审计日志发送到独立受控存储;
- 审计日志本身不能由普通业务账号删除或修改;
- 日志中没有不必要的密码、令牌和完整敏感字段;
- 审计配置变更也会被记录。
脱敏
- 调试、报表和测试环境只拿到必要字段;
- 脱敏后的数据仍满足测试所需关系和约束;
- 原始值不会从日志、错误消息、备份和临时文件旁路泄露;
- 明确哪些字段可检索、可统计、可恢复;
- 脱敏算法和密钥访问权限有独立审查。
注入
- 值全部使用参数绑定;
- 表名、列名、排序方向等结构只能来自固定白名单;
- 动态 SQL 已使用正确的标识符引用和参数机制;
- 存储过程、视图和定义者权限函数已审查;
- 错误响应不泄露 SQL、表结构和敏感参数;
- 注入测试失败时,数据库账号的最小权限仍能限制影响范围。
数据库安全的核心不是某一种产品特性,而是让每条数据访问路径都能回答三个问题:调用者是谁、它具体能做什么、发生后是否能被发现和恢复。最小权限限制影响面,加密保护数据副本和链路,审计提供可归因证据,脱敏降低非必要暴露,参数化查询守住语法边界;再配合连接池状态清理、备份加密和恢复演练,才构成完整的数据库安全治理闭环。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:数据库复制、分片与高可用:一致性、路由、故障转移和扩容
- 下一篇:MySQL 架构全景:连接层、优化器、执行器、存储引擎与日志
- 延伸:数据库连接与连接池:容量、超时、排队、泄漏和故障恢复
- 延伸:数据库备份与恢复:全量、增量、PITR、RPO/RTO 和恢复演练
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论