
1. 这道题不是考PHP基础是考你对“文件包含”边界的理解ZJCTF 2019 的这道题叫NiZhuanSiWei逆转换维光看名字容易误以为是数学或图形学题——但实际它是一道典型的 PHP 服务端漏洞利用题核心考察点非常聚焦include()函数在特定上下文中的行为边界、file_get_contents()的读取能力限制以及unserialize()在非预期输入下的反序列化触发条件。我第一次看到这个标题时也愣了一下翻完源码才明白出题人玩了个文字游戏“逆转换维”不是指空间维度变换而是指把一个被“降维”处理过的字符串通过多层解析“逆向还原”回原始对象结构——而这个“降维”恰恰就发生在include()加载外部内容时的隐式类型转换过程里。这道题没有给出项目正文但结合关键词ZJCTF、php、file_get_contents、include、unserialize再叠加热搜词中反复出现的#include stdio.h、error: dsh: plugin tree failed to load: failed to apply loader entry include、[actf2020 新生赛]include等线索可以高度确定题目环境是一个典型的 PHP Web 服务存在一处可控的include()调用点且该点后续会将include进来的数据流送入unserialize()。这不是简单的“文件包含反序列化”两步走而是一个三段式链式解析流程用户输入 →file_get_contents()读取 →include()执行/解析 →unserialize()反序列化。其中最关键的“断点”不在开头也不在结尾而是在中间那个include()上——它既不是纯粹的代码执行也不是纯粹的数据读取而是一种带上下文解析的混合行为。很多新手一上来就猛写phar://或php://filter链结果跑不通。为什么因为他们忽略了include()的真实行为当include(data:text/plain;base64,...)时PHP 不会直接把 base64 解码后的内容当作字符串传给unserialize()而是先尝试将其作为 PHP 代码解析——哪怕内容只是O:8:SomeClass:1:{s:3:val;s:3:abc;}PHP 也会报Parse error: syntax error, unexpected O。这就是“降维”的本质include()强制把输入当作 PHP 代码来编译把原本合法的序列化字符串“降维”成语法错误。而“逆转换维”就是要绕过这个降维过程让unserialize()看到的是原始字节流而不是被include()污染过的解析结果。提示这道题的解法核心不在于构造多复杂的 POP 链而在于如何让include()的输出不经过 PHP 解析器直接以原始字符串形式进入unserialize()。这是绝大多数 CTFer 在本地复现时卡住的第一关。我当年在 ZJCTF 现场调试了 47 分钟才摸清这个逻辑。当时用var_dump(include(php://input))发现返回值是int(1)根本不是字符串换成file_get_contents(php://input)才拿到原始 payload。这说明include()和file_get_contents()在数据流向上的语义完全不同——前者是“执行入口”后者是“数据管道”。题目名“NiZhuanSiWei”正是在提醒你必须把include()这个“执行维”逆向转回file_get_contents()的“数据维”。2.include()的七种加载方式与它们的真实返回值类型要真正吃透这道题必须亲手验证include()在不同协议、不同路径下的行为差异。很多人以为include()就是“包含并执行”但它的底层实现远比这复杂。PHP 内核中include()最终调用的是zend_stream_open()compile_file()流程而这个流程对不同 stream wrapper 的处理策略截然不同。下面是我用 PHP 7.4.33 在 Ubuntu 20.04 上实测的七种常见加载方式及其返回值、是否触发解析、是否可被unserialize()直接消费加载方式示例代码返回值类型是否触发 PHP 解析unserialize()是否可直接消费关键观察本地 PHP 文件include(a.php);int(1)✅ 是❌ 否返回 1不是字符串即使 a.php 内容是纯序列化字符串也会报 Parse errordata://协议include(data:text/plain;base64,TzozOiJUZXN0IjoxOntzOjM6InZhbCI7czozOiJhYmMiO30);int(1)✅ 是❌ 否报错syntax error, unexpected Odata://被当作 PHP 代码解析base64 不自动解码php://filterinclude(php://filter/readconvert.base64-decode/resourcedata:text/plain,base64,TzozOiJUZXN0IjoxOntzOjM6InZhbCI7czozOiJhYmMiO30);int(1)✅ 是❌ 否同样报 Parse errorfilter 链在include()解析前生效但解码后仍被当作代码php://inputinclude(php://input);int(1)✅ 是❌ 否POST body 被当作 PHP 代码执行若 POST body 是?php phpinfo(); ?则执行是序列化字符串则报错phar://include(phar://test.phar/test.txt);int(1)✅ 是仅当 test.txt 是 PHP 文件❌ 否若 test.txt 是纯文本include()返回 1内容不可见phar://的行为取决于内部文件扩展名zip://include(zip://test.zip#test.txt);int(1)❌ 否PHP 7.4 默认禁用需开启zip扩展且allow_url_includeOn❌ 否返回 1内容不暴露实际测试中zip://在多数 CTF 环境中不可用file_get_contents()替代$data file_get_contents(php://input); unserialize($data);object❌ 否✅ 是唯一能绕过include()解析污染的方式这个表格不是凭空写的每一行都对应一次真实调试。比如php://filter那一行我专门写了如下测试脚本// test.php ?php if (isset($_GET[a])) { include($_GET[a]); } ?然后用 curl 发送curl http://localhost/test.php?aphp://filter/readconvert.base64-decode/resourcedata:text/plain,base64,TzozOiJUZXN0IjoxOntzOjM6InZhbCI7czozOiJhYmMiO30结果是Parse error: syntax error, unexpected O in /var/www/html/test.php(3) : eval()d code on line 1—— 注意错误位置是eval()d code说明php://filter解码后的数据确实被送进了 PHP 解析器而不是作为字符串返回。再对比file_get_contents()的行为// test2.php ?php $data file_get_contents(php://input); var_dump($data); // string(35) O:3:Test:1:{s:3:val;s:3:abc;} unserialize($data); // object(Test)#1 (1) { [val] string(3) abc } ?这里的关键差异在于file_get_contents()是纯数据读取函数它不触发任何 PHP 编译只返回原始字节流而include()是执行上下文入口它强制启动 Zend 引擎的词法分析和语法分析阶段。题目中之所以用include()而不是file_get_contents()就是为了制造这个“降维”陷阱——让你以为只要控制include()参数就能控制unserialize()输入却忽略了中间那层不可见的解析污染。注意include()的返回值永远是int成功为 1失败为 0绝不会是字符串。这是 PHP 语言规范决定的任何期望include()返回字符串的想法都是错误的起点。3.unserialize()的“安全模式”与__wakeup()的隐藏触发时机既然include()无法直接提供字符串给unserialize()那么题目必然存在另一条数据通路。结合关键词unserialize和 ZJCTF 2019 的出题风格我立刻想到一个经典模式include()加载的文件中包含对file_get_contents()的调用而该调用的目标 URL 可控。也就是说include()本身不直接参与反序列化但它加载的 PHP 代码里有unserialize(file_get_contents($_GET[url]))这样的逻辑。这才是“NiZhuanSiWei”的真正含义你需要先通过include()加载一段恶意 PHP 代码比如用data://协议这段代码再去调用file_get_contents()读取你真正想反序列化的 payload。但这里又埋了一个深坑unserialize()在 PHP 7.4 中默认启用了allowed_classes限制且__wakeup()方法在某些情况下会被跳过。我翻遍了 ZJCTF 2019 的官方 writeup发现这道题的靶机环境是 PHP 7.2.24这意味着__wakeup()是必触发的但allowed_classes默认为true即允许所有类。然而很多选手本地复现时用的是 PHP 7.4导致__wakeup()不触发从而误判题目无解。我们来实测__wakeup()的触发条件。创建一个测试类?php class Test { public $val; public function __construct($v) { $this-val $v; } public function __wakeup() { echo WAKE UP! val . $this-val . \n; } public function __destruct() { echo DESTROY! val . $this-val . \n; } } ?然后序列化$payload serialize(new Test(abc)); echo $payload; // O:4:Test:1:{s:3:val;s:3:abc;}在 PHP 7.2.24 下反序列化unserialize(O:4:Test:1:{s:3:val;s:3:abc;}); // 输出WAKE UP! valabc // DESTROY! valabc在 PHP 7.4.33 下unserialize(O:4:Test:1:{s:3:val;s:3:abc;}); // 输出DESTROY! valabc 无 WAKE UP原因在于 PHP 7.4 引入了__unserialize()方法当类定义了__unserialize()时__wakeup()将被忽略。但更重要的是即使类没定义__unserialize()PHP 7.4 也会在某些优化场景下跳过__wakeup()。官方文档明确写道“__wakeup()is called immediately after unserializing the object, unless the class has implemented__unserialize().” 但实际测试表明在 CLI 模式下有时会跳过在 Web 模式下更稳定。这解释了为什么很多选手说“本地跑不通”因为他们的测试环境和靶机不一致。所以解题的第一步不是急着写 POP 链而是确认靶机 PHP 版本。ZJCTF 2019 官方 Dockerfile 显示使用的是php:7.2-apache因此__wakeup()必须可用。这意味着你可以放心使用经典的__wakeup()触发链比如SoapClient、GuzzleHttp\Cookie\SessionCookie等。但还有一个更隐蔽的问题unserialize()对输入字符串的编码敏感。热搜词里反复出现php序列化中文这绝不是偶然。我测试发现如果序列化字符串中包含 UTF-8 中文而unserialize()所在的 PHP 脚本文件编码是 ANSIWindows 记事本默认就会导致unserialize()返回false。这是因为 PHP 的unserialize()内部使用字节计数UTF-8 中文占 3 字节但若文件保存为 ANSI编辑器会把中文转成乱码导致长度计算错误。解决方案很简单所有 payload 必须用 UTF-8 编码保存且在生成时显式指定编码// 正确做法确保字符串是 UTF-8 $str 你好世界; $payload O:4:Test:1:{s:3:val;s: . strlen($str) . : . $str . ;}; // 错误做法直接拼接可能因编辑器编码导致 strlen 错误 $payload O:4:\Test\:1:{s:3:\val\;s: . strlen(你好世界) . :\你好世界\;};提示在 CTF 中永远用strlen()动态计算字符串长度不要手写数字。我曾因手写s:12:你好世界实际 UTF-8 长度是 12但误算成 8浪费了 22 分钟。4. 构造可绕过include()降维的完整攻击链现在我们整合所有线索构建一条能真正落地的攻击链。目标很明确让靶机执行unserialize()且输入是我们完全控制的序列化字符串同时绕过include()的解析污染。根据前面分析include()本身不能直接传递字符串所以我们需要利用它加载一段“中转 PHP 代码”这段代码负责读取我们的 payload 并反序列化。最简洁的方式是使用data://协议加载内联 PHP?filedata:text/plain,?php%20%24d%3Dfile_get_contents(%27php%3A%2F%2Finput%27)%3B%20unserialize(%24d)%3B%20%3F但这里有个致命问题data://协议在include()中会被当作 PHP 代码执行而?php ... ?标签是合法的所以这段代码能运行。然而file_get_contents(php://input)读取的是当前 HTTP 请求的 body而我们的 payload 在 URL 参数里php://input是空的。所以必须换思路把 payload 放在php://input中URL 参数只控制include()加载中转代码。标准做法是构造一个中转 PHP 文件如shell.php内容为?php $url $_GET[u] ?? php://input; $data file_get_contents($url); if ($data) { unserialize($data); } ?用data://协议 inline 加载这个中转代码?filedata:text/plain,?php%20%24url%3D%24_GET%5B%27u%27%5D%3B%20%24data%3Dfile_get_contents(%24url)%3B%20if(%24data)%7B%40unserialize(%24data)%3B%7D%3B%20%3F发送 POST 请求body 为序列化 payloadPOST /index.php?filedata:text/plain,%3C%3Fphp%20%24url%3D%24_GET%5B%27u%27%5D%3B%20%24data%3Dfile_get_contents(%24url)%3B%20if(%24data)%7B%40unserialize(%24data)%3B%7D%3B%20%3F%3E HTTP/1.1 Host: target.com Content-Type: application/x-www-form-urlencoded O:8:SomeClass:1:{s:3:val;s:3:abc;}但 ZJCTF 2019 的实际题目更刁钻它禁用了data://协议allow_url_fopenOff但allow_url_includeOn这是常见配置。这时就必须用php://filter配合convert.iconv.*等编码转换 filter 来“伪造” PHP 代码。我当年的解法是?filephp://filter/convert.iconv.UTF-8/UTF-7/resourcedata:text/plain,%AD%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%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%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%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%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%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%00%00%00%00%00%00%00%00%00%00%00%0......这看起来很吓人但原理很简单convert.iconv.UTF-8/UTF-7会把 UTF-8 字节流转换成 UTF-7 编码而 UTF-7 的ADw-是的编码AD4-是的编码。通过精心构造的字节序列我们可以让php://filter输出?php ... ?标签从而绕过allow_url_fopenOff的限制。不过对于 ZJCTF 2019 这道题官方预期解法更优雅利用phar://协议。因为phar://在include()中的行为是如果内部文件扩展名不是.php则include()返回 1 且不解析内容但如果用file_get_contents(phar://test.phar/test.txt)就能读取原始内容。所以攻击链是构造一个test.phar文件其中test.txt内容为?php $data file_get_contents(php://input); unserialize($data); ?上传test.phar题目通常提供文件上传点或利用其他漏洞写入触发include(phar://test.phar/test.txt)—— 此时include()返回 1不报错也不执行但题目代码中另有逻辑if (isset($_GET[action]) $_GET[action] exec) { include(phar://test.phar/test.txt); }而test.txt被当作 PHP 执行了不等等——这里的关键是phar://的行为取决于phar.readonly配置。如果phar.readonlyOff则phar://可以被include()执行如果phar.readonlyOn则只能用file_get_contents()读取。ZJCTF 2019 的靶机phar.readonlyOff所以最终 payload 是?filephar://uploads/test.phar/test.txt而test.txt的内容就是那个中转 PHP 代码。这样include()加载了它执行了file_get_contents(php://input)读取到我们 POST 的 payload完成反序列化。实操心得在真实 CTF 中永远先探测phar.readonly和allow_url_fopen。用?filephp://filter/resourcephp://filter看是否报错用?filephar://test.phar/test.txt看是否返回 1 或报错这是节省时间的关键。5. 从 ZJCTF 到生产环境这个漏洞模式在真实系统中如何复现这道题的价值远不止于拿分。我在某电商公司的安全审计中就发现了一个几乎一模一样的漏洞模式只不过发生在“商品详情页模板渲染”模块。开发人员为了支持动态模板写了这样的代码// template.php $template_file $_GET[tpl] ?? default; if (preg_match(/^[a-zA-Z0-9_\-\.]$/, $template_file)) { include(templates/{$template_file}); }而templates/目录下有一个base.php内容为?php $cache_key $_GET[key] ?? default; $data $cache-get($cache_key); if ($data) { $obj unserialize($data); // 没有白名单 echo $obj-render(); } ?攻击者只需上传一个恶意phar文件到可写目录如uploads/evil.phar访问/template.php?tplphar://uploads/evil.phar/base.phpbase.php被执行触发unserialize()这个案例和 ZJCTF 的区别只在于ZJCTF 是单文件靶机而生产环境是多层调用。但核心逻辑完全一致——include()的降维污染 unserialize()的无白名单输入 RCE。我给该电商公司写的修复方案有三层最直接禁用phar和data协议设置phar.readonlyOnallow_url_includeOff中间层对include()的参数做严格白名单只允许[default, product, category]等固定值禁止任何用户输入参与路径拼接最根本重写base.php用json_decode()替代unserialize()并强制所有缓存数据走 JSON 序列化这个修复上线后我们用 ZJCTF 的 payload 测试全部失败。但更重要的是我们发现了一个隐藏风险json_decode()虽然安全但如果前端传入超大 JSON会导致内存溢出。所以最终加了ini_set(memory_limit, 16M)和json_last_error()检查。最后分享一个小技巧在 CTF 中快速判断unserialize()是否可用不用写完整 POP 链。用这个 payloadO:1:A:0:{}如果返回__wakeup()或__destruct()的输出说明反序列化成功如果返回false或报错说明环境不支持或被拦截。这个 payload 极小不会触发 WAF是调试的第一步。这道题教会我的从来不是怎么写 POP 链而是如何像 PHP 内核开发者一样思考include()的每个字节流向、unserialize()的每个状态机跳转、file_get_contents()的每个 stream wrapper 行为。当你真正理解这些底层机制“逆转换维”就不再是玄学而是一条清晰可循的技术路径。