Java基础漏洞 - SQLI

前言

本文主要对 Java 下 SQLI 的漏洞成因、防御思路做了分析总结,方便理清代码审计时的思路,漏洞样例代码参考自 Hello-Java-Sec 的sql 注入部分。

JDBC 漏洞点

JDBC 是相对较老的数据库操作方式了,印象中我在本科课程还用 eclipse 开发过小网页,其中分页查询数据的功能点用到了它。简单来说,JDBC 提供了一套调用接口,使得开发人员可以在Java代码里写SQL,并传入数据库执行。

动态拼接参数

漏洞成因

动态拼接参数一般是使用 java.sql.Statement 类进行 SQL 查询时,sql 语句动态拼接用户传参导致的注入漏洞。

例如下面代码中,id 参数直接做了字符串拼接,显然是可以直接注入的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@ApiOperation(value = "vul: JDBC语句拼接")
@GetMapping("/vul1")
public String vul1(String id) {
StringBuilder result = new StringBuilder();
String sql = "select * from users where id = '" + id + "'";

try {
Class.forName("com.mysql.cj.jdbc.Driver");
Connection conn = DriverManager.getConnection(db_url, db_user, db_pass);

Statement stmt = conn.createStatement();

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");
result.append(String.format("查询结果 %s: %s", res_name, res_pass));
}

rs.close();
stmt.close();
conn.close();
return result.toString();
} catch (Exception e) {
// 输出错误,用于报错注入
return e.toString();
}
}

进一步,使用字符串动态拼接可实现不同关键字的注入,如 like注入、in注入和 order by 注入。

1
2
3
4
5
6
7
String sql_like = "select * from users where id like '%" + id + "%'";
String sql_order = "select * from users order by " + column_name;
String sql_in = "delete from users where id in(" + del_ids +");"

Connection conn = DriverManager.getConnection(db_url, db_user, db_pass);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql_xxx);

防御思路

  1. 预编译SQL
  2. 黑白名单
  3. 参数类型强转

使用java.sql.PreparedStatement预编译SQL语句可以解决动态拼接的问题,简单来说就是使用占位符代替直接传入的参数,然后调用setter去做赋值,实现了sql 注入的防御。使用预编译的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@ApiOperation(value = "safe:JDBC预编译", notes = "采用预编译的方法,使用?占位,也叫参数化的SQL")
@GetMapping("/safe1")
public String safe1(String id) {
StringBuilder result = new StringBuilder();
String sql = "select * from users where id = ?";

try {
Class.forName("com.mysql.cj.jdbc.Driver");
Connection conn = DriverManager.getConnection(db_url, db_user, db_pass);
PreparedStatement st = conn.prepareStatement(sql);
st.setString(1, id);
log.info("[safe] 执行SQL语句: {}", st);
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);
}

rs.close();
st.close();
conn.close();
return result.toString();
} catch (Exception e) {
return e.toString();
}
}

注意:并不是使用了预编译SQL就是绝对安全的,下一小节就指出了预编译使用不当、无法使用预编译而导致的漏洞。

类似的防御策略还有:黑白名单、类型强转、第三方安全编码组件等,这里以类型强转为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@ApiOperation(value = "safe:强制数据类型")
@GetMapping("/safe4")
public Map<String, Object> safe4(Integer id) {
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
dataSource.setUrl(db_url);
dataSource.setUsername(db_user);
dataSource.setPassword(db_pass);

JdbcTemplate jdbctemplate = new JdbcTemplate(dataSource);

String sql_vul = "select * from users where id = " + id;

return jdbctemplate.queryForMap(sql_vul);
}

预编译未使用占位符

漏洞成因

预编译起作用的前提是正确使用 PreparedStatement ,如果仅调用查询,而不使用占位符进行参数拼接,本质上还是会导致动态参数拼接的问题,从而导致注入。

例如下面代码虽然用到了预编译的函数,但是并未使用占位符,从而导致注入漏洞。这个问题多是因为开发人员的粗心大意导致的,而在如今的AI辅助编程时代,类似的错误会越来越少。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@ApiOperation(value = "vul:JDBC预编译拼接", notes = "采用预编译的方法,但没使用?占位,此时进行预编译也无法阻止SQL注入")
@GetMapping("/vul2")
public String vul2(String id) {
StringBuilder result = new StringBuilder();
String sql = "select * from users where id = " + id;

try {
Class.forName("com.mysql.cj.jdbc.Driver");
Connection conn = DriverManager.getConnection(db_url, db_user, db_pass);

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);
}

rs.close();
st.close();
conn.close();
return result.toString();
} catch (Exception e) {
return e.toString();
}
}

防御思路

对于这种代码编写错误,要规范安全开发流程,在上线前要做统一的代码审查,避免类似的问题。

order by 注入

漏洞成因

这个漏洞比较有意思,由于 PreparedStatement 预编译的函数会把传参转换成字符串,所以会出现下面的情况:

1
2
3
4
## 正常语句
select * from users order by id
## 预编译转义后
select * from users order by "id"

