SecMap - 反序列化,PHP 篇
PHP 反序列化,需要一些 PHP 面向对象编程的基础,如果你还没掌握,建议阅读:
https://www.runoob.com/php/php-oop.html
来光速入个门
为什么有序列化与反序列化的存在呢?首先思考一个问题,假如我想把一个变量的值保存在硬盘上而不是内存里,应该怎么做呢?可以选择保存在文本文件里,也可以选择保存在数据库里。那么进一步,如果是一个对象呢?
1 | |
这是 PHP 的一个类,而 T 是 A 的一个实例。如果我们想把 T 存在硬盘上,应该怎么存呢?你可能会想,我可以记录一下类的名字 A,然后在记录一下它有一个 public 属性是 $a,然后利用 json 存:
1 | |
如果需要还原,则反过来对应的处理。看起来这个方法还可以,但是如果这个类更加复杂呢?比如我们变量值:
1 | |
该怎么办呢?
这就是序列化与反序列化解决的问题,序列化 负责按照规定的格式,将一个对象的重要信息转换为可以存储或可以网络传输的形式;反序列化 从存储中读取或从网络接收一个已经被序列化的对象,按照规定的格式,重新创建该对象。
在 PHP 中,序列化函数是 serialize(),反序列化函数是 unserialize(),比如上面那个例子,输出是 O:1:"A":1:{s:1:"a";i:1;}
要注意的是:
1 | |
更多类型的字母表示,可以参考这个:
https://www.php.net/manual/zh/function.serialize.php#66147
我们知道,PHP 可以对属性或方法的访问控制:
那么显然,这肯定也会影响序列化的结果,比如:
1 | |
对 T 进行序列化,得到:O:1:"A":3:{s:1:"a";N;s:4:"*b";N;s:4:"Ac";N;}:
1 | |
首先来看 s:4:"*b";N;,长度为明明为 2,为什么是 4 呢?原因是前后都有空字符:

