首页 > 编程笔记 > Java笔记 阅读:1

SQL注入是什么,常见的SQL注入漏洞(非常详细)

随着互联网的快速发展,Java 作为一种广泛应用的编程语言,正被越来越多的软件开发人员使用,这也引发了人们对 Java 代码安全性的高度关注。恶意代码攻击、SQL 注入、数据泄露等安全问题成为开发人员亟需应对的挑战。

SQL 注入是一种严重的安全威胁,它可能导致数据泄露、数据修改或系统命令的非法执行。本节将介绍 SQL 注入漏洞的产生缘由,分析在 JDBC 连接、MyBatis 框架中常见的 SQL 注入漏洞形式,总结 SQL 注入漏洞代码审计要点,以及 SQL 注入漏洞的防御方法。

SQL注入是什么

SQL 注入攻击是黑客对数据库进行攻击的常用手段之一。

随着 B/S 模式应用开发的发展,越来越多的程序员采用这种模式编写应用程序。但是由于程序员的水平和经验参差不齐,因此相当大一部分程序员在编写代码时没有对用户输入数据的合法性进行判断,导致应用程序存在安全隐患。

下图所示为 SQL 注入攻击示意图:


图 1 SQL 注入攻击示意图

当攻击者发送的恶意请求(1'or '1'='1)没有经过合法性检查就被应用服务器拼接成 SQL 语句并执行时,就构成了 SQL 注入攻击。这时,攻击者可以通过构造任意的 SQL 语句对数据库进行操作,具有极大的危害性。

常见的SQL注入漏洞

在 Java 中,连接并执行数据库操作主要有两种方式:

1) JDBC下的SQL注入

JDBC(Java DataBase Connectivity)即 Java 数据库连接,它是一种标准的 Java API,用于定义客户端程序与数据库交互的规范。

JDBC 提供了一套通过 Java 操作数据库的完整接口。这些接口的实现依赖于特定的 JDBC 驱动程序,不同的数据库对应着不同的驱动程序。当用户需要通过 Java 操作某个特定的数据库类型时,就需要使用该类型数据库对应的 JDBC 驱动。当用户调用 JDBC API 时,JDBC 将用户的请求交给 JDBC 驱动,最终由驱动负责与数据库进行实际的交互。

JDBC 执行 SQL 语句有 3 种方法,分别为 Statement、PreparedStatement 和 CallableStatement。其中:
在实际的业务环境中,以 Statement 和 PreparedStatement 两种方法为主。

下面是通过 JDBC 连接 MySQL 中的 java_sec_code 数据库,并查询 users 表的示例。该示例通过 Statement 方法执行 SQL 语句。
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
 
public class SqlInjection {
   public static void main(String[] args){
      String driver = "com.mysql.jdbc.Driver";
      String url = "jdbc:mysql://localhost:3306/java_sec_code";
      String user = "root";
      String password = "root";
      Connection con = null;
      Statement statement = null;
      ResultSet resultSet = null;
 
      try {
         Class.forName(driver);
         con = DriverManager.getConnection(url, user, password);
         if (!con.isClosed()){
            System.out.println("数据库连接成功");
         }
         statement = con.createStatement();
         String id = "1";
         //String id = "1 or 1=1";
         String sqlQuery = "select * from users where id = " + id;
         resultSet = statement.executeQuery(sqlQuery);
         while(resultSet.next()){
            System.out.println("id: " + resultSet.getInt("id") + "  username = " +
            resultSet.getString("username"));
         }
      } catch (ClassNotFoundException e){
         System.out.println("数据库驱动没有安装");
      } catch (SQLException sqlException){
         System.out.println("数据库连接失败");
      } finally {
         try{
            if(resultSet != null){
               resultSet.close();
            }
            if(statement != null){
               statement.close();
            }
            if(con != null){
               con.close();
            }
         } catch (SQLException e){
            System.out.println(e.getMessage());
         }
      }
   }
}
当需要执行的 SQL 语句的参数 id 为正常值 1(即“String id = "1";”)时,执行结果返回 id 为 1 的用户名:

数据库连接成功
id: 1  username = admin


当需要执行的 SQL 语句参数 id 为恶意值 1 or 1=1(即“String id="1or1=1";”)时,执行结果如下:

数据库连接成功
id: 1  username = admin
id: 2  username = joychou

可以看到,这里将 users 表中的所有用户名都显示出来了。

如果参数 id 是外部输入的值,那么直接将其拼接到 SQL 语句中执行,就可能会被恶意利用来拼接并执行任意的 SQL 语句。因此,从这里可以看到,在 JDBC 中,通过 Statement 方法执行 SQL 语句是一种不安全的方法。为了避免安全风险,推荐使用 PreparedStatement 方法来执行 SQL 语句。

