PHP反序列化绕过与字符过滤:网鼎杯AreUSerialz完整复盘

发布时间:2026/10/15 4:55:27
PHP反序列化绕过与字符过滤:网鼎杯AreUSerialz完整复盘 网鼎杯2020青龙组这道AreUSerialz是我在比赛结束之后回头复盘了很久的一道PHP反序列化题。先说结论这道题本身没有特别复杂的利用链也不涉及特别偏门的函数但它把“反序列化入口”和“字符过滤”结合在了一起在is_valid那一步卡住了大量选手。很多人不是不会反序列化而是不知道序列化字符串里那些看不见的字符是怎么来的。这篇文章我用完整的复盘思路把整道题拆开讲一遍从源码审计、魔法方法触发时机、payload手工构造到本地环境差异和赛后扩展一次性说透。1. 赛后才看懂的题目名AreUSerialz到底在问什么1.1 我拿到的入口源码还原版题目入口就是一个普通的PHP页面访问后直接通过highlight_file把自身源码显示出来。源码的核心逻辑不长我凭印象还原一下主体结构?php error_reporting(0); class FileHandler { public $op; public $filename; public $content; public function __construct($op read, $filename index.php, $content ) { $this-op $op; $this-filename $filename; $this-content $content; } public function process() { if ($this-op 1) { return $this-read(); } else if ($this-op 2) { return $this-write(); } } private function read() { $content ; if (file_exists($this-filename)) { $content file_get_contents($this-filename); } return $content; } private function write() { if (file_put_contents($this-filename, $this-content) ! false) { return success; } } public function __destruct() { echo $this-process(); } public function __wakeup() { $this-op read; } } function is_valid($s) { for ($i 0; $i strlen($s); $i) { $char ord($s[$i]); if (!(($char 32 $char 126))) { return false; } } return true; } if (isset($_GET[str])) { $str (string)$_GET[str]; if (is_valid($str)) { $obj unserialize($str); } else { echo no; } } else { highlight_file(__FILE__); }这段代码还原得和原题基本一致。入口参数是str提交后先经过is_valid检查再进入unserialize。整个类里面有三个属性op、filename、contentprocess()方法根据op的值决定调用read()还是write()而__destruct()会在对象销毁时自动调用process()。1.2 整条代码链的第一印象第一次读这段源码我脑子里快速过了一遍如果unserialize一个FileHandler对象脚本结束或对象被覆盖时__destruct会被触发。__destruct调用process()。process()里判断$this-op是全等1还是全等2。如果op 1调用read()read()用file_get_contents读$this-filename指定的文件。如果op 2调用write()write()用file_put_contents写入内容。__wakeup()会在反序列化完成后自动调用把op强制改成read。表面上最直接的思路就是构造一个op1、filenameflag.php的对象反序列化后触发__destruct把flag.php读出来。但这里有两个障碍一是__wakeup会把op改掉二是is_valid对所有字符做了严格的可见字符校验。所以这道题真正的考点就落在如何绕过__wakeup以及如何让序列化payload通过is_valid的过滤。1.3 题目名的暗示AreUSerialz读起来就是Are You Serialize明显在提示这是一道反序列化题。另一个隐藏线索是它把serialize拼错成serialz类似题目里常见的那种“拿发音抖机灵”的命名方式。如果你在比赛页面上看到这种名字基本可以锁定PHP反序列化或Python反序列化方向。配合源码里的FileHandler类名和unserialize函数攻击方向就非常明确了。2. 漏洞触发的关键不在process而在魔法方法的执行时机2.1 unserialize后的自动回调PHP反序列化最核心的机制是unserialize把一个字符串还原成对象时如果类定义存在会自动调用对应的魔法方法。__wakeup在对象被反序列化时立即触发__destruct在对象生命周期结束时触发。这两个时机差异非常重要。__wakeup相当于“对象刚被重建后的初始化钩子”__destruct相当于“对象销毁前的清理钩子”。在CTF题里这两个钩子经常是漏洞链的第一环。本题里__wakeup把op强制改成read就是为了阻止攻击者通过反序列化直接指定op1。如果不处理这一步process()里拿到的永远是read而read()方法只有在op 1时才会被调用。2.2 __wakeup在这里扮演的角色有人可能会问__wakeup把它改成read而read这个字符串不等于1也不等于2那process()不就是什么都不干吗看起来这个防守思路挺简单粗暴但它确实有效因为如果你直接在payload里写op1反序列化过程中__wakeup会把op改掉最终process()执行时看到的op已经不是1了。所以这里需要绕过__wakeup。PHP历史上有一个经典的绕过方式就是CVE-2016-7124当反序列化字符串中声明的属性数量大于对象实际的属性数量时__wakeup会被跳过。比如FileHandler实际有3个属性但如果序列化字符串里写O:11:FileHandler:4:{...}声明有4个属性PHP在解析时发现数量对不上就会直接跳过__wakeup的调用。2.3 拼图的第一块用属性数量绕过__wakeup这个绕过方式的关键在于理解序列化字符串的结构。序列化字符串里属性数量就写在类名后面的冒号数字里O:11:FileHandler:3:{...}这里的3表示后面有3个属性。如果把它改成4而实际只提供3个属性PHP在旧版本7.4之前会因内部处理逻辑不一致而跳过__wakeup。这个数字必须大于实际属性数但不能大到破坏整个字符串结构。比赛里最常用的写法就是把实际的3改成4。一旦绕过__wakeupop就不会被改写成read它会保留我们在payload中设置的1。于是__destruct触发process()时就能顺利进入read()分支实现任意文件读取。这里要提醒一下CVE-2016-7124的绕过在新版本PHP中已经被修复了。在较新的PHP 7.4及以上环境里即使属性数量对不上__wakeup也会被正常调用。所以做题时要先确认目标环境的PHP版本。不过网鼎杯这类比赛里的Web容器一般不会是特别新的版本这个姿势在当时的题目环境下是有效的。3. 真正拦住大部分人的是is_valid里的那行ASCII判断3.1 is_valid到底过滤了什么把__wakeup绕过想明白后我兴冲冲地本地serialize了一个对象结果提交后页面直接输出了no。问题出在is_valid这个函数上。它的逻辑非常朴素遍历整个字符串对每个字符取ord()检查ASCII码是否在32到126之间。32是空格126是波浪号~这个范围基本就是“可打印ASCII字符”。这意味着提交的序列化字符串里不能出现任何不可见字符或高位字符。比如常见的\x00空字节ASCII码是0直接会被拦下再比如某些多字节编码字符ord()取出来超过126也会被拦下。看起来这个过滤不算严格因为ASCII可打印字符基本覆盖了数字、字母、常见符号但问题在于PHP原生的serialize()函数在处理protected和private属性时会在属性名中嵌入空字节。3.2 序列化字符串里的隐形空字节这是非常关键的一个细节。假定FileHandler的属性是private $op用PHP的serialize()序列化后属性名的格式会变成这样s:6:\x00FileHandler\x00op;其中\x00就是空字节。对于protected属性格式是s:5:\x00*\x00op;这里的\x00同样存在。is_valid遍历字符串时遇到\x00会直接返回falsepayload被丢弃。很多人在这一步卡死就是因为本地测试时直接用serialize()生成payload提交后被is_valid拦截却不知道拦截的具体原因。我当时也是通过var_dump输出序列化字符串并用bin2hex查看十六进制才意识到里面藏着空字节。绕开的办法很简单把类属性全部写成public。public属性在序列化时不会在属性名里嵌入空字节属性名就是干干净净的op、filename、content整个payload完全由可打印ASCII字符组成就能通过is_valid检查。3.3 解法全public属性手写payload这一步其实就是手动构造序列化字符串不依赖PHP的serialize()输出。把属性设为public后序列化结构会非常干净。一个标准的payload长这样O:11:FileHandler:4:{s:2:op;s:1:1;s:8:filename;s:8:flag.php;s:7:content;s:0:;}逐段拆开看O:11:FileHandler表示一个类名长度为11的对象。:4表示声明有4个属性用于绕过__wakeup。s:2:op表示属性名是长度为2的字符串op。s:1:1表示属性值op为长度1的字符串1。s:8:filename表示属性名filename长度为8。s:8:flag.php表示文件名长度为8。s:7:content表示属性名content长度为7。s:0:表示空字符串长度0。每个字符都是ASCII码32到126之间的可打印字符因此可以顺利通过is_valid。3.4 顺手验证原生类为什么在这里不好使这道题还可以延伸出一个思考既然入口是unserialize为什么不下手直接构造原生PHP类比如php://filter配合某些自动触发读取的类原因很简单is_valid要求payload里不能出现空字节而很多原生类的魔术方法触发链需要私有属性或特殊格式很容易在序列化字符串中带出空字节和高位字符。更直接的原因是题目自己定义了FileHandler利用它自己提供的read()方法就能读取文件完全不需要绕到原生类上。如果你在别的题目里遇到类似的字符过滤第一步永远是检查当前可利用类能否以public属性完成攻击链。能的话手写序列化字符串是最稳妥的。4. 手动构造Payload的完整过程4.1 先跑serialize再手工改动我个人的习惯是先在本地把目标类定义好用PHP原生serialize()生成一版标准payload然后在此基础上手工修改。这样做的目的不是直接拿它提交而是确认属性顺序和类名长度不会出错。本地测试脚本大概这样?php class FileHandler { public $op 1; public $filename flag.php; public $content ; } $obj new FileHandler(); echo serialize($obj);输出结果是O:11:FileHandler:3:{s:2:op;s:1:1;s:8:filename;s:8:flag.php;s:7:content;s:0:;}注意这里的属性数量是3需要改成4才能绕过__wakeup。另外如果我把属性声明成protected或private这里就会直接看到空字节。这道题的核心就是这两个改动改属性为public改属性数量为4。4.2 把每个字段长度算清楚手写序列化字符串最忌讳长度写错。s:8:filename里的8必须和filename的实际字符数一致。如果写成7或9PHP反序列化时会报错或者产生畸形解析导致对象构造失败。我来列一个常用长度对照表方便手工构造时直接查字符串长度FileHandler11op2filename8content7flag.php81121手工写完payload后我的习惯是在本地用unserialize()跑一遍看看对象属性是否和预期一致?php $payload O:11:FileHandler:4:{s:2:op;s:1:1;s:8:filename;s:8:flag.php;s:7:content;s:0:;}; $obj unserialize($payload); var_dump($obj);本地跑通后再把payload做URL编码提交。由于payload里只有{}、;、:、这些符号在GET参数里可以直接提交也可以做一遍标准URL编码防止某些字符被服务端或中间层改写。4.3 第一次提交与错误排查我第一轮提交用的是本地serialize()直接生成的payload结果被is_valid拦下。排查方法很简单把is_valid复制到本地对payload跑一遍确认它返回false然后把payload的每个字符用bin2hex打印出来立刻就能看到空字节\x00。改用全public属性并手写属性数量为4后在本地模拟完整流程?php $payload O:11:FileHandler:4:{s:2:op;s:1:1;s:8:filename;s:8:flag.php;s:7:content;s:0:;}; var_dump(is_valid($payload)); $obj unserialize($payload); // 模拟请求结束对象销毁is_valid返回trueunserialize后的对象op仍为1filename为flag.php。对象销毁时__destruct触发process()进入read()分支成功读取flag.php的内容。这个流程完全可行。4.4 本地版本差异同一个payload在不同PHP上的生死这里必须重点提一下环境差异。CVE-2016-7124的绕过在PHP 7.4.0之后已经被修复如果本地PHP版本过高即使属性数量写4__wakeup依然会被调用op会被强制改成read导致读取失败。我本地当时用的是PHP 7.2系列__wakeup能被成功绕过。如果你在本地测试时发现payload不生效优先确认PHP版本。如果版本过高可以考虑两种思路一是用一个老版本PHP容器来测试二是换一种绕过__wakeup的方式但那样难度会明显增加。网鼎杯赛题环境通常不会太新所以这道题预设的利用姿势才成立。5. 赛后扩展如果再改几个条件题目会变成什么样5.1 如果__wakeup不能绕过这道题会卡得更死假设目标环境是PHP 7.4以上属性数量绕过失效那么__wakeup一定会把op改成read。此时process()里无论传什么对象op都是read既不是1也不是2整个利用链直接断掉。这种情况下可以考虑其他思路比如寻找类中是否有其他可以利用的魔术方法传参点或者尝试在read()分支中利用对象属性进行更复杂的文件操作。但就这道题来说__wakeup强制改写是相当有效的防守所以版本差异直接决定了题目难度。5.2 如果is_valid被移除原生类就来抢戏is_valid其实过滤掉了大量原生类链。比如PHP的SoapClient、SimpleXMLElement等类在某些触发链中需要私有属性或特殊字符序列化字符串经常带空字节。一旦is_valid被移除题目就会变成一个开放性明显更高的反序列化题选手可以基于原生类构造SSRF、XXE或其它利用链。这也是为什么这道题看起来“单纯”——它用is_valid把选手限制在了一个自定义类的简单POP链上。理解这个设计意图你就能明白它为什么没有把难度做得更深。5.3 换一个视角read/write两条链的实际利用价值题目提供了read和write两条链。read链可以直接任意文件读取write链则是file_put_contents($this-filename, $this-content)如果目标环境允许写文件且知道Web目录路径甚至可以尝试写一句话木马直接getshell。但write链有几个前提需要绕过__wakeup否则op会被改写。需要知道Web根目录的绝对路径。需要file_put_contents能成功写入。比赛场景下read链是最高效稳妥的直接读flag.php即可。但作为赛后复盘write链也值得理解因为真实漏洞利用中文件写入往往能带来更大的控制权。6. 复盘清单做反序列化题我现在的固定动作6.1 踩坑清单这道题我踩过的坑基本可以归结为三点第一直接用serialize()生成的payload提交忽略protected和private属性带来的空字节。这个坑在PHP反序列化题里非常常见因为类源码里属性声明方式决定了序列化字符串的形态。第二忘记把属性数量从3改成4导致__wakeup被触发op被改写成read攻击失败。第三本地PHP版本和远程环境不一致导致本地能跑的payload在远程不生效。做题前先确认题目环境版本能少走很多弯路。6.2 我的做题顺序复盘之后我把反序列化题的标准流程固定成了这样读源码确定入口参数和类结构。列出所有魔术方法明确触发时机。分析process()等业务方法确定可利用的代码路径。检查过滤函数看它限制的是字符范围还是字符串模式。本地构造对象先serialize一版再手工调整属性可见性和属性数量。用unserialize在本地完整跑通利用链后再对payload做URL编码提交。这套流程看起来死板但确实能避免遗漏。6.3 为什么建议自己写一版PHP反序列化小工具经过这道题之后我写了一个小的PHP脚本用来快速生成和校验手写序列化字符串。脚本的核心逻辑很简单输入类名、属性名、属性值的键值对输出序列化字符串并自动检查每个字符的ASCII码是否在32到126之间。这个脚本的价值在于它能逼着你搞清楚每一个长度、每一个冒号、每一个引号的含义而不是完全依赖PHP的serialize()输出。很多反序列化题的难点不在利用链本身而在payload的精确构造字符长度错一位整个对象就无法解析。自己动手写一个生成器是彻底理解序列化格式最好的方式。最后再分享一个小经验拿到这类题不要急着去网上搜现成payload先把源码手抄一遍把魔法方法执行顺序画在纸上然后手动算一遍序列化字符串的每个长度。等你把自己写的payload成功跑通你对PHP反序列化的理解会往前迈一大截。这道题的价值不在于难而在于它把一个完整的防线设计得非常工整值得反复练手。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询