浅谈Java中的SQL注入
2025-01-21 10:54:12

环境

Java: jdk_8u65

Mysql: 9.1.0

Mysql可视化:DBeaver 24.3.2

JDBC

JDBC(Java Database Connectivity) 是一个 API(应用程序编程接口),用于 Java 应用程序与数据库之间的连接。它是直接用于数据库操作的接口,而不是介于应用程序和数据库之间的中间层。

1、Statement SQL语句拼接造成SQL注入

1
2
String sql = "select * from person where username = '"+username+"' and password = '"+password+"'";
ResultSet resultSet = statement.executeQuery(sql);

2、PreparedStatement 预编译使用不当

预编译先编译 sql语句,无论后续用户输入如何,都作为字符串数据处理,而 sql注入只针对编译的过程进行破坏,所以预编译可以防御 sql注入。

并且还减少了 sql语句的编译次数,提高了性能。

正确的预编译语句如下所示:

1
2
3
4
5
6
7
String sql = "select * from person where username = ? and password = ?";
//先编译
PreparedStatement preparedStatement = connection.prepareStatement(sql);
//后赋值
preparedStatement.setString(1, username);
preparedStatement.setString(2, password);
ResultSet resultSet = preparedStatement.executeQuery();

但是预编译使用不当也会出现 sql注入。

(1) 未使用占位符,而是直接拼接

虽然使用了预编译,但是没有使用 ? 进行占位,所以本质上还是直接拼接导致的 sql注入。

1
2
3
4
5
//创建sql语句,直接拼接
String sql = "SELECT * FROM users WHERE username = '" + req.getParameter("username") + "' AND password = '" + req.getParameter("password") + "'";
//预编译sql语句
PreparedStatement pstt = connection.prepareStatement(sql);
ResultSet resultSet = pstt.executeQuery(sql);//返回结果集,封装了全部的产部的查询结果
(2) in语句查询

使用 in语句的场景有很多,比如收藏场景,勾选多篇文章,同时进行收藏,请求包可能如下所示:

1
messageId=223,556,889

当从数据库进行查询时,因为不清楚用户选中的文章的数量,开发可能会直接使用拼接的方式构造查询语句,导致sql注入。

1
2
3
String sql = "select * from person where id in ("+id+")";
PreparedStatement preparedStatement = connection.prepareStatement(sql);
ResultSet resultSet = preparedStatement.executeQuery();

image-20250119121142554

修复:

可以先对用户传入的数据进行处理,确定对象的个数,根据个数增加占位符,再使用预编译。

比如 ‘取消收藏’的场景:

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
public int gradeDelete(Connection con, String delIds) throws Exception{
String num = "";
//将对象分割开来,分割的点以实际而定
String[] spl = delIds.split(".");

//根据对象的个数添加同量的占位符?,用来预编译
for(int i = 0; i< spl.length; i++){
if(i == 0){
num += "?";
} else {
num += ".?";
}
}
String sql = "delete from users where id in("+num+")";
prepareStatement pstmt = con.prepareStatement(sql);
try {
for(int j = 0; j < spl.length; j++){
pstmt.setInt(j+1, integer.parseint(spl[j]));
}
return pstmt.executeUpdate();
} catch(Exception e){
//
}

return 0;
}
(3) Like语句模糊查询

有些业务会使用模糊查询,比如搜索框等场景

1
2
3
4
5
String sql = "select username from person where username like '%" + username + "%'";
//先编译
PreparedStatement preparedStatement = connection.prepareStatement(sql);
//后赋值
ResultSet resultSet = preparedStatement.executeQuery();

image-20250119123430175

修复:

和 in语句类似,先对用户输入的数据进行判断,再添加占位符进行预编译:

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
boolean jud = true;
int v = 0;
String sql = "select * from users ";
System.out.println("请输入要查询的内容:");
String con = sc.nextLine();

//先对用户输入进行处理
for (int i = 0; i < con.length(); i++){
if(!Character.isDigit(con.charAt(i))){
jud = false;
break;
}
}
if(jud){
sql += "where password like ?";
v = 1;
}else{
sql += "where username like ? and password like ?";
v = 2;
}

//预编译sql语句
PreparedStatement pstt = connection.prepareStatement(sql);
if(v == 1){
pstt.setString(1, "%"+con+"%");
}else if (v == 2){
pstt.setString(1, "%"+con+"%");
pstt.setString(2, "%"+con+"%");
}

但是 like的预编译并非百分百可靠,可以通过特殊字符 % 和 _ 来实现注入。

