Mybatis下的SQL注入漏洞原理及防护方法
阅读原文时间:2023年07月08日阅读:1

目录

一、前言

之前我一直认为 Mybatis 框架下已经实现预编译机制,很多东西都封装好了,应该基本上不会再有 SQL 注入问题了。近期在渗透中发现,在实际项目中,即使使用了 Mybatis 框架,但仍然有可能因为编码人员安全意识不足而导致 SQL 注入问题。出现情况还真不少,因此有了这篇文章。

二、SQL 注入漏洞原理

SQL 注入(SQL Injection)是发生在 Web 程序中数据库层的安全漏洞,是网站存在最多也是最简单的漏洞。主要原因是程序对用户输入数据的合法性没有判断和处理,导致攻击者可以在 Web 应用程序中事先定义好的 SQL 语句中添加额外的 SQL 语句,在管理员不知情的情况下实现非法操作,以此来实现欺骗数据库服务器执行非授权的任意查询,从而进一步获取到数据信息。

简单地说,就是通过在用户可控参数中注入 SQL 语法,破坏原有 SQL 结构,达到编写程序时意料之外结果的攻击行为。其成因可以归结为如下原因造成的:

  1. 程序编写者在处理应用程序和数据库交互时,使用字符串拼接的方式构造 SQL 语句。
  2. 且未对用户可控参数进行足够的过滤。

下面使用DVWA靶场来进行演示,网站架构为PHP,我们重点关注漏洞原理即可。

该页面提供了一个简单的查询功能,可以根据前端输入的用户ID来查询对应的用户信息。如图,输入 1,返回了对应 admin 用户的信息。

查看该页面的源代码:

<?php

