蟬知 CMS5.6 反射型 XSS 審計復現過程分享
- 2019 年 10 月 7 日
- 筆記
最近在深入學習反射 XSS 時遇到蟬知 CMS5.6 反射型 XSS 這個案列,乍一看網上的漏洞介紹少之又少,也沒有詳細的審計復現流程。雖然是 17 年的漏洞了,不巧本人正是一個喜歡鑽研的人。這個 CMS 引起我極大的興趣。在基本沒有開發經驗的前提下,目前只對 MVC 有一點很淺顯的了解後我打算啃下這塊硬骨頭,並且這也是我第一個較完整的審計復現的一個 CMS,前前後後用了接近 3 天的時間才差不多搞懂觸發的流程,對我來說可以說是非常艱難了,幸運的是我還是啃了下來。
可能這個漏洞不新鮮,但是我想說的是發現漏洞的過程,漏洞引發的思考價值遠遠高於漏洞本身,所以我打算將這個不怎麼完美的審計流程分享出來,讓初學者少走一些彎路。文章中可能會有很多不足的地方,還望各位大佬不要吝嗇一一指出。
0x01 初現
本次審計參考《蟬知 CMS v5.6 user-deny 反射型 XSS漏洞》
https://www.seebug.org/vuldb/ssvid-92660)
網易雲課堂王松老師的 XSS 課程
復現環境:apache+php5.4
測試工具:vscode+phpstorm
先來看看漏洞描述:
蟬知開源版 CMS v5.6 在
user
模組的deny()
方法中渲染模板文件時,對用戶輸入的參數進行渲染,且沒有正確處理,導致可繞過一些過濾,從而造成反射性 XSS。
具體該deny()
方法在system/module/user/control.php
文件,該模板文件是template/default/user/deny.html.php
文件。
payload:
"><script>alert(1);</script><"
經過三次編碼後
%252522%25253e%25253cscript%25253ealert(1)%25253b%25253c%25252fscript%25253e%25253c%252522
放入鏈接
www.x.com/chanzhi/www/index.php/user-deny-%252522%25253e%25253cscript%25253ealert(1)%25253b%25253c%25252fscript%25253e%25253c%252522

為什麼這麼神奇導致了 XSS?看我一一道來
遇到 XSS 我們第一步應該看一下源碼,看看是輸出在什麼地方,怎麼輸出的,以便我們更好的去分析

可以看到在 script 標籤中被插入了我們的惡意語句,此時在後面還有很多奇奇怪怪的語句,這到底是怎麼回事呢,別急,跟著我一步步去發現
在這之前我們先來了解下什麼是MVC模式
M 即模型(Model):模型組件包含應用程式的功能內核,他封裝了相應的數據並輸出執行特定應用程式處理的過程;模型也提供訪問數據的函數。也就是說模型只會負責數據的存取。
V 即視圖(View):將資訊顯示給用戶(可以定義多個視圖)。你看到的 HTML 頁面都是通過視圖來進行展示的,也就是說視圖只會負責數據的展示。
C 即控制器(Controller):處理用戶輸入的資訊。負責從模型存取數據,然後通過視圖來展示,控制用戶輸入,並向模型發送數據,是應用程式中處理用戶交互的部分。負責管理與用戶交互交互控制。也就是說控制器本身不生產數據,它只處理數據並充當搬運工的角色。
而蟬知 CMS 正是採用其自家的zentaoPHP
框架使用 MVC 架構二次開發的
在審計的時候應該大致了解下審計程式的結構,採用的框架,目錄資訊等等,這樣在遇到複雜環境時可以知道其到底是做了什麼樣的工作 :
zentaoPHP 框架手冊地址:
http://devel.cnezsoft.com/book/zentaophphelp/about-10.html
蟬知 CMS 目錄結構:
https://www.chanzhi.org/book/chanzhieps/150.html
有興趣可以自己去了解下,因為筆者對框架還不是太熟悉,所以文中涉及結構部分可能很少。但我想說的是,做審計這是必須要懂的,而我也在一步步去了解。
在 PHP 框架中還有一個很重要的功能就是路由,作用是:
1、簡化 URL 地址,方便記憶
2、有利於搜索引擎的優化
3、URL 路由處理類進行處理後,轉發到邏輯處理類,邏輯處理類將請求結果返回給用戶。
手冊說明