所以,对于 protected 的类成员,名称的格式为 %00*%00+属性名。
再看上图,s:4:"Ac";N; 道理也是一样的,所以对于 private 的类成员,名称的格式为 %00 + 类名 + %00 +属性名。
不过这个是有例外的,如何不使用 %00 对 protected、private 类成员进行序列化(同时反序列化的时候也要能成功),这个考点在 CTF 中经常出现,后面会说。
总之,序列化的格式对我们非常重要,因为后面构造或者修改攻击向量的时候都要按照这个格式来。为了方便起见,本文演示的均为 public 类成员,其他类型其实是同理的。
先举个最简单的例子:
1 | |
如果 $GET 可控的话(比如 http 的 get 参数),那么很容易就能篡改 a 的值:
1 | |
来通过 check() 的检查。
通过这个例子,再次强调一下,PHP 序列化攻击的核心方法就是控制类的属性
这里顺便提一下,让我们自己去按照反序列化格式写 payload,太麻烦了,所以一般是先写好攻击代码,然后序列化输出,就是 payload 了。
由于序列化不会对类方法进行操作,所以我们就算能篡改属性,也需要有拥有这个属性的某个方法被调用,才能完成攻击,所以攻击场景比较少。好在我们还可以利用 PHP 的魔术方法,帮助拓展一下攻击场景。
首先列一下常见的魔术方法(Magic methods):
__construct: 当对象创建时会自动调用,(注意,在 unserialize 时不会自动调用)__destruct: 当对象被销毁时自动调用__sleep: serialize 时自动调用__wakeup: unserialize 时自动调用__get: 当从不可访问的属性读取数据时自动调用__set(): 用于将数据写入不可访问的属性__call: 在对象上下文中调用不可访问的方法时自动调用__callStatic: 在静态上下文中调用不可访问的方法时自动调用__isset: 在不可访问的属性上调用 isset() 或 empty() 时自动调用__unset: 在不可访问的属性上使用 unset() 时自动调用__invoke: 当尝试将对象调用为函数时自动调用__toString: 用于一个对象被当成字符串使用(如 echo、拼接等)时自动调用那么这有啥用呢?
__destruct,里面可以写一段用于关闭数据库连接的代码,这样只要实例被销毁,就会自动关闭数据库连接。所以如果条件合适,魔术方法里的属性本身就可以被利用。这提升了反序列化出现的可能性。我打算用一个例子说明上面的用途,实例代码如下:
1 | |
在这里例子中,由于 $user 并没有调用 check,所以无法通过 check 函数来实施反序列化攻击(用途 1),但是由于有 __destruct 的存在,所以我们可以利用它来完成反序列化攻击(用途 1、2):
1 | |
这样我们就可以控制执行的流程,要执行 class B、C 里的 check 都可以。
如果你还无法理解,那么再来看一个例子:
1 | |
对于这个例子来说,由于危险的函数都被过滤了,所以我们没法直接利用 gettime 中的 call_user_func 完成攻击。
那么换个思路,由于 unserialize 没有被过滤,那么反序列化攻击是否可行呢?虽然 Test 中没有任何自定义的方法可以让我们利用,但是有 __destruct,它会在实例销毁的时候自动运行,最后它还会调用 gettime,调用 gettime 就意味着调用了 call_user_func,并且反序列化时我们可以控制 $this->func 和 $this->p,所以相当于 call_user_func 的参数是可控的,那么答案就呼之欲出了:
1 | |
所以这样就可以执行命令了:
1 | |
总结一下,魔术方法提升了反序列化触发的可能性与出现的可能性。
接下来玩点更有意思的
上面提到,private 和 protected 的类成员,需要额外添加控制字符 %00。这个特征经常被 WAF ban 掉。而在序列化内容中使用大写 S 表示字符串时,此时就支持将后面的字符串用 16 进制表示,所以就可以使用 \x00 来表示 %00。这个手法常用于绕过 WAF 或者 CTF 题(例如网鼎杯 AreUSerialz)。
最后,对于 7.2 及以上版本的 PHP,序列化 private 和 protected 的类成员时,可以直接用 public 了。所以复现的时候需要注意一下版本。
POP 面向属性编程(Property-Oriented Programing),缩写看起来很牛逼,其实就是先找到最后需要触发的语句,然后往上层根据调用链一步一步溯源到最开始触发的语句,分析好调用链后,再一步步从触发语句构造序列化结果。
先举个比较简单的例子:
1 | |
首先分析一下思路:
__destruct 执行 class C 的 check,这一步很简单,和上面的例子是一样的。You are root,还需要通过 class C 中 check 的 if,而由于 hash 的不可逆性,我们不知道 8f95eca949e2ec377434ea3fea1cc381 对应的原字符串是什么,但是如果我们同时篡改 $md5 和 $password,就可以通过 if 的检查了:1 | |
上面例子仅仅只有两层,下面再举个更加复杂一些的例子:
1 | |
首先一样,从要触发的语句出发,一步步分析:
__toString 里,而我们知道,__toString 是 string1 的实例,被当做字符串处理的时候会触发__invoke,会将 $this->mod1 当做字符串来拼接,所以我们需要让 $this->mod1 == string1 的实例,来触发 class string1 的 __toString。而 __invoke 是将对象当做函数来调用的时候会触发__call,里面有个 $s1(),那么只要让 $s1 == func 的实例,即可触发 class func 的 __invoke。而 __call 是调用一个不存在的方法是会触发__destruct 调用了 $this->mod1 的 test1 方法;class Call 中有个 test1 调用了 $this->mod1 的 test2 方法。那么很明显,class start_gg 的 __destruct 可以用于触发 class funct 中的 __call;如果要用 class Call 的 test1 也不是不可以,但是也需要经过 class start_gg 的辅助,所以直接使用 class start_gg 比较简洁。分析完毕,从后往前构造调用链即可:
1 | |
综上,POP 算是寻找反序列化漏洞的标准思路。
在《非常见协议大礼包》中搁置的一个知识点:
https://www.tr0y.wang/2021/05/17/SecMap-非常见协议大礼包/#phar
在这里补全。
phar 文件介绍
首先看一下 phar 文件的格式:
__HALT_COMPILER(),这是 phar 的文件标识,让 phar 扩展识别这是一个标准的 phar 文件用的。所以最小的 stub 就是 <?php __HALT_COMPILER();举个创建 phar 的示例:
1 | |
代码很短,需要注意的地方却很多:
.phar 后缀,否则会报错:...Cannot create phar 'phar.gif', file extension (or combination) not recognised...。但是生成之后,就可以随意更改文件名了。$phar->setStub("GIF89a<?php __HALT_COMPILER();"); 伪造成 gif 文件这里看一下生成之后的 test.phar(注意,生成的时候需要加上 phar.readonly=0 配置):

