PHP反序列化漏洞实战:从easy_serialize_php到代码审计

发布时间:2026/9/17 1:07:21
PHP反序列化漏洞实战:从easy_serialize_php到代码审计 刚在BUUCTF上刷到[安洵杯 2019]easy_serialize_php这道题的时候我第一反应是它“又是反序列化”第二反应是这题名字里带着easy应该不会太难。实际做下来发现确实不难但它非常典型一个可控的unserialize入口、一个带文件读取逻辑的魔术方法再加上一层简单粗暴的关键字替换过滤三样东西拼在一起就是一个完整的“反序列化 代码审计”练手场景。这篇文章我完整还原自己从拿到题目到拿到flag的思考路径重点讲清楚为什么直接构造payload就能成功以及在哪些情况下容易踩坑希望能帮到刚接触PHP反序列化、想搞懂漏洞原理而不是只会抄payload的读者。1. 题目概览与考点拆解1.1 题目基本信息安洵杯是安洵信息主办的安全赛事2019年这道Web题放在Web方向题名easy_serialize_phpBUUCTF平台上可以直接复现。题目环境是一个PHP站点访问首页会直接输出源码同时页面里还有一个phpinfo入口。这类题目表面上给的信息很多真正有用的其实很少考验的是你从一堆输出里找出关键链路的能力。这道题涉及的知识点主要有三个。第一是PHP反序列化具体说就是unserialize函数处理了用户可控数据配合魔术方法形成文件读取第二是代码审计需要从源码里找到过滤函数和输出flag的触发条件第三是对过滤器本身的理解如果过滤规则没覆盖目标文件名就能直接绕过。一个容易让人迷惑的点是题目名字带easy但很多人在第一步就会被phpinfo带偏以为要把精力花在找PHP版本漏洞或者禁用的函数上。实际上这题的flag读取和phpinfo并没有直接关系phpinfo只用来确认当前PHP环境和辅助判断代码逻辑。做题要先分清“干扰信息”和“有效信息”这本身就是代码审计的基本功。1.2 反序列化漏洞原理回顾先简单回顾一下PHP序列化和反序列化的基础。serialize()能把数组、对象这类复杂结构变成一串带类型标记的字符串。例如一个简单的FileHandler对象序列化之后长这样O:11:FileHandler:2:{s:2:op;s:1:1;s:8:filename;s:13:d0g3_f1ag.php;}我来拆一下这个格式最开头的O表示对象Object11是类名的字节长度FileHandler是类名2是属性个数花括号里是具体属性。每个属性都是一组“类型:长度:内容;”的结构比如s:2:op表示字符串类型属性名长度是2个字节内容是op。反序列化漏洞的核心在于unserialize的输入如果来自外部并且后续逻辑会用到还原出来的对象属性攻击者就能伪造对象把属性值改成任意内容。真正危险的往往不是函数本身而是这个对象类型中存在某些“魔术方法”。PHP的魔术方法以双下划线开头在特定时机自动触发最常被利用的有四个__wakeup在反序列化时触发__destruct在对象销毁时触发__toString在对象被当作字符串时触发__call在调用不存在的方法时触发。本题就是典型的__destruct利用只要反序列化成功创建一个对象脚本结束或变量被覆盖时对象销毁__destruct就会自动执行。如果方法内部根据某个属性决定是否读取文件我们就把这个属性改造成满足条件的值。可以这么理解反序列化相当于给了一张“提货单”攻击者把提货单上的货物名称属性涂改成自己想要的仓库管理员魔术方法按单发货。因为PHP反序列化不校验提货单是谁开的、内容是否合理只按格式取货。1.3 解题前的基本准备拿到题目先做三件事。第一把源码完整读一遍标注出所有可控参数第二列出所有类和魔术方法看有没有能到敏感函数file_get_contents、eval、include等的路径第三梳理过滤函数明确哪些字符会被替换、替换成什么这决定了后面payload能不能直接构造。拿这道题来说可控入口很明确POST一个data参数经过filter函数后交给unserialize。目标类FileHandler里有一个__destruct当op属性严格等于字符串2时会file_get_contents读取filename属性指向的文件。看到这里解题方向就清晰了构造一个FileHandler对象让op2filename指向目标flag文件然后把序列化串放在data参数里提交。这一步其实已经完成了70%的工作。2. 代码审计定位漏洞入口2.1 从首页源码和phpinfo确认环境首页highlight_file(FILE)直接输出源码这对做题来说是送分项完全不用猜。翻源码的时候要注意文件末尾或者if/else分支很多题目会把关键逻辑藏在phpinfo()调用附近。本题的phpinfo入口一般长这样如果接收到了某个参数就调用phpinfo()否则走正常逻辑。你访问首页就会看到整个PHP配置包括版本、已加载模块、disable_functions等。实际做题不需要把phpinfo全看完瞄一眼PHP版本和有没有危险函数限制就够了重点还是回到源码。nginx、apache、PHP版本这些信息在本地复现时才有参考意义。我在BUUCTF上做这题的时候第一步就把所有代码逻辑过了一遍真正影响解题的代码不到十行。2.2 FileHandler对象与魔术方法代码审计的重头戏是FileHandler类。这个类通常只有两个公共属性op和filename。op用来控制进入哪个分支filename用来指定要读取的文件。真正危险的是它的__destruct方法伪代码如下public function __destruct() { if ($this-op 2) { echo file_get_contents($this-filename); } else { echo no file; } }这里有几个细节值得注意。第一个是严格比较意味着op的值必须是字符串2不能是数字2或者带空格的2 。构造payload时一定要写s:1:2绝不能写s:1:2或者i:2。第二个是file_get_contents的返回值直接echo出来所以只要走到这个分支文件内容就会明文回显到页面上不用考虑二次写入或外带数据。审计到这里思路其实已经完成了大半只需要让对象在销毁时满足op 2。由于两个属性都是public序列化时不需要考虑private或者protected的%00前缀属性名就是裸的op和filename这在构造payload时能省不少事。如果属性是private序列化串里的键名会带上类名和空字节写payload时很容易数错长度。2.3 filter函数的过滤规则过滤函数是题目最关键的“关卡”它长这样function filter($str) { return str_replace(flag, , $str); }或者用preg_replace规则大意相同把字符串里所有flag替换成空字符串。这个过滤作用于整个反序列化串所以如果payload里写了flag.php过滤后就会变成.php导致s:8:flag.php的长度声明和实际内容不匹配进而让unserialize解析失败或结果错乱。比如说假设你构造的payload是O:11:FileHandler:2:{s:2:op;s:1:2;s:8:filename;s:8:flag.php;}filter处理后会变成O:11:FileHandler:2:{s:2:op;s:1:2;s:8:filename;s:8:.php;}PHP反序列化器看到s:8:时会老老实实读取8个字节作为字符串值但.php实际只有4个字节于是它会把后面紧跟着的;}也吞进去最后得到的filename值根本不是flag.php反序列化结果也会变得完全不可控。但本题的目标文件是d0g3_f1ag.php注意它是f1ag字母l换成了数字1整串不含连续子串flag所以绕过了过滤。这个文件名就是出题人故意设计的只要仔细读源码或者扫目录就会发现。代码审计的价值就在这里如果你以为必须读flag.php就会钻进构造复杂逃逸payload的死胡同反过来说如果你把目标文件名记错成flag.php又会卡在filter上。3. 反序列化利用与字符串逃逸3.1 直接构造payload为什么能成功明确了类和过滤规则后最直接的payload是这样O:11:FileHandler:2:{s:2:op;s:1:2;s:8:filename;s:13:d0g3_f1ag.php;}提交方式是把这段字符串赋给POST参数data。由于串里没有flag这个连续子串filter函数执行后原样返回unserialize正常创建对象。脚本继续运行到某个结束点时对象销毁__destruct触发op 2成立filename被解析为d0g3_f1ag.phpfile_get_contents读到文件内容并输出。这里有个新手容易踩的坑把data参数放在GET里或者用错Content-Type。题目读的是$_POST[data]所以要用POST方式提交body格式写datapayload。如果用Burp Suite直接在请求体里写dataO%3A11%3A%22FileHandler%22...也能过但我一般先用脚本本地测试确认后再用Burp重放。3.2 重复属性覆盖一种更通用的技巧如果题目不允许直接构造比如类里有别的字段必须满足或者过滤规则影响关键属性名还有一招重复属性覆盖。PHP反序列化时如果同一个属性名出现多次后面的会覆盖前面的值。举个例子O:11:FileHandler:3:{s:2:op;s:1:1;s:2:op;s:1:2;s:8:filename;s:13:d0g3_f1ag.php;}这里属性个数声明为3实际有三个键值对。第一个op1第二个op2反序列化完成后op的值是最后一次赋值的2。filename正常保留。这个方法在面对“必须先通过某个默认值检查再改成目标值”的场景时非常有用也是反序列化变量覆盖的基础思路。另一个相关技巧是篡改属性个数。早期PHP版本存在一个经典的绕过把序列化串里的属性个数改大可以让__wakeup方法不执行这就是CVE-2016-7124。虽然新版PHP修复了这种行为但理解它有助于你明白属性个数在反序列化中的作用。本题没有用到不过刷题时会经常看到类似payload可以往这个方向思考。3.3 字符串逃逸中长度是怎么算的字符串逃逸是反序列化进阶内容简单讲就是过滤函数改变了字符串长度但声明长度不变于是反序列化器会从后面的字符里“多读”或者“少读”若干个字节导致结构偏移。分两种情况过滤后字符串变短叫变短逃逸过滤后字符串变长叫变长逃逸。变短逃逸举例。假设有个键值对写成s:20:flagflagflagflagflag;filter删除所有flag后字面只有s:20:;但声明长度还是20。PHP反序列化时按声明长度读取内容就会把后面紧跟着的一串字符一并吞进这个字符串值里。如果你精确计算被吞掉的字符数就可以让剩余的字符串恰好从某个合法键值对开始。这就是payload构造的核心不是脑子一热塞一堆flag而是让“被吞掉的字符数量”等于你想消化的垃圾内容长度。我用一个简化公式来描述变短逃逸的计算思路设声明长度为L过滤后实际内容长度为R那么反序列化器会额外吞掉后面的L - R个字符我们要保证吞掉的这L - R个字符恰好是计划中的垃圾内容并且垃圾内容结束后剩余字符串刚好从这个一个合法键值对开始。变长逃逸正好相反。如果过滤函数把某个短词替换成长词比如str_replace(a, bb, $str)声明长度小于实际内容反序列化器读不满就会读少后面原本的属性就可能被“挤出去”形成新的非法键值对从而实现属性注入。很多注重考查绕过能力的题目会同时混合这两种情况。对本道题来说直接payload就够用逃逸知识属于了解后能举一反三的储备。3.4 完整payload与提交过程最终提交请求相当于POST /index.php HTTP/1.1 Host: target Content-Type: application/x-www-form-urlencoded dataO%3A11%3A%22FileHandler%22%3A2%3A%7Bs%3A2%3A%22op%22%3Bs%3A1%3A%222%22%3Bs%3A8%3A%22filename%22%3Bs%3A13%3A%22d0g3_f1ag.php%22%3B%7D我用Python脚本提交会更稳避免手写URL编码出错import requests url http://target/index.php payload O:11:FileHandler:2:{s:2:op;s:1:2;s:8:filename;s:13:d0g3_f1ag.php;} r requests.post(url, data{data: payload}) print(r.text)运行脚本后如果页面上回显了以flag开头的字符串就成功了。BUUCTF平台的flag通常是flag{...}直接复制提交即可。如果页面回显“no file”可能的原因有几种一是op不是字符串2检查payload里有没有把类型写成i:2二是filename文件路径不对题目环境里文件不在当前目录需要结合源码或目录扫描调整路径三是反序列化虽然成功了但对象析构时机比输出早看不到任何内容这种时候要再看代码里有没有显式unset或者脚本中途exit的逻辑。4. 踩坑记录与调试技巧4.1 本地复现环境怎么搭刷这类题我强烈建议本地搭一个PHP环境不用非要装完整套能跑unserialize和file_get_contents就够了。Windows下可以装PHPStudy或者直接用命令行phpLinux下用apt装php-cli即可命令是sudo apt install php-cli php -S 127.0.0.1:8080把题目源码保存成php文件放到当前目录用php -S起一个临时服务然后写脚本去打。本地复现的好处是能随便调试、打印反序列化结果不用在远程环境上一遍遍试错。调试时我习惯把unserialize的结果直接var_dump出来确认属性值到底是不是想象中的样子。比如写上$obj unserialize($_POST[data]); var_dump($obj);然后提交payload观察输出的对象属性。如果属性值不对说明序列化串的构造有问题尤其是长度数值写错反序列化会静默失败或产生奇怪结果。4.2 unserialize返回false的排查常见的第一类问题是长度不对。反序列化串是严格依赖长度声明的格式s:8:filename里的8不能多也不能少一旦多一个字节或少一个字节PHP就无法解析。遇到返回false的情况先数一遍每个字符串的长度特别注意中文按字节数计算而不是字符数。第二类问题是特殊字符如果属性名或值里有引号、反斜杠、空字节之类会导致字符串提前结束或解析错位需要正确转义。第三类问题是过滤函数改变了payload比如手滑把flag写进串里filter删掉后长度错乱这在本地调试时一眼就能看出来。还有一个容易忽略的点PHP版本差异。同样一个反序列化串在PHP 5.6、7.0、7.4、8.0下解析行为可能有细微差别尤其是属性个数篡改、结尾多余字符串、字符串逃逸的容错处理。远程题目的PHP版本通常能通过phpinfo确认本地复现时尽量保持版本一致。4.3 调试反序列化对象状态的小工具除了直接var_dump还有几个实用技巧。第一用serialize()反向生成合法序列化串先构造一个PHP对象再序列化这样可以避免手写长度错误。第二写一个简单的payload生成器把类名、属性名、属性值抽成变量自动计算长度并拼装字符串。第三如果要测试字符串逃逸可以先写脚本输出filter处理后的字符串再用unserialize去跑观察是否达到预期。把“人算”变成“程序算”能省很多时间。以这个题目为例本地可以这样快速验证payload?php class FileHandler { public $op 2; public $filename d0g3_f1ag.php; } $obj new FileHandler(); echo serialize($obj);把输出作为payload提交再对比手工写的长度是否一致。这个方法在构造复杂对象时特别有用。5. 从这道题延伸出的审计套路5.1 反序列化题目通用审计步骤以后拿到反序列化题可以按这个顺序走先找出所有外部可控入口比如$_GET、$_POST、$_COOKIE、请求头等再看这些入口的数据流向有没有经过unserialize、文件读写、include这类敏感函数然后列出源码中定义的所有类特别是带魔术方法的类最后看魔术方法内部有没有能到达敏感函数的分支并关注分支条件中涉及的属性。理顺之后就开始构造payload。先用本地环境生成合法序列化串再根据过滤规则和版本差异调整。如果过滤删了关键字优先想大小写绕过、变体命名绕过如果都不行再上字符串逃逸或重复属性覆盖。这道题用不上高级技巧但流程完全一样刷多了就会形成肌肉记忆。5.2 常见的过滤绕过思路过滤多半是为了限制反序列化串中的关键字。第一类限制是屏蔽类名比如类名里有敏感词可以用大小写混写或者借助其他类中转。第二类是屏蔽属性名比如不允许出现filename可以考虑重复属性覆盖或者利用动态属性名绕过。第三类是屏蔽文件路径比如过滤flag、php、/等优先考虑目标文件的变体命名实在不行再考虑php://filter伪协议读源码或者用目录穿越。要记住filter的作用范围是整个字符串包括属性名、数组键名、类名和所有值。这意味着你不仅要看目标值有没有被过滤还要看整个payload中的所有关键字。很多新手构造payload时只顾着把filename的值改成目标文件忘了类名里可能带有被过滤的子串结果反序列化直接失败。5.3 如何验证是否解题成功远程环境没有终端怎么判断成功最简单是看响应内容。如果file_get_contents成功页面会多出一段文本通常就是flag。如果没有回显检查响应状态码、Content-Length变化、是否出现报错信息。有时候对象析构发生在输出缓冲之后看不到内容就要想办法让脚本停顿或把内容写进日志。本题的代码是直接echo所以只要构造成功flag会直接印在页面上。拿到flag后在BUUCTF平台提交注意平台有时候有格式要求大多要保留flag{...}的完整花括号。如果平台提示错误先确认是不是复制多了空格或换行这个坑我踩过不止一次。6. 黑盒视角下的突破口6.1 没有源码时如何判断反序列化点如果题目藏在黑盒环境下不直接显示源码怎么判断它是不是反序列化题可以从几个侧写入手。第一看响应头PHP站点通常会有X-Powered-By: PHP并且框架特征不明显第二看报错信息故意提交一个畸形的序列化串如果页面回显unserialize相关的warning说明unserialize确实存在第三看参数特征很多反序列化题喜欢用data、payload、input、obj这类参数接收序列化数据。还有一招是时间盲测。比如提交一个包含大量嵌套对象的序列化串如果响应时间明显变长也能侧面验证unserialize的存在。不过这只是辅助手段最终还是得想办法拿到源码。常见的做法是尝试php://filter伪协议/index.php?fphp://filter/readconvert.base64-encode/resourceindex.php如果页面输出了base64字符串解码就能拿到源码。这招在PHP环境下非常通用配合文件读取漏洞几乎是标配。6.2 善用报错信息反推过滤规则黑盒情况下filter函数的内容往往需要通过报错反推。比如先提交一个包含flag.php的payload看页面是回显“no file”还是直接报错、返回空。如果回显“no file”说明反序列化成功但op值不对如果返回空白或解析错误说明payload被过滤破坏了。通过控制变量就能逐步还原过滤规则。更高效的方式是构造一个不会触发敏感操作的“探针对象”比如把op设成1filename设成一个不存在的文件这样即使反序列化成功也只会走到else分支。然后再逐步修改属性值观察哪一步导致行为变化从而判断过滤到底作用于哪个字段。这题在BUUCTF上给我的整体感觉是反序列化题不一定都要构造复杂的gadget链很多easy题只是基础知识的组合。认真审计代码把可控参数、过滤器、魔术方法三者串起来答案往往就在源码里。我自己刷这道题时最有感触的一点是别被题目分类和名字吓住。看到“反序列化”脑子里就浮现一堆复杂的gadget链但这题完全不是那样它本质是代码审计选择题答案就摆在源码里。做题时先把源码读明白标注每个参数和分支再决定要不要上复杂技术。很多时候最直接的payload就是最有效的payload。最后分享一个实战小技巧碰到PHP反序列化题先把源码下载到本地用php -r跑一个和远程一样的过滤函数把你准备提交的payload先过一遍filter再unserialize确认对象属性是预期的再发远程。这样能省下大部分试错时间。希望这篇复盘对正准备入门反序列化的朋友有帮助。