理解 SQL 注入及使用 JDBC 防止 SQL 注入

HYF Lv3

当开发人员构建与数据库交互的应用程序时,安全性是至关重要的。其中一个常见的安全威胁是SQL注入攻击。在本文中,我们将深入探讨什么是 SQL 注入,以及如何使用 Java JDBC (Java 数据库连接)技术来有效地防止 SQL 注入攻击。

1 什么是 SQL 注入

SQL 注入(SQL Injection)是一种常见的网络安全漏洞,指的是攻击者通过在应用程序中插入恶意的 SQL 代码,从而绕过正常的数据验证和过滤机制,成功地执行未经授权的数据库操作。SQL 注入攻击的目标是通过构造精心设计的恶意输入,欺骗数据库服务器,使其执行攻击者所期望的操作,可能包括获取敏感数据、篡改数据、删除数据,甚至控制数据库服务器等。
SQL 注入的原理是利用应用程序在构建 SQL 查询或命令时未正确对用户输入数据进行处理。如果应用程序不正确地将用户提供的输入数据(如表单字段、URL 参数等)进行转义、验证或参数绑定,攻击者可以利用这个漏洞在应用程序的 SQL 查询中插入恶意的 SQL 代码。

SQL 注入本质上是由于字符串拼接出现问题导致的安全漏洞。攻击者通过在输入中插入特殊的字符、关键字或恶意代码,可以改变应用程序预期的查询语义,甚至执行未经授权的数据库操作。通常发生在使用动态 SQL 查询的场景,例如将用户输入直接拼接到查询语句中,或者使用字符串拼接来构建查询条件。

2 模拟 SQL 注入问题

接下来将使用一个简单的 Java 程序来模拟登录情况
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
/**
* @author -侑枫
* @date 2023/8/9 20:19:36
*/
public class JdbcSimulationLoginUnsafe {
public static void main(String[] args) throws Exception {
// 建立数据库连接
Connection conn = DriverManager.getConnection(
"jdbc:mysql:///testdb?characterEncoding=utf8&useSSL=false",
"root", "000000");

Scanner scan = new Scanner(System.in);
// 接收输入的用户名密码
String name = scan.nextLine();
String pw = scan.nextLine();

String sql = "select * from userpw where username = '" + name + "'" +
"and password ='" + pw + "' ";

// 创建数据库查询的Statement对象
Statement stmt = conn.createStatement();

// 执行SQL查询
ResultSet rs = stmt.executeQuery(sql);

System.out.println(rs.next() ? "登陆成功" : "登陆失败");

// 释放资源:关闭数据库结果集、Statement和连接
rs.close();
stmt.close();
conn.close();
}
}
可以看到该程序通过 JDBC 连接到 MySQL 数据库,然后从用户输入中获取用户名和密码,构建一个 SQL 查询语句,最后执行查询,验证用户输入的用户名和密码是否匹配数据库中的用户凭据,并输出登陆是否成功的消息。具体执行流程如下:

1.建立数据库连接:通过JDBC连接到MySQL数据库。连接字符串中指定了数据库名称、字符编码等参数。

2.获取用户输入:通过Scanner读取用户输入的用户名和密码。

3.构建SQL查询:使用输入的用户名和密码构建一个SQL查询语句,以检查数据库中是否存在匹配的用户名和密码。

4.创建Statement对象:使用数据库连接创建一个Statement对象,用于执行SQL查询。

5.执行查询:执行构建好的SQL查询,从数据库中检索匹配的记录。

6.判断登陆结果:根据查询结果判断是否登录成功,如果查询结果包含记录(即rs.next()返回true),则输出 “登陆成功”,否则输出 “登陆失败”。

7.释放资源:关闭数据库结果集、Statement对象和数据库连接,释放资源。

看起来很完美,但该代码存在 SQl 注入的风险问题,因为它直接将用户输入的数据插入到 SQL 查询中,没有进行参数绑定和预编译处理,这使得该程序容易受到 SQL 注入攻击。

3 详细解释 SQL 注入

从代码我们可以得知最后执行 SQL 查询的 SQL 语句为:

select * from userpw where username = '[name]' and password ='[pw]';

假设我们不知道用户名也不知道密码,但我们输入的用户名为:”login”,密码为 “123’ or ‘1’ = ‘1”
那么将会出现什么情况呢?我们将拆解 SQL 语句来解释:

select * from userpw where username = 'login' and password ='123' or '1' = '1';

发现问题了吗?

根据逻辑运算优先级,and 优先级比 or 优先级来得更高,所以 and 将比 or 优先执行。而代码能否执行,将从 [username = ‘login’ and password =’123’] 和 [‘1’ = ‘1’] 得到结果。
很显然,用户名和密码都是我们随意输入的,前者为 true 的可能性也许接近作者去买彩票的概率。而后者 [‘1’ = ‘1’] 恒为 true,所以最终的结果接近 false or true = true,即代码会执行成功。这种恶意构造的密码会让 SQL 查询条件总是成立,从而绕过了正常的身份验证。

