PHP垃圾回收器与反序列化利用
PHP垃圾回收器与反序列化利用
垃圾回收机制
指php会自动释放程序不再需要的已分配的内存块。
PHP5.3之前
采用引用计数的方式,给每个内存对象分配一个计数器,每当内存对象被引用时,计数器+1,引用撤销后(unset()),计数器-1,对技术器=0时,对内存对象进行销毁,垃圾回收机制完成,php一个生命周期后会释放此进程/线程所占的内容
存在问题:两个或多个对象相互引用,使得计数器永远不为0,导致内存对象无法被回收
PHP5.3
加入复杂算法检测引用环的存在,避免内存泄露。
每个php变量存在一个叫zval的变量容器中,存储了变量的类型和值,还存储了“is_ref”bool型变量以标识该变量是否属于引用集合;还有一个“refcount”,用于表示指向这个zval变量容器的变量个数,注意,多个变量是可以共用一个变量容器的。
1 |
|
以上代码会输出
1 | a: (refcount=3, is_ref=0)='new string' |
PHP7的NTS版本
在该版本,上述通过赋值同一个变量的情况已经不会再被计数,PHP7中,zval可以被引用计数或不被引用
- 对于null,bool,int和double的类型变量,refcount永远不会计数;
- 对于对象、资源类型,refcount计数和php5的一致;
- 对于字符串,未被引用的变量被称为“实际字符串”。而那些被引用的字符串也不计数
- 对于数组,未引用的变量被称为“不可变数组”。其数组本身计数与php5一致,但是数组里面的每个键值对的计数,则按前面三条的规则(即如果是字符串也不在计数);如果使用opcache,则代码中的常量数组文字将被转换为不可变数组。
1 |
|
输出如下
1 | 测试字符串引用计数 |
回收周期
PHP垃圾回收机制默认打开,可以设置php.ini值的zend.enable_gc
,或者调用gc_enable() 和 gc_disable()函数。
当垃圾回收机制打开时,算法判断在根缓存区满时,执行循环查找,根缓存区大小需要通过修改php源码文件Zend/zend_gc.c中的常量GC_ROOT_BUFFER_MAX_ENTRIES,然后重新编译PHP,来修改这个值。
调用gc_disable()函数释放内存之前,先调用gc_collect_cycles()函数,以免根缓存区空间不足
垃圾的产生
PHP中一些复杂数据类型头部有一个GC,用于支持垃圾回收。
zend_reference 这个类型,这个是 PHP7 新增的变量类型,当对变量使用 “&” 操作时,会创建新的中间结构体 zend_reference,这个结构体会真正的指向对应的 value 结构。
1 | // 当进行如下赋值操作时 |
$b和$c
的zval都是通过中间结构体再指向最终的zend_string
回收的过程
如果当变量的 refcount 减小后大于 0,PHP 并不会立即对这个变量进行垃圾鉴定和回收,而是放入一个缓冲区中,等这个缓冲区满了以后 (10000 个值) 再统一进行处理,加入缓冲区的是变量 zend_value 里的 gc,目前垃圾只会出现在数组和对象两种类型中,数组的情况上面已经介绍了,对象的情况则是成员属性引用对象本身导致的,其它类型不会出现这种变量中的成员引用变量自身的情况,所以垃圾回收只会处理这两种类型的变量。
gc 的结构 zend_refcounted_h 具体如下:
1 |
|
一个变量只能加入一次缓冲区,为了防止重复加入,变量加入后会把 zend_refcounted_h.gc_info 置为 GC_PURPLE,即标为紫色,后续不会重复插入。
反序列化中的利用
__destruct魔术方法
当某个对象成为垃圾或者当对象被显式销毁时执行
- 显示销毁:unset或赋值NULL
- 隐式销毁:代码执行完毕后将所有申请的内存释放掉
在常规思路中destruct是隐式销毁触发的,尝试显式销毁
旧版本GC
简单的判断了一下变量的zval的refcount是否为0,是的话就释放否则不释放直至进程结束。
新版本GC-zval结构体
主动销毁变量:
1 |
|
refcount计数减1,说明unset并非一定会释放内存,当有两个变量指向的时候,并非会释放变量占用的内存,只是refcount减1.
触发垃圾回收
该算法的实现可以在Zend/zend_gc.c
( https://github.com/php/php-src/blob/PHP-5.6.0/Zend/zend_gc.c )中找到。每当销毁zval时(如在该zval上调用unset时),垃圾回收算法会检查其是否为数组或对象,除了这俩个类型外其他都不能包含循环引用,这一检查过程使用gc_zval_possible_root
函数来实现。任何这种潜在的zval都被称为根(Root),并会被添加到一个名为gc_root_buffer
的列表中。
然后,将会重复上述步骤,直至满足下述条件之一:
gc_collect_cycles()
被手动调用- 垃圾存储空间将满。这也就意味着,在根缓冲区的位置已经存储了10000个zval,并且即将添加新的根。10000时预定义常量GC_ROOT_BUFFER_MAX_ENTRIES,当出现第10001个zval时,将再次调用gc_zval_possible_root进行检查,此时会调用
gc_collect_cycles
以处理并刷新当前缓冲区
可以得到触发思路,填满垃圾存储空间
反序列化
反序列化过程允许一遍又一遍地传递相同的索引,所以不断会填充内存空间。一旦重新使用数组索引,旧元素的引用计数器就会递减。在反序列化过程中将会调用zend_hash_update
,它将调用旧元素的析构函数(Destructor)。每当zval被销毁时,都会涉及到垃圾回收。这也就意味着,所有创建的数组都会开始填充垃圾缓冲区,直至超出其空间导致对gc_collect_cycles
的调用。
ArrayObject
1 | // POC of the ArrayObject GC vulnerability |
实际上,一旦该示例执行,外部数组(由$outer_array
引用)将会被释放,并且zval将会被$filter2
的zval覆盖,导致输出”bbbb”。
ArrayObject的反序列化函数接受对另一个数组的引用,以用于初始化的目的。这也就意味着,一旦我们对一个ArrayObject进行反序列化后,就可以引用任何之前已经被反序列化过的数组。此外,这还将允许我们将整个哈希表中的所有条目递减两次。
1、得到一个应被释放的目标zval X;
2、创建一个数组Y,其中包含几处对zval X的引用:array(ref_to_X, ref_to_X, […], ref_to_X)
;
3、创建一个ArrayObject,它将使用数组Y的内容进行初始化,因此会返回一次由垃圾回收标记算法访问过的数组Y的所有子元素。
通过上述步骤,我们可以操纵标记算法,对数组Y中的所有引用实现两次访问。但是,在反序列化过程中创建引用将会导致引用计数器增加2,所以还要找到解决方案:
4、使用与步骤3相同的方法,额外再创建一个ArrayObject。
一旦标记算法访问第二个ArrayObject,它将开始对数组Y中的所有引用进行第三次递减。我们现在就有方法能够使引用计数器递减,可以将该方法用于对任意目标zval的引用计数器实现清零。
举个例子
1 |
|
我们假如要执行__destruct方法,打印flag,就得绕过这个throw new Exception
。因为__destruct
方法是在该对象被回收时调用,而exception
会中断该进程对该对象的销毁。所以我们需要强制让php的GC(垃圾回收机制)去进行该对象的回收。
核心思想:反序列化一个数组,然后再利用第一个索引,来触发GC
EXP
1 | class B{ |
造成该漏洞的主要原因是ArrayObject缺少垃圾回收函数。该漏洞称为“双递减漏洞”
- 标题: PHP垃圾回收器与反序列化利用
- 作者: Sl0th
- 创建于 : 2022-10-20 23:29:13
- 更新于 : 2024-11-11 18:23:06
- 链接: http://sl0th.top/2022/10/20/PHP垃圾回收器与反序列化利用/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。