Ecshop 2.x-3.x 远程代码执行漏洞

sql注入

前言

国庆复习了三个tp框架的反序列化漏洞,感觉复现的时候能学到好多,这里再分析一个Ecshop 2.x-3.x 远程代码执行漏洞

环境搭建

源码下载: https://www.mycodes.net/44/9232.htm

poc演示截图

调用链

单步调试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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']; //$back_act可控
}
else
{
$back_act = 'user.php';
}

}
...
$smarty->assign('back_act', $back_act);
$smarty->display('user_passport.dwt');
}
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; //$this->_var[$tpl_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
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;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function fetch($filename, $cache_id = '')
{
...
if (strncmp($filename,'str:', 4) == 0)
{
$out = $this->_eval($this->fetch_str(substr($filename, 4)));
}
else
{
...
$out = $this->make_compiled($filename); //$filename = C:/FakeD/Software/phpstudy/PHPTutorial/WWW/ecshop/themes/default/user_passport.dwt
...
}
return $out;
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 make_compiled($filename)
{
$name = $this->compile_dir . '/' . basename($filename) . '.php';
...

if ($filestat['mtime'] <= $expires && !$this->force_compile)
{
if (file_exists($name))
{
$source = $this->_require($name); //$name = C:/FakeD/Software/phpstudy/PHPTutorial/WWW/ecshop/temp/compiled/user_passport.dwt.php
if ($source == '')
{
$expires = 0;
}
}
else
{
$source = '';
$expires = 0;
}
}
...

return $source;
}
1
2
3
4
5
6
7
8
9
function _require($filename)
{
ob_start();
include $filename;
$content = ob_get_contents();
ob_end_clean();

return $content;
}

回到display函数

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

(放弃打码了orz)

poc这里就不放了,公网已大范围公开。

至此,整个 sql 注入漏洞部分的原理已经清晰了:

  1. 通过 assign 方法注册一个可控的 $back_act 变量进入模版中
  2. 随后 display 方法中,使用了模版类变量 $_echash 将解析变量后的模版内容分为 5 个部分(在模版中共有 4 个 $_echash),而我们可控的 ‘back_act’ 变量在 4 个 $_echash 之后,所以我们再次构造第 5 个 $_echash 以及 insert_mod 方法的参数。因为此时我们可控的内容会成为第 6 部分,其键值为 5 ,是奇数,可以进入 insert_mod 方法。
  3. 动态选用 insert_ads 方法,该方法中有直接拼接 sql 语句的行为,且其参数 $arr 我们完全可控,可控部分在 sql 语句的 limit 之后,通过 procedure analyse(extractvalue(rand(),concat(0x3a,version())),1); 这样的方式触发报错注入即可。

Getshell

1
2
3
4
5
6
7
8
9
10
//ecshop\includes\cls_template.php
function _eval($content)
{
ob_start();
eval('?' . '>' . trim($content));
$content = ob_get_contents();
ob_end_clean();

return $content;
}

可以看到这里我们一个eval函数,如果我们能够控制$content,我们即可getshell。

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

这里要进入_eval首先$filename要满足那个if条件,然后有一个fetch_str函数,这里一堆waf,因为这里也是需要控制$filename,所以之后再看。先找到哪里调用了fetch函数

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

if (!function_exists('version_compare') || version_compare(phpversion(), '5.3.0', '<')) {
return preg_replace("/{([^\}\{\n]*)}/e", "\$this->select('\\1');", $source);
} else {
return include(ROOT_PATH . 'includes' . DIRECTORY_SEPARATOR . 'patch' . DIRECTORY_SEPARATOR . 'includes_cls_template_fetch_str.php');
}
}

全局搜做找到这个,刚好同时符合我们调用_eval函数的条件,那么我们怎么控制$position_style呢。

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

// $arr['num'] = intval($arr['num']);
// $arr['id'] = intval($arr['id']);
$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']];
}
$ads = array();
$position_style = '';

foreach ($res AS $row)
{
if ($row['position_id'] != $arr['id'])
{
continue;
}
$position_style = $row['position_style'];
...
}
$position_style = 'str:' . $position_style;

...

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

...

return $val;
}

追溯,可以看到这里$position_style是从数据库中查询出来的,而且是在第9字段,而$arr[‘id’]、$arr[‘num’]是我们可控的,很容易控制数据库执行返回的语句。

