PreparedStatement 是如何防止SQL注入的
PreparedStatement 是如何防止SQL注入的
PreparedStatement是Java中用于执行SQL语句的接口,它通过预编译SQL语句和参数绑定的方式,有效防止了SQL注入攻击。本文将通过具体示例和源码分析,深入探讨PreparedStatement是如何实现这一安全机制的。
数据准备
为了方便演示,我们创建一个user
表来模拟登录的情况:
USE learnjdbc;
CREATE TABLE user (
id BIGINT AUTO_INCREMENT NOT NULL,
name VARCHAR(50) NOT NULL,
password VARCHAR(50) NOT NULL,
PRIMARY KEY(id)
) Engine=INNODB DEFAULT CHARSET=UTF8;
INSERT INTO user (id, name, password) VALUES (1, 'peterjxl', '123456')
INSERT INTO user (id, name, password) VALUES (2, 'peter', '123456')
一个简单的登录逻辑
这里也说明下登录的逻辑:
- 用户输入用户名和密码
- Java程序收到,并执行SQL判断用户名密码是否存在
- 如果一致则认为通过
SQL逻辑如下:
select count(1) from user where name = '用户名' and password = '密码'
如果用户输入的是正确的用户名和密码(例如peterjxl和123456),则执行的SQL是:
select count(1) from user where name = 'peterjxl' and password = '123456'
执行结果是1,说明存在数据,并且用户名和密码都是正确的,认为通过了用户密码校验,登录成功。
相关代码如下:这里假设name和password是用户输入的变量。
String name = "peterjxl";
String password = "123456";
Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD);
Statement statement = conn.createStatement();
ResultSet rs = statement.executeQuery("select count(1) from user where name ='" + name + "' and password = '" + password + "'");
while (rs.next()){
if (0 != rs.getInt(1)){
System.out.println("登录成功");
}else{
System.out.println("登录失败");
}
}
使用Statement演示SQL注入
如果用户输入的是一个特殊的字符呢?
String name = "peterjxl";
String password = "1' or ''='";
Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD);
Statement statement = conn.createStatement();
ResultSet rs = statement.executeQuery("select count(1) from user where name ='" + name + "' and password = '" + password + "'");
while (rs.next()){
if (0 != rs.getInt(1)){
System.out.println("登录成功");
}else{
System.out.println("登录失败");
}
}
以上代码拼接SQL后,如下:
select count(1) from user where name = 'peterjxl' and PASSWORD = '1' or ''=''
也就是说,即使用户不知道密码,只要使用SQL注入,就可以登录成功。
更糟糕的情况是,如果用户直接输入了一些删除表和数据库的语句,一旦被执行,后果不堪设想!例如他们可以这样拼接一大串字符:
select count(1) from user where name = 'peterjxl' and PASSWORD = '1' or ''='';
delete from user;
select count(1) from user where ''=''
使用PreparedStatement演示SQL注入
如果我们使用的是PreparedStatement呢?能否SQL注入?我们来试试看:为了方便,第8行我们打印执行的SQL
String name = "peterjxl";
String password = "1' or ''='";
Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD);
PreparedStatement preparedStatement = conn.prepareStatement("select count(1) from user where name = ? and password = ?");
preparedStatement.setObject(1, name);
preparedStatement.setObject(2, password);
ResultSet rs = preparedStatement.executeQuery();
System.out.println("sql = " + preparedStatement.toString());
while (rs.next()){
if (0 != rs.getInt(1)){
System.out.println("登录成功");
}else{
System.out.println("登录失败");
}
}
运行结果如下:
sql = com.mysql.cj.jdbc.ClientPreparedStatement: select count(1) from user where name = 'peterjxl' and password = '1'' or ''''='''
登录失败
为什么PreparedStatement能防止SQL注入?因为它会把用户输入的单引号做转义,从而防止SQL注入。
PreparedStatement的源码
PreparedStatement只是一个接口,具体的实现类得看数据库厂商实现的源码是怎么样的,这里以MySQL驱动为例,观察其源码。
如果你使用IDE,可以直接在IDE里查看源码,这里以IntelliJ IDEA为例:
然后我们可以打断点调试,注意fillSendPacket
方法,将sql转换为字节流的过程中,给占位符中包含的单引号',额外加上一个单引号。
接下来我们继续debug,到底是哪个操作加上了单引号:
找到buildComQuery()
方法,有如下代码,其中:
staticSqlStrings[]
:存储的是以占位符?
划分开的sql语句转换的字节数组,不包含参数,即
staticSqlStrings[0]
=SELECT * FROM staff_info a WHERE a.staff_age =
staticSqlStrings[1]
=and a.staff_sex =
bindValues[]
:绑定的参数数组
从以上源码可以看出,sql语句和参数共同组装成sendPacket
发给远程数据库做执行,而防SQL注入的处理,肯定就在writeAsText()
流程中;
我们继续往下,来到com.mysql.cj.protocol.a.StringValueEncoder
类中,该类用于处理String参数,于是可以定位到在将参数转换为字节数组的getBytes()
方法中对输入的字符串进行了StringUtils.escapeString()
处理:
既然已经发现字符串的工具类调用,不出意外我们马上就要找到答案了
可以看到方法中对各类特殊字符都做了转义处理,常规的添加斜杠\如换行符\n;而我们这里涉及的单引号',转义时会再添加一个',这也解释了上述最终执行sql的生成。
注意,不同版本的数据库驱动,代码可能有点不同:
- MySQL 5的驱动是加上反斜杠\做转义;
- MySQL 8的驱动是给单引号额外加上一个单引号做转义
但无论如何实现,都能防止SQL注入。
不同版本的MySQL驱动,都是开源的,读者可以去官网下载:MySQL :: MySQL Connector/J 8.0 Developer Guide :: 4.3 Installing from Source
总结
生产环境一定要用PreparedStatement,而不是用Statement!