SQL 注入防护:从参数化到权限最小化——Web 应用的安全开发、漏洞复现与事务边界
2026/9/13 19:49:10 网站建设 项目流程

文章目录

    • 每日一句正能量
    • 摘要
    • 1. 背景与问题
    • 2. 环境与数据
    • 3. 复现过程
      • 3.1 本地复现字符串拼接缺陷
      • 3.2 为什么黑名单过滤不可靠
      • 3.3 动态排序也是常见入口
    • 4. 方案实施
      • 4.1 JDBC:PreparedStatement 是第一道防线
      • 4.2 JdbcTemplate:不要先拼接再传入
      • 4.3 MyBatis:`#{}` 与 `${}` 是安全边界
      • 4.4 动态 ORDER BY 必须白名单
      • 4.5 MyBatis 动态排序
      • 4.6 JPA / Hibernate 参数绑定
      • 4.7 IN 查询仍应参数化
      • 4.8 参数化之外:数据库账号必须最小权限
      • 4.9 迁移账号和运行账号必须分开
      • 4.10 事务边界:数据库异常必须触发回滚
      • 4.11 不要把 SQL 和参数完整打印到异常页
      • 4.12 SQL 日志也要安全
      • 4.13 输入校验不是参数化的替代品
      • 4.14 存储过程也可能存在注入
      • 4.15 安全测试怎么做
    • 5. 结果对比
      • 实施前
      • 实施后
    • 6. 风险与复盘
      • 6.1 参数化不是万能的
      • 6.2 最小权限不能替代参数化
      • 6.3 不要依赖 WAF 作为唯一防线
      • 6.4 ORM 项目仍然需要代码审查
      • 6.5 安全日志也可能泄密
      • 6.6 数据库账号不要跨库授权
    • 结语

每日一句正能量

放过别人实质是放过自己。
对别人的怨恨、不原谅,就像自己喝下毒药却指望对方痛苦。执着的囚笼,锁住的其实是自己。心灵的内存有限,清理垃圾,才能运行新的程序。

摘要

SQL 注入之所以多年仍然存在,并不是因为 PreparedStatement 太难用,而是因为真实 Web 项目里经常同时存在:

历史 JDBC 代码 MyBatis 动态 SQL JPA Native Query 动态排序 批量导入 报表查询 多数据源 高权限数据库账号

只要其中某一条链路重新把外部输入拼进 SQL 结构,风险就会重新出现。

因此,SQL 注入防护不能只停留在“把字符串拼接改成?”。更成熟的方案应该同时覆盖:

参数化查询 动态 SQL 白名单 驱动 / ORM 正确绑定 数据库最小权限 异常回滚 日志脱敏 安全测试

本文以一个本地 Web 查询接口为例,从漏洞复现开始,逐步改造成可以落地到生产开发规范里的防护方案。

文中的漏洞复现仅用于本地测试环境与修复验证,不针对真实网站或第三方系统。


1. 背景与问题

假设用户中心提供一个按用户名查询接口:

GET /users/search?name=alice

初版代码:

@GetMapping("/users/search")publicList<Map<String,Object>>search(@RequestParamStringname){Stringsql="SELECT id, user_name, email, status "+"FROM users "+"WHERE user_name = '"+name+"'";returnjdbcTemplate.queryForList(sql);}

这段代码最大的问题是:

用户输入直接进入 SQL 文本。

数据库无法区分哪些字符是开发人员写的 SQL,哪些字符来自用户输入。只要输入改变了查询表达式,原本的过滤条件就可能失效。

安全修复的目标不是“过滤几个特殊字符”,而是从根本上让 SQL 结构和业务数据走不同通道。


2. 环境与数据

示例环境:

JDK 21 Spring Boot 3.3+ MySQL 8.0+ MySQL Connector/J HikariCP Spring JDBC MyBatis 3.x Hibernate 6 / JPA

测试表:

CREATETABLEusers(idBIGINTPRIMARYKEYAUTO_INCREMENT,user_nameVARCHAR(64)NOTNULL,emailVARCHAR(128)NOTNULL,statusVARCHAR(16)NOTNULL,created_atTIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMP,UNIQUEKEYuk_user_name(user_name),KEYidx_status(status));

