大家好,我是风筝。
事情是这样子的,在10年以前,某个月黑风高夜的夜里,虽然这么说有点暴露年龄了,但无所谓,毕竟我也才18而已。我打开电脑,在浏览器中输入我们高中学校的网址,页面很熟悉,很简陋,也没什么设计感,不过学校的网站从来都是这种风格,直到今天依然是这样。
这次访问与以往有些不同,因为我的目的很明确。作为学校的一员,理应为学校做些贡献的,为学校官网找些安全漏洞也算贡献的一种吧(强烈的求生欲)。
之所以选择学校的官网,一来是因为熟悉,先从熟悉的东西下手,一定错不了。二来是因为之前使用网站的时候碰过到一些异常的页面,直接就是异常堆栈直接抛出来。正好那段时间对网络安全比较有兴趣,在研究SQL 注入的时候正好想到在学校官网上碰到的问题好像就存在 SQL 注入的风险。于是,顺理成章,我的第一个目标就锁定了学校官网。
问题就出在一个大概这样的搜索页面,真正的网站已经改版过好多次了,以前的页面找不到了。
当时我在上面随便输入了一些内容,里面含有单引号,然后一点击搜索,页面就直接出现了异常提示,类似于下面这样子的。别惊讶,当时很多网站都是这样的,异常是直接抛出来的,别说以前了,现在也不少。
于是我用那时候刚学会的皮毛知识,加上两个好用的工具,轻松拿到了数据库的数据,其中就包括了管理员的账号、密码,密码还是明文的,你说气人不。
然后通过后台管理员登录页面进入了管理员后台,当然了,只是进去看了看,什么都没碰,而且后台也没什么重要数据,顶多就是一些通知、新闻等数据。
我是怎么做到的呢,不说具体细节了,也确实没什么技术含量,而且时间太长也记不清了,后面就说一下 SQL 注入的原理和具体操作。
什么是 SQL 注入
SQL注入,是发生于应用程序与数据库层的安全漏洞。简而言之,是在输入的字符串之中注入SQL指令,在设计不良的程序当中忽略了字符检查,那么这些注入进去的恶意指令就会被数据库服务器误认为是正常的SQL指令而运行,因此遭到破坏或是入侵。
SQL 注入一般发生在用户交互场景中,比如需要用户自已输入信息的输入框,或者下拉选择选项的这种,如果不做好输入内容的过滤,就很可能发生 SQL 注入。
就拿这个登录界面来说,用户名和密码都是你要输入的内容,点击登录按钮之后,会把你输入的值传递到服务端,服务端再到数据库进行查询。
假设后端的查询语句是这样的,不要在乎这是什么语法,只是举个例子。
String sql = "select * from `user` where account={account} and password={password}";
正常的情况,比如 account
输入的是一个电话号码 13001980988
,密码是 123456
,那拼接出的 SQL 语句就是
String sql = "select * from `user` where account='13001980988' and password='123456'";
然后,服务端通过数据库连接执行这条语句:
select * from `user` where account='13001980988' and password='123456';
最后,数据库正常返回符合条件的记录,代码中再根据结果进行判断,执行后面的逻辑。
不正常的输入
SQL 注入就是通过不正常的输入来获取程序开发者意料之外的结果。
什么是不正常的输入呢?
比如我在用户名输入框中输入的内容是这样子的 :13001980988' or 1=1 --
,密码输入框随便输入什么都无所谓,然后点击登录,传输给后端,后端拼接出来的结果就是这样的:
String sql = "select * from `user` where account='13001980988' or 1=1 --' and password='123456'";
然后,服务端通过数据库连接会执行下面这条语句:
select * from `user` where account='13001980988' or 1=1 --' and password='123456'
以 MySQL 为例, --
是 MySQL 中的注释符号,上面的语句中 --
后面的相当于是注释内容了,所以最后实际执行的 SQL 语句是这样的:
select * from `user` where account='13001980988' or 1=1
于是,SQL 注入就这么发生了,显然有了 or 1=1
这个条件,表中所有的记录都符合条件。如果在用户名输入框中输入的是 admin
、administrator
等已知的后台管理员账号,那就可以用管理员账号直接登录系统了。
上面就是 SQL 注入的基本原理。
SQL 注入遍地都是的年代
在9、10年前,也就是在我小时候(对,这个词好,小时候)。那时候智能手机才刚刚出来,塞班系统还很贵,根本就买不起。用着功能机,30M的流量能用坚持一个月,聊天只靠 QQ 和 短信,微信才刚要问世,更别提什么 APP 了,根本就没有。那时候,PC Web 才是根正苗红的网络主宰,如果说要在网上干点儿什么的话,那必须要有一个配套的网站才可以。
互联网还没有发展的这么成熟,用的技术也比较原始,绝大多数的网站是用 PHP 写的,还有很多用 ASP 。可能有些同学都不知道 ASP 是什么,它虽然也是微软的,但是却不是 ASP.NET。数据库很多用的是 MySQL ,还有一部分用的是更原始的 Access,可能又触到某些同学的盲区了,这不怪你没见识,只怪你太年轻。
一些小公司啊、学校啊、政府部门网站啊、各种论坛啊等等,各种五花八门的网站。不像现在这样,无论你用 PHP、Java 还是 Python,都有很多成熟的开发框架供你选择,成熟的框架必然会减少漏洞和降低被攻击的风险。但那时候没有这么多框架供选择,就比如很多学校会选择用 ASP + Access 组合的架构来开发自己的学校官网、教务管理系统等,功能上比较简单,但是全靠手工去写,就说 SQL 查询吧,从建立数据库连接到拼接 SQL 语句,再到执行查询处理查询结果,全都要自己实现,并没有什么 ORM 框架、数据库连接池供选择,由此就带来了 SQL 注入的风险。
而且建网站,如果不想开发的话,有很多 CMS 框架,尤其 PHP 的很多,现在依旧使用广泛的有 WordPress,当时国内的有 Discuz、DEDECMS 等一批傻瓜建站的 CMS 系统,由于代码都是开源的,而且 WordPress 还支持插件,所以会有很多相关的漏洞爆出来,尤其在多年以前,有了漏洞,想拿下一个网站真是太容易了,即使漏洞已经公布并有了解决方案,但依然有好多网站不及时修补和升级。现在在 Google 中搜索相关的 SQL注入关键词,有很多相关介绍。
说了这么多,这不都是 PHP 的代码吗?嘘,只是碰巧而已,说明 PHP 市场大呀,毕竟PHP是最好的开发语言。那 Java 中就没有了吗,当然有啊。
MyBatis 中的 SQL注入风险
最近在看一些代码,Spring Boot + MyBatis 的,偶然发现一个模糊查询的方法的 SQL 语句中用到了 like '%${keyword}%'
这样的查询条件,这一看就有 SQL 注入漏洞。
大家可能都了解,MyBatis 是可以解决 SQL 注入的问题的。一般我们在使用 MyBatis 的时候都会把 SQL 语句单独的放到 xml 文件中,在 SQL 语句中支持两种格式的参数占位符,一种是 #{parameter}
,另一种是 ${parameter}
,在这两种参数占位符中,#{parameter}
是安全的,不存在SQL注入漏洞,而 ${parameter}
是存在 SQL 注入漏洞的。
安全的占位符格式
#{parameter}
这种占位符会在 MySQL中进行预编译,所以你观察到 MyBatis 打印出来的日志是这样的:
select * from `user` where account=? and password=?
其实在框架底层,是 JDBC 中的 PreparedStatement 类在起作用,PreparedStatement 是我们很熟悉的 Statement 的子类,它的对象包含了编译好的SQL语句。这种预编译的方式不仅能提高安全性,而且在多次执行同一个SQL时,能够提高效率。原因是SQL已编译好,再次执行时无需再编译。
不知道你有没有写过直接用 JDBC 操作数据库的代码,反正大学老师就告诉我们要用占位符去做数据库查询,而不是拼接 SQL 字符串,因为用占位符的方式安全。其实,MyBatis 的预编译模式的底层实现就可以理解为下面这样的。
Connection conn = getConn();//获得连接
String sql = "select * from `user` where account=? and password=?"; //执行sql前会预编译号该条语句
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, "13001980988");
pstmt.setString(2, "123456");
ResultSet rs=pstmt.executeUpdate();
不安全的占位符格式
${parameter}
这种格式是不进行预编译的,也就相当于字符串的拼接,存在 SQL注入的问题,如果非要用这种方式,那需要在程序中对参数进行安全性校验。强烈建议在使用 MyBatis 的过程中不要使用 ${parameter}
这种格式的占位符,而要使用 #{parameter}
这种格式。
来一波SQL注入
我模拟了一个 SQL 注入的场景,很简单,就是一个模糊查询的接口,根据用户输入的关键词查询。
数据库中有两张表,分别为用户表(user)和 新闻表(news)。
用户表:
新闻表:
NewsMapper 类:
public interface NewsMapper {List<News> selectNewsLikeTitle(@Param("keyword") String keyword);
}
实际的 SQL 语句,注意,正是用到了 ${keyword}
,才有了 SQL 注入的问题。
<select id="selectNewsLikeTitle" resultType="org.kite.purely.mybatis.entity.News">select * from news where title like '%${keyword}%';
</select>
然后我写了一个控制台,接收输入的参数,传给 selectNewsLikeTitle
,以便来尝试 SQL 注入。
代码已上传至 github,链接放在了文末,需要的同学自取
正常的输入
新闻表就三条数据,我以 Docker
作为关键词,正常情况下,应该是这样的返回结果:
蓝色是我输入的关键词,后面跟着查询语句。一个标准的模糊查询语句,最后输出的结果也没问题。
select * from news where title like '%Docker%';
SQL 注入
如果使用者都这么守规矩就好了,但是真实情况往往并不是这样的,有些人就是喜欢躲在阴暗的角落放着冷箭,大部分情况下是有利可图,极少部分干脆就只是为了满足变态心理。
1、查询所有记录
有同学已经看出来了,输入空值不就是查询所有吗?对的,没错,但真实情况下,前端或者 Controller、Service 层会做拦截,不允许查询所有。
正常逻辑不允许,但是 SQL 注入就可以。我输入下面这样的条件参数,看看会出现什么结果呢?
Docker' or 1=1 or 1='
结果出来了,三条数据全部查询出来了。
因为构造的 SQL 语句已经完全变味儿了,SQL 语句是这样的,由于条件 or 1=1
的加持,导致任何记录都符合条件。
select * from news where title like '%Docker' or 1=1 or 1='%';
通过添加'
来保证条件中字符串前后单引号的闭合。
还可以是这样的条件
Docker' or 1=1 --
或者
Docker' or 1=1 #
因为--
和#
都是 MySQL 中的注释符号,用它们来注释掉关键注入后面的部分,最后构造出来的 SQL 语句是:
select * from news where title like '%Docker' or 1=1 -- %';
或
select * from news where title like '%Docker' or 1=1 #%';
所以,最后有效的部分就是注释符号前面的部分,自然,查询出来的就是所有的记录。
select * from news where title like '%Docker' or 1=1
这种情况,其实保密数据没有什么泄漏,但是,它可能会拖垮数据库,抛开 Redis 缓存什么的不谈,假设仅有 MySQL 这一层,假设数据库中有几万条、几十万条数据,黑客不断制造这样的模糊查询,你的数据库服务马上就会挂掉。
2、联查其他表,危险行为
把数据库拖垮已经很不爽了,但是更严重的,是获取数据。
我想要通过这条查询语句把 user
表的数据也套出来,你看着是不是就有点儿意思了。怎么办呢,通过 union
就可以。
前提是我已经知道有 user 表的存在了,别问怎么知道的,反正是已经知道了,而且黑客有很多办法能猜到。
我构造这样的参数:
Docker' union select * from `user` -- 注释掉后面的内容
执行一下,出现这样的提示:
构造出来的 SQL 没有问题,就是我们想要的。
select * from news where title like '%Docker' union select * from `user` -- 注释掉后面的内容%';
但是这用户体验很好,给出了具体的异常。体验好是对于攻击者而言的,如果每次异常都把原始异常信息抛出,那能给攻击者省不少事儿,就像下面这个异常。
Cause: java.sql.SQLException: The used SELECT statements have a different number of columns
这是因为 news 表和 user 表的列数不一致导致的,前后列数不一致,那这时候怎么办呢?
构造出下面这样的查询语句可以试探出 news 表的列数,其中 select 1,2 from user
中的 1,2
表示假设 news 表有两列,可以从 1 到 n,当尝试到哪一个而不出错或者正常返回的时候,表示 news 表就有多少列了。
select * from news where title like '%Docker' union select 1,2 from `user`;
要构造这样的语句,需要输入的参数是:
Docker' union select 1,2 from user #
因为 news 表只有两列,所以上面的参数可以成功执行。
盲注
大多数网站都不会将异常信息直接返回的,当攻击者拿不到即时的异常反馈时,就像是合着眼睛去猜,这种情况就叫做盲注。盲注是需要极大的耐心、高超的技术以及丰富的经验的,所以说黑客真不是好当的。
当试探出 news 表的列数后,再去配合它筛选 user 表的列就可以了,user 表的列名其实也是靠各种猜测的,比如常规的命名 name、account、phone、mobile、password 等,或者根据你返回给前端的属性对应着猜,比如返回的用户名是 userName,那你数据库中的字段就很有可能是 user_name。
假设我猜 user 表有 phone 这个字段,那我构造的参数就可以是下面这样的:
Docker' union all select 1,phone from user # 注释掉后面的内容 ,来确定news表有两个字段
最后执行下来,结果是这样的,四条 user 记录全出来了。
或者我还确定 user 表中有 password 列,那就可以直接把 password 再取出来了,如果 password 再是明文的,那就热闹了。
有了用户名和密码,是不是就有点危险了。
3、更危险的高权限
还有更危险的呢,假设你程序中数据库连接用到的账号是高权限的,比如 root 账号,有好多中小应用都这么用,别惊讶。
发散思维想一想,这时候能干嘛?
由于权限够高,那意味着可以执行任何合法的 SQL 语句了。在 MySQL 中有数据库 information_schema
,它存储着当前数据库实例中的很多重要信息,而且其中的表结构都是公开透明的,那这样一来呢,我们就可以通过这个数据库掌握当前数据库服务的几乎所有内容了。
不仅如此,高权限用户还能通过 MySQL 的读写文件功能实现更多的功能,比如配合 webshell 上传木马,获取服务器的控制权,从而实现脱裤(拖库)。
当然了,说的轻松,实现起来就困难了,不过只要有漏洞,就会被利用。
一些工具
俗话说,工欲善其事,必先利其器。漏洞哪儿那么容易挖,很多有价值的漏洞确实是厉害的黑客手工挖出来的。
为了方便的挖掘常见漏洞及利用漏洞,有很多网络安全专家开放出来的工具,可以让我等小白简单上手。比如我上学时候用到的「啊D注入工具」。可以用来扫码注入点、SQL注入检测、管理入口检测等。
还有比较专业的 SQL 注入工具「SQLMap」,它是一款命令行工具。SQLMap 提供了丰富的命令来帮我们发现漏洞、利用漏洞。
另外,如果你想学习 SQL 注入的一些基础,可以直接整个靶场来玩玩儿。比如这个 Pikachu 就不错。
https://github.com/zhuifengshaonianhanlu/pikachu
总结
本文并不是为了教各位如何完成 SQL注入,毕竟,我也没这个实力。当然,制造漏洞的实力还是有的。
只是想说,在写代码的时候一定要注意,稍有不慎就可能写出有漏洞的 SQL,所以,尽量用成熟框架的标准写法,不要图省事自己拼 SQL。
不光是 SQL 这部分,其他的涉及到用户交互的地方都要注入,比如用户表单,有时候可能产生 xss 漏洞,还有文件上传的部分,别用户上传了木马都不知道怎么回事。
愿你的代码没有 bug。虽然这是不可能的。
文中的测试代码已放至仓库:https://github.com/huzhicheng/SQL_Injection,有需要的同学自取。