现在我们尝试去输入一下用户名和密码来验证结果是否如我们所想:

1
2
3
4
5
6
7
8
9
10
11
12
# 查看数据库账号密码

MariaDB [testdb]> select * from userpw;
+----+----------+----------+
| id | username | password |
+----+----------+----------+
| 1 | zhangsan | 123456 |
| 2 | lisi | 123456 |
+----+----------+----------+
2 rows in set (0.001 sec)

MariaDB [testdb]>

使用用户名 ‘zhangsan’ 密码为’123456’,以及错误的用户名和密码来测试登录:

1
2
3
4
5
zhangsan
123456
登陆成功

Process finished with exit code 0
1
2
3
4
5
wangwu
12345
登陆失败

Process finished with exit code 0

可以看到在使用正确的用户名和密码成功登录了,使用错误的用户名和密码登录失败。证明了该代码的可用性,接下来使用恶意构造的代码来进行模拟 SQL 注入:

1
2
3
4
5
zhaoliu
123' or '1' = '1
登陆成功

Process finished with exit code 0

可以看到结果如我们所预料,SQl 注入成功,恶意构造的代码绕过了正常的身份验证。

4 JDBC PreparedStatement 介绍

PreparedStatement 是 Java JDBC API 中的一个重要接口,用于预编译 SQL 语句,以提高数据库查询的性能和安全性。PreparedStatement 在防止 SQL 注入攻击方面的主要机制是通过参数绑定(Parameter Binding)。大概有以下五点:

1.预编译查询:PreparedStatement 首先将 SQL 查询进行预编译,这意味着查询字符串被解析、优化和编译一次,然后在后续的查询中重复使用,避免了每次查询都进行解析和优化,提高了数据库性能。

2.参数占位符:在 SQL 查询中,我们可以使用参数占位符(?)来表示需要填充的值,而不是将实际的值嵌入到 SQL 查询字符串中。以此避开字符串拼接导致的 SQL 注入问题。

3.参数绑定:使用 PreparedStatement,我们可以通过 setXXX 方法(例如 setString、setInt 等)将值绑定到参数占位符上,XXX 表示值的数据类型。这样,我们可以将实际的用户输入传递给 PreparedStatement,而不是将用户输入直接插入到 SQL 查询字符串中。

4.自动转义:在参数绑定过程中,PreparedStatement 会自动对输入的值进行必要的转义和处理,确保输入的内容不会被错误地解释为 SQL 代码。这包括转义特殊字符和处理字符串中的引号等。

5 使用 JDBC PreparedStatement 防止 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
30
31
32
33
34
35
36
37
/**
* @author -侑枫
* @date 2023/8/9 20:33:29
*/
public class JdbcSimulationLoginSafe {
public static void main(String[] args) throws Exception {
// 建立数据库连接
Connection conn = DriverManager.getConnection(
"jdbc:mysql:///testdb?characterEncoding=utf8&useSSL=false&useServerPrepStmts=true",
"root", "000000");

Scanner scan = new Scanner(System.in);
// 接收输入的用户名密码
String name = scan.nextLine();
String pw = scan.nextLine();

// 定义SQL查询语句,使用参数占位符 '?' 来保护查询
String sql = "select * from userpw where username = ? and password = ?";

// 创建预编译的PreparedStatement对象,该对象可以防止SQL注入攻击
PreparedStatement pstmt = conn.prepareStatement(sql);

//设置'?'的值(即设置第几个'?'为何值)
pstmt.setString(1, name);
pstmt.setString(2, pw);

// 执行预编译的查询
ResultSet rs = pstmt.executeQuery();

System.out.println(rs.next() ? "登陆成功" : "登陆失败");

//释放资源
rs.close();
pstmt.close();
conn.close();
}
}
这段代码展示了使用 PreparedStatement 来防止 SQL 注入的示例。让我们一步一步解释代码的流程和原理:

1.建立数据库连接:首先,代码通过 DriverManager.getConnection 方法建立到数据库的连接。连接字符串中包含了数据库的 URL,字符编码、禁用SSL等参数。

2.定义 SQL 查询:创建了一个预先定义的 SQL 查询语句,使用 ? 占位符来表示参数。

3.创建 PreparedStatement 对象:使用连接对象的 prepareStatement 方法创建了一个 PreparedStatement 对象,该对象用于执行预编译的 SQL 查询。

4.参数绑定:使用 setXXX 方法将用户输入的用户名和密码绑定到 PreparedStatement 对象中的占位符上。在这里,setString(1, name) 表示将第一个 ? 设置为输入的用户名 name,setString(2, pw) 表示将第二个 ? 设置为输入的密码 pw。

5.执行查询:通过 executeQuery 方法执行预编译的查询。数据库会将绑定的参数值插入到查询中,然后执行查询操作。

6.处理查询结果:通过 ResultSet 对象处理查询的结果。在这个示例中,通过 rs.next() 来判断是否有匹配的记录。如果有匹配的记录,输出 “登陆成功”,否则输出 “登陆失败”。

