1. 程式人生 > >記一次ThinkPHP原始碼審計

記一次ThinkPHP原始碼審計

一、寫在前面

週末閒得蛋疼,審了一套朋友給的系統,過程挺有意思的,開始的時候覺得基於TP3.0二次開發的系統應該是蠻簡單的,畢竟TP爆了很多漏洞。後來發現開發做了不少安全措施。因此記錄一下這次審計,當時自己學習的記錄吧。

二、後臺注入

最開始的時候,因為快吃飯了,就用RIPS掃了掃(事實證明,沒有什麼毛用)。說是掃到了一個物件注入,然後就看了看程式碼,大致如下:

$where = unserialize(base64_decode($_REQUEST['_where']));
$result=$m->where($where)->order("id desc")->select();

講真,這裡一眼就能看出來是有個注入的,最簡單的方式就是$_REQUEST['_where']經過base64_decode()->unserialize()是字串,直接就可以注入了。
但是這裡到底有沒有物件注入呢?我的理解是反序列化導致的物件注入,至少得有對應的魔術方法__wakeup或者__destruct,並且在魔術方法中有敏感的操作。不僅如此,有時候可能還需要構造POP鏈,具體參照:這篇文章
因此,我搜索了一下對應的兩個魔術方法,全文都沒有定義,基本上是無解了。
因為後臺的漏洞一般都是比較雞肋的,因此放棄後臺漏洞的挖掘,轉而移步前臺。

二、前臺注入

public function Exit()
{
    $res = M('member')->where(array('id'=>$_GET['userid']))->count();
    if($res){
      echo "1";
    }else{
       echo "0";
    }
}
低版本的TP在這裡一定是有漏洞的,至於為什麼可以看這篇文章。然後,我們就可以構造userid[0]=exp&userid[1]=xxxx'or 1=1#之類的語句了,事實上這套系統被修改過了,嘗試的過程中發現被一個叫checkPost的函式過濾了,如下所示:
static private function checkPost() {
    if( isset($_POST) && is_array($_POST)) {
        foreach($_POST as $key=>$data){
            if(is_array($data))
            {
                self::selfCheck($data);
            }
        }
    }
    if( isset($_GET) && is_array($_GET)) {
        foreach($_GET as $key=>$data){
            if(is_array($data))
            {
                self::selfCheck($data);
            }
        }
    }
}
static private function selfCheck($data)
{
    $ary=array('exp','eq','neq','gt','egt','lt','elt','like','notlike','not like','in','notin','not in','between','notbetween','not between');
    if(is_array($data))
    {
        if(count($data)>0)
        {
            array_map(array('Think','selfCheck'),$data);
        }
    }
    else
    {
        if($data!="")
        {
            if( in_array(trim(strtolower($data)),$ary) )
            {
                throw_exception("引數有非法引數");
            }
            else
            {
                //匹配 not in  、not between、not like
                preg_match('/^not[\ ]+(in|between|like)$/',trim(strtolower($data)), $matches);
                if( !empty($matches) )
                {
                    throw_exception("引數有非法引數");
                }
            }
        }
    }
}
也就是說,ThinkPHP中的exp等那一波漏洞都不能用了。這就很尷尬了,而且前臺可以輸入的地方真的不多。
沒辦法,挨著看有點受不了,就開始搜尋$_GET$_POST以及$_REQUEST之類的全域性變數,其實除了這三種方法獲取輸入,系統還使用了I方法(我特麼真的是服了,I方法是TP-3.1.3之後才新增的,這裡系統顯示的是TP-3.0)。
果不其然,發現了唯一一處字串拼接的地方
$saleData = M('report')->where("(infoid='{$this->userinfo['id']}' or userid='{$this->userinfo['id']}') and id={$_GET['id']}")->find();
問題到這裡就解決了,其實它還用到了I方法獲取輸入,參考這裡。我看了看,這個完全是TP-3.2的東西啊,最開始應該早就走了彎路。好吧,沒有發現字串拼接的地方。

三、邏輯漏洞