现在应该说我们控制了$filename参数的内容,那么接下来就是绕waf了。首先进入了smarty_prefilter_preCompile函数

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 smarty_prefilter_preCompile($source)
{
$file_type = strtolower(strrchr($this->_current_file, '.'));
$tmp_dir = 'themes/' . $GLOBALS['_CFG']['template'] . '/'; // 模板所在路径

...

/* 替换文件编码头部 */
if (strpos($source, "\xEF\xBB\xBF") !== FALSE)
{
$source = str_replace("\xEF\xBB\xBF", '', $source);
}

$pattern = array(
'/<!--[^>|\n]*?({.+?})[^<|{|\n]*?-->/', // 替换smarty注释
'/<!--[^<|>|{|\n]*?-->/', // 替换不换行的html注释
'/(href=["|\'])\.\.\/(.*?)(["|\'])/i', // 替换相对链接
'/((?:background|src)\s*=\s*["|\'])(?:\.\/|\.\.\/)?(images\/.*?["|\'])/is', // 在images前加上 $tmp_dir
'/((?:background|background-image):\s*?url\()(?:\.\/|\.\.\/)?(images\/)/is', // 在images前加上 $tmp_dir
'/([\'|"])\.\.\//is', // 以../开头的路径全部修正为空
);
$replace = array(
'\1',
'',
'\1\2\3',
'\1' . $tmp_dir . '\2',
'\1' . $tmp_dir . '\2',
'\1'
);
return preg_replace($pattern, $replace, $source);
}

1
2
3
4
//ecshop\includes\patch\includes_cls_template_fetch_str.php
<?php
$template = $this;
return preg_replace_callback("/{([^\}\{\n]*)}/", function($r) use(&$template){return $template->select($r[1]);}, $source);

这里的正则是匹配大括号包裹的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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)) . '; ?>';
}
...
}

这里可以看到如果去掉{}之后开头如果是$会继续调用

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
function get_val($val)
{
...
if (strrpos($val, '[') !== false)
{
if (!function_exists('version_compare') || version_compare(phpversion(), '5.3.0', '<')) {
$val = preg_replace("/\[([^\[\]]*)\]/eis", "'.'.str_replace('$','\$','\\1')", $val);
} else {
include(ROOT_PATH . 'includes' . DIRECTORY_SEPARATOR . 'patch' . DIRECTORY_SEPARATOR . 'includes_cls_template_get_val.php');
}
}

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);
}
...
return $p;
}
1
2
3
//ecshop\includes\patch\includes_cls_template_get_val.php
<?php
$val = preg_replace_callback("/\[([^\[\]]*)\]/is", function($r){return '.' . $r[1];}, $val);

1
2
3
4
5
6
7
8
9
10
11
12
13
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 . '\']';
}
...
return $p;
}

可以看到这里采用了拼接,最后就直接调用了_eval,

直接getshell。

poc这里就不公开了,公网已大范围公开。

补充

把这个cms的全局waf分割一下,以后审的时候用得到,大家直接复制到这个网站即可看到Regulex

sql的waf

1
2
3
4
5
6
7
8
9
10
11
[^\{\s]{1}(\s|\b)+(?:select\b|update\b|insert(?:(\/\*.*?\*\/)|(\s)|(\+))+into\b).+?(?:from\b|set\b)

[^\{\s]{1}(\s|\b)+(?:create|delete|drop|truncate|rename|desc)(?:(\/\*.*?\*\/)|(\s)|(\+))+(?:table\b|from\b|database\b)|into(?:(\/\*.*?\*\/)|\s|\+)+(?:dump|out)file\b|\bsleep\([\s]*[\d]+[\s]*\)

benchmark\(([^\,]*)\,([^\,]*)\)

(?:declare|set|select)\b.*@

union\b.*(?:select|all)\b

(?:select|update|insert|create|delete|drop|grant|truncate|rename|exec|desc|from|table|database|set|where)\b.*(charset|ascii|bin|char|uncompress|concat|concat_ws|conv|export_set|hex|instr|left|load_file|locate|mid|sub|substring|oct|reverse|right|unhex)\(|(?:master\.\.sysdatabases|msysaccessobjects|msysqueries|sysmodules|mysql\.db|sys\.database_name|information_schema\.|sysobjects|sp_makewebtask|xp_cmdshell|sp_oamethod|sp_addextendedproc|sp_oacreate|xp_regread|sys\.dbms_export_extension)

query_string的xss waf

1
2
3
\=\+\/v(?:8|9|\+|\/)

\%0acontent\-(?:id|location|type|transfer\-encoding)

xss的waf

1
2
3
4
5
[\'\\"\;\*\<\>].*\bon[a-zA-Z]{3,15}[\s\r\n\v\f]*\=

\b(?:expression)\(|\<script[\s\\\/]

\b(?:eval|alert|prompt|msgbox)\s*\(|url\((?:\#|data|javascript)

other的waf

1
2
3
\.\.[\\\/].*\%00([^0-9a-fA-F]|$)

%00[\'\\"\.]
-------------本文结束感谢您的阅读-------------