概述
在网上,很多博客和面试题通常都会告诉你: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
替换逻辑
在
业务代码—>事务框架—>持久层框架—>数据源框架一通操作后,最终会抵达PreparedStatement#execute方法,查询并返回结果集。该方法有好几种重载,这并不重要。
现在我们写一段业务查询逻辑(随便什么查询语句都行,只要是使用#{}就行),并把断点打在public boolean execute(),就像下图这样:
注意到这个this里有个字段叫originalSql,你可以把值复制出来,也可以直接查看。你会发现这个sql中的动态参数都还是
?。那么替换的逻辑在哪里?
你可以一直单步前进,这方法里有很多逻辑与本期主题无关,直接跳过没有大碍。(其实我现在也还不了解里面的大部分逻辑),但这并不妨碍我们找到?替换成字符串的地方。
直到你快进到一行Buffer sendPacket = fillSendPacket();,当你执行过后,再查看sendPacket里的值你会发现,这里面的格式是16进制的。大概长下图这样:
这tm啥啊?别急,我们换个方式看看:
是不是一下就清晰了?而且在执行
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语句块,这里面就是处理转义的,大概长这样:
在里面,他会转义:【0,\n,\r,\,',"】等等。
最后,字符串以byte的形式存放在parameterValues中,等待请求报文的拼接。