文章目录
- 每日一句正能量
- 摘要
- 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 TABLE、ALTER TABLE、CREATE 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
欢迎 👍点赞✍评论⭐收藏,欢迎指正