下面是使用 PreparedStatement 方法执行 SQL 语句的代码示例:
import java.sql.*;
 
public class SqlInjectionPreparedStatement {
   public static void main(String[] args){
      String driver = "com.mysql.jdbc.Driver";
      String url = "jdbc:mysql://localhost:3306/java_sec_code";
      String user = "root";
      String password = "root";
      Connection con = null;
      PreparedStatement preparedstatement = null;
      ResultSet resultSet = null;
 
      try {
         Class.forName(driver);
         con = DriverManager.getConnection(url, user, password);
         if (!con.isClosed()){
            System.out.println("数据库连接成功");
         }
         String sqlQuery = "select * from users where id = ?";
         preparedstatement = con.preparedStatement(sqlQuery);
 
         String id = "1";
         //String id = "1 or 1=1";
 
         preparedstatement.setString(1,id);
 
         resultSet = preparedstatement.executeQuery();
         while(resultSet.next()){
            System.out.println("id: " + resultSet.getInt("id") + "  username = " +
            resultSet.getString("username"));
         }
      } catch (ClassNotFoundException e){
         System.out.println("数据库驱动没有安装");
      } catch (SQLException sqlException){
         System.out.println("数据库连接失败");
      } finally {
         try{
            if(resultSet != null){
               resultSet.close();
            }
            if(preparedstatement != null){
               preparedstatement.close();
            }
            if(con != null){
               con.close();
            }
         } catch (SQLException e){
            System.out.println(e.getMessage());
         }
      }
 
   }
}
在该示例中,以问号(?)作为占位符提前为 SQL 语句中的变量占据了位置,并编译了要执行的 SQL 语句。当后面有参数值需要添加到 SQL 语句中时,这些值只会作为该参数的值进行处理,而不会作为 SQL 语句本身的关键词进行拼接。执行结果如下,当输入的 id 为 1 时,正常输出对应 id 为 1 的用户名。

数据库连接成功
id: 1  username = admin


而当输入 id 为 1 or 1=1 时,输出的仍是 id 为 1 的用户名,说明此时我们构造的 SQL 注入语句“or 1=1”并没有拼接到将要执行的 SQL 语句中,而只是以参数值的形式参与查询。预编译恶意 SQL 语句的执行结果如下所示:

数据库连接成功
id: 1  username = admin


但是需要注意的是,使用 PreparedStatement 方法并不意味着绝对的安全。首先,在遇到输入值为字符串却不能添加引号的情况,就不能通过预编译进行参数化处理。例如,order by 后面的值往往是字段名,在 SQL 语句中不能添加引号,类似地还有 SQL 关键字、库名、表名、函数名等。其次,即使使用了 PreparedStatement 方法,如果用法错误,依然无法有效预防SQL注入攻击。

如下所示,在参数值拼接完之后再进行预编译,仍然存在SQL注入漏洞:

数据库连接成功
id: 1  username = admin
id: 2  username = joychou

2) MyBatis框架下的SQL注入

MyBatis 是一个支持定制化 SQL、存储过程及高级映射的优秀持久层框架。它极大地简化了数据库操作,免除了几乎所有的 JDBC 代码编写,包括手动设置参数和获取结果集的工作。

MyBatis 允许开发者通过简单的 XML 或注解来配置和映射原生 Map,以及将接口和 Java 的 POJO(PlainOld Java Object,普通的Java对象)映射成数据库中的记录。

MyBatis 负责处理网站与数据库之间的数据交互,它对 JDBC 操作数据库的过程进行了封装,使开发者只需要关注 SQL 本身,而不需要花费精力去关注 JDBC 底层细节,如注册驱动、创建 connection、创建 statement、手动设置参数和结果集检索等。MyBatis 底层基于 JDBC 实现,最终也是通过生成的 JDBC 代码访问数据库。

MyBatis 通常与其他框架组合使用,常见的有 SSM 和 SSH 等。

MyBatis 框架的配置文件为 resources\MybatisConfig.xml,其具体内容如下图所示:


图 2 MyBatis框架的配置文件resources\MybatisConfig.xml

可以看到,在该配置文件中存在数据库连接驱动、数据库连接 URL、数据库账号、数据库密码以及对应的 SQL 映射文件等。

在 MyBatis 框架中,SQL 注入点在 SQL 映射文件中,SQL 映射文件的具体内容如下图所示:


图 3 MyBatis框架中的SQL映射文件具体内容

在 MyBatis 框架的 SQL 映射文件中,SQL 语句与传入的参数值并不是直接通过字符串连接符“+”进行拼接的,而是使用占位符“#{}”与拼接符“${}”来连接的。

下面是 MyBatis 框架中执行 SQL 语句的代码示例:
package com.mbtest;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
 