if( isset( $_REQUEST[ 'Submit' ] ) ) {
    // Get input
    $id = $_REQUEST[ 'id' ];

    // Check database
    $query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
    $result = mysqli_query($GLOBALS["___mysqli_ston"],  $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

    // Get results
    while( $row = mysqli_fetch_assoc( $result ) ) {
        // Get values
        $first = $row["first_name"];
        $last  = $row["last_name"];

        // Feedback for end user
        echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
    }

    mysqli_close($GLOBALS["___mysqli_ston"]);
}

?>

进行代码审计可以发现,程序将前端输入的 id 参数未加任何处理,直接拼接在了 SQL 语句中,那么此时就导致了SQL注入漏洞。

若此时攻击者输入的用户ID为 1' or 1='1,则程序拼接后执行的 SQL 语句变成了:

SELECT first_name, last_name FROM users WHERE user_id = '1' or 1='1';

可见,攻击者通过单引号 ' 闭合了数据库查询语句,并且在查询条件之后构造了“或 1=1”,即“或真”的逻辑,导致查询出了全部用户的数据。

如果攻击者可以任意替代提交的字符串,就可以利用 SQL 注入漏洞改变原有 SQL 语句的含义,进而执行任意 SQL 命令,入侵数据库进行脱库、删库,甚至通过数据库提权获取系统权限,造成不可估量的损失。(SQL注入的场景类型非常之多,攻击手法、绕过姿势也非常多,本文不作重点讨论)

一般来说,防御 SQL 注入的最佳方式就是使用预编译语句(其他防御方法还有很多,本文不作重点讨论),绑定变量。例如:

String sql = "SELECT * FROM user_table WHERE username = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, "zxd");
ResultSet results = pstmt.executeQuery();

使用预编译的 SQL 语句,SQL 语句的语义不会发生改变。在 SQL 语句中,变量用占位符 ? 表示,攻击者无法改变 SQL 的结构。

三、Mybatis 框架简介

Mybatis 的 SQL 语句可以基于注解的方式写在类方法上面,更多的是以 xml 的方式写到 xml 文件。Mybatis 中 SQL 语句需要我们自己手动编写或者用 generator 自动生成。编写 xml 文件时,MyBatis 支持两种参数符号,#{}${}

  • #{} 使用预编译,通过 PreparedStatement 和占位符来实现,会把参数部分用一个占位符 ? 替代,而后注入的参数将不会再进行 SQL 编译,而是当作字符串处理。可以有效避免 SQL 注入漏洞
  • ${} 表示使用拼接字符串,将接受到参数的内容不加任何修饰符拼接在 SQL 中。易导致 SQL 注入漏洞。

两者的区别如下:

  1. #{} 为参数占位符 ?,即 SQL 预编译。${} 为字符串替换,即 SQL 拼接。
  2. #{} 是“动态解析->预编译->执行”的过程。${} 是“动态解析->编译->执行”的过程。
  3. #{} 的变量替换是在 DBMS 中。${} 的变量替换是在 DBMS 外。
  4. 变量替换后,#{} 对应的变量自动加上引号。变量替换后,${} 对应的变量不会加上引号。

下面以一个查询场景进行简单演示,数据库表 user_table 的表数据如下:

若没有采用 JDBC 的预编译模式,查询 SQL 写为:

<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
    select * from user_table where username = '${username}'
</select>

这种写法就产生了 SQL 语句的动态拼接,这样格式的参数会直接参与 SQL 语句的编译,从而不能避免SQL注入攻击。

若此时攻击者提交的参数值为 zxd' or 1='1,如下图,利用 SQL 注入漏洞,成功查询了所有用户数据。

因此,应用 Mybatis 框架 SQL语句的安全写法(即 JDBC 预编译模式):

<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
    select * from user_table where username = #{username}
</select>

可见,此时采用 JDBC 预编译模式,即使攻击者尝试 SQL 注入攻击,也只会将参数整体作为字符串处理,有效避免了 SQL 注入问题。

四、Mybatis 框架下的 SQL 注入问题及防护方法

还是以上节的查询场景举例,Mybatis 框架下易产生 SQL 注入漏洞的情况主要有以下三种:

在模糊查询场景下,考虑安全编码规范,使用 #{} 传入参数:

<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
    select * from user_table where username like '%#{username}%'
</select>

在这种情况下使用 #{} 程序会报错:

于是很多安全经验不足的程序员就把 #{} 号改成了 ${},如果应用层代码没有对用户输入的内容做处理势必会产生SQL注入漏洞。

<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
    select * from user_table where username like '%${username}%'
</select>

若此时攻击者提交的参数值为 zxd' or 1=1#,如下图,利用 SQL 注入漏洞,成功查询了所有用户数据。

因此,安全的写法应当使用 CONCAT 函数连接通配符

<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
    select * from user_table where username like concat('%',#{username},'%')
</select>

在 IN 关键字之后使用 #{} 查询多个参数:

<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
    select * from user_table where username in (#{usernames})
</select>

正常提交查询参数 'zxd','hhh',因为预编译机制,系统将我们输入的字符当作了一个字符串,因此查询结果为空,不能满足业务功能需求。

于是很多安全经验不足的程序员就把 #{} 号改成了 ${}

<select id="getUser" parameterType="java.lang.String" resultType="user.NewUserDO">
    select * from user_table where username in (${usernames})
</select>

攻击者提交参数值 'hhh') or 1=1#,利用 SQL 注入漏洞,成功查询了所有用户数据。

因此,此种情况下,安全的做法应当使用 foreach 标签

<select id="getUserFromList" resultType="user.NewUserDO">
    select * from user_table where username in
        <foreach collection="list" item="username" open="(" separator="," close=")">
            #{username}
        </foreach>
</select>

动态排序功能,需要在 ORDER BY 之后传入参数,考虑安全编码规范,使用 #{} 传入参数:

<select id="getUserOrder" parameterType="java.lang.String" resultType="user.NewUserDO">
    select * from user_table order by #{column} limit 0,1
</select>

提交参数 username 根据用户名字段排序。但因为预编译机制,系统将我们输入的字符当作了一个字符串,根据字符串排序是不生效的,不能满足业务功能需求。(根据用户名字段排序,此时正常应返回 root 用户)

于是很多安全经验不足的程序员就把 #{} 号改成了 ${}

<select id="getUserOrder" parameterType="java.lang.String" resultType="user.NewUserDO">
    select * from user_table order by ${column} limit 0,1
</select>

攻击者提交参数值 username#,利用 SQL 注入漏洞,成功查询了所有用户数据。

因此,此种情况下,安全的做法应当在 Java 代码层面来进行解决。可以设置一个字段值的白名单,仅允许用户传入白名单内的字段。

String sort = request.getParameter("sort");
String[] sortWhiteList = {"id", "username", "password"};
if(!Arrays.asList(sortWhiteList).contains(sort)){
    sort = "id";
}

或者仅允许用户传入索引值,代码再将索引值映射成对应字段。

String sort = request.getParameter("sort");
switch(sort){
    case "1":
        sort = "id";
        break;
    case "2":
        sort = "username";
        break;
    case "3":
        sort = "password";
        break;
    default:
        sort = "id";
        break;
}

需要注意的是在 mybatis-generator 自动生成的 SQL 语句中,ORDER BY 使用的也是 ${},而 LIKE 和 IN 没有问题。