“id”不被识别为列名,进而导致排序失效,所以编写包含排序的语句时,只能通过拼接的方式,而如果没对参数进行严格过滤,就容易造成SQL注入。漏洞代码在动态参数拼接那一节写过了,伪代码如下:

1
2
3
4
String sql_order = "select * from users order by " + id;
Connection conn = DriverManager.getConnection(db_url, db_user, db_pass);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql_order);

防御思路

由于无法使用预编译,所以需要对用户传参做校验,推荐使用白名单过滤,指定传参必须为列名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@ApiOperation(value = "safe: order by 参数白名单过滤")
@GetMapping("/safe5")
public String safe5(String column) {
StringBuilder result = new StringBuilder();

// 限制column参数只能是id、username、password三者之一
Set<String> allowedFields = new HashSet<>(Arrays.asList("id", "username", "password"));

if (allowedFields.contains(column)) {
try {
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 order by " + column;
log.info("[safe] 执行SQL语句: {}", sql);

ResultSet rs = stmt.executeQuery(sql);
while (rs.next()) {
String info = String.format("查询结果 %s: %s", rs.getString("user"), rs.getString("pass"));
result.append(info);
}
rs.close();
stmt.close();
conn.close();
return result.toString();
} catch (Exception e) {
return e.toString();
}
} else {
return "xxx";
}
}

Mybatis 漏洞点

Mybatis / Mybatis plus 是目前比较新的数据库操作方式,它提供了 xml配置 / mapper接口注解 两种映射方式,结合赖注入可以实现非常简洁的数据库调用。(个人更喜欢注解方式)

xml配置方式

mapper接口注解方式

动态拼接参数

漏洞成因

Mybatis 和 JDBC 类似,有传参的预编译和非预编译之分。一般 来说,#{param} 会被转义,而 ${param} 则会直接注入。

所以如果开发人员使用到 ${}并且传参用户可控,那么就会造成 SQL 注入。特别地,由于 order by、like 和 in 关键字在使用 #{} 做转义时会报错,所以开发者容易下意识选择 ${} 解决问题,进而加大了 Mybatis 出现 SQL 注入的可能。

常见的漏洞代码如下,总共有:

1.使用 xml 映射方式,传参使用 ${}

title:"UserMapper.xml"
1
2
3
4
5
6
<!-- id的值必须和数据处理层的接口名一致 -->
<select id="orderBy" resultType="com.best.hello.entity.User">
select *
from users
order by ${field} ${sort}
</select>
title:"UserMapper.java"
1
2
3
4
5
6
public interface UserMapper {

List<User> orderBy(String field, String sort);
List<User> orderBySafe(String field);
}

2.使用接口注解方式,传参使用 ${}

title:"UserMapper.java"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface UserMapper {

// 1. 参数动态拼接
@Select("select * from users where id = ${id}")
List<User> queryByIdAsString(@Param("id") String id);
// 2. order by关键字注入,使用 #{} 会产生报错,因此容易写成 ${}
@Select("select * from users order by ${field} ${sort}")
List<User> orderBy2(@Param("field") String field, @Param("sort") String sort);

// 3. like关键字注入,搜索时使用 '%#{q}%' 会报错,因此容易写成 ${}
@Select("select * from users where user like '%${user}%'")
List<User> searchVul(String user);
// 4. in关键字注入, 搜索时使用 '%#{q}%' 会报错,因此容易写成 ${}
@Select("select * from users where id in (${ids})")
List<User> queryIdInIds(@Param("ids") String ids);
}

防御思路

对于直接动态拼接,把 ${} 改成 #{}即可;对于 order by、like、in 等关键字,由于报错问题,需要微调写法。

动态拼接安全代码,直接替换即可

1
2
3
4
5
public interface UserMapper {

@Select("select * from users where id = #{id}")
List<User> queryByIdAsString(@Param("id") String id);
}

order by 注入安全代码,通过 choose 标签设置 filed 白名单,然后再做对应处理去解决这个问题

title:"UserMapper.xml"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<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 pass desc limit 2
</otherwise>
</choose>
</select>

like 注入安全代码,需要使用 concat 做字符串拼接,否则会报错的

title:"UserMapper.java"
1
2
3
4
public interface UserMapper {    
@Select("select * from users where user like CONCAT('%', #{user}, '%')")
List<User> searchSafe(@Param("user") String user);
}

in 注入安全代码,需要使用到 foreach 标签

title:"UserMapper.xml"
1
2
3
4
<select id="findByUserNameVuln04" parameterType="String" resultMap="User">
select * from users where id in
<foreach collection="ids" item="item" open="("separatosr="," close=")">#{ids} </foreach>
</select>

总结

Java SQL 注入漏洞比较有意思的地方是,Java 由于 Mybatis 的存在,对数据库操作进一步封装的同时,却不支持例如 order by 语句的预编译,导致开发者会偏向于使用更为”便捷“的 ${},进而导致漏洞产生。

当然值得庆幸的是,Mybatis Plus 目前已经支持上述语句的预编译,所以及时更新组件也是漏洞防御一大重点;不过也需要考虑项目兼容性和供应链安全的问题。

参考链接