演示代码:

1
2
3
4
5
6
String sql = "select * from person where username like ?";
//先编译
PreparedStatement preparedStatement = connection.prepareStatement(sql);
//后赋值
preparedStatement.setString(1, "%" + username + "%");
ResultSet resultSet = preparedStatement.executeQuery();

使用 % 和 _ 会导致返回所有结果,绕过预编译防御,所以要对 % 和 _ 进行过滤处理。

image-20250119140007769

(5) Order by注入

ORDER BY 是 SQL 中用于对查询结果进行排序的子句。它按照指定的列或表达式对数据进行升序(ASC)或降序(DESC)排列。默认情况下,ORDER BY 按升序排序。

正确的 order by查询使用如下所示:

1
select * from person order by id desc

其中 id为列名,意思是按照 id的大小进行生序排序。

但是预编译有一个特性,会强制给占位符所传入的数据加上 ‘’,’id’会被视为字符串而非列名,这样就不会按照预期进行排序。

所以在使用 JDBC连接的前提下想要使用 order by,就不能再使用预编译了,只能通过做好过滤,加WAF等方式来防御 sql注入。

Mybatis

MyBatis 是一款广泛使用的 Java 持久层框架,它通过将数据库操作与 Java 对象进行映射,简化了数据库操作的过程。MyBatis 允许开发者直接编写原生 SQL 查询语句,并通过 XML 或注解将 SQL 与 Java 方法进行映射,使得数据库操作更加灵活和高效。MyBatis 是一个 半自动化 的框架,开发者需要手动编写 SQL 语句,但它提供了映射功能,使得 SQL 查询结果可以直接映射为 Java 对象。

主要组件和原理

SqlSessionFactory

  • SqlSessionFactory 是 MyBatis 的核心工厂类,负责创建 SqlSession 对象。它会根据全局配置文件(mybatis-config.xml)读取配置并初始化相关信息。

SqlSession

  • SqlSession 是 MyBatis 的核心接口,用于执行 SQL 查询、插入、更新、删除等操作。通过 SqlSession,你可以获取到映射器接口的代理对象,并执行相应的 SQL 操作。

Mapper 接口

  • Mapper 接口定义了数据库操作的方法,通常这些方法与 SQL 映射文件中的 SQL 标签一一对应。Mapper 接口通常由开发者编写,方法名与 XML 映射文件中的 id 匹配。

    image-20250119142210066

映射器 XML 文件

  • 这个文件包含了 SQL 语句的定义,MyBatis 根据方法名称查找相应的 SQL 语句,并将查询结果映射成 Java 对象。

image-20250119142259787

通过 id与 UserMapper接口中的方法名进行匹配。resultType指定数据库查询的结果会被映射到哪一个Java类,这里查询结果被映射到了 com.best.hello.entity.User类中。

Mybatis的两种传参方式

${}: 将传入的参数直接拼接到 sql查询语句中,不做任何的处理。

#{}: 和预编译占位符异曲同工,先进性预编译后传参。将传入的数据都当作字符串,对传入的数据都加上双引号 “”。

(1) ${}直接拼接

某些开发使用 Mybatis框架时,会使用 ${}传参导致 sql注入:

1
2
3
4
5
<select id="getInformation" resultType="com.best.hello.entity.User">
select *
from users
where username=${username} and password=${password}
</select>

修复应使用 #{}:

1
2
3
4
5
<select id="getInformation" resultType="com.best.hello.entity.User">
select *
from users
where username=#{username} and password=#{password}
</select>
(2) in, Like查询

like,in查询语句同理,使用 #{}即可。

Like:

