[紅日安全]程式碼審計Day11 – unserialize反序列化漏洞
本文轉載自先知社群: ofollow,noindex" target="_blank">https://xz.aliyun.com/t/2733
本文由紅日安全成員: licong 編寫,如有不當,還望斧正。
前言
大家好,我們是紅日安全-程式碼審計小組。最近我們小組正在做一個PHP程式碼審計的專案,供大家學習交流,我們給這個專案起了一個名字叫 PHP-Audit-Labs 。現在大家所看到的系列文章,屬於專案 第一階段 的內容,本階段的內容題目均來自 PHP SECURITY CALENDAR 2017 。對於每一道題目,我們均給出對應的分析,並結合實際CMS進行解說。在文章的最後,我們還會留一道CTF題目,供大家練習,希望大家喜歡。下面是 第11篇 程式碼審計文章:
Day 11 - Pumpkin Pie
題目如下:
漏洞解析: (上圖程式碼第11行正則表示式應改為:'/O:\d:/')
題目考察對php反序列化函式的利用。在第10行 loadData() 函式中,我們發現了 unserialize 函式對傳入的 $data 變數進行了反序列。在反序列化前,對變數內容進行了判斷,先不考慮繞過,跟蹤一下變數,看看變數是否可控。在程式碼 第6行 ,呼叫了 loadData() 函式,$data變數來自於 __construct() 建構函式傳入的變數。程式碼第32行,對 Template 類進行了例項化,並將 cookie 中鍵為'data'資料作為初始化資料進行傳入,$data資料我們可控。開始考慮繞過對傳入資料的判斷。
程式碼 11行 ,第一個if,擷取前兩個字元,判斷反序列化內容是否為物件,如果為物件,返回為空。php可反序列化型別有String,Integer,Boolean,Null,Array,Object。去除掉Object後,考慮採用陣列中儲存物件進行繞過。
第二個if判斷,匹配 字串為 'O:任意十進位制:',將物件放入陣列進行反序列化後,仍然能夠匹配到,返回為空,考慮一下如何繞過正則匹配,PHP反序列化處理部分原始碼如下:
在PHP原始碼var_unserializer.c,對反序列化字串進行處理,在程式碼568行對字元進行判斷,並呼叫相應的函式進行處理,當字元為'O'時,呼叫 yy13 函式,在 yy13 函式中,對‘O‘字元的下一個字元進行判斷,如果是':',則呼叫 yy17 函式,如果不是則呼叫 yy3 函式,直接return 0,結束反序列化。接著看 yy17 函式。通過觀察yybm[]陣列可知,第一個if判斷是否為數字,如果為數字則跳轉到 yy20 函式,第二個判斷如果是'+'號則跳轉到 yy19 ,在 yy19 中,繼續對 +號 後面的字元進行判斷,如果為數字則跳轉到 yy20 ,如果不是則跳轉到 yy18 , y18 最終跳轉到 yy3 ,退出反序列化流程。由此,在'O:',後面可以增加'+',用來繞過正則判斷。
繞過了過濾以後,接下來考慮怎樣對反序列化進行利用,反序列化本質是將序列化的字串還原成對應的類例項,在該過程中,我們可控的是序列化字串的內容,也就是對應類中變數的值。我們無法直接呼叫類中的函式,但PHP在滿足一定的條件下,會自動觸發一些函式的呼叫,該類函式,我們稱為魔術方法。通過可控的類變數,觸發自動呼叫的魔術方法,以及魔術方法中存在的可利用點,進而形成反序列化漏洞的利用。
在程式碼31行,物件銷燬時會呼叫 createCache() 函式,函式將 $template 中的內容放到了 $cacheFile 對應的檔案中。 file_put_contents() 函式,當檔案不存在時,會建立該檔案。由此可構造一句話,寫入當前路徑。
$cacheFile 和 $template 為類變數,反序列化可控,由此,構造以下反序列化內容,別忘了加'+'號
放入cookie需進行URL編碼
a:1:{i:0;O:+8:"Template":2:{s:9:"cacheFile";s:10:"./test.php";s:8:"template";s:25:"<?php eval($_POST[xx]);?>";}}
檔案成功寫入:
例項分析
本次例項分析,選取的是 Typecho-1.1 版本,在該版本中,使用者可通過反序列化Cookie資料進行前臺Getshell。該漏洞出現於 install.php 檔案 230行 ,具體程式碼如下:
在上圖程式碼 第3行 ,對Cookie中的資料base64解碼以後,進行了反序列化操作,該值可控,接下來看一下程式碼觸發條件。檔案幾個關鍵判斷如下:
第一個if判斷,可通過GET傳遞 finish=任意值 繞過 ,第二if判斷是否有GET或者POST傳參,並判斷Referer是否為空,第四個if判斷Referer是否為本站點。緊接著還有判斷,如下圖:
第一個if判斷 $_GET['finish'] 是否設定,然後判斷 config.inc.php檔案 是否存在,安裝後已存在,第三個判斷cookie中 __typecho_config 引數是否為空,不為空。進入else分支。綜上,具體構造如下圖:
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));Typecho_Cookie::delete('__typecho_config');$db = new Typecho_Db($config['adapter'], $config['prefix']);
反序列化結果儲存到 $config 變數中,然後將 $config['adapter'] 和 $config['prefix'] 作為 Typecho_Db 類的初始化變數建立類例項。我們可以在 var/Typecho/Db.php 檔案中找到該類建構函式程式碼,具體如下:
上圖程式碼 第6行 ,對傳入的 $adapterName 變數進行了字串拼接操作,對於PHP而言,如果 $adapterName 型別為物件,則會呼叫該類 __toString() 魔術方法。可作為反序列化的一個觸發點,我們全域性搜尋一下 __toString() ,檢視是否有可利用的點。實際搜尋時,會發現有三個類都定義了 __toString() 方法:
第一處 var\Typecho\Config.php:
呼叫 serialize() 函式進行序列化操作,會自動觸發 __sleep() ,如果存在可利用的 __sleep() ,則可以進一步利用。
第二處 var\Typecho\Db\Query.php:
該方法用於構建SQL語句,並沒有執行資料庫操作,所以暫無利用價值。
第三處var\Typecho\Feed.php:
在程式碼 19行 , $this->_items 為類變數,反序列化可控,在程式碼 27行 , $item['author']->screenName ,如果 $item['author'] 中儲存的類沒有'screenName'屬性或該屬性為私有屬性,此時會觸發該類中的 __get() 魔法方法,這個可作為進一步利用的點,繼續往下看程式碼,未發現有危險函式的呼叫。
記一波魔術方法及對應的觸發條件,具體如下:
__wakeup() //使用unserialize時觸發 __sleep() //使用serialize時觸發 __destruct() //物件被銷燬時觸發 __call() //在物件上下文中呼叫不可訪問的方法時觸發 __callStatic() //在靜態上下文中呼叫不可訪問的方法時觸發 __get() //用於從不可訪問的屬性讀取資料 __set() //用於將資料寫入不可訪問的屬性 __isset() //在不可訪問的屬性上呼叫isset()或empty()觸發 __unset() //在不可訪問的屬性上使用unset()時觸發 __toString() //把類當作字串使用時觸發 __invoke() //當指令碼嘗試將物件呼叫為函式時觸發
在 var/Typecho/Request.php 的 Typecho_Request 類中,我們發現 __get() 方法,跟蹤該方法的呼叫,具體如下圖:
array_map() 函式和 call_user_func 函式,都可以作為利用點,$filter 作為呼叫函式,$value 為函式引數,跟蹤變數,看一下是否可控。這兩個變數都來源於類變數,反序列化可控。從上面的分析中,可知當 $item['author'] 滿足一定條件會觸發 __get 方法。
假設 $item['author'] 中儲存 Typecho_Request 類例項,此時呼叫 $item['author']->screenName ,在Typecho_Request 類中沒有該屬性,就會呼叫類中的 __get($key) 方法,$key 傳入的值為 scrrenName 。引數傳遞過程如下:$key='scrrenName'=>$this->_param[$key]=>$value
我們將 $this->_param['scrrenName'] 的值設定為想要執行的函式,構造 $this->_filter 為對應函式的引數值,具體構造如下:
接下來我們去看一下 Typecho_Feed 類的構造,該類在 var/Typecho/Feed.php 檔案中,程式碼如下:
上圖程式碼 第7行 ,滿足 self::RSS2 與 $this->_type 相等進入該分支,所以 $this->_type 需要構造,item['author'] 為觸發點,需要構造 $this_items ,具體構造如下:
程式碼 22行 在實際利用沒必要新增,install.php在程式碼 54行 呼叫 ob_start() 函式,該函式對輸出內容進行緩衝,反序列化漏洞利用結束後,在 var\Typecho\Db.php 程式碼121行,觸發異常,在 var\Typecho\Common.php 程式碼237行呼叫 ob_end_clean()函式 清除了緩衝區內容,導致無法看見執行結果,考慮在進入到異常處理前提前報錯結束程式。由此構造該資料。執行結果如下:
修復建議
造成該漏洞的原因主要有兩點:
-
當 config.inc.php 檔案存在的時,可繞過判斷繼續往下執行程式碼。
-
傳入反序列化函式的引數可控
修復方法:在 install.php 檔案第一行判斷 config.inc.php 是否存在,如果存在,則退出程式碼執行。
<?php if (file_exists(dirname(__FILE__) . '/config.inc.php'))exit('Access Denied'); ?>
結語
看完了上述分析,不知道大家是否對反序列化利用有了一定的瞭解,文中用到的CMS可以從 這裡 下載,當然文中若有不當之處,還望各位斧正。如果你對我們的專案感興趣,歡迎傳送郵件到 [email protected] 聯絡我們。Day11 的分析文章就到這裡,我們最後留了一道CTF題目給大家練手,題目如下:
<?php include "config.php"; class HITCON{ public $method; public $args; public $conn; function __construct($method, $args) { $this->method = $method; $this->args = $args; $this->__conn(); } function __conn() { global $db_host, $db_name, $db_user, $db_pass, $DEBUG; if (!$this->conn) $this->conn = mysql_connect($db_host, $db_user, $db_pass); mysql_select_db($db_name, $this->conn); if ($DEBUG) { $sql = "DROP TABLE IFEXISTSusers"; $this->__query($sql, $back=false); $sql = "CREATE TABLE IF NOT EXISTS users (username VARCHAR(64), password VARCHAR(64),role VARCHAR(256)) CHARACTER SET utf8"; $this->__query($sql, $back=false); $sql = "INSERT INTO users VALUES ('orange', '$db_pass', 'admin'), ('phddaa', 'ddaa', 'user')"; $this->__query($sql, $back=false); } mysql_query("SET names utf8"); mysql_query("SET sql_mode = 'strict_all_tables'"); } function __query($sql, $back=true) { $result = @mysql_query($sql); if ($back) { return @mysql_fetch_object($result); } } function login() { list($username, $password) = func_get_args(); $sql = sprintf("SELECT * FROM users WHERE username='%s' AND password='%s'", $username, md5($password)); $obj = $this->__query($sql); if ( $obj != false ) { define('IN_FLAG', TRUE); $this->loadData($obj->role); } else { $this->__die("sorry!"); } } function loadData($data) { if (substr($data, 0, 2) !== 'O:' && !preg_match('/O:\d:/', $data)) { return unserialize($data); } return []; } function __die($msg) { $this->__close(); header("Content-Type: application/json"); die( json_encode( array("msg"=> $msg) ) ); } function __close() { mysql_close($this->conn); } function source() { highlight_file(__FILE__); } function __destruct() { $this->__conn(); if (in_array($this->method, array("login", "source"))) { @call_user_func_array(array($this, $this->method), $this->args); } else { $this->__die("What do you do?"); } $this->__close(); } function __wakeup() { foreach($this->args as $k => $v) { $this->args[$k] = strtolower(trim(mysql_escape_string($v))); } } } class SoFun{ public $file='index.php'; function __destruct(){ if(!empty($this->file)) { include $this->file; } } function __wakeup(){ $this-> file='index.php'; } } if(isset($_GET["data"])) { @unserialize($_GET["data"]); } else { new HITCON("source", array()); } ?>
//config.php <?php $db_host = 'localhost'; $db_name = 'test'; $db_user = 'root'; $db_pass = '123'; $DEBUG = 'xx'; ?>
// flag.php <?php !defined('IN_FLAG') && exit('Access Denied'); echo "flag{un3eri@liz3_i3_s0_fun}"; ?>