一篇文章从0到1学习PHP反序列化
PHP反序列化
什么是序列化
序列化(Serialization)是将对象的状态信息(属性)转换为可以存储或传输的形式的过程。
他的目的就是存储对象信息,所以对象—>字符串,将对象或者数组转换为可存储/运输的字符串
介绍一点格式:
1 | |
为了更好的理解反序列化,举几个栗子:
1 | |
结果:Oobject:4类名长度:”test”类名:1变量数量:{s:3变量名字长度:”pub”变量名字;s:6值的长度:”benben”变量值;}
注意:不能序列化类;可以序列化对象
只序列化成员变量;不序列化成员函数
1 | |
结果:O:4:”test”:1:{s:9:”testpub”;s:6:”benben”;}
我们发现private私有属性会在变量名字前面加上当前所在的类名。
但是我们注意到,”testpub”明明是七个字符,为什么结果显示的却是9呢?
这是因为private私有属性序列化时,在变量名前加“%00类名%00”,所以应该是7+2=9个。(当我们用url编码结果时就会发现test前后多了%00,即空字符)
1 | |
结果:O:4:”test”:1:{s:6:”*pub”;s:6:”benben”;}
protected受保护属性序列化时,在变量名前加%00*%00
1 | |
结果:O:5:”test2”:1:{s:3:”ben”;O:4:”test”:1:{s:3:”pub”;s:6:”benben”;}}
实例化后的对象$a的成员变量’ben’调用实例化后的对象$b。
反序列化特性
1、反序列化之后的内容为一个对象。
2、反序列化生成的对象里的值,由反序列化里的值提供,与原有类预定义的值无关。
3、反序列化不触发类的成员方法(成员函数)(魔术方法除外),需要调用方法后才能触发。
魔术方法
1 | |
__wakeup()绕过方法
当序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行
演示一下:
1 | |
直接反序列化应该是/demo.php?a=O:4:"Test":1:{s:4:"name";s:5:"joker";}
但为了绕过wakeup:/demo.php?a=O:4:"Test":2:{s:4:"name";s:5:"joker";}
贴个直接替换语句的脚本,因为自己改也实在太累了:
1 | |
session简单介绍
在计算机中,尤其是在网络应用中,称为“会话控制”。Session 对象存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有会话,则 Web 服务器将自动创建一个 Session 对象。当会话过期或被放弃后,服务器将终止该会话。
当第一次访问网站时,Seesion_start()函数就会创建一个唯一的Session ID,并自动通过HTTP的响应头,将这个Session ID保存到客户端Cookie中。同时,也在服务器端创建一个以Session ID命名的文件,用于保存这个用户的会话信息。当同一个用户再次访问这个网站时,也会自动通过HTTP的请求头将Cookie中保存的Seesion ID再携带过来,这时Session_start()函数就不会再去分配一个新的Session ID,而是在服务器的硬盘中去寻找和这个Session ID同名的Session文件,将这之前为这个用户保存的会话信息读出,在当前脚本中应用,达到跟踪这个用户的目的。
session的存储机制
PHP中的Session中的内容并不是放在内存中的,而是以文件的方式来存储的,存储方式就是由配置项session.save_handler来进行确定的,默认是以文件的方式存储。存储的文件是以sess_sessionid来进行命名的,文件的内容就是Session值的序列化之后的内容。
先来大概了解一下PHP Session在php.ini中主要存在以下配置项:
| Directive | 含义 |
|---|---|
| session.save_handler | 设定用户自定义session存储函数,如果想使用PHP内置会话存储机制之外的可以使用本函数(数据库等方式)。默认为files |
| session.save_path | 设置session的存储路径,默认在/tmp |
| session.serialize_handler | 定义用来序列化/反序列化的处理器名字。默认使用php。 |
| session.auto_start | 指定会话模块是否在请求开始时启动一个会话,默认为0不启动 |
| session.upload_progress.enabed | 将上传文件的进度信息存储在session中。默认开启 |
| session.upload_progress.cleanup | 一旦读取了所有的POST数据,立即清除进度信息。默认开启 |
PHP-SEEESION反序列化
在PHP中Session有三种序列化的方式,分别是php,php_serialize,php_binary,不同的引擎所对应的Session的存储的方式不同(默认是使用php)
| 存储引擎 | 存储方式 |
|---|---|
| php_binary | 键名的长度对应的 ASCII 字符 + 键名 + 经过 serialize() 函数序列化处理的值 |
| php | 键名 + 竖线 + 经过 serialize() 函数序列处理的值 |
| php_serialize | (PHP>5.5.4) 经过 serialize() 函数序列化处理的数组 |
举几个栗子说明一下三种引擎的存储方式:
- php:
1 | |
user|s:5:”admin”;uid|i:1;
- php_binary:
1 | |
\x02ab i:1;\x01c i:2;
- php_serialize
1 | |
a:2:{
s:4:”user”;s:5:”admin”;
s:3:”uid”;i:1;
} //a是array
php-session反序列化漏洞点在于:
使用不同的引擎来处理session文件
$_SESSION变量直接可控
php引擎的存储格式是键名|serialized_string,而php_serialize引擎的存储格式是serialized_string。如果程序使用两个引擎来分别处理的话就会出现问题
结合大师傅给的例子介绍一下序列化时用php_serialize,反序列化时用php时的情况:
漏洞的主要原因在于不同的引擎对于竖杠’ | ‘的解析产生歧义。
对于php_serialize引擎来说’ | ‘可能只是一个正常的字符;但对于php引擎来说’ | ‘就是分隔符,前面是$_SESSION[‘username’]的键名 ,后面是GET参数经过serialize序列化后的值。从而在解析的时候造成了歧义,导致其在解析Session文件时直接对’ | ‘后的值进行反序列化处理。
当时看到这里的时候就有疑问,在使用php引擎读取Session文件时,为什么会自动对’ | ‘后面的内容进行反序列化呢?也没看到反序列化unserialize函数。
这是因为使用了session_start()这个函数 ,看一下官方说明:https://www.php.net/session_start/

