前言
最近的工作中遇到了一个使用MODX搭建的网站,而这个站碰巧licong师傅也看过,在他的指导下开始了对MODX源代码的审计
版本探测
MODX的管理路径为xxx.com/manager
直接google搜索MODX history release
,从搜索结果中可以找到两个网站提供历史发行版本下载:
实际上MODX有两套CMS,一套叫做evolution,另一套叫做revolution
通过下载这两套CMS并进行本地搭建之后与网站进行对比,确定网站所使用的的版本:
搜索相应版本是否存在漏洞
在官网上找到一个<=1.1
的RCE漏洞
网站上的描述是说MODX的Ajaxsearch, eForm和evoGallery组件存在远程命令执行漏洞,就只有这么一句话,下面我们就根据这条线索对源代码进行审计
环境搭建
MODX安装
下载完对应版本的源代码之后解压到根目录,访问进行安装,我这里使用的是php5.3 nts,跟着安装向导走,在创建数据表的地方会报错
老版本的MySQL使用TYPE而不是ENGINE,我使用的mysql版本为5.5.53,已经不支持TYPE,因此需要全局替换
出现如下页面即代表环境搭建成功
调试环境的搭建
php调试选择xdebug,这里我使用的是开发工具是IDEA,IDEA会自动检测到php文件并提示安装php相关插件,php运行环境的配置如下,xdebug的安装方式可以通过点击进行官网查看
由于审计过程中需要使用postman构造POST请求,为了能够正常调试,需要在请求的URL后面加上xdebug的session:?XDEBUG_SESSION_START=14759
注意每次调试XDEBUG_SESSION_START的值都会改变
在调试代码的过程中,我们可能会想要输出中间变量,这里有一个小技巧,就是使用print_r
输出数组的时候可以使用如下方式进行输出,方便我们查看数组结构:
审计代码
根据官网的描述,我们先从Ajaxsearch组件入手,使用Seay源代码审计系统自动审计,先筛选出存在远程命令执行漏洞风险且与Ajaxsearch组件相关的文件
在MODX 1.0.5的源代码中Ajaxsearch组件的入口文件只有一个evolution-1.0.5\assets\snippets\ajaxSearch\ajaxSearchPopup.php
直接在该文件的入口下断点,单步调试,调试完改文件之后得出的结论就是,payload中必须包含search
和as_version
,其中前者只要值不为空即可,后者值必须为1.9.2
该文件中除了run方法之外,不存在能够出发代码执行的操作,因此跟入run方法
run方法是AjaxSearch
类的方法,它负责完成ajax搜索,通读该类文件发现需要再跟入到AjaxSearchCtrl
类的run方法中
跟入run方法,执行AjaxSearchResults
类的getSearchResults
方法,根据上面自动审计的结果,该类文件的_doFilter
方法可能存在命令执行漏洞
$this->_filterValue = eval(substr($filterArray[1], 5));
在_doFilter
方法处下断点进行调试,发现我们根本没有进入该方法,直接返回了结果
我们回到AjaxSearch
类文件中,在调用AjaxSearchCtrl
类的run方法处下断点跟入
发现$valid
的值为false,无法进入可能存在漏洞的代码,因此我们跟入AjaxSearchInput
类的display方法一探究竟
问题出在AjaxSearchInput
类文件的165行,由于我们的AjaxSearchConfig
类对象的成员变量cfg值为null,导致$this->asCfg->cfg['maxWords']
为一个不存在的值,任意一个有值的变量都满足该if条件,最终导致valid变量为false
asCfg
成员变量是一个引用,在AjaxSearchInput
类的init方法中初始化,在此方法体中下断点
查看调用栈,找到$asCfg
变量初始化的地方:AjaxSearch
类文件,if (!$asCfg->initConfig($msgErr)) return $msgErr;
下断点跟入initConfig
方法,发现cfg成员变量是dcfg和ucfg合并之后的结果,dcfg是evolution-1.0.5\assets\snippets\ajaxSearch\configs\default.config.php
定义的一个关联数组,而ucfg来自此处$this->ucfg = $this->parseUserConfig(strip_tags($_POST['ucfg']));
他是我们通过POST请求传入的参数,跟入parseUserConfig
方法(我做了一些变量的输出,重点关注parseUserConfig
方法体):
<?php
//对传入的ucfg参数进行处理
function parseUserConfig($strUcfg) {
$ucfg = array();
// &一串不包含=的字符串=`一串不包含`的字符串`
$pattern = '/&([^=]*)=`([^`]*)`/';
//要匹配的内容在两个括号里,$out最终会变成一个长度为3的数组,第一个存储的是匹配出的所有模式,
//第二个存储的是第一个括号里的内容,第三个存储的是第二个括号里的内容
//根据输入的字符串,可能会匹配出多组,第一个括号里的模式存在out[1],第二个括号里的模式存在out[2]中
preg_match_all($pattern, $strUcfg, $out);
echo "<pre>";
print_r($out);
echo "<pre>";
foreach ($out[1] as $key => $values) $ucfg[$out[1][$key]] = $out[2][$key];
//处理完成后$ucfg会变成一个关联数组
//如果我们的$$strUcfg的值是&a=`aksdajldasjds`
//则$ucfg的值为[a] => aksdajldasjds
return $ucfg;
}
$strUcfg = '&a=`24yhgr5w3bsf`&b=`24wrgsfgesdg`';
$ret = parseUserConfig($strUcfg);
echo "<br />";
echo "<hr>";
echo "<br />";
echo "<pre>";
print_r($ret);
echo "<pre>";
运行结果:
现在我们的POST参数如下:
现在让我们回到这个断点:$this->_filterValue = eval(substr($filterArray[1], 5));
在前面的分析中,我们知道了AjaxSearchConfig
对象的cfg成员变量是由dcfg和ucfg使用array_merge
方法合并之后形成的,dcfg我们不可控,可控的变量是ucfg,ucfg是一个关联数组,我们开始的payload是:
&a=`aksdajldasjds`
形成的关联数组是a => aksdajldasjds
,如果我们把ucfg参数的值构造为下面的样子:
&a=`aksdajldasjds`&filter=`6986758`
则生成的关联数组是a => aksdajldasjds filter => 6986758
,所谓后来者居上,array_merge
方法会让后面的关联数组的键覆盖已经存在的键,如此一来,$this->asCfg->cfg['filter']
就是可控的变量了
现在我们直接将断点下在代码执行的地方
发送POST请求,未进入else分支,分析if分支的代码,回溯$filterArray
变量,发现该变量是由我们可控的变量$this->asCfg->cfg['filter']
使用,
分割生成的数组,我们要想进入else分支,就要满足substr($filterArray[1], 0, 5) == "@EVAL"
,这就要求我们构造的filter键的值包含,
不然数组长度为1,$filterArray[1]
根本就不存在,且,
后面的前5个字符应该为@EVAL
,后面为我们要执行的命令,最终构造出来的ucfg的值应该为:
&a=`aksdajldasjds`&filter=`6986758,@EVALCODE`
至此,代码审计完成,成功触发RCE漏洞
后记
本次代码审计的整体过程还是比较简单的,因为源代码并未对用户的恶意输入进行任何过滤
再次感谢licong师傅在此次审计过程中的指导
这里有一个地方需要说一下,在调试过程中我更改了ajaxSearchPopup.php
的代码(这是很严重的错误操作,代码审计是绝对不允许更改源代码的)
因为在调试过程中总是出现某些常量没有被定义的情况,因此直接将配置文件手动包含了进来,后来从官网论坛中了解到,必须要通过根目录下的index-ajax.php
进入ajaxSearchPopup.php
,一些变量的定义也是在该文件中进行的,不通过index-ajax.php
是无法使用$modx
变量的
在index-ajax.php
中进行了配置文件的引入
因此直接按照上面截图中的POST请求是无法触发RCE的,其实只需要分析index-ajax.php
对POST请求做一下修改即可,详情不再赘述
这里学到的一个经验就是,如果出现常量未定义的错误,可以去找一下常量是在哪个文件中定义的,然后找出所有引用了该文件的代码,分析和漏洞代码有关联的代码,最终确定入口