从1开始的Java代码审计·第三弹·SQL注入

前言

SQL注入是什么,有什么用,就不多介绍了。总结下漏洞的原因,主要是由于开发者对用户的输入没有做好过滤,直接将用户的输入带入到 SQL语句中,导致恶意用户可以控制服务器执行的SQL语句。

在 Java 中,操作SQL的主要有以下几种方式:

  1. java.sql.Statement

  2. java.sql.PrepareStatement

  3. 使用第三方 ORM 框架 —— MyBatisHibernate

下面我们来分析以上几种执行SQL的方式。

java.sql.Statement

java.sql.Statement 是最原始的执行SQL的接口,使用它时,需要手动拼接SQL语句,如下面这样:

1
2
3
String sql = "SELECT * FROM user WHERE id = '" + id + "'";
Statement statement = connection.createStatement();
statement.execute(sql);

假设这里 id 参数是直接从用户的请求里获取的,并且没有经过过滤,那么这处代码就会存在SQL注入漏洞。

构造请求 /?id='or 1 #,服务器将 'or 1 # 拼接到 sql 语句中,就会变成 SELECT * FROM user WHERE id = ''or 1 #,将返回 user 表的所有记录。

在任何时候,都不推荐使用 java.sql.Statement 这种方式来执行SQL。

因为这种方式写的代码可读性很差,容易出错,同时也存在很大的安全隐患。

java.sql.PrepareStatement

这个接口是对 java.sql.Statement 的拓展,拥有了防SQL注入的特性。

Tip: java.sql.Statement 每次执行一条SQL,都要重新编译一次SQL。而 java.sql.PreparedStatement 预编译的方式,会将SQL缓存在数据库,可以重复调用,相比 Statement 效率要高一些。

使用时,在SQL语句中,用 ? 作为占位符,代替需要传入的参数,然后将该语句传递给数据库,数据库会对这条语句进行预编译。如果要执行这条SQL,只要用特定的 set 方法,将传入的参数设置到SQL语句中的指定位置,然后调用 execute 方法执行这条完整的SQL。示例如下:

1
2
3
4
5
6
String sql = "SELECT * FROM user WHERE id = ?";
//预编译语句
PreparedStatement preparedStatement = connection.prepareStatement(sql);
//填入参数
preparedStatement.setString(1,reqStuId);
preparedStatement.executeQuery();

此时,如果我用之前的请求攻击,执行的SQL会变成 SELECT * FROM user WHERE id = '\'or 1 #',可以看到单引号是被转义了,同时参数也被一对单引号包裹,数字型注入也不存在了。

特殊情况

ORDER BY

我们已经知道,通过占位符传参,不管传递的是什么类型的值,都会被单引号包裹。而使用 ORDER BY 时,要求传入的是字段名或者是字段位置,如:

  1. SELECT * FROM user ORDER BY id

  2. SELECT * FROM user ORDER BY 1

如果传入的是引号包裹的字符串,那么 ORDER BY 会失效,如:SELECT * FROM user ORDER BY 'id'

所以,如果要动态传入 ORDER BY 参数,只能用字符串拼接的方式,如:

1
String sql = "SELECT * FROM user ORDER BY " + column;

那么这样依然可能会存在SQL注入的问题,在 Java 中会有两种情况:

  1. column 是字符串型

    这种情况和 Statement 中描述的一样,是存在注入的。要防御就必须要手动过滤,或者将字段名硬编码到 SQL 语句中,比如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    String column = "id";
    String sql ="";
    switch(column){
    case "id":
    sql = "SELECT * FROM user ORDER BY id";
    break;
    case "username":
    sql = "SELECT * FROM user ORDER BY username";
    break;
    ......
    }
  2. column 是 int 型

    因为 Java 是强类型语言,当用户传递的参数与后台定义的参数类型不匹配,程序会抛出异常,赋值失败。所以,不会存在注入的问题。

类似的, GROUP BY 也会有同样的问题。

MyBatis

基础篇提到的 JEESNS 用的就是 MyBatis,略过介绍。

MyBatis 使用内联参数 ${example}#{example},将查询的属性和参数做绑定,如下:

${}

1
2
3
4
5
<select id="selectStudentByStuId" resultMap="studentMap">
SELECT *
FROM student
WHERE stu_id = ${stuId}
</select>

#{}

1
2
3
4
5
<select id="selectStudentByStuId" resultMap="studentMap">
SELECT *
FROM student
WHERE stu_id = #{stuId}
</select>

两种方式有什么区别呢?接着看。

${} (不安全的写法)

使用 ${foo} 这样格式的传入参数会直接参与SQL编译,类似字符串拼接的效果,是存在SQL注入漏洞的。所以一般情况下,不会用这种方式绑定参数。

#{}

使用 #{} 做参数绑定时, MyBatis 会将SQL语句进行预编译,避免SQL注入的问题。

MyBatis 预编译模式的实现,在底层同样是依赖于 java.sql.PreparedStatement,所以 PreparedStatement 存在的问题,这里也会存在。

ORDER BY 只能通过 ${} 传递。为了避免SQL注入,需要手动过滤,或者在SQL里硬编码 ORDER BY 的字段名。

此外,还有一种情况 —— LIKE 模糊查询。

看下面这个写法:

1
2
3
4
5
6
<select id="selectStudentByFuzzyQuery" resultMap="studentMap">
SELECT *
FROM student
WHERE student.stu_name
LIKE '%#{stuName}%'
</select>