测试数据:

INSERTINTOusers(user_name,email,status)VALUES('alice','alice@example.com','ACTIVE'),('bob','bob@example.com','ACTIVE'),('carol','carol@example.com','DISABLED');

生产系统还应准备独立数据库账号,不使用:

root admin

作为 Web 应用连接用户。


3. 复现过程

3.1 本地复现字符串拼接缺陷

在本地测试环境中,可以构造一个会让原本精确匹配条件发生变化的输入,观察拼接后的 SQL。

重点不在某个固定攻击字符串,而是确认下面这个事实:

外部输入已经成为 SQL 结构的一部分。

为了便于修复验证,可以只在开发环境打印 SQL 模板:

log.debug("unsafe sql template generated");

不要把真实生产用户的敏感参数完整写入日志。

3.2 为什么黑名单过滤不可靠

有些代码会尝试:

name=name.replace("'","");

或者:

if(name.toLowerCase().contains("or")){thrownewIllegalArgumentException();}

这种方式非常脆弱,因为 SQL 语法涉及大小写、空白、注释、编码、函数、运算符以及数据库方言差异。安全规则很难完整覆盖。

正确方向是:

不要让输入参与 SQL 结构解析。

3.3 动态排序也是常见入口

接口:

GET /users?sort=created_at

错误代码:

Stringsql="SELECT * FROM users ORDER BY "+sort;

这里即使WHERE全部参数化,sort仍然属于 SQL 结构。

所以参数化查询不能自动解决所有动态 SQL 问题。


4. 方案实施

4.1 JDBC:PreparedStatement 是第一道防线

正确实现:

publicUserfindByName(Stringname)throwsSQLException{Stringsql=""" SELECT id, user_name, email, status FROM users WHERE user_name = ? """;try(Connectionc=dataSource.getConnection();PreparedStatementps=c.prepareStatement(sql)){ps.setString(1,name);try(ResultSetrs=ps.executeQuery()){if(!rs.next()){returnnull;}returnnewUser(rs.getLong("id"),rs.getString("user_name"),rs.getString("email"),rs.getString("status"));}}}

这里 SQL 模板始终是:

WHEREuser_name=?

输入只作为VARCHAR参数绑定。即使输入中存在 SQL 特殊字符,也只是普通字符串内容。

4.2 JdbcTemplate:不要先拼接再传入

正确:

publicUserfindByName(Stringname){returnjdbcTemplate.queryForObject(""" SELECT id, user_name, email, status FROM users WHERE user_name = ? """,(rs,rowNum)->newUser(rs.getLong("id"),rs.getString("user_name"),rs.getString("email"),rs.getString("status")),name);}

错误:

Stringsql="SELECT * FROM users WHERE user_name='"+name+"'";jdbcTemplate.queryForList(sql);

JdbcTemplate 本身并不会自动修复已经拼好的 SQL。

4.3 MyBatis:#{}${}是安全边界

正确:

<selectid="findByName"resultType="User">SELECT id, user_name, email, status FROM users WHERE user_name = #{name}</select>

#{name}会走参数绑定。

而:

WHERE user_name = '${name}'

是直接文本替换。

这意味着${}不能接收未经约束的外部输入。

4.4 动态 ORDER BY 必须白名单

列名通常不能通过:

ORDERBY?

直接参数化。

因此正确方案是白名单映射。

publicStringresolveSort(Stringinput){returnswitch(input){case"time"->"created_at";case"name"->"user_name";case"status"->"status";default->thrownewIllegalArgumentException("unsupported sort field");};}

然后:

Stringcolumn=resolveSort(sort);Stringsql=""" SELECT id, user_name, email, status FROM users ORDER BY %s LIMIT ? """.formatted(column);

这里虽然最终 SQL 使用了字符串格式化,但column不是用户原始输入,而是后端固定集合中的值。

原则可以总结为:

业务值 -> 参数化 SQL 结构 -> 白名单

4.5 MyBatis 动态排序

Mapper:

