1. 问题背景
本文起源于一个生产问题。我们在生产环境用户信息表的create_by字段看到了来自用户侧的输入,且未过滤特殊字符(注意到存在符号”+”),怀疑可能成为SQL注入点。
开发大佬反馈,该列数据的插入/更新使用了MyBatis-Plus。但MyBatis-Plus是什么?以下引用官方介绍:
What is MyBatis-Plus?
MyBatis-Plus is an powerful enhanced toolkit of MyBatis for simplify development. This toolkit provides some efficient, useful, out-of-the-box features for MyBatis, use it can effectively save your development time.
那么,为MyBatis提供便捷实现的MyBatis-Plus在方便开发者的同时是否可能引入SQL注入?本文将从该生产实例回答以下这个问题:
- MyBatis Plus如何防御SQL注入;
2. 测试环境准备
为了不影响生产数据,使用官方提供的mybatis-plus-sample-crud快速搭建本地测试环境。
mysql版本:8.0.35
mybatis-plus-spring-boot3-starter版本:3.5.4
3. 问题分析
1)确认生产数据来源
根据表格“create_by”字段搜索源代码,发现使用了MyBatis Plus的注解,该基于iBatis实现(详见第4节)。该字段来自用户传入的request参数,且未做任何过滤。
MemberInfo类定义了数据库字段create_by对应的属性:
查看引用,InfoMessagesSAOImpl.java不涉及数据库操作,注意MemberServiceImpl.java的214行、1910行:
分别在220/1915使用了mybatis-plus的insert接口;
insert接口来自mybatisplus
2)测试环境复现
由于生产使用了insert方法,故本地测试也用insert并观察是否存在注入。
测试数据表如下:
接下来,直接在mysql的console内直接执行含有注入payload的语句:
insert into sys_user (id, name, age, email) values (124,'Jasdf',42,'asdf@x.com');/**/DROP/**/TABLE/**/sys_user;/**/--#',25,'joh@asdf.com');
可以发现恶意语句被执行,sys_user表被删除:
重新建表,并将payload放入参数`name`中,观察MyBatisPlus如何处理恶意payload:
Jasdf',42,'asdf@x.com');/**/DROP/**/TABLE/**/sys_user;/**/--#
Application.xml添加配置,打印MyBatis-Plus构造后的SQL语句便于观察
# MyBatis-Plus 配置 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #开启sql日志
注意到payload经过MyBatis-Plus处理后依然是String类型:
Jasdf',42,'asdf@x.com');/**/DROP/**/TABLE/**/sys_user;/**/--# (String)
检查数据:
Payload作为完整的String被插入了数据库,说明注入未成功。
4. 防御方式解析
在第3节中,我们的注入未成功,下面通过debug方式展示mybatis-plus对攻击者的payload做了什么处理。
在SqlSourceBuilder.java的
ParameterMappingTokenHandler handler处下断点,可以看到原始语句首先被解析为为模板+数据,其中模板为:
`INSERT INTO sys_user ( id, name, age, email ) VALUES ( #{id}, #{name}, #{age}, #{email} )`
熟悉mybatis的同学都知道,相较于${value}不参与预编译的形式,#{value}形式的变量最终将调用preparedStatement来实现预编译。对于预编译的SQL语句,mysql将以开发者预定的参数类型构造sql语句,换句话说,不会将payload理解为新的SQL语句。
PrepareStatement调用:
开始预编译:
handler.parameterize对预编译语句进行变量初始化,首先是long类型的id,通过ibatis的setLong方法处理(由于SQL注入重点在于String类型,本文不进一步测试Int或者long类型的处理方式,仅关注String类型):
然后是String类型的name:
在boundSql对象中将模板SQL语句和参数对象一一对应
typeHandler.setParameter将‘name’和payload进行绑定
来到关键方法。Name作为string类型,setNonNullParameter对其赋值,调用了ps.setString(i, parameter);方法,该方法判断String类型的变量内的特殊符号是否需要转义:
可以发现攻击payload中含有的‘,将在此处被转义。除此之外,’\u0000’,’\n’,’\r’,…等符号也会被转义,视情况在特殊符号后添加\’,\n,\r,\Z等:
最终,经过预编译后,即将送入MySQL的stmt对象变为:
INSERT INTO sys_user ( id, name, age, email ) VALUES ( 1744564513751109634, 'Jasdf'',42,''asdf@x.com'');/**/DROP/**/TABLE/**/sys_user;/**/--#', 3, 'abc@mp.com' )
对比原始payload:
Jasdf',42,'asdf@x.com');/**/DROP/**/TABLE/**/sys_user;/**/--#
Jasdf'',42,''asdf@x.com'');/**/DROP/**/TABLE/**/sys_user;/**/--#
由此回答第一个问题,String类型的payload经过preparedStatement处理后,原有可能导致sql语句被截断的单引号’后添加了一个’作为转义,避免了SQL注入的发生。本文以“insert”语句为例,对于Update\Select\Create语句内的setString类型变量皆是如此,这里不再赘述。
竟有如此方便的MyBatis-Plus,不需要人工介入即可完成参数的预处理,那是不是只要使用它就可以远离SQL注入高枕无忧了呢?事实上并不能,且听下回分解。
5. 参考
- https://github.com/baomidou/mybatis-plus-samples