SQL 注入防护:从参数化到权限最小化——Web 应用的安全开发、漏洞复现与事务边界
文章目录每日一句正能量摘要1. 背景与问题2. 环境与数据3. 复现过程3.1 本地复现字符串拼接缺陷3.2 为什么黑名单过滤不可靠3.3 动态排序也是常见入口4. 方案实施4.1 JDBCPreparedStatement 是第一道防线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?namealice初版代码GetMapping(/users/search)publicListMapString,Objectsearch(RequestParamStringname){StringsqlSELECT 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,aliceexample.com,ACTIVE),(bob,bobexample.com,ACTIVE),(carol,carolexample.com,DISABLED);生产系统还应准备独立数据库账号不使用root admin作为 Web 应用连接用户。3. 复现过程3.1 本地复现字符串拼接缺陷在本地测试环境中可以构造一个会让原本精确匹配条件发生变化的输入观察拼接后的 SQL。重点不在某个固定攻击字符串而是确认下面这个事实外部输入已经成为 SQL 结构的一部分。为了便于修复验证可以只在开发环境打印 SQL 模板log.debug(unsafe sql template generated);不要把真实生产用户的敏感参数完整写入日志。3.2 为什么黑名单过滤不可靠有些代码会尝试namename.replace(,);或者if(name.toLowerCase().contains(or)){thrownewIllegalArgumentException();}这种方式非常脆弱因为 SQL 语法涉及大小写、空白、注释、编码、函数、运算符以及数据库方言差异。安全规则很难完整覆盖。正确方向是不要让输入参与 SQL 结构解析。3.3 动态排序也是常见入口接口GET /users?sortcreated_at错误代码StringsqlSELECT * FROM users ORDER BY sort;这里即使WHERE全部参数化sort仍然属于 SQL 结构。所以参数化查询不能自动解决所有动态 SQL 问题。4. 方案实施4.1 JDBCPreparedStatement 是第一道防线正确实现publicUserfindByName(Stringname)throwsSQLException{Stringsql SELECT id, user_name, email, status FROM users WHERE user_name ? ;try(ConnectioncdataSource.getConnection();PreparedStatementpsc.prepareStatement(sql)){ps.setString(1,name);try(ResultSetrsps.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);}错误StringsqlSELECT * FROM users WHERE user_namename;jdbcTemplate.queryForList(sql);JdbcTemplate 本身并不会自动修复已经拼好的 SQL。4.3 MyBatis#{}与${}是安全边界正确selectidfindByNameresultTypeUserSELECT 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){casetime-created_at;casename-user_name;casestatus-status;default-thrownewIllegalArgumentException(unsupported sort field);};}然后StringcolumnresolveSort(sort);Stringsql SELECT id, user_name, email, status FROM users ORDER BY %s LIMIT ? .formatted(column);这里虽然最终 SQL 使用了字符串格式化但column不是用户原始输入而是后端固定集合中的值。原则可以总结为业务值 - 参数化 SQL 结构 - 白名单4.5 MyBatis 动态排序MapperselectidfindUsersresultTypeUserSELECT id, user_name, email, status FROM users ORDER BY ${sortColumn}/select这里${sortColumn}只有在sortColumn已经过服务端白名单转换时才可以使用。Controller 层原始参数不能直接传给 Mapper。推荐StringsortColumnsortWhitelist.resolve(request.getSort());mapper.findUsers(sortColumn);4.6 JPA / Hibernate 参数绑定JPQLTypedQueryUserEntityqueryentityManager.createQuery( select u from UserEntity u where u.userName :name ,UserEntity.class);query.setParameter(name,name);Native QueryQueryqueryentityManager.createNativeQuery( SELECT id, user_name, email, status FROM users WHERE user_name :name );query.setParameter(name,name);不要写Stringjpqlselect u from UserEntity u where u.userName name;ORM 不会因为叫 Hibernate 就自动防止开发人员自己拼 SQL。4.7 IN 查询仍应参数化错误Stringjoinedids.stream().map(String::valueOf).collect(Collectors.joining(,));StringsqlSELECT * FROM users WHERE id IN (joined);更稳妥StringplaceholdersString.join(,,Collections.nCopies(ids.size(),?));StringsqlSELECT id,user_name,email,status FROM users WHERE id IN (placeholders);然后逐个绑定try(PreparedStatementpsconnection.prepareStatement(sql)){for(inti0;iids.size();i){ps.setLong(i1,ids.get(i));}}4.8 参数化之外数据库账号必须最小权限即使代码已经全部参数化也应该假设未来仍可能出现新的漏洞。所以数据库权限要成为第二道隔离层。例如应用账号CREATEUSERapp_user%IDENTIFIEDBYstrong-password;授权GRANTSELECT,INSERT,UPDATEONapp_db.*TOapp_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)publicResponseEntityApiErrorhandleDb(DataAccessExceptione){StringerrorIdUUID.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(pageSize1||pageSize100){thrownewIllegalArgumentException(pageSize out of range);}状态SetStringallowedSet.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_namename问题输入参与 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欢迎 点赞✍评论⭐收藏欢迎指正