展示栗子:
1 | |
1 | |
首先访问demo1.php,构造反序列化exp如下:
1 | |
提交payloadhttp://demo/demo1.php?username=|O:4:"demo":1:{s:8:"username";s:5:"admin";}
在上面中demo1.php是用php_serialize来存储session,传payload后存储内容是
1 | |
访问demo2.php的时候,session_start();会read() session 中存的username变量,由于此时php引擎变为了php,我们又恶意构造了|,php会将|后的value进行反序列化,然后我们demo对象中的username就变为了admin,使得输出yes

上面是$_SESSION变量可控的情况,但其实合理点的代码都不会让你直接写入session。不过话又说回来了,上有政策下有对策,$__SESSION变量不可直接控制我们也有办法。
在PHP中还存在一个upload_process机制,即自动在$_SESSION中创建一个键值对(key:value),value中刚好存在用户可控的部分,可以看下官方描述的,这个功能在文件上传的过程中利用session实时返回上传的进度。

从上面的大概描述大概得知此漏洞需要session.upload_progress.enabled为on,在上传文件的时候同时POST一个与session.upload_process.name的同名变量。后端会自动将POST的这个同名变量作为键进行序列化然后存储到session文件中。下次请求就会反序列化session文件,从中取出这个键。所以漏洞的根本原因还是使用了不同的Session处理引擎。
(
顺手举个例子说明一下这个索引的生成规则,可以帮助我们更好理解整一个流程
key 的生成规则是:
1 | |
也就是说:
1 | |
会生成:
1 | |
)
借高校战疫的一道CTF题目来讲述一下
1 | |
我们注意到这样一句话ini_set(‘session.serialize_handler’, ‘php’);,因此不难猜测本身在php.ini当中的设置可能是php_serialize,在查看了phpinfo后得证猜测正确,也知道了这道题的考点
那么我们就进入phpinfo查看一下,enabled=on表示upload_progress功能开始,也意味着当浏览器向服务器上传一个文件时,php将会把此次文件上传的详细信息(如上传时间、上传进度等)存储在session当中 ;只需往该地址任意 POST 一个名为 PHP_SESSION_UPLOAD_PROGRESS 的字段,就可以将filename的值赋值到session中
1 | |
接下来构造序列化payload
1 | |
由于采用Burp发包,为防止双引号被转义,在双引号前加上\,除此之外还要加上|
在这个页面随便上传一个文件,然后抓包修改filename的值

可以看到Here_1s_7he_fl4g_buT_You_Cannot_see.php这个文件,flag肯定在里面,但还有一个问题就是不知道这个路径,路径的问题就需要回到phpinfo页面去查看

