SQL注入漏洞的本质,不是代码写得烂,而是数据库被赋予了“看到不该看的数据”的能力。多数开发者把精力放在参数化查询和输入过滤上,这当然没错,但这是第一道防线。一旦WAF被绕过、ORM框架爆出0day、或者某个遗留接口忘了加预编译,攻击者拿到了合法的数据库连接,他能做什么?他能看到什么?这才是存储层权限隔离要回答的核心问题。它不是替代代码层的防御,而是假设代码层防御已经失效的前提下,数据库自身还能把损失锁死在一个极小范围内。
最小权限原则的落地困境理论上,应用程序连接数据库的账号应该只拥有执行必要操作的权限,这叫做最小权限原则。但在实际项目中,大量系统直接使用具有db_owner或ALL PRIVILEGES的账号连接数据库。原因很简单:开发阶段图省事,运维部署时没人拆解权限,业务迭代过程中不断新增建表、改表需求,最后这个高权限账号就一路从测试环境带到了生产环境。攻击者一旦通过注入点获取了这个数据库连接,就等于拿到了数据库的完整控制权,可以遍历所有库表、拖走全部数据、甚至在系统表里植入后门。
要打破这个局面,必须把“应用程序使用的数据库账号”和“数据库管理用的账号”彻底分离。应用程序账号永远不应该有DDL权限,不应该有访问系统表的权限,甚至不应该有跨库访问的权限。这个分离动作不是技术难点,而是工程纪律问题。实施时最常遇到的阻力来自ORM框架的自动建表功能,很多团队为了开发便利,让应用账号带着CREATE TABLE权限上线,这等于给注入攻击留了一扇后门。正确的做法是:建表变更走独立的迁移脚本,由DBA使用管理账号执行,应用账号只保留DML权限中的SELECT、INSERT、UPDATE、DELETE,并且要精确到表级别。
数据库用户与Schema的隔离架构单靠一个应用账号做权限控制,粒度太粗。一个中型业务系统内部往往包含用户数据、订单数据、日志数据、配置数据等多种不同敏感等级的信息。如果所有这些表都暴露给同一个数据库用户,那么注入攻击只要打穿一个点,就能横向访问所有表。存储层权限隔离的进阶做法,是按照业务模块和数据敏感度,把表划分到不同的Schema或Database下,为每个模块创建独立的数据库用户,只授予该用户访问其所属Schema的权限。
以PostgreSQL为例,可以这样构建隔离结构:
-- 创建不同模块的schema CREATE SCHEMA user_schema; CREATE SCHEMA order_schema; CREATE SCHEMA log_schema; -- 创建对应的数据库用户 CREATE USER app_user WITH PASSWORD 'strong_password_1'; CREATE USER app_order WITH PASSWORD 'strong_password_2'; CREATE USER app_log WITH PASSWORD 'strong_password_3'; -- 精确授权:每个用户只能访问自己的schema GRANT USAGE ON SCHEMA user_schema TO app_user; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA user_schema TO app_user; GRANT USAGE ON SCHEMA order_schema TO app_order; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA order_schema TO app_order; GRANT USAGE ON SCHEMA log_schema TO app_log; GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA log_schema TO app_log;
应用程序在运行时,根据当前操作的业务上下文,使用不同的数据库连接。用户模块的代码使用app_user连接,订单模块使用app_order连接,日志模块使用app_log连接。这样,即使订单模块的代码存在SQL注入漏洞,攻击者通过注入获取的数据库连接app_order,也只能访问order_schema下的表,无法触及user_schema中的用户密码哈希、手机号等核心隐私数据。这就是存储层隔离的核心价值——把一次成功的注入攻击的破坏半径,从整个数据库缩小到一个Schema。
读写分离与只读账号的强制使用很多系统的查询接口直接使用主库的读写账号,这为注入攻击提供了写入恶意数据的机会。存储层权限隔离要求对查询类业务强制使用只读账号,并且这个只读账号不仅要限制权限为SELECT,还要限制连接的是只读副本,而不是主库。这样即使注入发生在查询接口,攻击者也只能读取数据,无法执行INSERT、UPDATE或DELETE,更无法通过注入执行存储过程来提权。
实施时需要注意一个细节:只读账号不能仅仅是不授予写权限,还要在数据库层面使用角色继承来确保权限不会被意外扩大。以MySQL为例:
-- 创建只读角色 CREATE ROLE 'read_only_role'; GRANT SELECT ON mydb.* TO 'read_only_role'; -- 创建只读用户并赋予该角色 CREATE USER 'app_readonly'@'%' IDENTIFIED BY 'password'; GRANT 'read_only_role' TO 'app_readonly'@'%'; SET DEFAULT ROLE 'read_only_role' FOR 'app_readonly'@'%';
只读账号的另一个关键用途是应对UNION注入。攻击者通过UNION SELECT需要知道原查询的列数和数据类型,如果只读账号被严格限制了可访问的表,即使UNION注入成功,攻击者能读取的表范围也被锁死了。更进一步,可以通过数据库视图为只读账号提供脱敏后的数据,比如用户表只暴露用户ID和昵称,不暴露密码哈希和手机号。查询接口连接只读账号,只读账号只能看到视图,连基表都接触不到,这样注入攻击的危害被进一步压缩。
存储过程与函数的安全封装有一种观点认为存储过程能防SQL注入,这是误解。如果存储过程内部使用字符串拼接来构建动态SQL,它本身就有注入漏洞。存储过程真正的安全价值不在于防注入,而在于它可以作为数据访问的封装层,让应用账号根本不需要直接访问基表。把敏感操作封装在存储过程中,应用账号只被授予执行这些存储过程的权限,而不是对表的直接读写权限。
这种模式在金融和医疗等强合规行业已经使用多年。比如一个修改用户手机号的操作,不直接给应用账号UPDATE user表的权限,而是创建一个存储过程:
CREATE PROCEDURE update_user_phone(
IN p_user_id INT,
IN p_new_phone VARCHAR(20),
IN p_verification_code VARCHAR(6)
)
LANGUAGE SQL
MODIFIES SQL DATA
BEGIN
-- 验证验证码有效性
DECLARE v_valid INT DEFAULT 0;
SELECT COUNT(*) INTO v_valid FROM verification_codes
WHERE user_id = p_user_id AND code = p_verification_code AND expires_at > NOW();
IF v_valid = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '验证码无效';
END IF;
-- 执行更新
UPDATE users SET phone = p_new_phone WHERE id = p_user_id;
-- 写入审计日志
INSERT INTO audit_log(user_id, action, detail, created_at)
VALUES (p_user_id, 'UPDATE_PHONE', CONCAT('新手机号: ', p_new_phone), NOW());
END;
然后授权时,应用账号只获得这个存储过程的执行权限,对users表和audit_log表完全没有直接访问权。攻击者即使通过注入控制了数据库连接,他能调用的也只是这些预定义的存储过程,无法随意构造SQL语句来拖表。这种架构把数据库从一个被动存储变成了一个主动的安全执行环境,应用层和存储层之间形成了一道明确的契约边界。
行级安全策略的深度应用表级和Schema级的权限控制解决了“能不能看到这张表”的问题,但解决不了“能看到这张表里的哪些行”的问题。一个典型场景是SaaS多租户系统,所有租户的数据都存在同一张表里,通过tenant_id字段区分。如果应用代码在某个查询里忘了加WHERE tenant_id = ?,或者这个条件被注入攻击绕过了,租户A就能看到租户B的数据。行级安全策略正是用来在数据库层面强制隔离行数据的机制。
PostgreSQL的行级安全策略配置如下:
-- 启用行级安全
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- 创建策略:当前用户只能看到自己租户的数据
CREATE POLICY tenant_isolation ON orders
FOR ALL
TO app_order
USING (tenant_id = current_setting('app.current_tenant_id')::INT);
应用程序在建立数据库连接后,首先执行SET app.current_tenant_id = '租户ID',把这个租户上下文注入到数据库会话中。之后该连接执行的所有查询,PostgreSQL会自动在WHERE条件中追加tenant_id的判断,无论SQL语句本身是否包含了这个条件。这意味着即使应用代码完全忘了做租户过滤,或者注入攻击成功修改了SQL语句的结构,数据库依然会强制把数据隔离在租户范围内。攻击者无法通过修改SQL语句来绕过这个策略,因为策略是在数据库内核层面强制执行的,比应用层的任何过滤都更底层、更可靠。
MySQL在8.0版本之后也开始支持类似的特性,虽然语法不同,但思路一致。对于还在使用MySQL 5.7的系统,可以通过视图加触发器来模拟行级安全,虽然性能有损耗,但在安全收益面前是值得的。
审计与异常检测的存储层闭环权限隔离架构部署完成后,还需要一个验证和监控的闭环。数据库自带的审计日志可以记录每一条SQL的执行者、执行时间、执行内容。开启审计后,重点关注两类异常:一是应用账号尝试访问未授权表的行为,这可能是注入攻击在探测数据库结构;二是存储过程参数中出现SQL关键字或特殊字符,这可能是攻击者在尝试通过存储过程参数进行二次注入。
一个实用的做法是在数据库层面设置语句级别的触发器或审计策略,对敏感表的全表扫描操作进行告警。正常业务不应该出现SELECT * FROM users这种无限制查询,一旦审计日志捕获到这类语句,并且来源IP是应用服务器,就说明代码逻辑可能被注入了。这个告警信号比WAF的拦截日志更准确,因为WAF有误报,而数据库审计日志里出现全表扫描,是已经穿透到存储层的真实攻击行为。
权限隔离不是一次性工程,而是需要持续维护的安全基座。每上线一个新功能,每新建一张表,都要评估它应该归属哪个Schema、应该授权给哪个数据库用户、是否需要行级安全策略、是否需要封装在存储过程中。把这些动作嵌入到发布流程里,让权限变更和代码变更一样走评审,才能让存储层的防线持续有效。SQL注入会长期存在,新的绕过手法会不断出现,但只要存储层的权限隔离做得足够细粒度,攻击者拿到数据库连接的那一刻,能做的事情依然极其有限,这就是纵深防御的价值所在。
