PHP代码审计研究
利用搜索引擎寻找网络上存在SQL漏洞的CMS,自行进行环境搭建,并将最终存在注入的点利用sqlmap或者其他工具跑出来,结果截图!
课程:
大家好,这节课我们来开始代码审计的实战
首先交代一下知识背景,在当今在众多主流的框架中,基本上都会对于数据库的操作做一层封装.
比如这样的一条原生sql语句
SELECT * FROM `user` WHERE `name`='pockr';
经过一些PHP框架封装之后,写出来的会是这样的
DB::select('select * from user where name = :name', ['name' => 'pockr']);
上面的代码中使用了参数绑定,在sql语句中出现了 :name
这个变量,在后续的数组中对于name
这个变量填充了pockr
作为内容,通过参数绑定可以有效地避免sql注入.
然而这种要求手写sql语句再绑定参数的写法不怎么不友好,所以又多出了下面这种代码
DB::table('user')->where(['name'=>'pockr'])->first();
开发人员喜欢有类似这样的where
函数,只需要传递数组例如上面代码中的['name'=>'pockr']
,这种函数就会帮你拼接成sql语句中where条件
`name`='pockr'
上面的代码还有一些问题
1.user
这个数据表是一个字符串,写死的,如果日后数据表改名了不好维护
2.大部分系统充斥着都是以数据的主键作为条件查询的,而这些主键的名字一般叫id,每次都要用类似where(['id'=>1])
这样的代码太啰嗦
为了适应这种情况,又多出了类似这样的代码
User::findOne(1); User::get(1);
这里的User通常是一个model(模型),里面定义着一些属性,比如说对应的数据表的名字,数据表的主键名字以及其它的信息.
这样子像findOne(1)get(1)
的代码就会自己去按主键查询了.
同时为了方便附加额外的条件,这种get()findOne()
的函数都会允许传入一个数组,比如这种情况
User::findOne(['name'=>'pockr']); User::get(['name'=>'pockr']);
同样的,框架会对数组做转换,拼接成where
条件,于是我们最开始那条sql又回来了
SELECT * FROM `user` WHERE `name`='pockr';
这样安全和方便都兼顾到了,框架自身会对数组的值进行过滤或者参数绑定,防止sql注入.一切看起来都很美好,世界很和平.
那么问题来了,对于数组的键,应该怎么处理呢?
数据的键,就是上面代码中['name'=>'pockr']
的name
我们回顾一下我们的标准sql语句
SELECT * FROM `user` WHERE `name`='pockr';
可以看到name作为mysql数据表中的字段,两边被反引号包裹
如果字段里非要有一个反引号,比如name`,需要怎么样过滤呢?
答案是 `name```
到此我们总结一下
1.大部分PHP框架会对sql操作有封装,这些封装通常都会有类似get(),findOne()
这样的函数,这个函数一般用于接收一个字符串或数字,但是为了方便使用,还会设计成可以接收一个数组的形式,类似的函数还有拼接where
条件的where()
函数等.
2.在sql中,是用两个反引号包裹数据表的字段名
根据这2个特征点,我们不难分析出,有人可能贪图方便,或者是没有完整阅读框架文档,再或者是没有用一些用例测试过,反正在总总原因下,写出了这样的代码
User::findOne($_GET['id'])
那么这样的代码,就是一个危险代码特征,我们审计可以从这样的代码入手.
在有了这些知识背景之后,我们先要找出在数据库操作时,拼接where条件阶段,对于字段名不过滤或者过滤不当的框架或程序.
这个阶段还不需要审计代码,只是需要把一些demo搭建起来,然后写出类似上面的代码,fuzz一下对于字段名的过滤情况,观察结果就可以得出结论.
由于时间关系,这里就不带大家搭建demo了,我提前准备了一个框架,这个框架是Yii2,在2018年3月份的一个修复之前,它的数据库操作拼接where条件阶段,存在字段名过滤不严的情况,这个漏洞长达有4年之久.
在这4年期间,诞生了很多基于Yii2的项目,在这里我们挑一个商城fecshop来进行审计.因为商城系统的功能一般比cms的多.功能越多,代码就越多,容易出问题的地方也就越多.
先看下Yii拼接where条件的函数有哪些,这个扫一遍根据官方文档就很快知道,可以填入数组,然后在where条件拼接的有且不限于以下函数
yii\db\Query::where()
yii\db\ActiveRecord::where()
yii\db\ActiveRecord::findOne()
yii\db\ActiveRecord::findAll()
这些函数可以看作是上一节课我们提到的危险函数
废话不多说,接下来就根据这些"敏感函数"来开始审计这个商城系统吧.
项目的搭建直接跳过,我们直接以搭建好的版本开始审计.
根据上面的总结,我们知道findOne
函数的危害比较大,主要危害在于当参数是外部完全可控的情况,那么我们审计的目的也很明显了,先从检索findOne
函数开始开始
findOne(
可以检索出大部分调用findOne
函数的代码,再精准一些搜索
findOne($
可以检索出大部分传入可控变量的代码调用.
这块我们自己把控一下就好,比如用法师的代码审计工具里的规则提取一个修改一下
这里我简单改了一条正则表达式出来,专门用于搜索findOne函数中有变量传入的代码
工具部分看个人习惯,我习惯是用vscode
去搜索,开启正则表达式模式
findOne\s{0,5}\(.{0,40}\$\w{1,15}((\[["']|\[)\${0,1}[\w\[\]"']{0,30}){0,1}
可以看到右边出现了不少的搜索结果,这时候看你的注重点在哪里.基本上文件路径里包含adminbackend
这种都是后台的功能.如果你想找出前台的sql注入,就要确定好自己审计的优先级.
我们还可以通过更激进一点的搜索条件,搜索findOne($
来保证传入的参数百分百是个变量.
接下来就到了体力活的时候了,根据搜索到的结果代码一个一个点击进去回溯,看看变量是否可控.这里我直接以其中一个问题点给大家分析.
首先我选择的是这个Address.php
在商城系统里,出现Address
这个单词,大多数情况它会是一个收货地址相关的代码
它出现findOne的代码有3处
/** * @property $primaryKey | Int * @return Object(MyCoupon) * 通过id找到customer address的对象 */ protected function actionGetByPrimaryKey($primaryKey) { $one = $this->_addressModel->findOne($primaryKey); $primaryKey = $this->getPrimaryKey(); if ($one[$primaryKey]) { return $one; } else { return new $this->_addressModelName(); } } /** * @property $one|array , 保存的address数组 * @return int 返回保存的 address_id 的值。 */ protected function actionSave($one) { ... /** * @property $ids | Int or Array * @return bool * 如果传入的是id数组,则删除多个address,如果传入的是Int,则删除一个address * 删除address的同时,删除掉购物车中的address_id * 删除address的同时,如果删除的是default address,那么重新找出来一个address作为default address并保存到表中。 */ protected function actionRemove($ids, $customer_id) { ...
这几个函数顾名思义,一个是根据主键获取一条数据,一个是保存数据,一个是删除数据,我们来看获取数据的代码被谁调用了.
这里直接搜索actionGetByPrimaryKey
这个函数,发现没有调用的痕迹,基本都是一堆同名函数的声明.
出现这个结果,要么是真的没有地方调用,要么通过脚本语言灵活的特性去调用,这种特性无非就是去掉函数的第一个单词或者最后一个单词.
我们这个Address.php
的函数都是action
开头,那么不难推断出action
是一个通用的前缀,我们取后半段GetByPrimaryKey
搜索
这么一来结果的确多了很多,我们继续找到调用这个address
里的GetByPrimaryKey
的代码
同时我们发现右侧的结果非常有规律,根据这些规律还可以进一步调整我们的检索关键字为address->GetByPrimaryKey
其实这一步比较讲究运气,如果是质量比较高的开源项目的话,都有一定的规范,不会乱写乱改名,所以这里我们这一次搜索还是出了几个结果.
这几个结果文件所在的路径,分别是appfront
,apphtml5
,appserver
,如果你有看过一遍这个系统的介绍,appfront
是pc端页面相关的代码
我们挑一段短点的代码来分析
根据文件名Edit.php
不难猜测这可能是一个修改地址功能对应的代码.
而这个里调用address
的GetByPrimaryKey
的参数$this->_address_id
来源,则是上面那行$this->_address_id = Yii::$app->request->get('address_id');
代码
根据第二节课的知识,我们知道了在Yii框架里面Yii::$app->request->get
和$_GET
作用类似,同时我们也看到整段代码没有任何的过滤,这时候我们打开浏览器访问这套系统,找到有修改地址的地方,验证一下是不是对应这段代码.
验证过程也非常简单,无非是在代码里输出点什么东西,或者结束脚本,或者下个断点.
这里我简单结束了脚本,顺便输出pockr字样,保存代码后刷新页面
那么可以判断出这个页面对应的代码也找对了.
这个时候我们修改一下url中address_id参数改成一个数组,先看看有什么报错
SQLSTATE[42S22]: Column not found: 1054 Unknown column 'pockr123' in 'where clause' The SQL being executed was: SELECT * FROM `customer_address` WHERE `pockr123`='3'
报错也挺明显的,可以看到执行的sql里,where条件中的字段被篡改成了pockr123,电商系统的数据库显然不会有一个叫pockr123的字段,这一点根本不需要查看这个系统的数据库结构来佐证.
其实到这里漏洞已经发现完毕了,那么怎么去利用呢,这里也给大家讲解一下.
首先我们的pockr123
被一对反引号包裹着,我们先尝试闭合反引号.
先把url中的address_id[pockr123]
改成address_id[pockr123
]`,看看输出
发现尝试执行的sql语句变成了
SELECT * FROM `customer_address` WHERE pockr123`='3'
我们开篇的知识背景里介绍到,字段名的正确过滤方案是两边加反引号,而字段名出现反引号时,还要再这个引号前面加多1个反引号,也就是说这里的
pockr123`
应该被过滤成
`pockr123```
这里显然没有正确的过滤,只是检测到字段名包含反引号的时候就取消包裹了
如果想篡改这条sql证明sql注入存在,可以尝试让它执行的sql成这个样子
SELECT * FROM `customer_address` WHERE address_id=-1 or 1=1 and union select * from customer_address;# `='3'
我们用把字段名pockr123
改回address_id
,是为了防止sql语法自身的错误,观察这个customer_address
数据表发现存在address_id
这个字段,所以就把pockr123
改成了address_id
.
而address_id=-1
是为了让直接执行后续的条件,因为大部分的系统的主键都是从1开始的.
;#
则是为了结束这条sql,并且把后面的sql语句注释掉.
所以我们应该修改参数,让下面的这半截语句插入
address_id=-1 or 1=1 union select * from customer_address;#
那么我们的
address_id[pockr123`]
就改成了
address_id[address_id=-1 or 1=1 union select * from customer_address;#`]
当然不要忘记urlencode一下,
address_id%5Baddress_id%3D-1%20or%201%3D1%20union%20select%20*%20from%20customer_address%3B%23%60%5D
再次访问之后发现页面没有报错,那么怎么证明sql注入成功了呢?
这个系统自带了一个debug工具,下面可以直接看到这个页面执行了什么sql语句
或者你可以查看配置mysql的general_log
和general_log_file
属性,让每一条sql语句都写到日志里,然后查看数据库的日志
最后提一下,现在Yii框架已经修复了这个函数的问题,大家可以看下更新说明,它的方案其实是把数据表的所有字段列出来,然后判断你输入的字段名是否存在这些字段里,相当于一个白名单策略.在我们这个案例里,虽然更新Yii的版本可以防范住sql注入,但是仍然可以提交一个数组来篡改where条件,从而达到其它的目的.
里面还提到了where()
和filterWhere()
永远不会转义列名.大家可以顺着这个思路去挖掘其它类似的sql注入.