因此我们只需要把payload,当中改为print_r(file_get_contents("/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php"));即可获取flag
再讲个大师傅的本地搭的案例吧
在php.ini中对以下选项进行配置:
1 | |
session.auto_start = on 表示PHP在接收请求的时候会自动初始化Session,不再需要执行session_start()。
session.serialize_handler = php_serialize 表示默认使用php_serialize引擎进行存储。
session.upload_progress.cleanup = On 导致文件上传后,Session文件内容立即清空,这个时候就需要利用时间竞争,在Session文件内容清空前进行包含利用。
class.php
1 | |
index.php
1 | |
通读class.php文件,发现漏洞点在于可以通过调用foo3类中的eval方法造成命令执行漏洞。这是一个类的普通方法,要让这个方法执行,需要构造一个POP链。
- foo3类中execute方法没有发现调用的地方,但是在foo2类中的魔术方法__toString()中发现调用了同名方法,这里可以把foo2类中的$obj实例化为foo3类的对象,这样只要调用__toString()就相当于调用foo3类中execute方法。
1 | |
- 如果想触发foo2类中的魔术方法__toString()被触发,就需要foo2类或者类下的一个对象被当作字符串调用。而在foo1类中发现echo了一个对象,这里可以把foo1类中的$varr实例化为foo2类的一个对象,这样通过echo,把一个对象当作一个字符串调用,就可以触发foo2类中的__toString()方法。
1 | |
(tip:把“对象”传给 file_exists(),本质上会发生「对象 → 字符串」转换
👉 如果对象实现了 __toString(),就会触发它
👉 这是一个经典的 POP 链触发点
但⚠️在 PHP 8 之前和之后,行为不完全一样。在php8后不会发生转化为字符串了,而是会发生报错)
class.php文件的调用链为:
1 | |
思路有了现在来构造payload:
1 | |
再来分析一下index.php文件:
1. 发现使用php引擎来读取Session文件,而系统默认是使用php_serialize引擎来存储Session, 通过不同引擎的差异解析就可以反序列化rce。
2. 文件直接require(class.php),并且紧接着实例化一个foo1类的对象,这意味着使用php引擎解析完Session文件,反序列化payload直接就可以rce。
本地创建 up_sess.html,一个向 index.php 提交 POST 请求的表单文件,其中包括PHP_SESSION_UPLOAD_PROGRESS变量。
1 | |
在文件上传的时候使用burp抓包,在 PHP_SESSION_UPLOAD_PROGRESS 的 value 值中添加’ | ‘和序列化的字符串,payload为:
1 | |

由于session.upload_progress.cleanup = On,我们要用时间竞争在session文件清掉前完成

开始爆破

phar伪协议触发反序列化
通常我们在利用反序列化漏洞的时候,只能将序列化后的字符串传入unserialize(),随着代码安全性越来越高,利用难度也越来越大。但在不久前的Black Hat上提出利用phar文件会以序列化的形式存储用户自定义的meta-data这一特性,拓展了php反序列化漏洞的攻击面。该方法在文件系统函数(file_exists()、is_dir()等)参数可控的情况下,配合phar://伪协议,可以不依赖unserialize()直接进行反序列化操作。
phar介绍和漏洞原理
phar就是php压缩文档。它可以把多个文件归档到同一个文件中,而且不经过解压就能被php访问并执行,与file://,php://等类似,也是一种流包装器。
phar文件有四部分构成:
1. a stub
识别phar拓展的标识,格式为:xxx<?php xxx; __HALT_COMPILER();?>,对应的函数 Phar::setStub。前期内容不限,但必须以 __HALT_COMPILER();?>结尾,否则phar扩展将无法识别这个文件为phar文件。
2. a manifest describing the contents
phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是漏洞利用的核心部分。对应函数Phar::setMetadata—设置phar归档元数据。
3. the file contents
被压缩文件的内容。
4. [optional] a signature for verifying Phar integrity (phar file format only)
签名,放在文件末尾。对应函数Phar :: stopBuffering—停止缓冲对Phar存档的写入请求,并将更改保存到磁盘。
这里有两个关键点:
文件标识,必须以 __HALT_COMPILER();?> 结尾,但前面的内容没有限制,也就是说我们可以轻易伪造一个图片文件或者pdf文件来绕过一些上传限制
反序列化,phar存储的meta-data信息以序列化方式存储,当文件操作函数通过phar://伪协议解析phar文件时,文件内容会被解析成phar对象,然后phar对象内的meta-data会被反序列化。
meta-data是用serialize()生成并保存在phar文件中,当内核调用phar_parse_metadata()解析meta-data数据时,会调用php_var_unserialize()对其进行反序列化操作,因此会造成反序列化漏洞。
而在一些上传点,我们可以更改phar的文件头并且修改其后缀名绕过检测,如:test.gif,里面的meta-data却是我们提前写入的恶意代码,而且可利用的文件操作函数又很多,所以这是一种不错的绕过+执行的方法。
构造有序列化的phar文件
本地生成一个phar文件,要想使用Phar类里的方法,必须将php.ini文件中的phar.readonly配置项配置为0或Off

PHP内置phar类,其中的一些方法如下:
1 | |
生成phar文件的代码如下:
phar.php
1 | |
运行代码会生成一个phar.phar文件在当前目录下,使用winhex打开

