一、前言
之前我一直认为 Mybatis 框架下已经实现预编译机制,很多东西都封装好了,应该基本上不会再有 SQL 注入问题了。近期在渗透中发现,在实际项目中,即使使用了 Mybatis 框架,但仍然有可能因为编码人员安全意识不足而导致 SQL 注入问题。出现情况还真不少,因此有了这篇文章。
二、SQL 注入漏洞原理
1、概述
SQL 注入(SQL Injection)是发生在 Web 程序中数据库层的安全漏洞,是网站存在最多也是最简单的漏洞。主要原因是程序对用户输入数据的合法性没有判断和处理,导致攻击者可以在 Web 应用程序中事先定义好的 SQL 语句中添加额外的 SQL 语句,在管理员不知情的情况下实现非法操作,以此来实现欺骗数据库服务器执行非授权的任意查询,从而进一步获取到数据信息。
简单地说,就是通过在用户可控参数中注入 SQL 语法,破坏原有 SQL 结构,达到编写程序时意料之外结果的攻击行为。其成因可以归结为如下原因造成的:
- 程序编写者在处理应用程序和数据库交互时,使用字符串拼接的方式构造 SQL 语句。
- 且未对用户可控参数进行足够的过滤。
2、漏洞复现
下面使用DVWA靶场来进行演示,网站架构为PHP,我们重点关注漏洞原理即可。
该页面提供了一个简单的查询功能,可以根据前端输入的用户ID来查询对应的用户信息。如图,输入 1
,返回了对应 admin 用户的信息。
查看该页面的源代码:
<?php
if( isset( $_REQUEST[ 'Submit' ] ) ) {
// Get input
$id = $_REQUEST[ 'id' ];
// Check database
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
// Get results
while( $row = mysqli_fetch_assoc( $result ) ) {
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
mysqli_close($GLOBALS["___mysqli_ston"]);
}
?>
进行代码审计可以发现,程序将前端输入的 id
参数未加任何处理,直接拼接在了 SQL 语句中,那么此时就导致了SQL注入漏洞。
若此时攻击者输入的用户ID为 1' or 1='1
,则程序拼接后执行的 SQL 语句变成了:
SELECT first_name, last_name FROM users WHERE user_id = '1' or 1='1';
可见,攻击者通过单引号 '
闭合了数据库查询语句,并且在查询条件之后构造了“或 1=1”,即“或真”的逻辑,导致查询出了全部用户的数据。
如果攻击者可以任意替代提交的字符串,就可以利用 SQL 注入漏洞改变原有 SQL 语句的含义,进而执行任意 SQL 命令,入侵数据库进行脱库、删库,甚至通过数据库提权获取系统权限,造成不可估量的损失。(SQL注入的场景类型非常之多,攻击手法、绕过姿势也非常多,本文不作重点讨论)
3、修复建议
一般来说,防御 SQL 注入的最佳方式就是使用预编译语句(其他防御方法还有很多,本文不作重点讨论),绑定变量。例如:
String sql = "SELECT * FROM user_table WHERE username = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, "zxd");
ResultSet results = pstmt.executeQuery();
使用预编译的 SQL 语句,SQL 语句的语义不会发生改变。在 SQL 语句中,变量用占位符 ?
表示,攻击者无法改变 SQL 的结构。
三、Mybatis 框架简介
1、参数符号的两种方式
Mybatis 的 SQL 语句可以基于注解的方式写在类方法上面,更多的是以 xml 的方式写到 xml 文件。Mybatis 中 SQL 语句需要我们自己手动编写或者用 generator 自动生成。编写 xml 文件时,MyBatis 支持两种参数符号,#{}
和 ${}
。
#{}
使用预编译,通过 PreparedStatement 和占位符来实现,会把参数部分用一个占位符?
替代,而后注入的参数将不会再进行 SQL 编译,而是当作字符串处理。可以有效避免 SQL 注入漏洞。${}
表示使用拼接字符串,将接受到参数的内容不加任何修饰符拼接在 SQL 中。易导致 SQL 注入漏洞。
两者的区别如下:
#{}
为参数占位符?
,即 SQL 预编译。${}
为字符串替换,即 SQL 拼接。#{}
是“动态解析->预编译->执行”的过程。${}
是“动态解析->编译->执行”的过程。#{}
的变量替换是在 DBMS 中。${}
的变量替换是在 DBMS 外。- 变量替换后,
#{}
对应的变量自动加上引号。变量替换后,${}
对应的变量不会加上引号。
2、漏洞复现
下面以一个查询场景进行简单演示,数据库表 user_table 的表数据如下:
若没有采用 JDBC 的预编译模式,查询 SQL 写为:
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username = '${username}'
</select>
这种写法就产生了 SQL 语句的动态拼接,这样格式的参数会直接参与 SQL 语句的编译,从而不能避免SQL注入攻击。
若此时攻击者提交的参数值为 zxd' or 1='1
,如下图,利用 SQL 注入漏洞,成功查询了所有用户数据。
因此,应用 Mybatis 框架 SQL语句的安全写法(即 JDBC 预编译模式):
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username = #{username}
</select>
可见,此时采用 JDBC 预编译模式,即使攻击者尝试 SQL 注入攻击,也只会将参数整体作为字符串处理,有效避免了 SQL 注入问题。
四、Mybatis 框架下的 SQL 注入问题及防护方法
还是以上节的查询场景举例,Mybatis 框架下易产生 SQL 注入漏洞的情况主要有以下三种:
1、模糊查询
在模糊查询场景下,考虑安全编码规范,使用 #{}
传入参数:
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username like '%#{username}%'
</select>
在这种情况下使用 #{}
程序会报错:
于是很多安全经验不足的程序员就把 #{}
号改成了 ${}
,如果应用层代码没有对用户输入的内容做处理势必会产生SQL注入漏洞。
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username like '%${username}%'
</select>
若此时攻击者提交的参数值为 zxd' or 1=1#
,如下图,利用 SQL 注入漏洞,成功查询了所有用户数据。
因此,安全的写法应当使用 CONCAT 函数连接通配符:
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username like concat('%',#{username},'%')
</select>
2、带有 IN 谓词的查询
在 IN 关键字之后使用 #{}
查询多个参数:
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username in (#{usernames})
</select>
正常提交查询参数 'zxd','hhh'
,因为预编译机制,系统将我们输入的字符当作了一个字符串,因此查询结果为空,不能满足业务功能需求。
于是很多安全经验不足的程序员就把 #{}
号改成了 ${}
:
<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table where username in (${usernames})
</select>
攻击者提交参数值 'hhh') or 1=1#
,利用 SQL 注入漏洞,成功查询了所有用户数据。
因此,此种情况下,安全的做法应当使用 foreach 标签:
<select id="getUserFromList" resultType="user.NewUserDO">
select * from user_table where username in
<foreach collection="list" item="username" open="(" separator="," close=")">
#{username}
</foreach>
</select>
3、带有动态排序功能的查询
动态排序功能,需要在 ORDER BY 之后传入参数,考虑安全编码规范,使用 #{}
传入参数:
<select id="getUserOrder" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table order by #{column} limit 0,1
</select>
提交参数 username
根据用户名字段排序。但因为预编译机制,系统将我们输入的字符当作了一个字符串,根据字符串排序是不生效的,不能满足业务功能需求。(根据用户名字段排序,此时正常应返回 root
用户)
于是很多安全经验不足的程序员就把 #{}
号改成了 ${}
:
<select id="getUserOrder" parameterType="java.lang.String" resultType="user.NewUserDO">
select * from user_table order by ${column} limit 0,1
</select>
攻击者提交参数值 username#
,利用 SQL 注入漏洞,成功查询了所有用户数据。
因此,此种情况下,安全的做法应当在 Java 代码层面来进行解决。可以设置一个字段值的白名单,仅允许用户传入白名单内的字段。
String sort = request.getParameter("sort");
String[] sortWhiteList = {"id", "username", "password"};
if(!Arrays.asList(sortWhiteList).contains(sort)){
sort = "id";
}
或者仅允许用户传入索引值,代码再将索引值映射成对应字段。
String sort = request.getParameter("sort");
switch(sort){
case "1":
sort = "id";
break;
case "2":
sort = "username";
break;
case "3":
sort = "password";
break;
default:
sort = "id";
break;
}
需要注意的是在 mybatis-generator 自动生成的 SQL 语句中,ORDER BY 使用的也是 ${}
,而 LIKE 和 IN 没有问题。