import com.mbtest.entity.User;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.junit.Test;
import com.mbtest.dao.UserDao;
 
public class MyTest {
   @Test
   public  void testfindByid() throws IOException{
      String config = "MybatisConfig.xml";
      InputStream inputStream = Resources.getResourceAsStream(config);
      SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(inputStream);
      SqlSession session = factory.openSession();
      UserDao userDao = session.getMapper(UserDao.class);
      List<User> results = userDao.findById("1");
      for(User user:results){
         System.out.println(user.getresult());
      }
      session.close();
      inputStream.close();
   }
}
该代码调用的 SQL 映射文件内容如下图所示:


图 4 示例代码调用的SQL映射文件内容

在该 SQL 映射文件中,通过拼接符“${}”直接将 SQL 语句与传入的参数值拼接在一起,所以,如果传入的参数值可以被用户控制,这就可能会造成 SQL 注入漏洞被利用。

在上面的代码示例中,函数 userDao.findById 传入的参数值代表 SQL 映射文件中 SQL 语句传入的 id 值,函数 userDao.findById 传入的参数值为"1 and 1=1"时,运行该代码的结果如下图所示。


图 5 参数值为"1 and 1=1"时,示例代码的运行结果

可以看到,最终执行的 SQL 语句为“select * from ap where id=1 and 1=1”,查询结果为“username:admin, password:123456”。

当传入的 id 值为"1 or 1=1"时,运行该代码的结果如下图所示,表 ap 中的所有数据均被查询出来了。


图 6 参数值为"1 or 1=1"时,示例代码的运行结果

当 SQL 映射文件中通过占位符“#{}”连接 SQL 语句与传入的参数值(即 SQL 映射文件为下图所示的内容)时,传入的参数值将会执行预编译操作。


图 7 通过占位符“#{}”连接SQL语句与传入的参数值的SQL映射文件内容

将 SQL 映射文件中的拼接符“${}”修改为占位符“#{}”后,再次运行代码,运行结果如下图所示。


图 8 将拼接符“${}”修改为占位符“#{}”后,代码的运行结果

可以看到,运行出现了异常,最终执行的SQL语句为“select * from ap where id=?;”,这里出现异常的原因是传入 SQL映射文件的参数值会执行一次预编译操作。在图 7 所示的 SQL 映射文件中定义传入的参数类型为 int,而在代码中,实际传入的参数类型为 string,所以此处会出现异常。

在 MyBatis 框架的 SQL 映射文件中,占位符“#{}”只能在 SQL 语句的约束条件中使用,在非约束条件如表名、order by 值中无法使用占位符。若在非约束条件中使用占位符,则会出现如下图所示的异常。


图 9 在非约束条件中使用占位符时,示例代码的运行结果

由于在 SQL 的非约束条件中无法使用占位符“#{}”,当 SQL 映射文件中的 SQL 约束条件如表名、order by 值可由用户控制时,应用程序可能会受到SQL注入攻击。

如下图所示,构造一个 SQL 映射文件,其中表名是通过拼接符“${}”直接与 SQL 语句拼接起来的。


图 10 通过拼接符“${}”连接表名与SQL语句的SQL映射文件

将上文中执行 SQL 语句的代码示例中的函数 userDao.findById 参数值修改为"ap where id=1 or 1=1#",并运行该代码,运行结果如下图所示:


图 11 参数值为"ap where id=1 or 1=1#"时,示例代码的运行结果

可以看到最终执行的 SQL 语句为“select * from ap where id=1 or 1=1# where id=1;”,表 ap 中所有数据均被查询出来了。

SQL注入漏洞代码审计要点与防御方法

根据前面的内容可知,Java 应用程序操作数据库的方式不同,其 SQL 注入漏洞的表现形式也会有所差异。

当使用 JDBC 驱动操作数据库时,存在以下关键函数:
当通过 MyBatis 操作数据库时,存在以下特征符号:
在对 Java 代码进行审计以挖掘 SQL 注入漏洞时,可通过上述关键函数或特征符号快速定位相关 SQL 语句,并排查是否存在 SQL 注入漏洞。

根据上述在 JDBC 连接、MyBatis 框架环境下常见的 SQL 注入漏洞形式可以知道,在 Java 环境下,可以通过预编译的方式来防御 SQL 注入漏洞。其中 JDBC 通过 PreparedStatement 方法预编译 SQL 语句。MyBatis 框架通过 #{} 符号进行预编译。

除预编译 SQL 语句进行防御外,还可通过对用户输入的参数值进行过滤来防御数字型 SQL 注入漏洞。这包括检测是否存在 SQL 类关键词,以及对参数值进行数据类型判断等方法。

相关文章