可以明显的看到meta-data是以序列化的形式存储的,有序列化数据必然会有反序列化操作,php一大部分的文件系统函数在通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化,测试后受影响的函数如下:
| ****受影响的文件操作函数列表*** | ** ** | ||||
|---|---|---|---|---|---|
| fileatime | filectime | file_exists | file_get_contents | touch | get_meta_tags |
| file_put_contents | file | filegroup | fopen | hash_file | get_headers |
| fileinode | filemtime | fileowner | fileperms | md5_file | getimagesize |
| is_dir | is_executable | is_file | is_link | sha1_file | getimagesizefromstring |
| is_readable | is_writable | is_writeable | parse_ini_file | hash_update_file | imageloadfont |
| copy | unlink | stat | readfile | hash_hmac_file | exif_imagetype |
这些函数里面可以使用phar协议,当然还有常用的文件包含的几个函数 include、include_once、requrie、require_once
漏洞利用条件:
phar文件要能够上传到服务器端(如GET、POST),并且要有file_exists(),fopen(),file_get_contents(),include()等文件操作的函数
要有可用的魔术方法作为”跳板”;
文件操作函数的参数可控,且:,/,phar等特殊字符没有被过滤。
虽然某些函数能够支持phar://的协议,但是如果目标服务器没有关闭phar.readonly时,就不能正常执行反序列化操作。
在禁止phar开头的情况下的替代方法:
1 | |
虽然会报warning,但是还是会执行。
下面用2023京麒CTF solo-pop这道题目来给仔细讲讲
robots.txt提示下方两个文件:redis.conf、htaccess.txt

其中redis.conf提供了redis的密码:

.htaccess中的规则允许php解析.wupco后缀文件:

接下来分析index.php文件,test_upload类存在反序列化入口,可以调用对象的checkname方法

check类会检测后缀名,当后缀为jpg、png返回true

接下来看具体逻辑,这里提供了一个上传点,将我们上传的文件保存到check.jpg中

这里会判断check.jpg的文件内容,不允许check.jpg包含ph、\x、<?字样

程序的最后会用file_exists判断文件是否存在,且参数完全可控,因此本处存在phar反序列化

这里的攻击思路如下:
上传一个check.jpg,内容为phar文件的内容,当触发phar反序列化时会调用test_upload#__destruct(也许你会疑惑这里为什么会调用destruct,是因为phar自动反序列化对象后对象生命周期结束,所以调用了)接着控制check属性为SoapClient对象,由于会调用一个不存在的方法,因此会发起SSRF请求,我们控制SoapClient对redis发起请求,往网站根目录写文件完成RCE。
(SoapClient 是 PHP 内置类,来自SOAP拓展,要通过extension引入,extension=soap,当SoapClient)对象访问一个不存在的方法时,会认为这是一个SOAP RPC调用,根据设置的location自动发起http请求),迅速抛出栗子:
1 | |
提一下原本SoapClient的设计初衷吧,可以帮助理解
SOAP 是 远程调用协议,当时设计这个就不是为了调用本地方法,而是调用远程方法,方法名不是本地函数而是 RPC 名称
1 | |
但这里可以出网的特性就给攻击者埋下了可以利用的伏笔,而且SoapClient ≠ 只支持 HTTP
具体实现流程如下:
- 用AUTH认证密码,然后CONFIG SET设置备份路径和文件名,SET一个webshell,保存备份
- 再传给test_upload的check属性,然后将test_upload对象打包成phar文件
- 重命名为exp.jpg
- 注意webshell的位置不要带空格,不然会导致SET失败
1 | |
运行完脚本可以产生一个exp.jpg文件,这个jpg其实是个phar但为了符合白名单重命名,但不影响里面的内容
由于题目用正则匹配了文件内容,所以直接上传上面的包是不行的,check.jpg会被删掉,所以我们需要对exp.jpg做处理:
利用序列化字符串16进制绕过,将s改成S
✅
s和S在反序列化层面是等价的写法 含义 s普通字符串 S允许 16 进制转义的字符串 把?做16进制编码加个反斜杠,即\3f
由于还会匹配ph字样,所以把h也做16进制编码,编码成\68

因为phar文件的末尾会做签名校验,所以我们需要重新计算phar文件的签名:
1 | |
运行之后会生成datou.jpg,这个文件就是重新签名之后的,再用requests发个包,先把文件上传上去,再去触发phar反序列化
1 | |

其实感觉这里好像用gopher+ssrf直接访问redis好像也可以,抽空复现打打看
参考文章:
https://www.yuque.com/dat0u/ctf/sderglp3e16086dg