Java代码审计实战:SQL注入漏洞挖掘与MyBatis安全编码规范
1. 项目概述:从开发者到审计者的视角转变
做Java开发这么多年,从写第一行SELECT * FROM users开始,就知道SQL注入是个“老生常谈”的安全问题。但真正让我对它有切肤之痛的,不是看漏洞报告,而是几年前自己写的一个内部管理系统被白帽子“教育”了。那个项目里,一个简单的订单查询功能,因为图省事在ORDER BY后面直接拼接了用户传入的排序字段,导致被拖了库。从那时起,我才真正明白,“知道”和“在代码里避免”是两回事。这也是为什么后来我开始深入研究Java代码审计,尤其是SQL注入——我想知道,一个看似功能正常的Java Web应用,它的“血管”(数据流)里到底藏着多少我们亲手埋下的“雷”。
所谓Java代码审计中的SQL注入审计,本质上是一场“猫鼠游戏”。开发者在业务逻辑的驱动下,追求灵活与效率,可能会在动态SQL、排序、模糊查询等场景下使用字符串拼接。而审计者(或攻击者)则像侦探一样,沿着数据从HTTP请求进入,经过Controller、Service、Dao层,最终到达SQL语句的完整路径,寻找任何一处可能的拼接点,并判断其是否可控、是否被有效过滤。这个过程不仅需要熟悉Java Web的典型架构(如Spring Boot + MyBatis),更需要理解SQL注入在各种上下文(JDBC、Hibernate、JPA、MyBatis)下的不同表现形式。今天,我就结合自己踩过的坑和审过的项目,把这套方法论和实操细节系统地梳理一遍,目标是让你看完后,不仅能快速上手审计,更能从根本上理解如何写出更安全的代码。
2. SQL注入原理与Java中的典型脆弱点
要审计,先得知道漏洞是怎么产生的。SQL注入的核心原因就一句话:将用户可控的数据,未经充分验证或转义,直接拼接到了SQL语句中,改变了原语句的语义。在Java生态里,这个“拼接”动作发生在不同层次,形态各异。
2.1 不同持久层框架下的注入模式
JDBC原生拼接:这是最原始、也最容易被发现的类型。特征非常明显:代码中存在用加号(+)或StringBuilder拼接字符串来构造SQL的语句。
// 典型的错误示例:直接拼接 String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'"; Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(sql);这里,如果username传入admin' --,整个SQL语义就被篡改了,密码验证被注释掉。审计时,全局搜索createStatement()、executeQuery(、executeUpdate(,然后查看其执行的SQL字符串是否包含拼接变量。
MyBatis中的${}与#{}之辨:这是当前Java Web项目中最常见、也最隐蔽的注入点。MyBatis用#{}表示预编译占位符,用${}表示字符串替换。很多开发者,尤其是初学者,并不清楚两者的本质区别,甚至在官方文档不显眼的地方才有警告。
<!-- 安全:预编译,注入无效 --> <select id="findUser" parameterType="String" resultType="User"> SELECT * FROM user WHERE username = #{name} </select> <!-- 危险:直接替换,存在注入风险 --> <select id="findUser" parameterType="String" resultType="User"> SELECT * FROM user WHERE username = '${name}' </select>#{name}在运行时会被替换成?,然后通过PreparedStatement.setString()等方法安全地设置参数值。而${name}则是在SQL解析阶段,直接进行字符串替换。如果name的值是admin' OR '1'='1,替换后SQL语法依然正确,但逻辑被绕过。审计MyBatis项目,核心就是全局搜索${,尤其是在Mapper XML文件中。
Hibernate与JPA的误区:很多人认为使用ORM框架就高枕无忧了,这是致命的误解。Hibernate的HQL(Hibernate Query Language)和JPA的JPQL(Java Persistence Query Language)如果使用字符串拼接,同样会导致注入,这类注入通常被称为“HQL注入”或“JPQL注入”。
// 错误:HQL拼接 String hql = "FROM User WHERE username = '" + username + "'"; Query query = session.createQuery(hql); // 正确:使用参数绑定 String hql = "FROM User WHERE username = :username"; Query query = session.createQuery(hql); query.setParameter("username", username);ORM框架防止的是SQL注入,因为它最终生成的SQL是参数化的。但如果你拼接的是HQL/JPQL字符串,框架会将其完整地作为查询语言解析,攻击者同样可以注入HQL语句(例如通过' OR '1'='1绕过)。审计时需关注createQuery(、createNativeQuery((原生SQL拼接风险更高)等方法调用。
2.2 那些容易被忽略的“合法”拼接场景
并不是所有使用${}或字符串拼接的地方都立刻判“死刑”。有些场景下,使用#{}会导致语法错误,迫使开发者使用${},这就成了风险集中的高地。审计时需要特别关注以下四个高危场景:
动态表名/字段名:SQL语法不允许预编译占位符(
?)出现在表名或字段名位置。SELECT * FROM #{tableName}预编译后会变成SELECT * FROM ?,执行时会报错。因此,当功能需要动态选择表或字段时,开发者往往被迫使用${}。这里的风险在于,如果tableName参数用户可控(比如通过前端下拉框传入,但被恶意篡改),就可能注入UNION SELECT等子句。ORDER BY / GROUP BY 子句:与表名类似,
ORDER BY #{field}会被编译为ORDER BY 'field',导致按字符串常量排序,而非按字段排序,不符合预期。因此,动态排序功能常使用ORDER BY ${sortField} ${sortOrder}。如果sorField可控,攻击者可以传入id,(SELECT SLEEP(5))这类语句进行盲注。LIKE 模糊查询:这是一个经典误区。有人尝试
LIKE '%#{keyword}%',但预编译后是LIKE '%?%',数据库会将?连同百分号一起视为一个字符串参数,导致查询失败。错误的做法是使用LIKE '%${keyword}%',这就敞开了注入的大门。正确的做法是在SQL中使用数据库的字符串连接函数配合#{},如MyBatis中:LIKE CONCAT('%', #{keyword}, '%')。IN 语句:直接写
IN (#{ids})是不行的,因为预编译期望一个参数,但你传入的是一个列表。MyBatis提供了<foreach>标签来安全处理,但有些开发者会错误地拼接字符串:IN (${idList}),如果idList来自用户输入(如1,2,3) OR 1=1 --),注入就产生了。
注意:审计时看到这些场景使用了
${},要立刻提高警惕。但这只是第一步,关键还要看这个${}中的参数来源是否用户可控、是否被严格过滤或白名单校验。
3. 代码审计实战:四步定位SQL注入漏洞
理论清楚了,我们进入实战。假设拿到一个Spring Boot + MyBatis的项目代码,如何系统性地进行SQL注入审计?我总结为“四步定位法”。
3.1 第一步:全局扫描,锁定可疑点
工具先行,提高效率。可以使用grep、find命令,或者IDE的全局搜索功能(IntelliJ IDEA的Ctrl+Shift+F非常强大)。
搜索关键词清单:
- XML文件(MyBatis Mapper):
\$\{。这是最高效的方式,能直接定位到MyBatis中所有字符串替换点。 - Java代码中的SQL字符串:
\.executeQuery\(、\.executeUpdate\(、\.createStatement\(、\+.*['"](粗糙但广泛)、StringBuilder.*append.*SELECT、"SELECT.*\+"。 - Hibernate/JPA:
createQuery\(.*\+、createNativeQuery\(.*\+。 - 注解形式SQL(MyBatis @Select等):同样搜索
\$\{,因为注解中也可以使用。
例如,在项目根目录下执行:
# 查找所有Mapper XML中的${} find . -name "*.xml" -type f | xargs grep -l "\${" | grep -i mapper # 查找Java代码中的字符串拼接SQL find . -name "*.java" -type f | xargs grep -n "\.executeQuery\("3.2 第二步:逆向追踪,绘制数据流图
找到可疑点(比如一个${keyword})后,这只是漏洞的“终点”。我们需要逆向追踪,找到这个参数的“起点”,即它从哪里来。这个过程就像侦探破案,追踪资金的流向。
以MyBatis Mapper为例,标准追踪路径是:Mapper XML->Mapper Interface->Service Impl->Controller->HTTP Request
- 从Mapper XML开始:假设在
UserMapper.xml中发现SELECT * FROM user WHERE name LIKE '%${name}%'。 - 找到对应的Mapper接口:查看XML文件头部的
namespace,如com.example.dao.UserMapper。找到该Java接口,里面会有一个方法名与XML中select的id对应,例如List<User> findUsersByName(String name);。 - 找到Service层调用:在IDE中,右键点击这个
findUsersByName方法,选择“Find Usages”(查找引用)。通常会跳转到UserServiceImpl类中的一个方法。 - 分析Service方法:查看该Service方法的实现。参数
name是从哪里来的?可能是直接传入,也可能是从某个DTO对象中获取。继续向上追踪。 - 找到Controller层:再次使用“Find Usages”查找该Service方法的调用处,最终会定位到某个
@RestController或@Controller中的方法,该方法通常带有@GetMapping、@PostMapping等注解。 - 确认参数来源:查看Controller方法的参数。它可能使用了
@RequestParam("keyword") String keyword或@RequestBody UserQueryDTO dto。至此,你确认了参数name最终来源于用户HTTP请求。
追踪过程中的关键判断点:
- 参数类型:如果Mapper中
${}对应的接口方法参数是int、Integer、Long等数字类型,风险相对较低(但仍需警惕数字型注入,虽然罕见)。如果是String,风险陡增。 - 中间处理:在Service层或Controller层,是否对参数进行了过滤、转义或校验?例如,是否调用了
StringEscapeUtils.escapeSql(注意:这个方法是不推荐用于防SQL注入的!它只为JDBC转义,并非万无一失)、是否使用了正则表达式过滤了单引号、分号等?过滤逻辑是否严谨,能否被绕过(如双写、编码绕过)? - 全局过滤器/拦截器:查看项目是否有配置
Filter、Interceptor或AOP切面,对请求参数进行全局的SQL注入过滤。例如,是否过滤了sleep、benchmark、union select等关键字。但要注意,这种黑名单方式很容易被绕过。
3.3 第三步:上下文分析,评估真实风险
并非所有拼接都意味着立即可利用的漏洞。需要结合上下文进行深度分析。
场景一:动态排序(ORDER BY)
<select id="findUsers" resultType="User"> SELECT * FROM user ORDER BY ${sortField} ${sortOrder} </select>- 风险:极高。
sortField和sortOrder通常直接来自前端排序控件。 - 审计:追踪
sortField。如果前端固定传入id、name等字段名,且后端没有映射机制,攻击者可以修改请求,将sortField设置为id,(SELECT IF(SUBSTRING(database(),1,1)='a', SLEEP(5), 0)),即可进行基于时间的盲注。 - 安全方案:应使用白名单校验。在后端维护一个允许排序的字段列表,将前端传入的值与白名单比对,或用枚举限定。
// 安全做法:白名单映射 private static final Map<String, String> SORT_FIELD_WHITELIST = new HashMap<>(); static { SORT_FIELD_WHITELIST.put("createTime", "create_time"); SORT_FIELD_WHITELIST.put("userName", "username"); } String dbField = SORT_FIELD_WHITELIST.getOrDefault(userInputField, "id"); // 默认值 // 然后将dbField传入Mapper,此时使用${}风险可控,因为值已被限定场景二:模糊查询(LIKE)
<select id="search" resultType="Item"> SELECT * FROM items WHERE title LIKE '%${keyword}%' </select>- 风险:高。
keyword直接来自搜索框。 - 审计:这是最常见的注入点之一。需要检查Service层是否对
keyword做了处理。即便做了trim()或简单的替换空格,也防不住注入。 - 安全方案:必须使用
CONCAT函数或bind标签。
<!-- 方案1:使用CONCAT --> <select id="search" resultType="Item"> SELECT * FROM items WHERE title LIKE CONCAT('%', #{keyword}, '%') </select> <!-- 方案2:使用bind标签(MyBatis) --> <select id="search" resultType="Item"> <bind name="pattern" value="'%' + keyword + '%'" /> SELECT * FROM items WHERE title LIKE #{pattern} </select>实操心得:
bind标签创建的pattern变量,在内部也是通过预编译处理的,所以是安全的。这比在Java代码中拼接好%keyword%字符串再传给#{}更清晰。
场景三:IN语句错误做法:SELECT * FROM items WHERE id IN (${ids})。 安全做法:使用MyBatis的<foreach>标签遍历集合。
<select id="findByIds" resultType="Item"> SELECT * FROM items WHERE id IN <foreach collection="idList" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>审计时,如果发现IN语句使用${}拼接,且ids是一个由用户输入的逗号分隔字符串转换而来的列表,风险极高。
3.4 第四步:验证与利用链构造
在代码层面确认存在风险后,如果条件允许(如测试环境),需要构造利用链进行验证。
- 确定注入类型:是字符型(参数被引号包裹)还是数字型(无引号)?这决定了Payload的构造方式。例如
WHERE id = ${id}是数字型,WHERE name = '${name}'是字符型。 - 测试闭合:对于字符型,首先测试能否闭合引号。传入
name=test',观察应用是否报错(数据库语法错误)。如果报错,说明注入存在。 - 信息探测:尝试使用
UNION SELECT查询数据。需要判断列数,例如:name=test' UNION SELECT 1,2,3 --。如果页面正常显示并出现了数字2或3,说明该位置可以回显数据。 - 盲注测试:如果无回显,尝试基于布尔或时间的盲注。例如:
- 布尔盲注:
name=test' AND SUBSTRING(database(),1,1)='a' --,通过页面内容差异判断真假。 - 时间盲注:
name=test' AND IF(1=1, SLEEP(5), 0) --,观察响应是否延迟。
- 布尔盲注:
- 利用工具:可以使用sqlmap进行自动化验证,但前提是已获得测试授权。命令如:
sqlmap -u "http://target.com/search?keyword=*" --batch。
注意事项:在真实审计或渗透测试中,必须在授权范围内进行。未经授权的测试是违法行为。
4. 深入MyBatis:#{}与${}的底层差异与安全边界
很多开发者对MyBatis中#{}和${}的区别停留在“一个安全一个不安全”的层面,但知其然更要知其所以然。理解底层原理,才能更好地审计和编码。
4.1 预编译(PreparedStatement)是如何工作的?
当使用#{}时,MyBatis会创建一个PreparedStatement对象。SQL语句在发送到数据库之前就被编译了,语法结构已经固定。#{}会被替换成一个占位符?。后续传入的参数,无论是什么内容,都只会被当作数据(而不是代码)传递给这个已编译的语句。
// MyBatis(近似)底层做的事 String sql = "SELECT * FROM user WHERE id = ?"; // SQL已编译 PreparedStatement ps = connection.prepareStatement(sql); ps.setInt(1, userId); // 安全地设置参数 ResultSet rs = ps.executeQuery();数据库知道?的位置应该是一个整数类型的值,因此即使你传入1 OR 1=1,setInt方法也会将其强制转换为整数(可能失败或转换为1),或者直接将其视为一个完整的字符串值,而不会将其解析为SQL语法的一部分。这就从根本上杜绝了注入。
4.2 字符串替换(${})的风险本质
而${}是在SQL语句编译之前就进行简单的字符串替换。你可以把它想象成Java中的字符串拼接。
// 假设 userId = "1 OR 1=1" String sql = "SELECT * FROM user WHERE id = " + userId; // 拼接后:SELECT * FROM user WHERE id = 1 OR 1=1 Statement stmt = connection.createStatement(); // 创建Statement ResultSet rs = stmt.executeQuery(sql); // 执行拼接后的SQL替换后的完整SQL字符串被送到数据库编译执行。如果其中包含了SQL关键字和语法,数据库就会忠实执行。这就是注入发生的根本原因。
4.3 那些“安全”的${}使用场景
审计时,我们也会遇到一些使用了${}但风险极低或可控的情况,不要误报。
- 硬编码值或常量:
这里的值来自一个静态常量,用户无法控制。ORDER BY ${@com.example.constant.SortConstant@DEFAULT_FIELD} - 经过严格白名单校验的参数:如前文所述,动态排序字段经过白名单映射后,传入
${}的值仅限于id、name等预定义的几个,风险可控。 - 数字类型且业务逻辑强校验的参数:例如,分页参数
${pageNum},如果业务逻辑确保它只能是大于0的整数(通过Integer.parseInt并判断>0),那么注入空间也很小(但数字型注入理论存在,需结合业务看)。
审计策略:看到${},不要立刻标记为漏洞。必须完成逆向追踪,确认该参数的源头是否用户可控,以及在到达此处之前是否经过了有效的、不可绕过的安全处理。如果源头不可控或处理有效,则可以放行。
5. 进阶审计技巧与常见盲点
除了常规的CRUD操作,一些复杂的业务场景或框架特性会隐藏更深的注入点。
5.1 MyBatis动态SQL标签中的陷阱
MyBatis的<if>、<choose>、<when>、<otherwise>等动态SQL标签非常强大,但使用不当也会引入风险。
<select id="findUser" parameterType="UserQueryDTO" resultType="User"> SELECT * FROM user WHERE 1=1 <if test="name != null and name != ''"> AND name LIKE '%${name}%' <!-- 危险!在动态标签内使用了${} --> </if> <if test="orderBy != null"> ORDER BY ${orderBy} <!-- 危险! --> </if> </select>审计要点:检查动态SQL标签内部使用的表达式。test表达式中的name、orderBy是OGNL表达式,引用的是传入的参数对象属性。如果其中直接使用了${}拼接,风险同样存在。需要追踪UserQueryDTO中name和orderBy属性的来源。
5.2 注解式SQL的审计
MyBatis也支持在Mapper接口方法上直接使用@Select、@Update等注解编写SQL。审计方式与XML类似。
@Select("SELECT * FROM user WHERE username = '${username}'") // 危险! User findByUsername(@Param("username") String username); @Select("SELECT * FROM user WHERE username = #{username}") // 安全 User findByUsernameSafe(@Param("username") String username);全局搜索@Select(、@Update(等注解,检查其中的SQL字符串是否包含拼接。
5.3 批量操作与复杂嵌套查询
在批量插入、更新,或者多层子查询中,开发者可能为了性能或灵活性而使用拼接。
<insert id="batchInsert"> INSERT INTO user (name, age) VALUES <foreach collection="userList" item="user" separator=","> ('${user.name}', ${user.age}) <!-- 危险!user.name是字符串 --> </foreach> </insert>这里虽然用了<foreach>,但内部值仍用${}拼接,应改为#{user.name}和#{user.age}。
5.4 框架自动生成代码的坑
如MyBatis Generator或类似工具生成的代码,通常默认使用#{}。但后续开发者在手动修改功能时,可能会无意中将#{}改为${},如上文参考案例中的LIKE查询。审计时,对于自动生成的Mapper文件,要重点关注那些与默认生成模式不同的地方,尤其是手写修改过的部分。
6. 修复方案与安全编码规范
审计的最终目的不仅是发现问题,更是推动修复。针对发现的SQL注入点,应提供明确、可操作的修复建议。
6.1 优先使用预编译(#{})
这是铁律。99%的场景都应该使用#{}。
6.2 必须使用${}时的安全措施
对于表名、字段名、排序等场景,必须采用白名单机制。
示例:安全的动态ORDER BY实现
@Service public class UserService { private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("id", "create_time", "username"); private static final Set<String> ALLOWED_ORDERS = Set.of("ASC", "DESC"); public List<User> findUsers(String sortField, String sortOrder) { // 1. 白名单校验 if (!ALLOWED_SORT_FIELDS.contains(sortField)) { sortField = "id"; // 默认值 } if (!ALLOWED_ORDERS.contains(sortOrder.toUpperCase())) { sortOrder = "ASC"; } // 2. 可选的额外过滤:移除非字母数字下划线字符(防御性编程) // sortField = sortField.replaceAll("[^a-zA-Z0-9_]", ""); // 3. 将校验后的安全参数传入Mapper return userMapper.findUsersWithOrder(sortField, sortOrder); } }<!-- Mapper中可以使用${},因为参数已受控 --> <select id="findUsersWithOrder" resultType="User"> SELECT * FROM user ORDER BY ${safeSortField} ${safeSortOrder} </select>6.3 LIKE模糊查询的正确姿势
绝对不要使用LIKE '%${value}%'。正确做法:
<!-- MySQL --> <select id="search" resultType="Item"> SELECT * FROM items WHERE title LIKE CONCAT('%', #{keyword}, '%') </select> <!-- 或使用bind标签(数据库无关) --> <select id="search" resultType="Item"> <bind name="pattern" value="'%' + keyword + '%'" /> SELECT * FROM items WHERE title LIKE #{pattern} </select>6.4 IN语句的正确写法
使用MyBatis的<foreach>标签。
<select id="findByIds" resultType="Item"> SELECT * FROM items WHERE id IN <foreach collection="idList" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>确保传入的idList是一个List<Integer>或List<Long>,而不是逗号分隔的字符串。在Service层就做好转换和校验。
6.5 全局防御的局限性
有些项目会引入过滤器或拦截器,对请求参数中的SQL关键字进行过滤或转义。但这只能作为辅助手段,绝不能替代参数化查询。
- 黑名单过滤:容易绕过。如
SLEEP(5)可以写成SLEEP/**/(5)、SLEEP(5)(用%00空字节)、或使用编码。 - StringEscapeUtils.escapeSql:这是Apache Commons Lang的一个方法,但它仅转义少数字符(如单引号变两个单引号),对于数字型注入或没有引号的注入无能为力,且并非所有数据库都适用。结论:参数化查询(预编译)是唯一公认的、根本的解决方案。其他方法都应在充分评估风险后,作为补充措施。
7. 自动化审计工具辅助与人工审计的平衡
完全依赖工具或完全人工审计都是低效的。应该结合使用。
静态代码分析工具(SAST):
- SonarQube:可以配置规则检测Java代码中的SQL拼接(如发现
Statement.executeQuery拼接字符串)、MyBatis Mapper中的${}使用。 - FindBugs/SpotBugs:有规则能检测JDBC相关的注入问题。
- 专有工具:如Fortify SCA、Checkmarx等商业工具,对Java SQL注入的检测规则比较成熟。
- 局限性:工具会产生大量误报(如把安全的
${}常量也报出来)和漏报(尤其是经过多层封装的、逻辑复杂的注入点)。它只能作为初步筛查,所有报告必须经过人工复核。
- SonarQube:可以配置规则检测Java代码中的SQL拼接(如发现
人工审计的核心价值:
- 理解业务上下文:工具不知道
${sortField}是否经过了白名单校验,但人工追踪代码可以。 - 分析复杂数据流:参数可能经过AOP处理、多个Service方法转换、从Session或缓存中获取,工具很难完整追踪。
- 识别逻辑漏洞:工具主要找语法模式,而人工能发现“数字型参数在特定业务逻辑下可能被利用”这类更深层的问题。
- 理解业务上下文:工具不知道
推荐的审计流程:
- 使用SAST工具对全量代码进行扫描,生成初步报告。
- 根据报告,优先审查高危漏洞点(如Mapper中的
${}、Java中的字符串拼接SQL)。 - 针对核心业务模块(如用户管理、订单查询、搜索功能)进行人工“代码走查”,尤其关注接收外部参数的入口方法。
- 对发现的问题点,严格遵循“逆向追踪数据流”的方法进行确认。
- 编写审计报告,清晰描述漏洞位置、风险数据流、利用方式、修复建议。
8. 实战案例复盘:一个电商项目的SQL注入挖掘
去年我审计过一个开源的Spring Boot电商项目,就遇到了一个非常典型的、多层封装的SQL注入案例,它完美展示了审计的完整链条。
漏洞发现:
全局扫描:使用
grep -r "\${" --include="*.xml" .,在商品搜索的Mapper文件GoodsMapper.xml中发现:<select id="searchGoods" resultType="Goods"> SELECT * FROM goods WHERE 1=1 <if test="keyword != null and keyword != ''"> AND (goods_name LIKE '%${keyword}%' OR goods_desc LIKE '%${keyword}%') </if> <if test="orderBy != null"> ORDER BY ${orderBy} </if> </select>两个高危点:
LIKE '%${keyword}%'和ORDER BY ${orderBy}。逆向追踪:
- 找到
GoodsMapper接口中的searchGoods方法,参数是一个Map<String, Object>。 - 在
GoodsServiceImpl中找到调用,发现keyword和orderBy都是从传入的SearchDTO对象中获取。 - 追踪到
GoodsController,发现一个/goods/search的接口,使用@RequestBody SearchDTO dto接收JSON参数。SearchDTO中有keyword和orderBy字段。 - 关键发现:在Controller和Service层,没有对这两个字段进行任何过滤或校验!
- 找到
风险确认:
keyword是字符串,直接用于LIKE拼接,存在明显的字符型注入。orderBy也是字符串,用于ORDER BY拼接,存在注入可能。
构造利用:
- 启动本地测试环境。
- 发送POST请求到
/goods/search,Body为:{"keyword": "test' AND '1'='1", "orderBy": "id"} - 观察日志,发现执行的SQL为:
... WHERE 1=1 AND (goods_name LIKE '%test' AND '1'='1%' ...,由于单引号被闭合,AND '1'='1成为永真条件,成功注入。 - 进一步,可以构造
keyword为test' UNION SELECT 1,2,database(),4,5 --来获取数据库名。
修复建议:
- 将
LIKE语句改为LIKE CONCAT('%', #{keyword}, '%')。 - 对
orderBy建立白名单:List<String> allowedFields = Arrays.asList("id", "price", "create_time");,校验传入值是否在白名单内,不在则使用默认值。
- 将
这个案例的教训是:即使项目使用了MyBatis这样的半自动化框架,如果开发者不了解${}和#{}的安全差异,且缺乏必要的输入校验,依然会制造出严重的漏洞。代码审计的价值,就在于发现这些隐藏在“便捷”功能背后的安全债务。
9. 总结与个人体会
干了这么多年开发和审计,我最大的体会是:安全是一种习惯,而不是一项功能。SQL注入这种“古老”的漏洞之所以经久不衰,不是因为技术有多难防,而是因为开发者在追求功能、赶进度时,最容易牺牲的就是那些“不起眼”的安全细节。
对于开发者,我的建议是:把“使用#{}”刻在肌肉记忆里。每当你要写SQL,手碰到键盘,第一个反应就应该是“这里能不能用#{}?”。如果不能用,立刻警铃大作,然后去查文档、问同事,寻找安全的替代方案(如白名单、CONCAT函数、<foreach>标签)。不要心存侥幸,攻击者不会因为你的业务逻辑简单就放过你。
对于审计者或安全工程师,我们的角色更像是“代码医生”和“布道者”。审计时,要像侦探一样耐心、细致,不放过任何一条数据流。报告问题时,不能只说“这里有SQL注入,高危”,更要清晰地描述攻击路径、提供可立即执行的修复代码,甚至最好能给团队做一次简短的培训,解释为什么${}危险,以及安全的做法是什么。推动修复的过程,也是提升整个团队安全水位的过程。
最后,工具永远在迭代,攻击手段也在翻新,但安全的核心原则——不信任任何用户输入,对输入进行校验、过滤,对输出进行编码,在操作数据时使用参数化查询——是永恒不变的。把这些原则内化到日常开发中,我们才能从源头上减少漏洞的产生,写出更健壮、更可靠的代码。