错误的预编译使用

有的开发人员可能以为只要 sql 语句经过了预编译就不会造成 sql 注入。其实预编译加参数占位才可以防御 sql 注入,只要用了字符串拼接 sql 语句,就都会有 sql 注入的风险。下面是一个错误的示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.just.demo;

import java.sql.*;

public class Demo2 {
public static void main(String[] args) throws Exception {
Class.forName("com.mysql.cj.jdbc.Driver");
String url = "jdbc:mysql://localhost:3306/db1";
String user = "root";
String password = "root";
Connection conn = DriverManager.getConnection(url, user, password);
String username = "anchor";
String sql = "select * from tb_user where username = '" + username + "'";
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
System.out.println("============================");
System.out.println(rs.getString(1));
System.out.println(rs.getString(2));
System.out.println(rs.getString(3));
}
}
}

参数位于like位置

如果我们没有试过把参数放到 sql 语句中 like 的位置,我们第一想法可能 sql 语句会写成下面的样子:

1
String sql = "select * from tb_user where username like '%?%'" ;

但是可以发现上面的写法会无法绑定参数到 ? 的位置。这是因为 ? 的位置在两个引号中,此时程序会把这个 ? 当作字符串处理。
所以这种情况下,开发可能会选择直接使用拼接语句,如下:

1
String sql = "select * from tb_user where username like '%" + var + "%'" ;

可能开发以为这里使用了预编译就不会存在 sql 注入,但是这里由于用的是字符串拼接的 sql 语句,因此还是可以注入。

这里如果既想使用预编译,又想使用 like 模糊查询,正确的写法应该如下:

1
String sql = "select * from tb_user where username like concat('%', ?, '%')" ;

参数位于order by位置

这种情况我们第一想法的写法可能如下:

但是会发现这里 sql 语句执行的结果和预计的结果不一致。

这里因为预编译在参数绑定的时候会把参数当作字符串来处理,而 order by 后面不应该接字符串,否则 order by 和没用一样。

所以实际情况如果真的要 order by 后面接动态参数,这里大概率也会被写成拼接,从而造成 sql 注入。解决方式只能是对这里对传入的动态参数加内容过滤。

其它类似order by等不可参数化的地方

由于参数化会把参数都当作字符串来处理,而 sql 语句中有些地方不能是字符串,例如这里提到的 order by ,还有 group by

参数位于in位置

Mysqljdbc 虽然对预编译提供了 setArray() 和 conn.createArrayOf() 方法,但是并没有实现 conn.createArrayOf() 方法,导致如果想要 in 后面传入动态参数,大概率也要手动拼接传入数组内的元素,这就也可能造成 sql 注入。

可以看到这个方法压根没实现。

宽字节注入+假预编译

php 中的 pdo 默认使用的是 假预编译 来防止的 sql 注入。 真预编译 是对 sql 执行的过程进行预编译来参数绑定,而 假预编译 只是对 sql 语句做了转义。一个是数据库来实现的预编译,一个是程序框架来实现的预编译。

那为什么开发者要做一个虚假的预编译呢,那是因为一个参数:PDO::ATTR_EMULATE_PREPARES ,这个选项用来配置 PDO 是否使用模拟预编译,默认是 true ,因此默认情况下 PDO 采用的是模拟预编译模式,设置成 false 以后,才会使用真正的预编译。开启这个选项主要是用来兼容部分不支持预编译的数据库(如 sqllite 与低版本 MySQL ),对于模拟预编译,会 由客户端程序内部参数绑定这一过程(而不是数据库),内部 prepare 之后再将拼接的 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
<?php
# 建立数据库连接
$dbs = "mysql:host=127.0.0.1;dbname=db1";
$dbname = "root";
$passwd = "root";

// 创建连接,选择数据库,检测连接
try {
$conn = new PDO($dbs, $dbname, $passwd);
echo "Successful\n";
} catch (PDOException $e) {
die ("Error!: " . $e->getMessage() . "\n");
}

# 预处理语句
$username = "xxx%df' union select 1,database(),3;#";
$username = urldecode($username);
$conn->query('SET NAMES GBK');
$stmt = $conn->prepare("select * from tb_user where username = ?");
$stmt->bindParam(1, $username);
$stmt->execute();
$fraction = $stmt->fetch();
var_dump($fraction);
$conn = null; # 关闭链接
?>

成功注入!

如果要使用真预编译,需要加上下面这一句代码。

1
$conn->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

判断真假预编译的方式

最直观的判断方式就是看数据库的 sql 语句执行日志中是否执行了 prepare 操作。下面是一个真预编译情况:

而假预编译这里只会执行 execute 操作,而避免 sql 注入用的是转义或者其它操作,这就存在绕过的机会。

通过 \ 来转义避免:

通过在 ' 后面自动再加上一个 ' 闭合参数中的引号来避免:

而实际测试发现,其实很多框架都默认使用的是假预编译,除非专门配置了预编译配置。

参考文章

https://forum.butian.net/share/1559
https://fushuling.com/index.php/2023/10/27/%E9%A2%84%E7%BC%96%E8%AF%91%E4%B8%8Esql%E6%B3%A8%E5%85%A5/
https://xz.aliyun.com/t/7132