【Java代码审计】SQL注入篇
- 1.Java执行SQL语句的几种方式
- 2.Java SQL注入
- SQL语句参数直接动态拼接
- 预编译依然采用拼接
- order by注入
- %和_模糊查询
- MyBatis中使用存在风险的语法
- 3.Java常规注入代码审计思路
- 4.二次注入代码审计
1.Java执行SQL语句的几种方式
1、JDBC Statement执行SQL语句
java.sql.Statement是Java JDBC下执行SQL语句的一种原生方式,执行语句时需要通过拼接来执行。若拼接的语句没有经过过滤,将出现SQL注入漏洞
驱动注册完成后,实例化Statement对象,SQL语句为select * from users where id = '" + id + "'
,通过拼接的方式传入id的值
Class.forName("com.mysql.cj.jdbc.Driver");
Connection conn = DriverManager.getConnection(db_url, db_user, db_pass);
Statement stmt = conn.createStatement();
String sql = "select * from users where id = '" + id + "'";
log.info("[vul] 执行SQL语句: " + sql);
ResultSet rs = stmt.executeQuery(sql);
2、PreparedStatement执行SQL语句
PreparedStatement是继承statement的子接口,包含已编译的SQL语句。PreparedStatement会预处理SQL语句,SQL语句可具有一个或多个IN参数。IN参数的值在SQL语句创建时未被指定,而是为每个IN参数保留一个问号(?
)作为占位符。每个问号的值,必须在该语句执行之前通过适当的setXXX方法来提供。如果是int型则用setInt
方法,如果是string型则用setString
方法
PreparedStatement预编译的特性使得其执行SQL语句要比Statement快,SQL语句会编译在数据库系统中,执行计划会被缓存起来,使用预处理语句比普通语句更快。PreparedStatement预编译还有另一个优势,可以有效地防止SQL注入攻击,其相当于Statement的升级版
String sql = "select * from users where id = ?";
PreparedStatement st = conn.prepareStatement(sql);
st.setString(1, id);
ResultSet rs = st.executeQuery();
3、MyBatis执行SQL语句
MyBatis是一个Java持久化框架,它通过XML描述符或注解把对象与存储过程或SQL语句关联起来,它支持自定义SQL、存储过程以及高级映射
MyBatis框架底层已经实现了对SQL注入的防御,但存在使用不当的情况下,仍然存在SQL注入的风险
①MyBatis注解存储SQL语句
②MyBatis映射存储SQL语句
2.Java SQL注入
SQL语句参数直接动态拼接
在常见的场景下SQL注入是由SQL语句参数直接动态拼接的
例如如下代码:
String sql = "select * from users where id = '" + id + "'";
log.info("[vul] 执行SQL语句: " + sql);
ResultSet rs = stmt.executeQuery(sql);
while (rs.next()) {
String res_name = rs.getString("user");
String res_pass = rs.getString("pass");
String info = String.format("查询结果 %s: %s", res_name, res_pass);
result.append(info);
}
一个普通的查询,会输出账户名和密码:
http://localhost:8888/SQLI/JDBC/vul1?id=1
但倘若我们使用引号闭合SQL语句,并使用updatexml构造一个恶意的报错语句,就可以执行任何我们想要执行的SQL命令:
http://127.0.0.1:8888/SQLI/JDBC/vul1?id=1' and updatexml(1,concat(0x7e,(SELECT user()),0x7e),1)--%20+
防御方法,可以采用黑名单过滤的方式(误杀比较严重)
public static boolean checkSql(String content) {
String[] black_list = {"'", ";", "--", "+", ",", "%", "=", ">", "*", "(", ")", "and", "or", "exec", "insert", "select", "delete", "update", "count", "drop", "chr", "mid", "master", "truncate", "char", "declare"};
for (String s : black_list) {
if (content.toLowerCase().contains(s)) {
return true;
}
}
return false;
}
预编译依然采用拼接
使用PrepareStatement执行SQL语句是因为预编译参数化查询能够有效地防止SQL注入。那么是否能将使用Statement执行SQL语句的方式丢弃掉,使用PrepareStatement执行SQL语句防止SQL注入?
答案是否定的,很多开发者因为个人开发习惯的原因,没有按照PrepareStatement正确的开发方式进行数据库连接查询,在预编译语句中使用错误编程方式,那么即使使用了SQL语句拼接的方式,同样也会产生SQL注入漏洞
例如:
String sql = "select * from users where id = " + id;
log.info("[vul] 执行SQL语句: " + sql);
PreparedStatement st = conn.prepareStatement(sql);
ResultSet rs = st.executeQuery();
while (rs.next()) {
String res_name = rs.getString("user");
String res_pass = rs.getString("pass");
String info = String.format("查询结果%n %s: %s%n", res_name, res_pass);
result.append(info);
}
一个普通的查询,会输出账户名和密码:
http://127.0.0.1:8888/SQLI/JDBC/vul2?id=1
但倘若我们使用引号闭合SQL语句,就可以构造恶意的payload来获取用户表的所有数据:
http://127.0.0.1:8888/SQLI/JDBC/vul2?id=2%20or%201=1
防御方法,是采用占位符的方式执行SQL命令:
public String safe1(String id) {
String sql = "select * from users where id = ?";
PreparedStatement st = conn.prepareStatement(sql);
st.setString(1, id);
ResultSet rs = st.executeQuery();
}
order by注入
在有些特殊情况下不能使用PrepareStatement,比较典型的就是使用order by子句进行排序。order by子句后面需要加字段名或者字段位置,而字段名是不能带引号的,否则就会被认为是一个字符串而不是字段名。PrepareStatement是使用占位符传入参数的,传递的字符都会有单引号包裹,“ps.setString(1,id)
”会自动给值加上引号,这样就会导致order by子句失效
例如:
因为order by只能使用字符串拼接的方式,当使用“String sql="SELECT*FROM user"+"order by"+id
”进行id参数拼接时,就出现了SQL注入漏洞。id参数传入的值为“String id="2 or 1=1"
”,因为存在SQL注入漏洞,故当执行完成后会将所有的user表中信息输出
防御方法是执行严格的过滤或使用类似Mybatis的排序映射
%和_模糊查询
在Java预编译查询中不会对%
和_
进行转义处理,而%
和_
刚好是like查询的通配符,如果没有做好相关的过滤,就有可能导致恶意模糊查询,占用服务器性能,甚至可能耗尽资源,造成服务器宕机
如图,当传入的username为“"%user%"
”时,通过动态调试发现数据库在执行时并没有将%
进行转义处理,而是作为通配符进行查询的
对于此攻击方式最好的防范措施就是进行过滤,此类攻击场景大多出现在查询的功能接口中,直接将%
进行过滤就是最简单和有效的方式
MyBatis中使用存在风险的语法
#{}
在底层实现上使用“?
”作为占位符来生成PreparedStatement,也是参数化查询预编译的机制,这样既快又安全。${}
将传入的数据直接显示生成在SQL语句中,类似于字符串拼接,可能会出现SQL注入的风险
示例:
${id}
不会进行SQL参数化查询,如果传入的数据没有经过过滤就有可能出现SQL注入,设置传入的id的值为“1 and 1=2 union select 1,database(),3
”,所以输出了SQL注入后数据库的数据信息:
另外,在前面order by注入中已经讲到,order by子句不能使用参数化查询的方式,只能使用字符拼接的方式,而在MyBatis中#{}
是进行参数化查询的,如果在MyBatis的order by子句中使用#{}
,则order by子句会失效,所以要使用order by子句只能使用${}
例如,一个风险代码如下:
<select id="orderBy" resultType="com.best.hello.entity.User">
select * from users order by ${field} ${sort}
</select>
一个正常的排序访问,会输出按照field排序的结果:
http://127.0.0.1:8888/SQLI/MyBatis/vul/order?field=id&sort=desc
倘若输入恶意的payload,则可以导致注入:
http://127.0.0.1:8888/SQLI/MyBatis/vul/order?field=id&sort=desc,abs(111111)
防御方法是执行严格的过滤或使用Mybatis的排序映射
例如,如下是一个良好的解决order by注入的查询:
<select id="orderBySafe" resultType="com.best.hello.entity.User">
select * from users
<choose>
<when test="field == 'id'">
order by id desc
</when>
<when test="field == 'user'">
order by user desc
</when>
<otherwise>
order by id desc
</otherwise>
</choose>
</select>
还有一种情况,上面提到的%
和_
模糊查询,MyBatis的like子句中使用#{}
程序会报错,例如:“select*from users where name like'%#{user}%'
”;为了避免报错只能使用${}
,例如:“select*from users where name like'%${user}%'
”;但${}
可能会存在SQL注入漏洞,要避免SQL注入漏洞就要进行过滤
例如,下面是一个存在SQL注入的代码:
@Select("select * from users where user like '%${q}%'")
List<User> search(String q);
防御这种类型的攻击,一种有效的方式是强制数据类型,使用 ${}
本身是存在注入的,但由于强制使用Integer或long类型导致注入无效(无法注入字符串)
@Select("select * from users where id = ${id}")
List<User> queryById2(@Param("id") Integer id);
当然,最有效的方式,还是使用 #{}
安全编码,不过为了语法的正确性,要采用CONCAT
函数进行拼接语句
@Select("select * from users where user like CONCAT('%', #{user}, '%')")
List<User> queryByUser(@Param("user") String user);
另外,MyBatis的in
子句中使用#{}
参数化查询,会将“select * from users where name in (#{user})
”转变为“select * from users where name like (''user1','user2','user3','user4'')
”,这样把“‘user1’,‘user2’,‘user3’,‘user4’”当作一个整体,偏离了原来的程序设计逻辑,无法查到数据,所以也存在上述的问题
3.Java常规注入代码审计思路
我们可以总结出下面这些常见的关键字,通过这些关键字便可快速地定位到SQL语句的附近,进而进行有针对性的审计:
例如我们搜索Statement,发现如下代码片段:
Statement stmt = conn.createStatement();
String sql = "select * from users where id = '" + id + "'";
log.info("[vul] 执行SQL语句: " + sql);
ResultSet rs = stmt.executeQuery(sql);
id是用户传入的参数,现在我们回看source点,看看对于id有没有什么过滤,以及过滤能否绕过:
追踪我们controller的视图,查看MvcConfig类:
进入视图文件,发现id是直接传入的方式,没有任何的过滤和全局过滤器:
注入产生!
4.二次注入代码审计
1、与常规注入一样,通过搜索SQL关键字定位至存在SQL语句的文件
2、跟进“UserMapper.java”文件,可以发现:其中定义了大量SQL语句,但大多数使用了#
号的安全写法。通过搜索可以发现:以下语句使用了不安全的$
号
3、通过搜索调用栈,在UserService.java中可以找到其对应的调用
4、通读代码可以发现其逻辑为:从session中取出username,随后拼入SQL语句进行查询。我们接着查找session的调用,便能找到其赋值依据。最终在login逻辑中成功地找到了session的赋值过程
5、这里可以看到username的值来源于user.getUsername(),也就是说,username的值是通过登录时输入用户名获取的,由于前面存在if逻辑判断,因此此处取到的应是成功登录后的用户名。那么我们可以接着寻找注册逻辑,以便对漏洞进行利用
6、注册逻辑直接调用UserMapper进行入库操作,并没有对用户名进行过滤。同时入库时采用的是#
号的安全写法,最后会通过预编译执行SQL语句
7、这里存在的注入为二次注入,而我们想要触发该漏洞则需先注册一个存在注入语句的用户名进行登录,随后通过触发info逻辑进行二次注入。通过查看逻辑可以知道:info是通过路由/info
进行触发的
8、首先,注册账号名为“'union select user(),2,3#
”的用户名
9、登录后触发info逻辑