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不难猜测这可能是一个修改地址功能对应的代码.

而这个里调用addressGetByPrimaryKey的参数$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_loggeneral_log_file属性,让每一条sql语句都写到日志里,然后查看数据库的日志

最后提一下,现在Yii框架已经修复了这个函数的问题,大家可以看下更新说明,它的方案其实是把数据表的所有字段列出来,然后判断你输入的字段名是否存在这些字段里,相当于一个白名单策略.在我们这个案例里,虽然更新Yii的版本可以防范住sql注入,但是仍然可以提交一个数组来篡改where条件,从而达到其它的目的.

里面还提到了where()filterWhere()永远不会转义列名.大家可以顺着这个思路去挖掘其它类似的sql注入.