所謂的 pathinfo 模式,就是形如這樣的 url:xxx.com/index.php/c/index/aa/cc,apache 在處理這個 url 的時候會把 index.php 後面的部分輸入到環境變數 $_SERVER['PATH_INFO'],它等於 /c/index/aa/cc。
知道了這麼多基礎知識後是不是覺得也不是那麼難呢。根據框架資訊,我們的輸入的數據會先進入路由,再通過路由轉發到控制器,那麼就來找找數據到底是在哪兒被接收的,處理流程是怎麼樣的。如果通過一般的方法一步步走的話我感覺對於我來說是個不小的挑戰,這裡就使用一個小技巧,既然知道數據的大致處理地方,我們就來逆推數據,尋找數據最開始的那個點。
0x02 找尋

根據漏洞描述,關鍵點在 deny 方法中對模組的處理處,那麼我們就找到 deny 方法來下個斷點

可以看到在調度類的 deny 方法中調用了 createLink 方法
官方手冊說明
$this->createLink('blog', 'view', 'id=17&cat=123')
第一個參數是模組名稱,第二個參數是方法名,第三個參數是參數,使用 key1=value1&key2=value2 這種方式來進行傳參。
如果運行方式為 PATH_INFO,這樣會生成 blog-view-17-123.html 這樣的鏈接。
如果運行方式為 GET,則生成 ?m=blog&f=view&id=17&cat=123&t=html 的鏈接。
也就是說傳入的三個參數會構造這樣一個鏈接 user-deny-1-2-3
第一個參數為我們構造的惡意腳本,在左邊調用堆棧處可以看到整個大致的調用流程。
點擊上一個回到上一步看看進行了怎樣的調用


call_user_func_array(array("user","deny"),$this->params) // 調用回調函數,並把一個數組參數作為回調函數的參數
通過左邊的變數名監視,可以看到通過該函數調用了 user 類的 deny 方法,並將模組資訊等作為參數傳入了該方法。知道了存儲惡意腳本的屬性$this->params
就好辦了。我們直接搜索$this->params
,查找對其賦值的地方

先進入第一處賦值看看

