1. 项目概述:从“找漏洞”到“懂业务”的审计思维转变
提到Java代码审计里的SQL注入,很多刚入行的安全工程师或者开发同学,第一反应可能就是去代码里搜
Statement
、
executeQuery
这些关键词,看看有没有拼接字符串。这没错,是基本功,但如果你只停留在这个层面,那可能连一半的漏洞都挖不出来,更别提在复杂的业务系统里精准定位风险了。我干了十多年安全,从黑盒渗透做到白盒审计,最大的体会就是:
审计SQL注入,七分靠对业务和架构的理解,三分才是看代码
。今天,我就以一个老审计员的视角,跟你聊聊怎么系统性地、有深度地给Java应用做SQL注入审计,不止于“找”,更要“防”和“治”。
SQL注入之所以经久不衰,本质上是因为“数据”和“指令”的边界在代码层被模糊了。攻击者通过精心构造的输入,让原本处理数据的SQL语句,变成了可被执行的命令。对于Java应用,这个问题在Web时代初期尤为突出,但随着框架普及和开发者安全意识提升,显式的、低级的拼接漏洞变少了,但漏洞的形态却变得更加隐蔽和多样化。我们的审计目标,也从最初的“发现漏洞”,升级为“评估整个数据交互层的安全水位”,并推动建立长效的防御机制。这篇文章,我会带你走完一个完整的审计闭环:从风险入口梳理、到核心代码模式分析、再到框架特性绕过、最后到修复方案与流程建设。无论你是想提升代码安全性的开发者,还是刚踏入应用安全领域的工程师,相信都能从中获得可直接落地的实操经验。
2. 审计前的战略准备:划定战场与绘制地图
在真正打开IDE看代码之前,盲目的搜索是效率最低下的做法。高手审计,始于“侦察”。你需要先搞清楚这个应用是怎么“说话”的,数据从哪来,到哪去。
2.1 应用架构与技术栈画像
首先,你得给目标应用画个像。这是一个传统的Spring MVC + MyBatis项目,还是一个用了Spring Boot + JPA的微服务?或者更老一些,用的是Struts2 + 原生JDBC?
-
Web框架
:Spring MVC、Struts2、JFinal等。这决定了HTTP请求如何被映射到Controller/Action,参数如何绑定。比如,Spring的
@RequestParam、@PathVariable、@RequestBody都是重要的数据入口点。 -
ORM框架
:MyBatis(包括XML配置和注解版
@Select等)、Hibernate/JPA、JdbcTemplate等。这是SQL注入风险的核心区,但不同框架的风险模式天差地别。MyBatis的${}拼接是经典风险点,而Hibernate的HQL/JPQL注入又有其特殊性。 - 数据库连接池 :Druid、HikariCP、C3P0等。一些连接池(如Druid)自带SQL防火墙和监控功能,这既是审计时需要关注的配置点(是否开启过滤),也是事后监控的依托。
- 其他组件 :是否使用了页面模板引擎(如Thymeleaf、Freemarker,这里可能涉及二次注入的展示点)?是否有复杂的权限框架(如Shiro、Spring Security,其SQL查询也可能存在注入)?
实操心得 :最快的方式是看项目的
pom.xml或build.gradle文件。同时,全局搜索@Controller、@RestController、@Mapper、EntityManager等注解或类名,能迅速勾勒出技术栈轮廓。对于遗留系统,可能还有纯Servlet和JSP,这些地方更是漏洞重灾区。
2.2 数据流与风险入口梳理
数据从哪里进入应用,哪里就是我们的主战场。你需要梳理出所有用户可控的输入入口:
-
HTTP请求参数 :
- GET参数 :URL中的查询字符串。
-
POST参数
:
application/x-www-form-urlencoded、multipart/form-data格式。 -
JSON/XML请求体
:
application/json、application/xml。框架的反序列化过程是入口。 -
HTTP头
:
Cookie、X-Forwarded-For等自定义头。常被忽略,但某些业务(如根据设备头查询)可能会用到。 -
URL路径变量
:如
/user/{id}中的id。
-
外部数据源 :
- 文件上传内容 :解析Excel、CSV、XML文件后入库的数据。
- 第三方API回调参数 :支付回调、消息推送等传递的参数。
- 数据库读取的数据 :这是“二次注入”的源头。从数据库A读出的未经验证的数据,又被拼接到查询数据库B的SQL中。
- 缓存数据 :从Redis、Memcached中取出的数据,如果当初写入时未过滤,同样危险。
-
内部“不可信”边界 :
- 不同微服务之间的RPC调用(Dubbo、Feign)参数。
- 消息队列(Kafka、RocketMQ)消费的消息内容。
- 即使来自内部管理后台的输入,也应视为潜在风险点,权限提升漏洞往往由此产生。
绘制一张简单的 数据流图 (哪怕只是在白板上画),标记出数据从这些入口,经过哪些处理层(参数解析、业务逻辑、DAO层),最终到达SQL执行点的路径,这对后续的定向审计有极大帮助。
3. 核心漏洞模式深度解析与手工审计技巧
有了地图,我们就可以开始战术性的搜索和审查了。下面针对几种最常见的模式,讲解如何发现和验证。
3.1 原生JDBC与Statement的“古典派”漏洞
这是最原始,但也最致命的模式。特征是在代码中直接拼接字符串构造SQL。
// 经典错误示例
String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql);
审计方法 :
-
全局搜索关键词
:
Statement、executeQuery、executeUpdate、execute。重点关注这些方法调用时,其参数字符串sql的构造方式。 -
动态跟踪
:如果字符串拼接使用了
+号或StringBuilder,且拼接的变量来源于外部输入(如request.getParameter),基本可以确定漏洞。 -
注意PreparedStatement的误用
:
PreparedStatement本是防注入利器,但如果用错,依然无效。
关键点 :所有用户输入必须作为// 错误:仍然在拼接SQL String sql = "SELECT * FROM users WHERE id = " + id; PreparedStatement pstmt = connection.prepareStatement(sql); // 拼接发生在prepareStatement之前,注入依然存在!?占位符的参数,通过setString、setInt等方法传入。审计时要看?的位置和setXXX方法的参数来源。
3.2 MyBatis框架下的“隐形杀手”
MyBatis是目前Java中最流行的ORM框架之一,其注入风险主要源于动态SQL标签中的
${}
使用和模糊查询的错误写法。
1.
${}
与
#{}
的抉择
这是MyBatis审计的重中之重。
#{}
是预编译占位符,安全;
${}
是字符串替换,危险。
<!-- 高危:使用${}拼接排序字段或表名 -->
<select id="selectUsers" resultType="User">
SELECT * FROM users
ORDER BY ${orderBy} ${orderType}
</select>
攻击者可以控制
orderBy
参数为
id; DROP TABLE users --
,虽然不一定能直接执行多条语句(取决于数据库驱动配置),但足以造成异常或信息泄露。
审计方法 :
-
搜索所有
*Mapper.xml文件中的${。 -
分析每一个
${}的使用场景。 可接受的使用场景极少 ,通常仅限于动态指定表名或列名(这些通常不是用户直接输入,而是内部枚举值)。任何将用户输入直接放入${}的行为,都必须视为高危。 -
检查对应的Java Mapper接口,看参数是否使用了
@Param注解,并追溯这些参数的来源。
2. 模糊查询
like
的常见陷阱
<!-- 错误写法1:直接使用${} -->
<select id="findByName" resultType="User">
SELECT * FROM users WHERE name LIKE '%${name}%'
</select>
<!-- 错误写法2:在Java代码中拼接% -->
<select id="findByName" resultType="User">
SELECT * FROM users WHERE name LIKE #{name} <!-- Java代码中传参时:name = "%" + userInput + "%" -->
</select>
第一种写法存在注入。第二种写法虽然用了
#{}
,但如果在Java层拼接了
%
,
#{}
仍然安全,但这不是最佳实践。
安全写法 :
<select id="findByName" resultType="User">
SELECT * FROM users WHERE name LIKE CONCAT('%', #{name}, '%')
</select>
<!-- 或使用数据库特定的函数,如MySQL的CONCAT -->
审计时,搜索
LIKE
关键字,检查其后的条件写法。
3. MyBatis注解版
@Select
等
风险同理,审计时搜索
@Select
、
@Update
等注解,查看其中的SQL字符串是否包含拼接。
@Select("SELECT * FROM users WHERE username = '${username}'") // 高危!
User findByUsername(@Param("username") String username);
3.3 JPA/Hibernate的HQL/JPQL注入
使用JPA时,我们操作的是HQL(Hibernate)或JPQL(标准),它是面向对象的查询语言,但同样存在注入。
风险点 :
// 错误:拼接HQL
String hql = "FROM User WHERE username = '" + username + "'";
Query query = session.createQuery(hql);
// 错误:拼接JPQL
String jpql = "SELECT u FROM User u WHERE u.email = '" + email + "'";
TypedQuery<User> query = entityManager.createQuery(jpql, User.class);
安全做法是使用参数化查询 :
// 位置参数
Query query = session.createQuery("FROM User WHERE username = ?1");
query.setParameter(1, username);
// 命名参数(推荐,更清晰)
Query query = session.createQuery("FROM User WHERE username = :uname");
query.setParameter("uname", username);
审计方法 :
-
搜索
createQuery、createNativeQuery(原生SQL,风险同JDBC)方法调用。 - 检查传入的查询字符串是否由固定字符串和变量拼接而成。
-
特别注意
Criteria API是类型安全的,通常不存在注入问题,但也要注意其中可能拼接字符串的地方。
3.4 排序、分页等动态场景的“重灾区”
业务中常见的“按某列排序”、“自定义字段查询”等功能,极易引入注入。
审计技巧 :
-
白名单校验
:审查排序字段、查询字段名是否经过白名单过滤。正确的做法是在后端维护一个允许排序的字段列表(如
["id", "name", "createTime"]),将用户传入的字段与之比对,不匹配则使用默认值。private static final Set<String> ALLOWED_ORDER_FIELDS = Set.of("id", "name", "create_time"); public String sanitizeOrderBy(String input) { return ALLOWED_ORDER_FIELDS.contains(input) ? input : "id"; } -
分页参数
:
pageNum和pageSize需要强转为整数,并限制最大值,防止内存消耗过大(虽然这不是注入,但是相关安全风险)。 -
IN语句的动态拼接 :当需要查询id in (1,2,3)时,如果id列表来自用户输入(如“1,2,3”),不能直接拼接。应使用MyBatis的<foreach>标签配合#{},或JPA的参数化查询动态设置参数个数。
3.5 二次注入:潜伏的“慢性毒药”
这是最容易被忽略的一种高危漏洞。数据在存入数据库时进行了正确的转义或参数化,但在 从数据库取出后,再次被用于拼接SQL时,没有经过处理 。
典型场景 :
-
用户注册时,用户名为
admin' --。后端使用参数化查询安全地存入了数据库。 -
另一个功能(如“忘记密码”),根据用户名查找密保问题,执行SQL:
SELECT question FROM users WHERE username = ‘“ + usernameFromDb + ”’。这里的usernameFromDb直接从第一步的数据库读出,值为admin' --。拼接后SQL变为:
这直接导致查询条件被篡改,可能绕过权限查看他人信息。SELECT question FROM users WHERE username = 'admin' --'
审计方法 :
- 这需要跟踪数据流。关注那些 从数据库查询出数据后,又将该数据作为条件去执行另一次数据库查询 的逻辑。
- 重点审计“用户资料展示后又作为查询条件”、“缓存数据回填查询”等场景。
- 防御手段是: 所有从外部存储(包括数据库)读出的数据,在重新参与SQL拼接时,都应视为不可信输入,重新进行校验或参数化 。
4. 进阶:工具辅助与灰盒测试验证
纯人工审计效率有限,尤其在大型项目中。我们需要借助工具,但工具不是万能的,需要结合人工判断。
4.1 静态代码分析工具(SAST)的运用与局限
工具如 SonarQube 、 Fortify SCA 、 Checkmarx 以及国产的 Kunpeng 、 CodeQL 等,可以快速扫描出潜在的代码缺陷。
- 如何使用 :将项目代码导入扫描器,运行针对“SQL注入”的规则集。
-
如何分析结果
:
-
不要盲目相信
:工具会产生大量误报(False Positive)。例如,它可能将一段虽然拼接了字符串,但字符串来源是内部常量(如
“ACTIVE”)的代码也报为高危。 -
聚焦数据流
:工具的优势在于跟踪数据流。查看漏洞报告时,重点关注它描绘的“污点传播路径”:从源头(Source,如
getParameter)到漏洞点(Sink,如executeQuery)的完整路径。如果路径清晰且中间无有效的净化操作(白名单、参数化),那这就是一个真漏洞。 - 验证 :对工具报出的中高危漏洞,必须人工进行代码走查,确认数据流的真实性和可利用性。
-
不要盲目相信
:工具会产生大量误报(False Positive)。例如,它可能将一段虽然拼接了字符串,但字符串来源是内部常量(如
4.2 交互式应用安全测试(IAST)与运行时插桩
IAST(如 洞态IAST 、 悬镜IAST )是更先进的灰盒测试手段。它在应用运行时,通过插桩技术监控数据流和SQL执行。
-
优势
:
- 误报率极低 :它能真实看到HTTP请求参数是否流入了SQL语句,并能捕获到最终执行的SQL形态,几乎可以100%确认漏洞是否存在。
- 发现未知漏洞 :不依赖规则,只要攻击payload触发了异常的SQL执行,就能告警。
- 精准定位 :直接给出漏洞所在的代码文件、行数、完整的调用栈。
- 审计中的应用 :在测试环境部署IAST代理,然后进行正常的功能测试或简单的安全扫描。IAST后台会自动分析出潜在的漏洞。审计人员可以将其结果作为重点复查清单。
4.3 手工验证与Payload构造
无论工具多强大,手工验证是最终确认环节。
-
构造试探性Payload :
-
对于字符串参数,尝试输入单引号
‘。观察应用是否返回数据库错误信息(如MySQL的You have an error in your SQL syntax)。这本身就是一种信息泄露漏洞,也暗示可能存在注入。 -
尝试逻辑测试:
1‘ AND ‘1’=‘1与1‘ AND ‘1’=‘2。如果两个请求返回的结果不同(如一个正常返回数据,一个返回空),则存在布尔盲注的可能。 -
尝试延时测试:
1‘ AND SLEEP(5)--。如果响应延迟了大约5秒,则存在基于时间的盲注。
-
对于字符串参数,尝试输入单引号
-
利用数据库特性 :
-
MySQL
:注意
--(后面有空格)是注释符,#也是。/**/可用于绕过空格过滤。CONCAT函数常用于盲注。 -
Oracle
:注释符是
--。常用UTL_HTTP、DBMS_LOCK.SLEEP进行带外通信或延时。 -
PostgreSQL
:注释符是
--。pg_sleep()用于延时。 -
SQL Server
:注释符是
--。WAITFOR DELAY ‘0:0:5’用于延时。
注意事项 :在测试环境进行!严禁在生产环境进行任何攻击性测试。延时测试要设置超时,避免对测试环境造成DoS。
-
MySQL
:注意
5. 漏洞修复方案与安全编码规范
审计的最终目的不是找茬,而是推动修复和建立规范。给出明确、可操作的修复方案至关重要。
5.1 根本大法:参数化查询(Prepared Statement)
原则:让SQL语句的“结构”和“数据”分离。
-
JDBC
:必须使用
PreparedStatement,且所有变量都用?占位符和setXXX方法。 -
MyBatis
:一律使用
#{}。动态表名/列名等极少数必须使用${}的场景,必须在前端或后端做严格的 白名单校验 。 -
JPA/Hibernate
:使用命名参数(
:name)或位置参数(?1),配合setParameter。 -
JdbcTemplate
:使用
?占位符和参数数组,或者使用NamedParameterJdbcTemplate。
5.2 输入验证与净化
参数化查询是首选,但在某些无法参数化的场景(如动态排序字段),必须进行严格的输入处理。
-
白名单优于黑名单 :对于字段名、排序方向(ASC/DESC)、状态枚举值等,定义明确的允许值集合。
// 好的做法 private static final Map<String, String> COLUMN_MAPPING = new HashMap<>(); static { COLUMN_MAPPING.put("userName", "u.username"); COLUMN_MAPPING.put("createTime", "u.create_time"); } public String getSafeOrderColumn(String input) { return COLUMN_MAPPING.getOrDefault(input, "u.id"); } -
类型强转 :对于ID、页码、大小等应为数字的参数,在入口处就转换为整数,并捕获异常。
try { int page = Integer.parseInt(request.getParameter("page")); page = Math.max(1, page); // 确保最小值 } catch (NumberFormatException e) { page = 1; } -
谨慎使用过滤函数 :通用过滤(如移除单引号)很容易被绕过(如编码绕过)。 不推荐 作为主要防御手段,只能作为辅助。
5.3 纵深防御措施
-
最小权限原则
:为应用数据库账户配置最小的必要权限。通常,Web应用账户只需要
SELECT、INSERT、UPDATE、DELETE,绝不应有DROP、CREATE、ALTER或FILE权限。 - 启用数据库日志与监控 :开启数据库的慢查询日志和审计日志。使用Druid等连接池的Filter功能,监控异常SQL。
- Web应用防火墙(WAF) :在网络层部署WAF,可以拦截常见的SQL注入攻击payload。但WAF是缓解措施,不能替代代码修复。
- 定期依赖库扫描 :使用OWASP Dependency-Check等工具,检查项目依赖的第三方库(如MyBatis、数据库驱动)是否存在已知安全漏洞。
5.4 建立安全开发生命周期(SDL)
将安全嵌入开发流程,才能长治久安。
- 安全培训 :让开发人员理解SQL注入的原理和危害,掌握参数化查询等安全编码方法。
- 代码规范 :在团队编码规范中明确禁止SQL字符串拼接,强制使用参数化。
- 代码审查(Code Review) :将SQL注入检查作为CR的必选项。重点关注DAO层、Mapper文件的修改。
- 自动化安全检查 :在CI/CD流水线中集成SAST工具,对每次提交和构建进行扫描,发现问题及时阻断。
- 定期安全审计 :像本次讨论的这样,定期(如每季度)对核心业务代码进行专项安全审计。
6. 实战案例:一个复杂的多层级注入审计过程
让我分享一个印象深刻的真实案例(已脱敏)。某系统有一个复杂的综合查询功能,前端可以传几十个过滤条件到后端。
第一步:定位入口
通过抓包,发现一个
POST /api/complex/query
的接口,请求体是一个巨大的JSON,包含
filters
数组,每个filter有
field
(字段名)、
operator
(操作符)、
value
(值)等。
第二步:代码追踪
在代码中,找到了对应的Controller方法,参数用
@RequestBody
接收为一个
QueryRequest
对象。该对象被传递到一个
QueryService
中。
第三步:分析SQL构建
QueryService
的核心是一个
StringBuilder
,它遍历
filters
,根据
field
和
operator
动态拼接
WHERE
子句。关键代码片段如下:
// 伪代码,危险示例
StringBuilder whereClause = new StringBuilder(" WHERE 1=1 ");
for (Filter filter : filters) {
whereClause.append(" AND ")
.append(filter.getField()) // 直接拼接字段名!
.append(" ")
.append(filter.getOperator()) // 直接拼接操作符!
.append(" '")
.append(filter.getValue().replace("'", "''")) // 试图转义,但治标不治本
.append("'");
}
String sql = "SELECT * FROM large_table " + whereClause.toString();
风险分析 :
-
filter.getField()和filter.getOperator()直接拼接,存在注入。攻击者可以设置field为id,operator为= 1) OR (1=1 --,从而完全绕过条件。 -
对
value的替换单引号处理,是典型的黑名单过滤,可以被多种编码方式绕过(如char(49)表示数字1)。
第四步:设计修复方案
-
字段名和操作符白名单化
:建立所有允许查询的字段映射表(Map)和允许的操作符集合(Set,如
=,>,<,LIKE,IN需要特殊处理)。private static final Map<String, String> SAFE_FIELD_MAP = ...; // 映射前端字段名到真实数据库列名 private static final Set<String> SAFE_OPERATORS = Set.of("=", ">", "<", ">=", "<=", "LIKE"); String dbColumn = SAFE_FIELD_MAP.get(userInputField); if (dbColumn == null) { throw new InvalidParameterException("非法字段"); } if (!SAFE_OPERATORS.contains(userInputOperator)) { throw ...; } -
值参数化
:不再拼接
value,而是使用PreparedStatement。需要重构SQL构建逻辑,从拼接字符串改为构建带?的SQL和参数列表。List<Object> paramList = new ArrayList<>(); StringBuilder whereClause = new StringBuilder(" WHERE 1=1 "); for (Filter filter : validFilters) { whereClause.append(" AND ").append(dbColumn).append(" ").append(operator).append(" ?"); paramList.add(filter.getValue()); // 值放入参数列表 } // 后续使用JdbcTemplate或PreparedStatement,传入sql和paramList -
对于
IN查询 :需要生成多个占位符?,如id in (?, ?, ?),并将值列表展开到参数列表中。
第五步:回归测试 修复后,需要全面测试该查询功能的各类边界情况,确保功能正常且注入Payload全部被有效拦截。
这个案例告诉我们,面对复杂的动态查询,不能有丝毫松懈。任何用户可控的、用于改变SQL 结构 的部分(字段、表名、操作符、关键字),都必须进行严格的白名单控制;而数据部分,则必须交给参数化查询。
审计SQL注入,是一个需要耐心、细心和系统化思维的工作。它不仅仅是技术活,更是对业务理解、数据流梳理和风险意识的全方位考验。从简单的字符串拼接查起,深入到框架的特定用法,再扩展到二次注入和隐秘的数据流,最后落脚于可落地的修复方案和流程建设,这才是完整的审计闭环。希望这篇长文能为你提供一个清晰的审计路线图和实用的工具箱。记住,安全的代码不是偶然写出来的,而是通过规范、工具和持续的意识培养,一步步构建出来的。
1332

被折叠的 条评论
为什么被折叠?



