Mybatis为什么#{}可以防注入(深入MySQL驱动)

3,982 阅读4分钟

概述

在网上,很多博客和面试题通常都会告诉你:Mybatis应当使用#{},而不是使用${}。因为#{}可以防SQL注入。

如果这些博客再深入一些,会顺带提到${}的原理是直接进行字符串拼接,而#{}的原理是将#{}替换成?并使用PreparedStatement提交查询语句,并告诉你PreparedStatement可以防sql注入。比如这篇博客:juejin.cn/post/719779…

但当你去查看PreparedStatement是什么的时候就懵了,因为他是一个接口,准确的说是JDK的接口java.sql.PreparedStatement。那么,为什么使用JDK定义的一个接口就能防SQL注入?为什么参数替换成?就能防SQL注入?很多博客都没讲到点上!

先说结论

java.sql.PreparedStatement确实是一个接口,其实实现类由各种数据库驱动库提供。比如MySQL驱动(mysql-connector-java-5.1.49)提供的类com.mysql.jdbc.PreparedStatement。该类实现了JDK PreparedStatement的各个方法,并在执行查询语句前将?替换为对应类型的参数。如果参数类型为字符串,则将?替换为转义过的字符串对应的字节(因为整个查询请求报文都是二进制,为了效率)。

同样是字符串替换,SQL拼接上转义过的字符串就不会发生SQL注入。举个例子:

-- 例子1 ${}
sql: select id, name from user where name = ${name}
parameters: '; drop table user; --'
拼接后: select id, name from user where name = ''; drop table user; --''-- 例子2 #{}
sql: select id, name from user where name = #{name}
PreparedStatement: select id, name from user where name = ?
parameters: '; drop table user; --'
拼接后: select id, name from user where name = '\'; drop table user; --\''

看懂了伐?本质上#{}${}都是字符串替换。 $是直接替换,#是转义后替换。

那么,转义后的字符串是在哪步进行拼接的?是客户端(业务方)拼接还是服务端(数据库)拼接?请往下看。

源码解析

关于#{}替换为?的原理在博客:juejin.cn/post/719779…中已经讲了,这里不再赘述。

本文主要讲?替换为转义过的字符串的过程。

这里以MySQL驱动为例(mysql-connector-java-5.1.49)

com.mysql.jdbc.PreparedStatement

替换逻辑

image-20230626150636334.png业务代码—>事务框架—>持久层框架—>数据源框架一通操作后,最终会抵达PreparedStatement#execute方法,查询并返回结果集。该方法有好几种重载,这并不重要。

现在我们写一段业务查询逻辑(随便什么查询语句都行,只要是使用#{}就行),并把断点打在public boolean execute(),就像下图这样:

image-20230626150917425.png注意到这个this里有个字段叫originalSql,你可以把值复制出来,也可以直接查看。你会发现这个sql中的动态参数都还是?。那么替换的逻辑在哪里?

你可以一直单步前进,这方法里有很多逻辑与本期主题无关,直接跳过没有大碍。(其实我现在也还不了解里面的大部分逻辑),但这并不妨碍我们找到?替换成字符串的地方。

直到你快进到一行Buffer sendPacket = fillSendPacket();,当你执行过后,再查看sendPacket里的值你会发现,这里面的格式是16进制的。大概长下图这样:

image-20230626151744758.png这tm啥啊?别急,我们换个方式看看:

image-20230626152004182.png是不是一下就清晰了?而且在执行fillSendPacket后,?也被替换成转义后的字符串了。如果你继续深入fillSendPacket方法,你会发现他只在干一件事:拼接16进制的请求报文。

注意我的入参,看到\'了吗,我实际上传入的是',但是被驱动转成了\'。这就是防SQL注入的原理。

现在我们石锤了#{}的底层就是替换为转义后的字符串。那么参数是在哪里进行转义的?

转义逻辑

参数的转义其实在set阶段就已经完成了。

这里我们回顾一下PreparedStatement的用法:

Connection con = DriverManager.getConnection(url, user, password); // 1
String sql = "select id, name from user where name = ?"; // 2
ps = con.prepareStatement(sql); // 3
ps.setString(1, "三明治"); // 4

没错,转义发生在第四步。

我们可以找到com.mysql.jdbc.PreparedStatement#setString方法,方法注释上并没有说内部逻辑会进行转义,但是你往下拉会发现一个很大的for+switch语句块,这里面就是处理转义的,大概长这样:

image-20230626154542901.png

在里面,他会转义:【0,\n,\r,\,',"】等等。

最后,字符串以byte的形式存放在parameterValues中,等待请求报文的拼接。