朋友告訴我,這套系統註冊的功能關閉了,雖然是前臺注入,還是很雞肋啊。掩面哭泣啊,這種MVC的框架,一般驗證登入狀態都在父類中做好了。
無奈,看了很久登入驗證的問題,沒有繞過去。也就是說,沒有登入無法繞過又沒有賬號是沒法注入的。似乎到現在已經陷入了僵局,此時我看了看網站目錄,我發現網站還有個應用Install
如果能夠重灌也是極好的,因此看了看程式碼

下面是控制器的程式碼
if((isset($_REQUEST['step']) ? $_REQUEST['step']:'') !='done' && ACTION_NAME != 'done' && file_exists('./install.lock')){
    die('重新安裝請刪除/Install/install.lock檔案');
}
只要我們傳遞step=done就可以繞過構造器了。接著看敏感函式,發現了一個比較有意思的函式create_admin,通常在安裝程式的時候會有建立管理員這一步,這裡居然屬性設定為了public,二話不說,新增一個管理員賬號。
public function create_admin()
{
    //建立超級管理員帳號
    $Install   = D('Install');
 
    $result        = $Install->create_admin($this->langs);
 
    if( !$result ) exit( '建立管理員帳號失敗' );
 
    echo 'OK';
}

四、命令執行

現在已經擁有後臺許可權、並且後臺可以新增前臺使用者來進行SQL注入。因此現在更加關注的寫檔案、程式碼執行、命令執行等等。所以就搜尋了一下關鍵函式,發現一處比較關鍵的。

function backup(){
    $name='新資料備份';
    $name = I("post.backname/s")==""?$name:I("post.backname/s");
    if(adminshow('cliSwitch')){
        //判斷Windows還是Linux
        if(IS_WIN){
            $ini = ini_get_all();                    
            $path = $ini['extension_dir']['local_value'];           
            $php_path = str_replace('\\', '/', $path);           
            $php_path = str_replace(array('/ext/', '/ext'), array('/', '/'), $php_path);           
            $real_path = $php_path . 'php.exe';
            chdir(ROOT_PATH);//更改當前工作路徑
            $cmd = $real_path." ".ROOT_PATH."clibr.php Backup backall backname,".$name." >recerr.log";
            pclose(popen("start /B ". $cmd, "r"));  
        }else{
            chdir(ROOT_PATH);
            $cmd="php ".ROOT_PATH."clibr.php Backup backall backname,".$name." >recerr.log";
            exec($cmd . " &",$out,$re);
        }
        $this->ajaxReturn(array(),"正在備份中",0);
    }else{
        $result=$this->backall($name);
        if($result==""){
            $this->ajaxReturn(array(),"備份完成,用時".G('run','end').'秒',1);
        }else{
            $this->ajaxReturn(array(),$result,0);
        }
    }
}
看了下關鍵函式adminshow,其實就是檢視配置檔案而已。它檢查ADMIN_SHOW這個欄位中是否含有cliSwitch的值,我看了看,預設是沒有的;如果有,就會造成字串拼接導致命令執行漏洞。
function adminshow($shows){
    $adminshowss=explode(',',CONFIG('ADMIN_SHOW'));
    if(in_array($shows,$adminshowss)){
        return  true; 
    }
    return false;
}
接下來搜尋ADMIN_SHOW,檢視是否有設定這個配置檔案的地方,果然有。
public function save(){
    $showstrss = '';
    foreach($_POST as $k=>$v)
    {
        if($k!='LOG_RECORD' && $k!='APP_DEBUG' && $k!='LOG_LEVEL')
        {
          $showstrss.=",".$k;
        }
    }
    M()->startTrans();
    CONFIG('ADMIN_SHOW',trim($showstrss,","));
}

在這個控制器下面,就可以設定ADMIN_SHOW這個鍵的配置引數了。

五、全域性過濾

我發現,在執行命令的時候發現有些引數被過濾了。它配置了DEFAULT_FILTERhtmlspecialchars預設過濾了一些字元。因此,在命令拼接的時候可以使用|或者||,在寫檔案的時候可以使用wget或者bitsadmin之類的命令