php通过序列化和反序列化来存储和传递一些数据。
序列化和反序列化
serialize和unserialize
serialize() 将一个对象转换成字符串,保存对象的值方便之后的传递与使用。
unserialize()将序列化了的字符串还原为一个对象。
php序列化格式
1 |
|
结果:O:6:"people":2:{s:4:"name";s:4:"test";s:3:"age";s:2:"18";}
除了代表对象的O,还有:
1 | a - array 数组 |
其中,S
表示转义的二进制字符串,不可见字符都会表示成\xx
的形式,比如
1 | $a = chr(0); |
对于类对象的序列化
- var和public声明的字段都是公共字段,因此它们的字段名的序列化格式是相同的。公共字段的字段名按照声明时的字段名进行序列化。
- protected声明的字段为保护字段,在所声明的类和该类的子类中可见,但在该类的对象实例中不可见,因此保护字段的字段名在序列化时,字段名前面会加上
\0*\0
的前缀。(这里的\0
表示ascii码为0的字符) - private声明的字段为私有字段,只在所声明的类中可见,在该类的子类和该类的对象实例中均不可见。因此私有字段的字段名在序列化时,字段前面会加上
\0<declared class name>\0
的前缀。(这里的表示的是声明该私有字段的类的类名,而不是被序列化的对象的类名。 - 字段名被作为字符串序列化时,字符串值中包括根据其可见性所加的前缀。字符串长度也包括所加前缀的长度。其中 \0 字符也是计算长度的。
一个例子:
1 |
|
结果:O:6:"people":3:{s:4:"name";s:4:"test";s:11:"<0x00>people<0x00>age";s:2:"18";s:7:"<0x00>*<0x00>home";s:7:"sichuan";}
魔术方法
魔术方法 | 触发条件 |
---|---|
__construct() | 构造函数,创建对象时触发 |
__destruct() | 析构函数,对象被销毁时触发 |
__call() | 在对象上下文中调用不可访问或不存在的方法时触发 |
__callStatic() | 在静态上下文中调用不可访问的方法时触发 |
__get() | 用于从不可访问或不存在的属性读取数据 |
__set() | 用于将数据写入不可访问或不存在的属性 |
__isset() | 在不可访问的属性上调用isset()或empty()触发 |
__unset() | 在不可访问的属性上使用unset()时触发 |
__sleep() | 使用serialize()函数时触发 |
__wakeup() | 被unserialize()反序列化时触发 |
__toString() | 一个类被当做字符串时触发。用于一个类被当成字符串时应怎样回应。例如 echo $obj; 应该显示些什么。此方法必须返回一个字符串,否则会产生错误 |
__invoke() | 当尝试以调用函数的方式调用一个对象时触发 |
__set_state() | 当调用 var_export()导出类时,此静态方法会被调用 |
__clone() | 对象复制可以通过clone 关键字来完成,此时将调用对象的__clone()方法 |
__debuginfo() | 转储对象以获取应显示的属性时,此方法由var_dump()调用 |
php反序列化漏洞
php使用unserialize()
函数将序列化了的字符串反序列化为对象,而当传入unserialize()
的参数可控时,可以通过传入特定字符串来控制对象内部的变量。
漏洞利用条件:unserialize()参数可控
魔法函数可能造成的威胁
PHP中反序列化导致实例化对象,除了利用PHP本身的漏洞以外,我们通常找__destruct
、__wakeup
、__toString
等方法,看看这些方法中是否有可利用的代码。其中__destruct
在对象销毁的时候被调用,__wakeup
在唤醒(反序列化)的时候被调用,__toString
在对象被转换成字符串的时候被调用。
优先级:__wakeup>__toString>__destruct
__toString():
其实,当反序列化后的对象被输出在模板中的时候(转换成字符串的时候),就可以触发相应的漏洞。
常见__tostring触发条件:
echo ($obj)
/print($obj)
打印时会触发- 字符串连接时
"I am {$obj}"
或'test'.$obj
- 格式化字符串时
sprintf('i am %s', $obj)
- 与字符串进行
==
比较时(PHP进行==
比较的时候会转换参数类型)if($obj == 'admin')
- 格式化SQL语句,绑定参数时
- 使用
in_array
进行比较时in_array($obj, ['admin', 'guest'])
数组中有字符串的时候会被调用 - 使用
switch
进行比较时1
2
3
4
5
6
7
8switch ($obj) {
case 'admin':
# code...
break;
default:
# code...
break;
}
__wakeup()方法的绕过
CVE-2016-7124漏洞:__wakeup
触发于unserilize()
调用之前, 当反序列化时的字符串所对应的对象的数目大于实际数目时,__wakeup()函数就不会被调用. 并且不会重建为对象, 但是会触发其他的魔术方法比如__destruct()
适用版本:
PHP5.x系列为5.6.25之前
php7.x系列为7.0.10之前
例如:
1 | class test |
此时,正常的序列化字符串为:O:4:"test":1:{s:4:"code";s:18:"echo phpversion();";}
可以修改对象属性数目来绕过__wakeup
:O:4:"test":2:{s:4:"code";s:18:"echo phpversion();";}
php反序列化字符串的一个特性
对象长度前添加一个"+"
号,反序列化时不会有影响
例如:O:4:"test":1:{s:4:"code";s:18:"echo phpversion();";}
== O:+4:"test":1:{s:4:"code";s:18:"echo phpversion();";}
由此,可以绕过一些简单的正则匹配,比如:preg_match('/O:\d:/', $data)
php7.2+反序列化的特点
php7.2+以上的版本反序列化时对属性类型不敏感,具体属性类型是public
还是其他取决于类的定义,例如:
1 | class A{ |
在php7.2以上的版本执行以上代码时会将将name
和age
反序列化为protect
和public
的属性
而在php7.2以下的版本则不会
php7反序列化的特性
看一个例子:
1 |
|
payload:O:1:"B":1:{s:3:"obj";O:1:"A":2:{s:3:"var";s:14:"var_dump(123);";;}}
通过调试源码可以发现,在php7中,反序列化过程是将序列化字符串依次分解,获得反序列化后的类型和值,在这个过程中,会将反序列化的对象生成并保存为临时对象,如果反序列化失败,则将调用相应函数来摧毁临时对象,并释放内容。在释放已经生成的临时对象时,仍然会调用相应的魔法函数(如__destruct
)
实际上,可以描述为:
获取反序列化字符串 –> 根据类型进行反序列化 —> 查表找到对应的反序列化类 –> 根据字符串判断元素个数 –> new
出新实例 –> 判断是否具有魔法函数__wakeup
并标记 –> 迭代解析化剩下的字符串 —> 释放空间并判断是否具有具有标记 —> 开启调用__wakeup
。
分析一下上面的payload反序列化的整个过程:
- 创建
B
类的实例 - 判断
B
没有__wakeup
B.obj = A
类的实例- 创建
A
类的实例 - 判断
A
有__wakeup
并标记 A.var="var_dump(123);"
A
实例出错B
实例出错,反序列化错误- 进行
B
实例的销毁,因为B
实例没有__wakeup
标记,所以执行B
的__destruct
后进行B
实例的销毁 B
实例的销毁包含了A
实例的销毁,所以进行A
实例的销毁,因为A
实例有__wakeup
标记,所以不会执行__destruct
,直接销毁unserialize
函数返回false
phar反序列化
关于phar
简单的说,phar就是php的压缩文件,它可以把多个文件归档到同一个文件中,而且不经过解压就能被 php
访问并执行,与file://
,php://
等类似,也是一种流包装器。
phar结构由4个部分组成:
stub
phar 文件标识,格式为xxx<?php xxx; __HALT_COMPILER();?>
manifest
压缩文件的属性等信息,以序列化存储contents
压缩文件的内容signature
签名,放在文件末尾
需要注意的:
stub
部分的文件标识,必须以__HALT_COMPILER();?>
结尾,但前面的内容没有限制,也就是说我们可以轻易伪造一个图片文件来绕过一些上传限制;manifest
部分的反序列化,phar存储的meta-data
信息以序列化方式存储,当文件操作函数通过phar://伪协议解析phar文件时就会将数据反序列化,而这样的文件操作函数有很多。signature
部分,如果修改了前面的内容,则需要重新计算签名,计算签名的脚本:1
2
3
4
5
6
7
8from hashlib import sha1
with open('a.phar', 'rb') as file:
f = file.read()
s = f[:-28] # 获取要签名的数据
h = f[-8:] # 获取签名类型和GBMB标识
newf = s + sha1(s).digest() + h # 数据+签名+(类型+GBMB)
with open('a.new.phar', 'wb') as file:
file.write(newf) # 写入新文件
利用
利用条件:
- phar文件要能够上传到服务器端
- 要有可用的魔术方法作为“跳板”
- 要有文件操作函数,如
file_exists()
,fopen()
,file_get_contents()
,file()
- 文件操作函数的参数可控,且
:
、/
、phar
等特殊字符没有被过滤
测试:
如果有文件test.php
1 |
|
生成phar的文件phar.phar
可以这样写:
1 |
|
这样,当我们访问phar.php时,将会生成test.phar的phar文件。再将其上传到服务器上,最后以phar格式访问该文件就可getshell
绕过
更改文件格式
当上传文件的格式有特殊要求,例如gif等,就可以修改phar文件的stub
部分的内容,让其写入图片的文件头,即可绕过这类检测。
1 | $phar -> setStub('GIF89a<?php __HALT_COMPILER();?>'); //设置stub,添加gif文件头 |
除此之外,phar文件名字可以随意更改,因此针对后缀名的检测可以很简单的绕过。
绕过Phar关键字检测
如果对包含的文件进行了phar开头的过滤,比如:
1 | if (preg_match("/^php|^file|^phar|^dict|^zip/i",$filename){ |
可以使用如下方式绕过:
1 | // 即使用filter伪协议来进行绕过 |
绕过__HALT_COMPILER
检测
方法一:将序列化后的内容放到压缩包注释中,压缩为zip文件,如下:
1 | $a = new user(); |
利用时直接phar://phar.zip
即可反序列化成功
方法二:将phar文件使用gzip压缩,使用时直接phar包含gzip压缩后的文件即可