7.释放资源:最后,关闭 ResultSet、PreparedStatement 和数据库连接,释放资源。

这段代码利用了参数绑定和预编译的机制,从而有效地防止了 SQL 注入攻击。用户输入的数据不会被直接插入到 SQL 查询中,而是经过合适的处理和转义。即通过占位符 ? 和参数绑定的方式,以及预编译的查询,有效地提高了安全性,防止了恶意用户通过输入特殊字符来绕过身份验证,确保了查询的安全性,以防范 SQL 注入漏洞。

接下来我们运行一下这段代码,与模拟 SQl 注入的代码使用相同的测试条件测试一下:
使用用户名 ‘lisi’ 密码为’123456’,以及错误的用户名和密码来测试登录:

1
2
3
4
5
lisi
123456
登陆成功

Process finished with exit code 0
1
2
3
4
5
wangwu
54321
登陆失败

Process finished with exit code 0

可以看到在使用正确的用户名和密码成功登录了,使用错误的用户名和密码登录失败。证明了该代码的可用性,接下来使用恶意构造的代码来进行模拟 SQL 注入:

1
2
3
4
5
zhaoliu
123' or '1' = '1
登陆失败

Process finished with exit code 0

可以看到 PreparedStatement 接口成功防止了 SQL 注入问题。

尽管有许多现代的持久层框架,但 PreparedStatement 接口仍然是许多框架和项目中常用的数据库访问技术之一。以下是一些现代的 Java 框架或技术,它们仍然使用 PreparedStatement 接口或类似的机制:

1.Spring JDBC:Spring 框架中的 JDBC 模块支持使用 JdbcTemplate,它在底层使用了 PreparedStatement 和 ResultSet 来执行 SQL 查询和更新。

2.Spring Data JDBC:虽然 Spring Data JPA 是更为流行的,但 Spring Data 还有一个轻量级的 JDBC 模块,它在某些情况下可以使用 PreparedStatement 来执行基于 JDBC 的持久化操作。

3.Apache Commons DbUtils:这是一个 Apache Commons 项目中的小型 JDBC 辅助库,它在内部使用了 PreparedStatement 来简化数据库访问操作。

6 题外话:mybatis 的防 SQl 注入及 #{} 和 ${}

MyBatis 采用了类似于 PreparedStatement 的机制来防止 SQL 注入。MyBatis 使用参数绑定的方式#{}来处理传递给 SQL 查询和更新操作的参数,这就类似于 PreparedStatement 中的参数绑定。这种方式有助于防止 SQL 注入攻击,因为参数值会被预编译,并根据数据库的规则进行处理。

具体来说,MyBatis 的 #{} 语法会将参数值绑定到预编译的 SQL 查询中,这意味着参数值不会被直接拼接到 SQL 查询字符串中,而是以参数的方式传递给数据库。在处理参数时,MyBatis 会自动根据参数的类型进行类型转换,并自动转义特殊字符,以确保输入的数据不会被误解为 SQL 代码。这是 MyBatis 设计的一个重要安全特性,也是开发者在使用 MyBatis 时,推荐使用 #{} 语法的一个原因。

在 MyBatis 中,${}#{}是用于构建 SQL 查询或更新语句。它们的使用方式和行为有一些不同:

一.${} 语法:
1.${} 语法是简单的字符串替换,它会将参数的值直接插入到 SQL 语句中,类似于字符串拼接。
2.使用 ${} 语法时,参数值不会被自动进行任何转义或处理,它会直接替换到 SQL 查询中。这意味着如果不注意,可能会引发 SQL 注入风险。
3.${} 语法适用于需要动态生成表名、列名等情况,但应注意防止 SQL 注入攻击。

例如:

1
2
3
<select id="getUser" resultType="User">
SELECT * FROM userpw WHERE username = '${username}'
</select>
二.#{} 语法:
1.#{} 语法是参数绑定的方式,会使用预编译查询,以及自动处理参数类型和转义特殊字符。
2.使用 #{} 语法时,MyBatis 会将参数值绑定到预编译的查询中,并自动转义特殊字符,从而防止了 SQL 注入攻击。
#{} 语法是更安全和推荐的方式,特别是用于动态构建查询条件。

例如:

1
2
3
<select id="getUser" resultType="User">
SELECT * FROM userpw WHERE username = #{username} AND password = #{password}
</select>
总之, #{} 是 MyBatis 中更为安全和推荐的参数插入方式,它通过预编译查询和自动转义特殊字符来防止 SQL 注入。在需要动态生成表名、列名等情况下,可以使用 ${},但应谨慎处理以防止安全风险。

最后附上本文所写源代码:SQl 注入问题及 PreparedStatement 接口的使用

  • 标题: 理解 SQL 注入及使用 JDBC 防止 SQL 注入
  • 作者: HYF
  • 创建于 : 2023-08-09 21:26:32
  • 更新于 : 2024-07-27 21:21:51
  • 链接: https://youfeng.ink/PreparedStatement-c56df1a8385d/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。