PHP中GC垃圾回收机制
什么是GC(PHP中的GC)
GC,全称Garbage Collection(垃圾回收机制),在 PHP 中使用引用计数和回收周期用来自动管理内存,释放不再被程序使用的变量、对象等占用的空间,避免内存泄露
垃圾回收机制默认是启用的,可以通过在php.ini配置文件中设置zend.enable_gc来触发
这种数据一旦被当作垃圾回收之后,就相当于把一个程序的结尾画上了句号,在 PHP 序列化中,__destruct()方法就不会出现无法调用的情况
因为在 PHP 中,当对象被销毁时会自动调用__destruct()方法,但如果出现程序报错或抛出异常的情况,程序会被强行中断,内存回收机制无法正常触发,而如果利用GC垃圾回收机制提前销毁对象,就可以正常执行内存回收机制,可以正常触发__destruct()方法
引用计数
每个变量(如对象、数组等复杂类型)在内存中都有一个 “引用计数器” ,记录着当前有多少个变量引用它,当内存对象被变量引用时,refcount计数器+1;当变量引用撤掉后(如执行unset()后),refcount计数器-1;当refcount计数器为0时(数组对象为NULL),表明内存对象没有被使用,该内存对象则进行销毁,垃圾回收完成

创建变量
在 PHP 中,所有变量(无论类型)在底层都被封装为一个 zval 结构体,用于存储变量的值、类型以及内存管理相关的元数据,其中有两个字节
- **第一个是
refcount:**表示当前有多少个变量(或符号)引用这个zval结构体 - **第二个是
is_ref:**是bool值,表示该变量是否通过&被显式标记为引用(PHP引擎通过这个字节来区分普通变量和引用变量,PHP允许用户使用&来使用自定义引用)当is_ref = 1时,表示该zval处于 “引用模式”,所有指向它的变量都是引用关系(共享同一份数据,修改会相互影响);当is_ref = 0时,表示该zval处于 “普通模式”,变量间是独立引用(修改一个不会影响另一个,除非触发写时复制)
1 | |

xdebug_debug_zval()函数可以打印出变量内部的zval容器结构信息
这里定义了一个新变量$a,值为string,利用xdebug_debug_zval()函数打印出其内部的zval容器结构信息
1 | |
- **
refcount=1:**表示当前有1个变量($a)引用这个zval容器 - **
is_ref=0:**表示$a是普通变量,不是引用变量 - **
string:**是这个zval储存的值
1 | |

这里添加一个引用,通过&符号将$b定义为$a的引用变量,此时$a和$b指向同一个zval容器
- **
refcount=2:**表示当前有2个变量($a和$b)引用这个zval容器 - **
is_ref=1:**表示$a是引用变量(通过&显示创建的引用关系) - **
string:**是这个zval储存的值
$a 和 $b 本质上是同一个zval的两个 “别名”,修改其中一个变量的值会同时影响另一个,如:
1 | |

这里修改了$b,打印$a会发现$a的值变成和$b一样的了,因为$b是$a的引用变量,都指向同一个zval容器
zval容器销毁
1 | |

利用unset()函数可以销毁变量
1 | |
首先定义一个$a变量,会创建一个zval容器,此时引用计数refcount=1(仅$a引用),is_ref=0($a是普通变量)
1 | |
然后利用&将$b定义为$a的引用变量,此时引用计数refcount=2($a和$b引用),is_ref=1(zval被标记引用变量)
1 | |
接着销毁$b变量,此时refcount=1(仅引用$a),is_ref=1(即使只剩一个变量,引用属性不会自动重置)
1 | |
最后销毁$a变量,此时refcount=0,zval容器被 PHP 自动销毁,所以打印zval时没有输出,提示没有这个东西
PHP反序列化中触发GC的方法
对象被unset()函数处理
GC如果在PHP反序列化中生效,那它就会直接触发_destruct方法,如:
1 | |

这里的test类中有个num属性,还有__construct()和__destruct()方法,每次触发这俩方法时都会打印出传入的参数和对应方法的拼接后的字符串
有三个test类的实例化对象$a、$b、$c,其中在实例化对象$a后使用了unset()方法销毁了对象$a
如果没有unset($a);这句代码,结果是:

在实例化对象时会先依次打印出(传入的参数)+__construct,最后销毁对象也会依次打印出(传入的参数)+__destruct()
但是如果加上了unset($a);这句代码提前销毁了对象$a,就会提前触发__destruct()方法

数组对象为NULL
同键覆盖
当对象为NULL时,对象也会被销毁,也会触发__destruct()方法
如:
1 | |

在A类中,如果触发了__destruct()方法,就会打印出flag,可以通过GET请求方法传参1上传序列化内容,在执行反序列化代码后会立刻抛出异常throw new Exception('NO NO NO');,然后程序就会立刻中断,无法触发__destruct()方法打印出flag
解决方法:
反序列化一个数组,然后写入第一个索引为对象,将第二个赋值为NULL(随意赋值)
1 | |
1 | |
a:2:表示这是一个包含 2 个元素的数组i:0;表示第一个元素的索引是 0O:1:"A":0:{}表示第一个元素是 A 类的实例(没有属性)i:1;表示第二个元素的索引是 1i:0;表示第二个元素是NULL(这里这个元素可以随意填)
可以看一下对象$a的zval
1 | |