1
2
3
4
5
6
mysql:
select * from users where username like concat('%',#{username},'%')
oracle:
select * from users where username like '%'||#{username}||'%'
sqlserver:
select * from users where username like '%'+#{username}+'%'

in:

1
2
3
4
5
6
mysql:
select * from users where username like concat('%',#{username},'%')
oracle:
select * from users where username like '%'||#{username}||'%'
sqlserver:
select * from users where username like '%'+#{username}+'%'
(3) Order by注入

与 Jdbc同理,若使用 #{}会给传参加上双引号,导致列名被当作字符串解析,所以不得不使用 ${},由此产生 sql注入。

Mapper接口:

1
2
3
public interface UserMapper {
List<User> orderBy(String field, String sort);
}

映射器xml文件:

1
2
3
4
5
<select id="orderBy" resultType="com.best.hello.entity.User">
select *
from users
order by ${field} ${sort}
</select>

一个很好的修复方法是 ‘排序映射’,以我的理解,是一种类似于白名单的机制,严格限制用户输入:

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>

用户的输入只能是 ‘id’ 和 ‘user’,若为其他直接使用默认排序,不再相信用户输入。

Mybatis-plus

Mybatis-Plus(简称MP)是基于MyBatis的增强工具,它简化了MyBatis的操作,提供了一些常用的功能,目的是让开发者在使用MyBatis时,能够更加高效、便捷地进行数据库操作。Mybatis-Plus不需要重写SQL语句,就能自动完成增、删、改、查等基本操作,减少了大量的代码重复性。

与 Mybatis不同,Mybatis-plus省去了 Mybatis所必需的映射器xml文件。

为了加深印象,我自己搭了一个使用 SpringBoot + Mybatis-plus的环境,添加依赖和数据库配置文件不多赘述。

Application.propertirs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
spring.application.name=SQL_Mybatis
server.port=9988
# MySQL 数据库连接配置
spring.datasource.url=jdbc:mysql://localhost:3306/demo_database?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# MyBatis-Plus 配置
#mybatis-plus.mapper-locations=classpath:/mapper/*Mapper.xml
mybatis-plus.type-aliases-package=org.kgty.sql_mybatis

# Hibernate/JPA配置(如果使用JPA)
spring.jpa.hibernate.ddl-auto=update
spring.jpa.database-platform=org.hibernate.dialect.MySQL5InnoDBDialect

创建实体类 - 可以类比为 Mybatis中的 resultType:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package org.kgty.sql_mybatis;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("person") // 表明实体类对应的表
public class person {
private int id; // 主键
private String username;
private String password;

//getter 和 setter方法
}

mapper层,继承 BaseMapper,BaseMapper中定义好了很多现成的 sql查询语句,供开发可以直接使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
package org.kgty.sql_mybatis.mapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Select;
import org.kgty.sql_mybatis.person;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface personMapper extends BaseMapper<person> {

}

service层:eq -> equal的意思,即 where username = username(用户输入)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package org.kgty.sql_mybatis.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.kgty.sql_mybatis.person;
import org.kgty.sql_mybatis.mapper.personMapper;
import java.util.List;

@Service
public class personService {
@Autowired
private personMapper pm;

public List<person> getPersons(String username) {
QueryWrapper<person> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", username);
return pm.selectList(queryWrapper);
}
}

Controller层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package org.kgty.sql_mybatis.controller;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.kgty.sql_mybatis.person;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import org.kgty.sql_mybatis.mapper.personMapper;
import org.kgty.sql_mybatis.service.personService;


@RestController
public class personController extends ServiceImpl<personMapper, person> {
@Autowired
private personService personService;

@RequestMapping("/person")
public List<person> getPersons(@RequestParam String username) {
return personService.getPersons(username);
}
}

启动项目,即可连接数据库,进行查询操作:

image-20250120111516266

BaseMapper中提供的查询方法都使用了预编译来避免 sql注入。

(1) apply()直接拼接导致 sql注入
1
2
3
4
5
public List<person> getPersons(String username) {
QueryWrapper<person> queryWrapper = new QueryWrapper<>();
queryWrapper.apply("username = '"+username+"'");
return pm.selectList(queryWrapper);
}

打印日志如图所示:

image-20250120122930513

如上代码使用 apply()方法会将 username直接拼接到 sql查询语句中,不进行任何预处理,导致 sql注入。

修复方法很简单,apply()也有预编译:

1
2
3
4
5
public List<person> getPersons(String username) {
QueryWrapper<person> queryWrapper = new QueryWrapper<>();
queryWrapper.apply("username = {0}", username);
return pm.selectList(queryWrapper);
}

使用 {0} 充当占位符,这样写可以达到预编译的效果,如下图打印日志所示:

image-20250120123210987

(2) last()使用不当

在 MyBatis-Plus 中,last() 方法用于在 SQL 语句的末尾拼接自定义的 SQL 片段。这对于在查询中添加一些特定的 SQL 语句非常有用,比如排序、分页或其他一些自定义条件。

1
2
3
4
5
public List<person> getPersons(String username) {
QueryWrapper<person> queryWrapper = new QueryWrapper<>();
queryWrapper.last("order by " + username); //注意 by后面要有空格
return pm.selectList(queryWrapper);
}

直接将 username拼接到 sql语句中,再去执行,造成 sql注入。

image-20250120124042691

image-20250120124053178

修复建议:

对用户的输入数据进行规范处理,采用过滤等操作,并且应尽量避免使用 last()。

(3) exists()和 notExists()使用不当:

EXISTSNOT EXISTS 常常用于查询中,需要根据某个条件是否存在来过滤数据。一般情况下,这类子查询会嵌套在 WHERE 子句中,判断某个数据是否满足特定条件。

业务场景:

比如有两个表 userorderorder 表中的每个订单都与 user 表中的某个用户相关联。如果你想查询那些有订单的用户,可以使用 EXISTS 子查询:

1
2
3
4
5
public List<User> getUsersWithOrders() {
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.exists("SELECT 1 FROM orders o WHERE o.user_id = user.id"); // EXISTS 子查询
return userMapper.selectList(queryWrapper);
}

最终的查询语句为:

1
SELECT * FROM user WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = user.id);

这个查询会返回那些有至少一个订单的用户。

notExists()同理。

但是如果直接拼接用户可控的输入就会造成 sql注入:

1
2
3
4
5
public List<person> getPersons(String username) {
QueryWrapper<person> queryWrapper = new QueryWrapper<>();
queryWrapper.exists("select * from person where username = '"+username+"'"); // EXISTS 子查询
return userMapper.selectList(queryWrapper);
}

image-20250120182226959

image-20250120182304057

修复可以继续采用 {index}的方式:

1
queryWrapper.exists("select * from person where username = {0}", username);
(4) having()使用不当

having() 主要用于对分组后的数据进行条件过滤。在一些需要对聚合结果进行筛选的场景下,having() 非常有用。例如,计算销售总额、订单数量等指标,并对这些指标进行过滤。

比如:查询订单数量大于 10 的用户:

假设我们有一个 orders 表,包含 user_idorder_id 等字段。如果我们想查询订单数量大于 10 的用户,可以使用 having() 方法:

1
2
3
4
5
6
7
public List<Order> getUsersWithMoreThan10Orders() {
QueryWrapper<Order> queryWrapper = new QueryWrapper<>();
queryWrapper.select("user_id", "COUNT(*) as order_count")
.groupBy("user_id")
.having("COUNT(*) > 10"); // 使用 HAVING 子句进行过滤
return orderMapper.selectList(queryWrapper);
}

这个查询会生成如下 SQL:

1
2
3
4
SELECT user_id, COUNT(*) as order_count 
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 10;

但是如果直接拼接用户输入就会造成 sql注入:

1
2
3
4
5
public List<person> getPersons(String username) {
QueryWrapper<person> queryWrapper = new QueryWrapper<>();
queryWrapper.groupBy("id").having("username = '"+username+"'");
return userMapper.selectList(queryWrapper);
}

类似的写法还有:只不过上面那个是字符型注入,下面这个是数字型注入罢了。

1
wrapper.select().groupBy("author").having("id > " +id);

依然是 {idnex}即可避免注入:

1
queryWrapper.groupBy("id").having("username = {0}", username);
(5) order by注入

orderBy(true, true, id) - 升序

orderBy(true, false, id) - 降序

orderByAsc() - 升序

orderByDesc() - 降序

order by注入与前面几种不同的是,order by不可以进行预编译,因为会强加上单引号,所以不能使用 {index}来进行修复了。

如果 order by 之后的数据用户可控,就存在 sql注入的风险。

1
queryWrapper.select().orderByDesc(username);

时间盲注:

image-20250120203555087

image-20250120203606647

(6) inSql()和 notInSql()使用不当:

insql() 方法用于在 WHERE 子句中添加一个 IN 子查询条件。IN 子句会检查字段的值是否存在于由子查询返回的结果集中。

假设我们有一个 orders 表,想查询所有 user_id 在某些 order_id 查询结果中的用户。可以使用 inSql() 方法来构造这个查询:

1
2
3
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.inSql("id", "SELECT user_id FROM orders WHERE order_date > '2023-01-01'");
List<User> users = userMapper.selectList(queryWrapper);

生成的 SQL查询语句如下:

1
SELECT * FROM user WHERE id IN (SELECT user_id FROM orders WHERE order_date > '2023-01-01');

总结了一下,Mybatis-plus出现的 sql注入本质上仍然是未进行预编译的直接拼接导致的。还有一些文章将 wrapper自定义 sql语句导致注入 和 使用xml文件进行映射产生的注入单独拿出来说,我认为最终还是会归咎到上面几种注入中,所以不过多赘述了。

上一页
2025-01-21 10:54:12
下一页