下個斷點看看,發現此處賦值點正是我們要找的,$params
為可控輸入,並在上方發現對$params
的賦值
public function setParamsByPathInfo($defaultParams = array()) { /* 分割URI。 Spit the URI. */ $items = explode($this->config->requestFix, $this->URI);//explode分割URI到$items $itemCount = count($items); $params = array(); /** * 前兩項為模組名和方法名,參數從下標2開始。 * The first two item is moduleName and methodName. So the params should begin at 2. **/ for($i = 2; $i < $itemCount; $i ++) { $key = key($defaultParams); // Get key from the $defaultParams. $params[$key] = str_replace('.', '-', $items[$i]);//循環$items元素替換.為-賦值給$params數組 next($defaultParams); }
所以這裡的的大致賦值流程為:
$this->URI=>$items[$i]=>$params[$key]=>$this->params
接下來繼續尋找$this->URI

賦值點為$this->getPathInfo();
跟進這個方法

在該方法里發現了數據的最初賦值點,之前可能做了很多初始化工作,但對URI的賦值是在這裡進行的。最後使用strpos
判斷是否有?形式的參數傳遞,這裡不存在,所以直接使用trim
處理返回了

做個測試,可以看到在整個流程開始$_SERVER['PATH_INFO']
就已經帶上了路徑資訊,是不是發現也不是那麼困難呢,只要肯動手。
所以$module
的大致流程就是:
$_SERVER['PATH_INFO'] => return $value => $pathInfo => $this->URI => $items => $params => $this->params => $module
0x03 解構
知道數據的大致流向,對於漏洞的理解會更深刻一些,而且還可能發現意想不到的東西,當然最重要的還是學習啦。
這裡我先選擇一個不會觸發 XSS 的 payload,在結合會觸發 XSS 的 payload 來學習,這樣印象會比較深刻,也比較容易理解。
經過二次編碼的 payload:
http://www.x.com/chanzhi/www/index.php/user-deny-%2522%253e%253cscript%253ealert(1)%253b%253c%252fscript%253e%253c%2522
整個流程為:
1、瀏覽器發送到伺服器的時候會對 URL 進行一次 decode
2、服務端接收到%22%3e%3cscript%3ealert(1)%3b%3c%2fscript%3e%3c%22


傳到這裡發現 URI 沒有變化,說明在前面的處理可能沒有命中,所以前面的賦值流程我就省略了
在載入 Module 時解析 URL 調用路由類中的setParamsByPathInfo
方法使用explode
函數以-對 URI 進行分割得到請求參數


獲取參數到params
數組

在 1680 行處調用mergeParams
方法,$params
作為$passedParams
參數傳入

1723 行處對使用array_values
返回了一個帶序號的數組,隨後在foreach
中遍歷$params
數組進行過濾合併請求的參數和默認參數到defaultParams
數組,關鍵點來了,在 1929 行處先使用urldecode
對惡意腳本進行解碼,再使用strip_tags
去除惡意腳本中的 HTML 標記 最後返回給$this->params

可以看到合併後惡意腳本 script 標籤被去除

緊接著使用call_user_func_array
回調控制器中的user
類的deny
方法生成拒絕頁面,$this->params
數組中的三個值作為參數傳入

deny
方法中調用了createLink
方法生成鏈接。

createLink
中使用parse_str
函數將 URL 分組

可以看到如果以這樣的形式合併到鏈接里也不會問題,問題就出在這個parse_str
函數,坑點就是默認會對傳入的字元做一次URLdecode
那麼根據這個點,我們再次對payload
URL 編碼一次,看看會怎麼樣

首先傳入的 URI 被瀏覽器解碼一次,根據前面的步驟取到 URI 中的惡意腳本

然後對惡意腳本進行了一次urldecode
並使用strip_tags
進行過濾,這時因為沒有完整的 HTML 標籤存在,所以繞過了該過濾函數。
可以看到如果以這樣的形式最終輸出也是是不會形成 XSS 的,那麼開發人員可能沒想到在經過parse_str
函數後會對該值又進行一次urldecode
,最後經過拼接直接輸出到頁面上,就這樣巧妙的繞過了過濾函數。

parse_str
函數分組後


可以看到createLink
方法返回的鏈接中包含了惡意腳本,那麼它最終又是怎麼輸出到頁面上的呢,我們繼續跟蹤下去。

隨後調用display
方法渲染模板並輸出。
根據漏洞說明在mergeJS()
方法處對 js 進行了合併,跟進到mergeJS()
方法

preg_match_all
處理的數據為$this->output
,查找賦值點

在控制器類 391 行找到賦值點,388 行使用ob_start
打開了輸出緩衝區,此方法經常在生成 HTML,或者整頁快取中使用,這時所有的輸出都會保存到緩衝區。緊接著包含視圖文件對模板進行渲染

包含 html 頭部進行渲染

在此文件中對整個 HTML 頭部進行渲染,24 行處將帶有惡意腳本的鏈接渲染到了link
標籤的href
屬性中,可以看到$mobileURL
值正是前面生成的鏈接,此時只是存入了緩衝區,還不會輸出。
但是這個$mobileURL
好像不是前面那個變數,繼續看下這個$mobileURL
是哪裡賦值的,回到控制器類,在ob_start()
函數上方發現一個熟悉的函數

相信做過 CTF 題目的小夥伴對這個函數應該不陌生,那就是extract
函數,在變數覆蓋漏洞中經常用到,該函數從數組中將變數導入到當前的符號表,使用數組鍵名作為變數名,使用數組鍵值作為變數值。

繼續渲染完頁面後回到控制器類,接下來使用了ob_get_contents
函數獲取到了輸出緩衝區的所有內容

緊接著在控制器類的mergeJS
方法中將頁面中帶有<script>
標籤的內容拼接合成為一個<script>
標籤


將帶有惡意腳本的內容合成到了一起

在 605 行從$this->output
的第 946 個位置開始替換,將帶有惡意語句的拼接 script 標籤插入了模板中

最後在控制器中調用了控制器類的 display 方法


在display
方法的結尾輸出了帶惡意腳本的頁面模板造成了 XSS
0x04 重現
第二個 XSS 漏洞由於 vscode 顯示$this->output
變數不全,無法跟蹤頁面完整渲染過程,所以接下來使用了 phpstorm 進行調試。
payload :
http://www.x.com/chanzhi/www/index.php/user-deny-1-2-aHR0cDovL3d3dy5iYWlkdS5jb20nPHNjcmlwdD5hbGVydCgzKTs8L3NjcmlwdD4n.html


惡意腳本輸出在了頁尾

和前面一樣,從 URI 中截取出了第三個參數referer
,也就是 base64 編碼的惡意腳本

通過call_user_func_array
回調deny
方法,傳入參數並賦值到view
對象$refererBeforeDeny
屬性

在控制器類 386 行轉換stdClass
對象為數組,並生成變數

在渲染拒絕頁面時使用 html 類 a 方法對參數進行了base64decode
生成了一個 a 標籤並且輸出到了頁面(存儲到了緩衝區),因為被base64
編碼了,所以繞過了前面的過濾

之後會調用mergeJS()
取到 js 腳本合併到頁面

最後輸出造成了 XSS
0x05 深思
為什麼會對參數 base64 編碼?導致過濾被繞過。相信小夥伴們也同樣困惑,那麼就一起來看看吧

在登錄頁面點擊註冊功能發現網址由


跳轉到了

很奇怪是吧,在註冊頁面應該有做許可權認證,未通過認證所以調用了 user 模組的 deny 方法渲染輸出了一個拒絕頁面,後面三個是作為參數傳入用來生成不同的頁面,其中返回前一頁按鈕鏈接正是由傳入 deny 方法的第三個參數refererBeforeDeny
決定的。因為使用了 URL 傳參,並且值為 URL,所以進行了 base64 編碼,不然會被過濾分割。
那麼我們就來跟蹤一下註冊頁面的調用流程,重點關注一下 refererBeforeDeny 是怎麼來的
現在我們知道全局的 base64 編碼都是使用的工具類中的safe64Encode
方法,先來搜索該方法的調用點

在搜索後發現一個可疑調用,在 model 中使用該方法編碼了來源頁面HTTP_REFERER
,下個斷點測試一下

果然斷下來了,該調用在 deny 方法中,在調用棧中可以看到在這之前調用了checkPriv()
方法檢查許可權
回退一下看看checkPriv()
的大致流程

在index.php
第 43 行調用了checkPriv()
,下個斷點,

在checkPriv
方法中 147 行調用isOpenMethod
判斷user
模組的register
方法是否開放
public function isOpenMethod($module, $method) { $module = strtolower($module); $method = strtolower($method); if($module == 'user' and strpos(',login|logout|deny|resetpassword|checkresetkey|yangconglogin|oauthbind|', $method)) return true; if($module == 'mail' and $method == 'sendmailcode') return true; if($module == 'guarder' and $method == 'validate') return true; if($module == 'misc' and $method == 'ajaxgetfingerprint') return true; if($module == 'wechat' and $method == 'response') return true; if($module == 'sitemap' and $method == 'index') return true; if($module == 'yangcong') return true; if(RUN_MODE == 'admin' and $this->app->user->admin != 'no' and isset($this->config->rights->admin[$module][$method])) return true; if(RUN_MODE == 'admin' and $module == 'farm' and $method == 'register') return true; if(RUN_MODE == 'admin' and $module == 'farm' and (strpos($method, 'api') !== false)) return true; if($module == 'widget' and RUN_MODE == 'admin') return true; if($this->loadModel('user')->isLogon() and stripos($method, 'ajax') !== false) return true; return false; }
在isOpenMethod
方法中可以看到默認開啟的模組和方法,並且對運行模式做了限制。

結果為 false,177 行進入到了hasPriv
鑒權函數檢查當前用戶是否有權使用user
模組的register
方法

在鑒權函數中的 212 行調用isAvailable
檢測了當前模組是否可用

可以看到該模組不在設置模組中,所以返回了 false

hasPriv
鑒權未通過。調用deny
方法在 299 行對referer
進行了編碼拼接

308 行調用createLink
生成了一個鏈接


最後調用js:locate
生成了 js 跳轉腳本

之後就是跳轉頁面調用 user 模組的 deny 方法展示拒絕頁面了。
到這裡整個流程大概清晰了,deny 方法的第三個參數 refererBeforeDeny 應該是作為拒絕頁面和跳轉頁面前一頁的介面,用於生成返回前一頁按鈕鏈接
測試一下 在不同域的根目錄新建一個鏈接頁面

點擊註冊跳轉註冊頁面

無許可權跳轉拒絕頁面並編碼傳入referer

referer
由 URL 傳入deny
方法用於生成返回前一頁按鈕鏈接
最後測試一下如果直接傳入未編碼的 URL:

在調度類 200 行調用了seo
類的parseURI
方法對 URI 進行處理

47 行被 '/' 分割賦值給module

回到調度類,http:
欄位會經過 URI 分割最終作為refererBeforeDeny
傳入deny
方法,最後渲染到頁面就是這樣

0x06 總結
第一個 XSS 和第二個 XSS 說白了都是由於對數據過濾的不充分,在多場景下沒有結合著實際對可控數據做處理,這也從側面反映出每一個點對於我們來說都是不能放過。這是個枯燥的過程,但也是提升的過程。
這兩個 XSS 審計復現下來發現自己懂的還是太少了,要學的很多。但是看到自己從一個懵懵懂懂什麼都不會的腳本小子,一路走來,看到那個遙遠的夢在一步步實現,真的會覺得自己在成長,在改變,這就夠了。
我就想這樣堅持下去,我覺得這也是我們不得不過的坎。我認為沒有什麼解決不了的問題,缺的就是耐心和時間。文中可能有很多錯誤,寫出來的目的還是希望能給初入程式碼審計的小夥伴一個思路。最後希望各位做安全的小夥伴在成功的道路上能夠越走越遠!