可以看到,class A 已经被序列化存储。
有了 test.phar 之后,我们就可以 include 了:
1 | |
利用
前提:
phar.readonly=Off其实这个技巧一句话就能说明白:phar 文件中的 meta-data 信息以序列化方式存储,当函数通过 phar:// 伪协议解析 phar 文件时,就会自动将数据反序列化。
假如有以下代码:
1 | |
以上面那个 test.phar 为例,由于 phar:// 伪协议会自动反序列化 meta-data,而我们已经篡改了 $msg,攻击的目的就达成了。
当然,受影响的函数可不仅仅只有 file_get_contents,强烈建议看一下资料 1、2,里面提到了一些技巧非常有意思:
compress.bzip2://phar://test.phar/test.txt 或者 compress.zlib://phar://test.phar/test.txt,可以!(虽然会有 warning)@$pdo->pgsqlCopyFromFile,可以!mysqli_query($m, "LOAD DATA LOCAL INFILE ..."),可以!其他更多的姿势或者函数,我觉得 CTF 还是用的多一些,平时我们也用不到。
PHP 的 session 介绍
先说一下 PHP 处理 session 的一些细节信息。
PHP 在存储 session 的时候会进行序列化,读取的时候会进行反序列化。它内置了多种用来序列化/反序列化的引擎,用于存取 $_SESSION 数据:
php: 键名 + | + 经过 serialize()/unserialize() 处理的值。这是现在默认的引擎。php_binary: 键名的长度对应的 ASCII 字符 + 键名 + 经过 serialize()/unserialize() 处理的值php_serialize: 直接使用 serialize()/unserialize() 函数。这是好像是以前默认的引擎(5.x)。session 相关的信息,可以在 phpinfo 里查到:

所以,在我这个 PHP 的配置中,不会自动记录 session,所以运行的时候需要改为一下 auto_start;session 内容是以文件方式来存储的(文件以 sess_ + sessionid 命名);由于存储的路径为空,所以运行的时候需要指定一下;序列化/反序列引擎为 php。
例如我们测试一下下面这个代码:
1 | |
用三种不同的引擎来处理 session:

是不是很简单?可以看到,session 文件里保存着的是反序列化之后的数据。
利用
那么这个有什么用呢?其实一句话就能说清楚:
不同的序列化/反序列化引擎对数据处理方式不同,造成了安全问题。
引擎为 php_binary 的时候,暂未发现有效的利用方式,所以目前主要还是 php 与 php_serialize 两者混用的时候导致的问题。
漏洞利用的原理,我觉得直接看例子比较直观。
注:这里我没搭 web 环境,因为 cli 完全可以用于测试
首先搞个读取 session 的文件:
1 | |
然后再来个生成 session 的代码:
1 | |
这个 session 用 php_serialize 序列化的结果是:
1 | |
如果我们用 php 引擎来解析这个结果,会得到什么呢?

为什么会这样呢?回顾上面,php 引擎的格式为:键名 + | + 经过 serialize()/unserialize() 处理的值。那么对于这个例子来说,name 就是 a:2:{s:5:"name0";s:4:"Tr0y";s:5:"name1";s:11:",s:4:"Tr1y"; 就是待反序列化的值。那么这里就非常清楚了,本质上就是通过 | 来完成注入(" 负责闭合格式,防止解析错误),让 php 引擎误以为前面全是 name,这样参与反序列化的数据就可以由我们来控制了。
举个例子吧:
1 | |
假设存 session 的时候,用的是 php_serialize,然后上面这个是会用到 session 的代码。
那么我们可以尝试在 session 注入如下内容:
1 | |
即可达到利用的目的:

这个技巧我觉得还是 CTF 多一些,研发也不太会去修改读存 session 的引擎,混用就更少见了。
这是一个 PHP 的 CVE,影响版本:
一句话就可以说清楚利用方式:当序列化字符串中表示对象中属性个数的数字,大于真正的属性个数时,就会跳过 __wakeup 函数的执行(会触发两个长度相关的 Notice: Unexpected end of serialized data)。
举个例子:
1 | |
在这个例子中,由于 class A 存在 __wakeup,里面初始化了 test 为 class B,所以按照常规来说,__destruct 里的 $this->test 就一定是 class B。而如果利用 CVE-2016-7124,将 $GET 中 class A 的属性个数改为 2,就可以绕过 __wakeup 的运行,从而在执行 __destruct 的 test 就是 class C 了:
1 | |
注:如果你不想搭建 PHP 低版本环境来测试的话,对于这么简单的例子,完全可以找个在线运行 PHP 的测试,比如:
https://www.dooccn.com/php/
最后说一下在 cli 中如何给诸如 $_GET 这种参数指定值呢?细心的橘友可以本文的图片中找到答案:
php -r '$_GET["func"] = "phpinfo"; require("./test.php")'
这样就可以给 test.php 中的 $_GET["func"] 赋值并运行 test.php
搭建 web 环境,太麻烦了
phar:// 利用的一些姿势 1:https://files.ripstech.com/slides/PHP.RUHR_2018_New_PHP_Exploitation_Techniques.pdfphar:// 利用的一些姿势 2:https://xz.aliyun.com/t/2958
反序列化篇还有后续
Java 和 Python 的
但我最近忙着做各种 “选择题”
精力不太能跟得上
慢慢写