php反序列化
Contents
前言
以前一直看 K0r3zn 师傅的一些文章,最近终于去加了师傅的好友 : )。师傅说学习要有一些知识的产出,那我就把前段时间学的知识整理一下吧~
序列化与反序列化
在 php 中,常见的序列化就是使用serialize()
将一个对象中的信息用字符串的形式记录。因为对象是在内存中存储的,其会随着脚本结束而销毁。但有些情况下需要将对象当前的状态保存下来,比如其中含有一些之后可能用到的属性。对象序列化可以将对象的状态通过数值和字符记录下来,便于存储和传输。在需要的时候将对象反序列化恢复后使用。序列化的对象可以是class
、array
等。
反序列化就是使用unseralize()
将序列化后的字符串,还原成相应的对象,即序列化的逆过程。
对象的属性初始化
当给一个对象的属性初始化时,可以给其属性赋予number
、string
、array
、null
、bool
类型的值。
1 | class test { |
如果在属性声明的时候赋予object
类型,则会报错。但是可以在该对象的方法中为属性赋予object
类型的值。
1 | class LL {} |
序列化格式
1 | class LL { |
这里所有属性都定义为 public 类型,对象序列化的大致格式如下:O:类名长度:"类名":对象的属性个数:{属性1;属性2;属性3;...}
属性1、属性2…等属性的具体格式如下:
1 | number类型:s:属性名的长度:"属性名";i:属性值 |
其中array
类型和object
类型的属性具体表示与上述格式一致。
注意:序列化对象时,不会保存常量的值,但是会保留父类中的属性。
访问修饰符
public、private、protectedpublic(公有)
:公有属性和方法可以在类的内部和外部访问。private(私有)
:私有属性和方法只能在其被定义的类内部访问,不会被继承。protected(受保护)
:受保护的属性和方法只能在内部访问,存在于任何子类中,可以被其自身以及其子类和父类访问。
前面使用的属性都是被定义为public
,其序列化的格式如上。而被定义为private
和protected
的属性值在属性名处有所区别,其他部分同public
。
1 | class LL {} |
可以看到private
属性名name
变成了testname
,而且该属性名的长度不是8,而是10。protected
属性名age
变成了*age
,长度不是4,而是6。
实际上,private
属性名的格式为%00类名%00属性名
,即%00test%00name
,而protected
属性名的格式为%00*%00属性名
,即%00*%00name
。它们都增加了两个空字符%00
,所以长度和实际看到的相差了2。
我觉得可以这样记:
因为private
属性只能在其被定义的类内部访问,且不会被继承,所以要在该属性前加上其类名
,告诉我们它是私有的。而protected
属性可以在其父类和子类中访问,所以在其前面加上*
,告诉我们它是受保护的。最后不要忘记加上两个空字符%00
。
php序列化只会对属性进行操作,不会对方法进行序列化。因为平常我们可能只需要在1.php
中记录一个对象的状态,而在2.php
中也有该类的定义,那我们便可以对1.php
中的对象进行序列化,到2.php
中进行反序列化取出该状态(属性值)去做后续处理。也可以将1.php
中的实例化的对象长久地保存在磁盘等设备上。
反序列化格式
1 | class test { |
php反序列化漏洞
php 反序列化漏洞,又称 php 对象注入漏洞。因为序列化对象时,只会存储其属性,所以该漏洞可利用的一个基本条件是对象中的属性可控,除此之外,我们可能还需要一些函数的帮助来触发该漏洞。
魔术方法
php中将所有以__
开头的类方法保留为魔术方法,它们都有一些特殊的用法。
常见的有:__construct()
、__destruct()
、__call()
、__callStatic()
、__set()
、__get()
、
__isset()
、__unset()
、__sleep()
、__wakeup()
、__toString()
、__invoke()
1 | __construct([ mixed $args = "" [, $...]]) : void |
这里要注意toString()
触发的条件,当该对象被传入任何接收string
类型参数的函数中时都会触发,且在将对象与字符串进行连接和比较等操作时也会触发。
overloading
动态地“创建”类属性和方法。通过魔术方法来实现。
当调用当前环境下未定义或不可见得类属性或方法时,重载方法会被调用。
1 | public __call( string $name, array $arguments ) : mixed |
为什么说需要这些魔术方法呢?因为我们只是单纯地控制某一个变量的属性并不能造成什么危害,而当我们通过这些魔术方法当作跳板,去调用其他类中的一些危险的存在利用点的方法时,就会产生更大的危害。
例题
以 plaidctf-2014 中的 kpop 题目为例。实战一下,通过魔术方法去调用其他类中的方法达到写 shell 的目的。
下面是classes.php
文件中我们主要利用的类:
这题就不细讲了。我们可以看到从Lyrics
类中的魔术方法__destruct()
开始。会一路调用能过滤数据、写数据到日志的其他类。但是最后写入数据的logs
目录,我们没有访问权限,而路径是由LogWriter_File
类中的$filename
属性控制,且过滤数据的规则是由OutputFilter
类的属性控制,根据前面可知,只要我们能找到可控的利用点,传入我们序列化后的数据,我们就可以自由的改变类中属性的值。
仔细读一下代码,看懂了整个逻辑后,可以发现在import.php
中存在反序列化操作:
1 | $data = unserialize(base64_decode($_POST['data'])); |
所以整体思路就是,我们序列化恶意的数据,其中控制路径和过滤的类属性根据我们需要修改,传入import.php
中,脚本结束后调用__destruct()
方法产生漏洞。
我们可以简单复现一下。就只写出需要用到的类和方法,并把import.php
中的入口写在同一文件。
接下来是构造 payload 的脚本:
将该值上传,便可在相应的目录下看到 shell 文件:
为了更好的理解整个过程,我是通过一个一个类去调用。其实这题因为Song
类中和log
类中有同名的方法log()
,而Lyrics
类的__destruct()
调用了log()
方法,所以我们可以省去构造Song
类这一步。这也是反序列化中需要注意的,我们可以通过同名方法实现调用其他类中的方法从而构造pop链。
不构造Song
类可以参考: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%8B%E5%B8%B8%E8%A7%81%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95/
__wakeup()绕过
适用于php 5.x < php 5.6.25
和php 7.x < php 7.0.10
。
当序列化后的字符串中表示对象属性个数的值大于真实的属性个数时,在对该字符串进行反序列化的时候就会跳过__wakeup()
的执行。
1 | class Test { |
在unserialize()
反序列化之前,会先调用__wakeup()
方法。我们可以通过该技巧绕过__wakeup()
中的属性检查或属性初始化。
session反序列化
当session_start()
被调用或者 php.ini 中session.auto_start
被设置为 1 时,php 内部会调用会话管理器,访问用户的$_SESSION
,并将其中的值序列化后存入指定目录的文件中。
php.ini 中有关参数:
1 | session.auto_start 指定会话模块是否在请求开始时启动一个会话。默认为0不启动。 |
三种处理器
我们从参数session.serialize_handler
开始:
主要看php
和php_serialize
这两种序列化机制:
1 | ini_set('session.serialize_handler', '...') // php | php_serialize |
当 session.serialize_handler=php 时,/tmp 目录下的 session 文件内容为:name|s:2:"LL";
当 session.serialize_handler=php_serialize 时,session 文件内容为:a:1:{s:4:"name";s:2:"LL";}
可以看到 php_serilaize 机制就是直接对数组 $_SESSION 调用了 serialize() 。而 php 机制格式是键名|反序列化的键值
。
产生原因
session 反序列化漏洞产生的原因主要是因为,将信息存入 session 文件时和将信息从 session 文件中取出来时,使用了不同的 session 处理器。
仔细看一下 php 处理器的格式,会发现它是通过|
分割数组键和值的,即当要从 session 文件中取出信息时,它会先去找|
分割符,找到后,将前面的部分直接取出作为键名,后面的部分调用 serialize() 反序列化后作为键值放入$_SESSION数组中。
那如果我们向 session 文件中存入数据时,使用的是 php_serialize 处理机制,比如存入$_SESSION['name']='|evil'
,可见我恶意加了一个|
分割符,存进去后,session 文件内容为a:1:{s:4:"name";s:5:"|evil";}
,因为在调用 serialize() 进行序列化的时候,会将键值直接放入序列化后的字符串中的某一部分,可见|evil
被直接写进去了。那如果我们从 session 文件中取数据时,用了 php 处理机制,根据前面所说,会取出$_SESSION['a:1:{s:4:"name";s:5:"'] = 对 evil";} 进行反序列化后的值
,而|
后面的数据在我们传入时可控,在取出时会对后面的值进行反序列化。
例题
1 | error_reporting(0); |
这里借用了 lctf 2018 的一行代码call_user_func($_GET['a'], $_POST);
。我们先生成一个序列化后的 test 类,值为O:4:"test":0:{}
。
通过call_user_func($_GET['a'], $_POST)
在存储 session 信息时,改变序列化 session 的处理器,默认为 php,我们改为 php_serialize 。具体可以去看一下session_start()
手册内容。
以 php_serialize 方式存储 session 信息,记得加|
:
可以看到我们成功将序列化的 test 类存储进 session 文件中,并且是以 php_serialize 格式:
带着上一步服务器返回的PHPSESSID
,不传入 post 数据,即等于直接调用session_start()
,此时默认处理器为 php ,取出数据时以|
分割,将后面部分反序列化成 test 类,当调用 __destruct() 时读出 flag :
上传表单
上面的情况是我们可以控制存储和取出 session 信息时,php 对 session 信息的处理方式,存储的时候以 php_serialize 机制序列化,取出的时候以 php 机制反序列化,因为格式的差异导致部分数据可控。
那如果我们无法任意控制 php 对 session 数据的处理呢?我们可以利用另外一种技巧。
session.upload_progress.enabled
是一个默认开启的选项,它会将上传文件时的进度信息,填充进 $_SESSION 数组。
当一个文件上传时,如果同时 post 一个与 php.ini 中设置的session.upload_progress.name
的值同名的变量,上传进度将会写入$_SESSION
中。session.upload_progress.name
默认值是PHP_SESSION_UPLOAD_PROGRESS
。
当 php 检测到这种 post 请求时,会在 $_SESSION 中添加一组数据,索引是session.upload_progress.prefix
的值加上 post 中PHP_SESSION_UPLOAD_PROGRESS
对应的值。session.upload_progress.prefix
默认值是upload_progress_
。
为了方便,我们把session.upload_progress.cleanup
设置为Off
。
例题
题目直接给出了源码:
这里看到开启了session会话,那我们就直接去看一下phpinfo()
中 session 部分的设置:
可以看到默认环境中是php_serialize
的处理器,而因为该脚本调用了ini_set()
修改了配置项,所以脚本中是php
处理器。
还可以看到当OowoO
类调用__destruct()
方法时,会执行eval()
函数,而传入该函数的是OowoO
类的属性$mdzz
,我们只要控制了该属性便可以构造一个 webshell。
为了看到具体过程,我们在本地复现一下。在最后打印一下 $_SESSION 可以看出反序列化时的操作。
要提交的序列化数据:
1 | class OowoO { |
提交表单:
执行了echo 'll';
:
总结一下整个过程,首先通过上面所讲的上传技巧,将上传文件的信息放进了 $_SESSION 中,该信息的一部分内容我们可以控制,然后 php 会以默认的环境中的处理器,在这里是 php_serialize ,处理该内容并存储进了 session 文件中。在从 test.html 转到 test.php 页面后,test.php 页面中因为通过ini_set()
修改了 session 处理器为 php ,所以该页面以 php 反序列化的格式取出了该内容,在反序列化时将OowoO
类还原,而该类中的$mdzz
属性是我们可控的。
从上图的 session 文件中看到,因为在 test.php 中是以 php 格式取出该 session 文件的内容,可见键名是我们恶意构造的|
前面的部分,而键值是|
后面可利用的序列化值。除了在上传文件名filename
中传入序列化数据,也可以在表单的name
属性中传入序列化数据,因为两者都会在 session 文件出现,只要我们控制好|
分割符即可。
phar反序列化
从上面可知,一般的反序列化漏洞需要找到unserialize()
函数才能利用,session反序列化,可以不需要使用到该函数,但是利用条件比较苛刻(我感觉)。而在 2018 Black Hat 上的议题中讲了一种新的反序列化利用方法,利用 phar 文件会以序列化的形式存储用户自定义的meta-data
这一特性,扩展了 php 反序列化漏洞的攻击面。
phar文件结构
- a stub
一个识别标志,前面的内容没有限制,但必须以__HALT_COMPILER();?>
结尾,为了让 phar 扩展可以识别该文件是一个 phar 文件。 - a manifest describing the contents
phar 文件本身是一个归档文件,类似 java 中的 jar 文件,里面存放了各文件的权限、属性等信息。这部分还会以序列化的形式存储用户自定义的 meta-data ,这是也是该攻击手法的核心之处。
- the file contents
具体的文件内容。 - [optional] a signature for verifying Phar integrity (phar file format only)
签名,放在文件末尾。
创建phar文件
php 中内置了一个Phar
类来处理相关的操作,需要将php.ini
中的phar.readonly
设置为Off
,否则无法生成 phar 文件,且要把 phar 写入的文件夹的权限设为可写。
可以看到该文件中的 meta-data ,即存入的Test
类,是以序列化形式存储的:
php 中一大部分的文件系统函数在通过phar://
伪协议解析 phar 文件时,都会将 meta-data 进行反序列化,受影响函数如下:
受影响函数列表 | |||
---|---|---|---|
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 |
测试
在另一文件phar_test.php
中测试我们刚刚生成的test.phar
。
1 |
|
成功将序列化后的 meta-data 进行反序列化:
利用条件
- phar 文件要能够上传到服务器。
- 有可用的魔术方法作为跳板。
- 文件操作函数的参数可控,且
:
、/
、phar
等特殊字符没有被过滤。
例题
可以参考这里。
该题还在a stub
中伪造了gif的文件头。因为 php 只是通过文件头中的 stub 识别 phar 文件,确切说是__HALT_COMPILER();?>
这段代码,对前面内容或者后缀名没有要求。
学习理解php源码
最近对 php 源码很感兴趣,因为前段时间发现在一些 ctf 比赛中,经常会从源码入手实现一些利用。比如有关 SimpleXMLELement 类的 xxe 、有关 SoapClient 类的 ssrf 等。
正在努力学习《深入理解php内核》,但是看不懂…,这里就试试结合各位师傅的文章去理解一下 phar 反序列化的底层实现。
理解php流
在 php 中,fopen()
函数可以用file://
打开本地文件、可以用 url 打开远程文件、也可以用phar://
打开 phar 文件,打开后会返回一个句柄,而fread()
、fwrite()
等函数能对资源句柄进行读写操作,fclose()
可以关闭资源。那 php 是如何做到使用一致的 api 对不同数据源进行操作的呢?这是因为 php 引入了“流”的概念,为不同的资源提供了统一的接口。
参考该文章可知,fopen()
函数会先查找路径是否以http://
、ftp://
等协议开头,有则从注册的包装器列表中查找对应包装器,fopen()
返回的流对象由包装器打开。在基础级别上,stream api 定义php_stream
对象为流操作的资源。
简单来说,php 中的流就像是 unix 中的管道,通过管道,可以将我们的数据从一个地方传输到另一个地方。任何流一旦打开,也可以应用任意数量的过滤器,这些过滤器在读取/写入流时处理数据。
理解php wrapper
因为存在各种各样的可流式数据,所以包装器可以对其接口进行封装。流操作的支持和具体操作由包装器决定。同样是读取数据(fread),从文件中读和从内存中读做法不同。另外有些操作对某些流不适用。例如 http 协议支持 fread ,但不支持 fwrite 。内置的协议包装器列表参考官方文档中的Supported Protocols and Wrappers。
在 php 中默认的流封装协议是file://
,具体可参考这里。
可以通过stream_get_wrappers()
查看当前系统中注册的 wrapper :
查看源码
我们先看一下常见的fopen()
函数,定义在ext/standard/file.c line 870
( ext 是官方扩展目录,包括了绝大多数 php 的函数定义和实现):
上部分是一些变量的初始化,主要看下部分。fopen()
的主要工作是获取了流对象并通过php_stream_to_zval()
转化成 php 值类型( zval )返回。流对象由php_stream_open_wrapper_ex
函数返回。ctags 跟一下,发现其宏定义在main/php_stream.h line 573
( main 目录主要实现了 php 的基本设施):
_php_stream_open_wrapper_ex()
在main/streams/stream.c line 1984
,结合该文章可以看到_php_stream_oprn_wrapper_ex()
调用了php_stream_locate_url_wrapper()
获取协议包装器( wrapper ),再调用相应的包装器打开资源并返回流对象。
前面说过流操作的支持和具体操作由包装器决定,实际上,流包装器会调用 php_stream 中 ops 成员的具体函数,这些函数在包装器打开流时被正确赋值。看一下_php_stream_wrapper_ops
,在main/php_stream.h line 162
:
可以看到,一个 wrapper ,支持的功能有:打开文件、删除文件、重命名文件以及获取文件的 metadata 。所以可以知道文件系统函数是通过这个 stream api 进行操作的。
再看一下file_get_contents()
的定义,发现其和fopen()
一样,都调用了php_stream_open_wrapper_ex()
函数。根据上面分析,该函数调用了php_stream_locate_url_wrapper()
获取 wrapper。
phar 组件注册了phar://
这个wrapper,即fopen()
和file_get_contents()
这些函数都能通过php_stream_locate_url_wrapper()
找到该 wrapper。看一下phar://
这个 wrapper 的定义:
这些函数都会最终都会调用phar_var_unserialize()
。
总结一下,以fopen()
为例,其他文件系统函数同理。首先一个流包装器会调用 php_stream 中 ops 成员的具体函数,phar 组件注册了一个phar://
wrapper ,定义了 ops 中的部分函数,fopen()
是通过 ops 成员提供 stream api 进行操作,且fopen()
能通过php_stream_locate_url_wrapper()
找到phar://
wrapper,当调用该 wrapper 的功能时,因为每个函数实现都最终调用了phar_var_unserialize()
,所以所有文件系统函数都可以触发此 phar 漏洞。
这些文件系统函数的一个共同特点就是,都调用了php_stream_locate_url_wrapper()
去获取 wrapper ,所以可以全局搜索一下调用该函数的php_stream_open_wrapper_ex()
。
整理
exif
- exif_thumbnail
- exif_imagetype
gd
- imageloadfont
- imagecreatefrom…
hash
- hash_hmac_file
- hash_file
- hash_update_file
- md5_file
- sha1_file
file/url
- touch()
- get_meta_tags
- get_headers
standard
- getimagesize
- getimagesizefromstring
zip
1 | $zip = new ZipArchive(); |
测试
当phar://
不能出现在前几个字符时候,可以使用compress.bzip2
或者compress.zlib://
:
出现场景
内容来自owasp:
在应用程序中,序列化可能被用于:
- 远程和进程间通信(rpc/ipc)
- 连线协议、web服务、消息代理
- 缓存/持久性
- 数据库、缓存服务器、文件系统
- http cookie、html表单参数、api身份验证令牌
防御方法
因为反序列化的缺陷可能导致远程代码执行等严重的攻击,所以我们需要对其进行防护:
- 对传入 unserilize() 的参数,进行严格地过滤。
- 在文件系统函数的参数可控时,进行严格地过滤。
- 严格检查上传文件内容,不能只是单纯地检查文件头
- 条件允许的情况下,禁用可执行系统命令、代码的危险函数。
- 注意不同类中的同名方法的编写,避免被用作反序列化的跳板。
总结
本文只是我对 php 反序列化漏洞的一点点理解与思考,也参考了很多的好文章。里面可能存在许多错误,尤其在源码学习这一部分,主要加上了很多在我看完文章分析后的个人理解,仅当作一点点参考,详细地分析可以到下面的链接中去学习 : )。
ref:
https://www.k0rz3n.com/2018/11/19/一篇文章带你深入理解PHP反序列化漏洞/#0X05-利用-phar-拓展-PHP-反序列化的攻击面
https://mochazz.github.io/2018/12/30/PHP%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96bug/
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/
https://paper.seebug.org/680/
https://blog.zsxsoft.com/post/38?from=timeline&isappinstalled=0
https://tlanyan.me/php-review-stream/
https://laravelacademy.org/post/7459.html%EF%BC%8C%E9%82%A3%E4%B9%88%E8%BF%99%E4%B8%AA%E8%AF%B4%E6%98%8E%E4%BA%86%E4%B8%80%E4%B8%AA%E4%BB%80%E4%B9%88%E9%97%AE%E9%A2%98%E5%91%A2%EF%BC%9F%E8%AF%B4%E6%98%8E%E6%88%91%E4%BB%ACPHP