SQL注入的根因并不复杂,绝大多数场景下是应用层拼接了用户输入,但真正让攻击者能“拖库”或“提权”的,往往是数据库账号权限过大。很多人做防注入只盯着参数化和WAF,却忽略了数据库连接账号本身就是一个巨大的攻击面。假设一条注入语句成功执行,如果当前数据库账号只有单库的SELECT和INSERT权限,攻击者连UNION SELECT跨表都做不到,更不用说读取系统表或写文件。所以最小权限配置不是可有可无的加固项,而是把注入后果“兜底”的关键一步。

先明确一个原则:应用连接数据库的账号,永远不应该使用root、sa或者具有DBA权限的系统账号。很多中小项目为了省事,直接用一个超级管理员账号跑业务,这在注入场景下等于把数据库完全交给攻击者。最小权限配置的核心是“按需授权”,也就是根据业务实际执行的SQL语句,反推出需要哪些权限,然后只授予这些权限,多一个都不给。

第一步:梳理业务真实SQL操作

不要凭感觉授权。先把应用代码里所有访问数据库的操作列出来,包括增删改查、存储过程调用、函数调用、临时表创建、事务操作等。可以按模块整理成一张表,比如用户模块需要SELECT用户表、INSERT登录日志表;订单模块需要INSERT订单表、UPDATE库存表、SELECT商品表。注意有些框架会自动执行一些隐式SQL,比如ORM的元数据查询、连接池的心跳检测、迁移脚本的schema检查,这些也要纳入权限需求。

一个常见的遗漏是“临时表”和“文件导入导出”。如果业务有报表导出功能,代码里可能用了SELECT ... INTO OUTFILE或者临时表,这类操作需要FILE权限或CREATE TEMPORARY TABLES权限。如果没梳理到,上线后功能会报错,但也不能因此直接给大权限,应该单独评估这个功能是否必要,或者用应用层生成文件替代数据库导出。

第二步:区分数据库层级权限

MySQL和PostgreSQL这类数据库的权限粒度可以到表级甚至列级。最小权限配置至少要精确到表级,能到列级更好。比如用户表的密码哈希字段,应用在登录时只需要SELECT这个字段,但注册时又需要INSERT。如果应用里没有更新密码以外的列,就不应该给整表UPDATE权限。MySQL里可以用GRANT语句精确控制:

-- 仅授予单库单表的SELECT和INSERT权限
GRANT SELECT, INSERT ON app_db.users TO 'app_user'@'%' IDENTIFIED BY 'strong_password';

-- 仅授予订单表的SELECT、INSERT、UPDATE权限,不授予DELETE
GRANT SELECT, INSERT, UPDATE ON app_db.orders TO 'app_user'@'%';

-- 授予临时表权限,但不授予创建永久表权限
GRANT CREATE TEMPORARY TABLES ON app_db.* TO 'app_user'@'%';

注意MySQL里UPDATE和DELETE是分开的权限,很多业务只需要逻辑删除,也就是UPDATE一个deleted标记位,根本不需要DELETE权限。如果攻击者注入成功,没有DELETE权限就无法物理删除数据,只能改标记,这能大幅降低破坏程度。

SQL Server的权限模型略有不同,但思路一致。不要用db_owner角色,而是用db_datareader和db_datawriter,再按需单独授予EXECUTE权限给存储过程。如果业务只通过存储过程访问数据,可以完全不给表级读写权限,只给存储过程的EXECUTE权限,这是更彻底的收缩方式。

第三步:网络层与主机层限制

数据库账号的权限配置不只是在数据库内部做,还要结合网络和主机层面。应用服务器连接数据库的账号,应该限制来源IP。MySQL里GRANT语句的host部分不要写'%',要写成具体的内网IP段,比如'192.168.10.%'或者具体到应用服务器的IP。如果应用和数据库在同一台机器,用'localhost'。这样即使账号密码泄露,攻击者从外部也无法直接连接。

另外数据库服务本身的监听地址也要限制。MySQL的bind-address不要设置为0.0.0.0,只监听内网网卡。PostgreSQL的listen_addresses同理。很多云数据库默认只开内网访问,但自建数据库经常忽略这一点,导致数据库端口暴露在公网。端口暴露加上弱密码,比SQL注入更直接。