<selectid="findUsers"resultType="User">SELECT id, user_name, email, status FROM users ORDER BY ${sortColumn}</select>

这里${sortColumn}只有在sortColumn已经过服务端白名单转换时才可以使用。

Controller 层原始参数不能直接传给 Mapper。

推荐:

StringsortColumn=sortWhitelist.resolve(request.getSort());mapper.findUsers(sortColumn);

4.6 JPA / Hibernate 参数绑定

JPQL:

TypedQuery<UserEntity>query=entityManager.createQuery(""" select u from UserEntity u where u.userName = :name """,UserEntity.class);query.setParameter("name",name);

Native Query:

Queryquery=entityManager.createNativeQuery(""" SELECT id, user_name, email, status FROM users WHERE user_name = :name """);query.setParameter("name",name);

不要写:

Stringjpql="select u from UserEntity u "+"where u.userName = '"+name+"'";

ORM 不会因为叫 Hibernate 就自动防止开发人员自己拼 SQL。

4.7 IN 查询仍应参数化

错误:

Stringjoined=ids.stream().map(String::valueOf).collect(Collectors.joining(","));Stringsql="SELECT * FROM users WHERE id IN ("+joined+")";

更稳妥:

Stringplaceholders=String.join(",",Collections.nCopies(ids.size(),"?"));Stringsql="SELECT id,user_name,email,status "+"FROM users WHERE id IN ("+placeholders+")";

然后逐个绑定:

try(PreparedStatementps=connection.prepareStatement(sql)){for(inti=0;i<ids.size();i++){ps.setLong(i+1,ids.get(i));}}

4.8 参数化之外:数据库账号必须最小权限

即使代码已经全部参数化,也应该假设未来仍可能出现新的漏洞。

所以数据库权限要成为第二道隔离层。

例如应用账号:

CREATEUSER'app_user'@'%'IDENTIFIEDBY'strong-password';

授权:

GRANTSELECT,INSERT,UPDATEONapp_db.*TO'app_user'@'%';

如果业务不需要删除,就不要授权DELETE

更不能给:

DROP ALTER CREATE USER GRANT OPTION FILE SUPER

这类高权限能力。

4.9 迁移账号和运行账号必须分开

很多系统为了方便,把 Flyway/Liquibase 使用的高权限账号直接给应用运行。

推荐拆分:

migration_user -> 发布时执行 DDL app_user -> 运行时只做必要 DML

应用漏洞不应该自动获得DROP TABLEALTER TABLECREATE USER等能力。

4.10 事务边界:数据库异常必须触发回滚

假设一个事务包含:

修改用户 写安全审计

代码:

@TransactionalpublicvoidupdateProfile(UpdateProfileRequestrequest){userRepository.update(request);auditRepository.insert(request.userId(),"PROFILE_UPDATED");}

如果数据库出现:

DataIntegrityViolationException BadSqlGrammarException

不应该这样:

catch(DataAccessExceptione){log.warn("ignore db error");}

因为事务可能已经需要回滚。

更稳妥:

catch(DataAccessExceptione){log.error("database operation failed, userId={}",request.userId(),e);throwe;}

让 Spring 事务管理器完成回滚。

4.11 不要把 SQL 和参数完整打印到异常页

生产环境不应把:

SQL 堆栈 数据库版本 表名 连接信息

直接返回给前端。

统一异常处理:

@RestControllerAdvicepublicclassGlobalExceptionHandler{@ExceptionHandler(DataAccessException.class)publicResponseEntity<ApiError>handleDb(DataAccessExceptione){StringerrorId=UUID.randomUUID().toString();log.error("database error, errorId={}",errorId,e);returnResponseEntity.status(500).body(newApiError("INTERNAL_ERROR",errorId));}}

前端只获得错误编号和通用错误码。

4.12 SQL 日志也要安全

推荐:

{"event":"sql_execute","sqlTemplate":"SELECT ... WHERE user_name = ?","parameterCount":1,"parameterTypes":["VARCHAR"],"durationMs":8}

不要把真实邮箱、手机号、Token 等敏感值完整还原到日志。