在这里,MyBatis 会把 %#{stuName}% 作为要查询的参数,数据库会执行 SELECT * FROM student WHERE student.stu_name LIKE '%#{stuName}%',导致查询失败,所以这里只能用 ${} 的方式传入。而如果用 ${} 又存在SQL注入的风险,怎么办呢?

最好的方法是,使用数据库自带的 CONCAT ,将 % 和我们用 #{} 传入参数连接起来,这样就既不存在注入的问题,也能满足需求啦。示例:

1
2
3
4
5
6
<select id="selectStudentByFuzzyQuery" resultMap="studentMap">
SELECT *
FROM student
WHERE student.stu_name
LIKE CONCAT('%',#{stuName},'%')
</select>

Hibernate

Hibernate 是一个高性能的 ORM 框架,可以自动生成 SQL 语句,通常与 StrutsSpring 一起搭配使用,也就是我们熟知的 SSH 框架。

Hibernate 支持多种操作数据库的方式,包括原生的 SQL,以及自家的 HQL

原生SQL

原生 SQL 的注入和前面介绍过的注入都一样,都是拼接的问题,就不细讲了。这里介绍下 Hibernate 写原生 SQL 时,可能会用到的几种写法吧。

要使用原生 SQL ,都会调用到 Sessions.createSQLQuery() 方法。

下面看第一种写法,如下:

1
2
3
4
5
6
7
8
session.beginTranscation();
List list = session.createSQLQuery("SELECT id,name FROM student").list();
session.getTranscation().commit();

//list 是查询的结果,list里的元素由Object数组构成
//Object数组的每个元素代表一个字段,需要强转才能使用
Object[] record = (Object[]) list.get(0);
System.out.println("id="+(Integer) record[0]+",name="+(String) record[1]);

第二种,上面的例子中,Hibernate 会使用 ResultSetMetadata 返回的标量值的实际类型。但是如果过多使用它会降低程序性能,所以通常会用 addScalar() 提前指定返回值的类型。代码如下:

1
2
3
4
List list = session.createSQLQuery("SELECT id,stu_name FROM student")
.addScalar("id", StandardBasicTypes.INTEGER)
.addScalar("stu_name", StandardBasicTypes.STRING)
.list();

第三种,上面的两个例子,返回的都是标量结果集,但是 Hibernate 是一个 ORM 框架,我们希望通过它,直接将返回的数据映射成对象。那怎么写呢?其实,很简单。只要为每个类和表写一个映射关系,让 Hibernate 知道该怎么把查到的数据转换成对象就行了(映射关系是如何写的,请自行百度)。然后,调用 addEntity() 将查询结果和类绑定一下,代码如下:

1
2
3
List<Student> list = session.createSQLQuery("SELECT * FROM student")
.addEntity(Student.class)
.list();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- Student映射文件 -->
<?xml version="1.0"?>
<!DOCTYPE hibernate-mapping PUBLIC
"-//Hibernate/Hibernate Mapping DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-mapping-3.0.dtd">

<hibernate-mapping package="edu.cuit.syclover.hibernate.Student">
<class name="edu.cuit.syclover.hibernate.Student" table="student">
<id name="id" column="id"/>
<property name="stuId" column="stu_id"/>
<property name="stuName" column="stu_name"/>
<property name="stuPassword" column="stu_password"/>
</class>
</hibernate-mapping>

HQL

HQLHibernate 独有的面向对象的查询语言,接近 SQLHibernate引擎会对 HQL 进行解析,翻译成 SQL,再将 SQL 交给数据库执行。

关于 HQL 的注入,限制很多。

HQL 的限制如下:

  1. 不能查询未做映射的表,所以想跨库查系统表基本没有希望。

    很多地方说 HQL 不支持 UNION,其实是错误的。Hibernate 支持 UNION 的。但是,想要使用 UNION,必须在模型的关系明确后可以,这种情况比较少见,所以会导致 UNION 失败。

  2. 表名,列名大小写敏感,查询时使用的列名大小写必须和映射类的属性一致。

  3. 不能用 *, # , --

  4. 无延时函数

所以,利用 HQL 是比较极限的一件事情。

本文不讨论如何 HQL 注入,想了解更多的注入手法,可以看这篇文章

HQL 会出现注入的地方还是在字符串拼接的时候,审计的时候看看 SQL 是不是用加号 + 的就行了。

比如这个例子:

1
2
List<Student> studentList = session.createQuery("FROM Student s WHERE s.stuId = " + stuId)
.list();

下面来看看 HQL 能防注入的安全写法。

第一种,使用具名参数 Named parameter

1
2
3
List<Student> studentList = session.createQuery("FROM Student s WHERE s.stuId = :stuId")
.setParameter("stuId",stuId)
.list();

第二种,占位符 Positional parameter

1
2
3
List<Student> studentList = session.createQuery("FROM Student s WHERE s.stuId = ?")
.setParameter(stuId)
.list()

这两种写法,和 PreparesSatement 的原理效果一样,都是以预编译的方式,通过参数绑定,将参数和 SQL 分离,保证 SQL 不被污染。

参考文章

文章目录
  1. 1. 前言
  2. 2. java.sql.Statement
  3. 3. java.sql.PrepareStatement
    1. 3.1. 特殊情况
      1. 3.1.1. ORDER BY
  4. 4. MyBatis
    1. 4.1. ${} (不安全的写法)
    2. 4.2. #{}
  5. 5. Hibernate
    1. 5.1. 原生SQL
    2. 5.2. HQL
  6. 6. 参考文章
|