第四步:存储过程和视图的权限隔离

如果业务逻辑复杂,直接给表级权限仍然不够细,可以考虑用存储过程和视图做权限隔离。比如应用只需要查询用户列表中的部分字段,可以创建一个视图,只暴露非敏感列,然后给应用账号授予这个视图的SELECT权限,而不是底层表的SELECT权限。这样即使注入发生在查询用户列表的语句中,攻击者也拿不到视图之外的字段。

-- 创建只暴露非敏感字段的视图
CREATE VIEW v_public_users AS
SELECT user_id, nickname, avatar_url, created_at
FROM users;

-- 只授予视图权限,不授予底层表权限
GRANT SELECT ON app_db.v_public_users TO 'app_user'@'%';

存储过程的作用更明显。如果所有写操作都封装在存储过程里,应用账号只需要EXECUTE权限,完全不需要表级INSERT、UPDATE、DELETE权限。攻击者即使注入成功,也只能调用已有的存储过程,无法自由构造任意SQL语句操作底层表。当然前提是存储过程内部没有拼接SQL,否则还是存在注入风险。

第五步:账号隔离与只读账号

一个应用内部可能有多个数据访问场景,比如后台管理、前台展示、定时任务、报表查询。不要所有场景共用一个账号。前台展示只需要读,就给只读账号;后台管理需要写,给读写账号;报表查询可能需要跨库,单独建一个只读账号并限制到具体表。这样某个场景被注入后,影响范围被限制在该场景对应的权限内。

只读账号的配置很简单,MySQL里只授予SELECT:

GRANT SELECT ON app_db.* TO 'app_readonly'@'192.168.10.%' IDENTIFIED BY 'readonly_password';

读写账号也不要直接给ALL PRIVILEGES。很多开发者习惯GRANT ALL ON app_db.*,这等于把CREATE、DROP、ALTER、INDEX这些DDL权限也给了应用账号。应用正常运行根本不需要DDL权限,除非有自动建表或迁移逻辑。如果注入语句是DROP TABLE或者ALTER TABLE,没有DDL权限就执行不了。所以读写账号应该只给DML权限:SELECT、INSERT、UPDATE、DELETE,按需组合。

第六步:定期审计权限变更

最小权限配置不是一次性的工作。业务迭代会新增表、新增功能,开发人员可能会临时申请加权限,但很少主动申请回收。时间一长,账号权限又会膨胀。需要定期做权限审计,至少每季度一次,把当前账号的实际权限和业务需求重新比对。MySQL里可以用SHOW GRANTS FOR 'app_user'@'%'查看当前权限,PostgreSQL用\du或者查询information_schema.role_table_grants。

审计时重点看几个高风险权限:FILE、SUPER、PROCESS、SHUTDOWN、CREATE USER、GRANT OPTION。这些权限与应用日常读写无关,一旦被注入利用,后果严重。MySQL里如果发现应用账号有这些权限,立即回收。另外检查是否有账号的host是'%',如果业务不需要外部访问,改成具体IP段。

第七步:结合参数化查询和输入校验

最小权限配置是防御体系的一层,不能替代参数化查询。两者是叠加关系,不是二选一。参数化查询从源头阻止注入语句被拼接执行,最小权限配置则在万一注入成功时限制攻击者的操作范围。输入校验同样重要,比如限制输入长度、类型、格式,能过滤掉大部分异常载荷。三层同时做,注入成功的概率和危害都会降到很低。

实际项目中,最小权限配置经常遇到阻力,因为开发人员觉得权限太小会导致功能报错,排查麻烦。解决方法是建立一套权限申请和测试流程:新功能上线前,在测试环境用最小权限账号跑一遍完整业务流程,确认没有权限报错,再上生产。如果报错,根据具体SQL语句精确补充权限,而不是直接给大权限。这样既能保证功能正常,又能维持最小权限原则。

最后强调一点:数据库账号的密码管理同样重要。最小权限账号如果使用弱密码,攻击者可能直接暴力破解登录,不需要SQL注入。密码要使用足够强度的随机字符串,定期轮换,并且不要硬编码在代码仓库里。使用环境变量或配置中心管理,配合数据库审计日志监控异常登录和异常SQL执行。权限配置和密码管理配合起来,才能形成完整的数据库访问安全基线。