漏洞概况
ext/standard/var_unserializer.c in PHP before 5.6.25 and 7.x before 7.0.10 mishandles certain invalid objects, which allows remote attackers to cause a denial of service or possibly have unspecified other impact via crafted serialized data that leads to a (1) __destruct call or (2) magic method call.
环境要求
- PHP 5 < 5.6.25
- PHP 7 < 7.0.10
漏洞分析
该漏洞的与php的魔术方法__wakeup()相关。我们知道,如果存在__wakeup()方法,进行反序列化操作调用unserialize()方法时会先调用__wakeup()方法。当序列化字符串中表示对象属性个数的数值大于真实属性个数时会绕过__wakeup()函数。
1 |
|
上面这段代码经过序列化后输出的结果是:
1 | O:4:"test":1:{s:4:"name";s:5:"fairy";} |
其中对象类型有以下几种:
1 | a - array |
下面是最简单的反序列化例子:
1 |
|
当传入的序列化字符串满足属性个数与真实属性个数一致时,是正常调用__wakeup()方法的
1 | ?s=O:4:"test":1:{s:4:"name";s:5:"fairy";} |
当传入的属性个数>真实属性个数时会绕过__wakeup()方法,直接执行__destruct()。
1 | ?s=O:4:"test":2:{s:4:"name";s:5:"fairy";} |
漏洞利用
下面是一个漏洞利用的例子:
1 |
|
__wakeup()方法中的这段代码:
1 | foreach (get_object_vars($this) as $k => $v) { |
会导致对象的属性都被清空为null,而__destruct()函数又会尝试写入传入的对象属性。
可以看到在正常情况下什么东西都不会被写入
将对象属性个数改为2就可以成功绕过__wakeup()
查看文件,已经写入。
CTF原题
这道题来自极客大挑战2019,可以在赵师傅的buuoj上进行练习。这道题就是简单的反序列化入门题,就一个小坑点。
打开题目可以看到
关键的提示是备份,在ctf比赛中常见的备份有:
1 | .bak |
尝试了下www.zip
,刚好发现了备份文件,下载下来解压有这些文件,但是用屁股想都知道flag不会在所谓的flag.php
中。
查看class.php
:
1 |
|
这段代码说明要读取flag要满足两个条件:
$this->username === 'admin'
$this->password != 100
而__wakeup()
方法会将$this->username
设置成guest
,所以只要绕过__wakeup()
方法就可以了。
payload:
1 |
|
生成payload:
1 | O:4:"Name":2:{s:14:"Nameusername";s:5:"admin";s:14:"Namepassword";i:100;} |
结合index.php中的代码:
1 |
|
将该字符串赋给get请求参数select
即可
1 | ?select=O:4:"Name":3:{s:14:"Nameusername";s:5:"admin";s:14:"Namepassword";i:100;} |
但是并没有如期得到flag?这是由php对象序列化中的字段名决定的。
前面已经介绍过,在php中,对象(object)通常被序列化为:
1 | O:<length>:"<class name>":<n>:{<field name 1><field value 1><field name |
其中var
,public
,protected
和private
声明的字段,但是不包括static
和const
声明的静态字段。也就是说只有实例(instance)字段。字段名是字符串型,序列化后的格式与字符串型数据序列化后的格式相同;字段值可以是任意类型,其序列化后的格式与其所对应的类型序列化后的格式相同。但字段名的序列化与他们声明的可见性是有关的(也就是public
、private
、protected
)。具体参考: https://blog.csdn.net/a5816138/article/details/53303299
对象字段名的序列化格式要求是这样的:
var
和public
:var
和public
声明的字段都是公共字段,因此它们的字段名的序列化格式是相同的。公共字段的字段名按照声明时的字段名进行序列化,但是序列化后的字段名中不包括声明时的变量前缀符号$
。protected
:protected
声明的字段为保护字段,在所声明的类和该类的子类中可见,但在该类的实例中不可见,因此保护字段的字段名在序列化时,字段名前面会加上\0*\0
,这里的\0
表示ASCII码的0
字符,而不是\0
组合。private
:private
声明的字段为私有字段,只在所声明的类中可见,在该类的子类和该类的对象实例中均不可见,所以私有字段的字段名在序列化时,字段名前面会加上\0<declared class name>\0
前缀。这里<declared class name>
表示的是声明该私有字段的类的类名,而不是被序列化的对象的类名。因为声明该私有字段的类不一定是被序列化的对象的类,也有可能是它的祖先类。- 字段名被作为字符串序列化时,字符串值中包括根据其可见性所加的前缀,字符串长度也包括所加前缀的长度,其中
\0
字符也是计算长度的。仔细看我们生成的payload,s:14:"Nameusername"
,可以发现前面的长度是计算\0
字符长度的,而\0
在ASCII中表示不可见。 \0
可以用%00
替代。
所以真正的payload为:
1 | ?select=O:4:"Name":3:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;} |
这里的一个坑点就是,如果你不用%00
而是\0
,就要注意,需要用python传值,直接url请求是不行的。前面已经说过,\0
表示ASCII码的0
字符,表示不可见,而不是\0
组合。如果直接用网址请求,那会被解析成两个字符\
+0
。
python exp:
1 | __author__ = 'Bantian' |
同样拿到flag。