4.13 输入校验不是参数化的替代品

例如分页:

if(pageSize<1||pageSize>100){thrownewIllegalArgumentException("pageSize out of range");}

状态:

Set<String>allowed=Set.of("ACTIVE","DISABLED");if(!allowed.contains(status)){thrownewIllegalArgumentException();}

这些校验负责业务合法性,参数化负责 SQL 结构安全,二者职责不同。

4.14 存储过程也可能存在注入

如果存储过程内部通过字符串拼接生成动态 SQL,再执行PREPARE/EXECUTE,依然存在把外部输入带入 SQL 结构的风险。

因此:

用了存储过程

不等于:

天然安全。

存储过程中的动态对象名、排序字段也应采用白名单或数据库提供的安全绑定方式。

4.15 安全测试怎么做

推荐在 CI 中加入:

单元测试 集成测试 SAST 依赖扫描 DAST 测试环境

针对 SQL 注入,重点检查:

字符串拼 SQL MyBatis ${} Native Query 拼接 动态 ORDER BY 动态表名 / 列名 数据库账号权限

代码扫描可以把这些模式作为高优先级规则。


5. 结果对比

实施前

代码:

"WHERE user_name='"+name+"'"

问题:

输入参与 SQL 结构 动态排序直传 应用账号权限过大 异常页暴露数据库细节 日志记录完整参数

任何一个漏洞都有可能放大影响范围。

实施后

查询:

WHEREuser_name=?

动态排序:

外部 sort -> 后端枚举 -> 固定列名

数据库账号:

仅 SELECT / INSERT / UPDATE

异常:

事务回滚 前端返回通用错误 服务端记录 errorId

日志:

SQL 模板 参数类型 不记录明文敏感值

安全能力已经从一个代码技巧,变成开发、ORM、数据库权限、异常和审计组成的完整链路。


6. 风险与复盘

6.1 参数化不是万能的

下面这些通常不能直接使用值参数:

表名 列名 ORDER BY ASC / DESC 部分数据库对象名

必须依靠后端固定映射、白名单和枚举。

6.2 最小权限不能替代参数化

即使数据库账号只有SELECT,注入仍然可能造成越权读取、隐私泄露或大查询拖垮数据库。

所以权限最小化只是降低影响范围,不是漏洞修复本身。

6.3 不要依赖 WAF 作为唯一防线

WAF 可以阻断部分恶意模式、限频和记录异常请求,但它看不到所有业务上下文。

安全顺序应是:

应用代码正确 数据库权限正确 WAF 再做额外保护

6.4 ORM 项目仍然需要代码审查

Hibernate、MyBatis 都能安全绑定参数,但开发人员仍然可能写:

字符串拼接 ${} createNativeQuery 拼接 Criteria 中错误动态表达式

所以不能因为使用 ORM 就降低 SQL 安全审查等级。

6.5 安全日志也可能泄密

SQL 日志、异常栈、APM 标签都可能包含:

手机号 邮箱 身份证号 Token 密码 地址

必须统一脱敏。

6.6 数据库账号不要跨库授权

Web 应用如果只访问:

app_db

就不要授权:

*.*

不同微服务最好使用不同账号、不同 schema、不同权限。

这样即使一个服务出现安全问题,也不会自然扩散到所有业务库。


结语

SQL 注入防护最容易被简化成:

“记得使用 PreparedStatement。”

但真正能支撑生产 Web 应用的方案应该是四层防线:

第一层: 参数化查询,让输入不进入 SQL 结构。 第二层: 动态 SQL 使用白名单,不允许列名和排序直传。 第三层: 数据库账号最小权限,限制漏洞影响范围。 第四层: 异常、事务和日志安全,避免二次泄露。

可以把核心原则总结为:

值必须参数化, 结构必须白名单, 账号必须最小权限, 异常必须受控。

只有这四层一起落地,SQL 注入防护才从“编码规范”真正升级为可审计、可验证、可持续执行的数据库安全工程。


转载自:https://blog.csdn.net/u014727709/article/details/165241718
欢迎 👍点赞✍评论⭐收藏,欢迎指正

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询