1 | |
- **最外层:数组
$a本身的状态:**第一个refcount=1, is_ref=0是$a本身的状态,仅被$a引用,所以refcount=1,并且没有被其他变量使用&显式声明引用,是个普通常量,所以is_ref=0 - **数组元素 0:
A类对象(class A { })的状态:**第二个refcount=1, is_ref=0是数组元素0对应的A类对象的状态,仅被数组的索引0引用,所以refcount=1,并且没有被其他变量使用&显式声明引用,是个普通常量,所以is_ref=0 - **数组元素 1:
NULL的状态:**最后的refcount=0, is_ref=0是数组元素1的状态,这里的值是简单类型(如整数、字符串、NULL等),仅是存储到数组中,并且会创建独立的副本(除非使用&显式声明),这个副本没有被其他任何变量引用,所以refcount=0(在 PHP 内部,这种 “仅作为数组元素存在的简单类型” 会被视为临时值,其引用计数会被优化为0),并且没有被其他变量使用&显式声明引用,是个普通常量,所以is_ref=0
如果将结果中的i:1改为i:0,数组里有两个相同的键 0,序列化流里后出现的会覆盖前面的键,反序列化后数组的最终内容是 array(0 => NULL)
1 | |
当unserialize() 开始读取序列化字符串并逐项处理,它先见到第一个条目,创建一个 A 对象实例(内部会构造 zval/object,refcount 初始为 1),并把这个对象放到数组的键 0 的位置上,然后继续读到第二个条目(同样的键 0,值为 NULL)。反序列化过程会将数组中键 0 的值替换成 NULL,替换的过程中,原来指向 A 对象的 zval 的引用被移除,导致该对象的引用计数减少,由于这个 A 对象没有其它引用(没有变量再指向它),引用计数立即变为 0;PHP 的引用计数机制会在引用计数为 0 时立即销毁对象并调用 __destruct()

不仅可以改后面的键,也可以改前面的i:0为i:1
1 | |
unserialize() 读到第 1 个条目 i:1;O:1:"A":0:{},会创建一个 A 对象实例(内部产生一个 zval/object),把它放到数组键 1 的槽上,对象此时的refcount是 1(被数组此槽引用)
继续读到第 2 个条目时,发现又是键 1,值为 NULL。反序列化实现会把数组中键 1 的值替换为 NULL,替换过程中,原来数组槽对 A 对象的引用被移除:对象的refcount从 1 变为 0,就会销毁对象触发__destruct()了

所以只要满足两个键是相同的,就会进行覆盖,数组对原对象的引用就会被移除,refcount就会变为0,就会直接触发__destrcut()方法了
流被截断或格式错误
直接在反序列化一个数组后,将最后的花括号}删去也可以绕过抛出异常的情况,如:
1 | |
1 | |

unserialize() 读取 a:2:{ ,准备解析一个长度为 2 的数组(开始分配数组结构 / 临时容器)
解析第一个条目 i:0;O:1:"A":0:{},创建 A 对象实例(内部会分配对象 zval),并把它赋到数组槽 0
解析第二个条目 i:1;N;,在数组槽 1 放入 NULL(或空字串等),不会影响槽 0
继续读取,发现流 已结束或后续语法不完整(期待 } 来结束数组,但是缺少了}),于是 unserialize() 报错(比如抛出一个 PHP warning/notice,并返回false)
在返回false 之前,运行时要清理先前分配的临时结构(数组与其包含的元素)。清理动作会释放数组槽里所有 zval,数组槽 0 的对象引用被释放,此时refcount变为0,所以 PHP 会立即销毁对象并调用 A::__destruct()
CTF例题
题目复现网址:https://buuoj.cn/challenges#[NewStarCTF%202023%20%E5%85%AC%E5%BC%80%E8%B5%9B%E9%81%93]More%20Fast
题目源码
1 | |
可以发现在Web类中的evil()函数中,可以利用($this->func)($this->var);这句代码进行RCE,将$func赋值为执行命令函数system()等,将$var赋值为想要执行的命令,并且不包含字符串flag即可,可以利用*,写成f*绕过
在Pwn类中的__invoke()魔术方法中的$this->obj->evil();可以调用evil()函数
在Reverse类中的__get()魔术方法中的($this->func)();是以调用函数的方式调用对象,可以触发__invoke()魔术方法
在Crypto类中的__toString()魔术方法中的$wel = $this->obj->good;是调用了一个不存在的成员属性,可以触发__get()魔术方法
在Start类中的__destruct()魔术方法中的die($this->errMsg) 会把 errMsg 强制转换为字符串,可以触发__toString魔术方法
综上分析可以得到一条完整的pop链了,但是在最后有句代码throw new Exception("Nope");,这个抛出异常的代码会导致__destruct()无法正常触发,这就需要利用GC回收机制进行绕过,反序列化一个数组,更改序列化后的字符串,使其出现同键的情况,或者是直接删去最后的花括号
1 | |
1 | |
同键形式
1 | |

去花括号形式
1 | |
