从一条反序列化链路到完整POP链构造的实战拆讲搞安全的朋友应该都有同感PHP反序列化这块儿你光看书总觉得懂了一拿到真实环境里的代码就发懵。前几天我在一个CTF模拟器上把一条比较典型的POP链从头到尾捋了一遍题目正好覆盖了标题里那一串关键词——php反序列化、魔术方法流程、漏洞触发条件、属性修改、属性类型特征、CVE绕过、字符串逃逸、原生类。这题不算难但它把这一整套逻辑串得非常紧凑非常适合拿来当“把知识点串成一条线”的练手样本。这篇文章我不会去背教科书就用我那天的实际操作顺序来讲把我怎么找入口、怎么一步步逆推链子、中间踩了哪些坑、最后怎么凑出payload的完整过程写出来。看完你应该能建立一个比较清晰的框架反序列化漏洞到底是怎么产生的POP链是怎么像拼图一样拼起来的各种边界情况比如字符串长度变化、访问控制限制、原生类利用又该怎么应对。1. 反序列化这门手艺漏洞根源到底是啥先把最基础的东西摆利索。PHP的序列化就是把对象、数组这类复杂结构转换成一段字符串方便存session、传网络包反序列化就是把这个字符串还原成对象。听起来是个挺朴实的功能对吧问题出在还原的过程中PHP会自动触发一系列魔术方法而这些魔术方法里如果写了危险操作而触发条件又被攻击者控制那就成了漏洞。我更喜欢用一个例子来理解这件事。假设你有一台自动售货机序列化就是你把它里面的货架布局记录成一张图纸反序列化就是拿着图纸重新把货架搭起来。图纸是攻击者递进来的那货架你怎么搭呢图纸上写“第一格放一个炸弹”你说这能怪自动售货机本身吗它只是想忠实还原图纸而已。反序列化的本质问题是你不对输入做任何信任校验就无条件照着图纸执行。真正让这个漏洞变得可怕的原因是PHP的“自动行为”。你new一个对象调用某个方法这都需要写代码但反序列化不需要你写代码PHP在还原对象的时候会自动去调某些特定的魔术方法。这意味着只要攻击者能控制那个反序列化的字符串就等于在你代码里插入了一段“隐形调用”。class User { public $name; public function __wakeup() { echo hello . $this-name; } } // 假设下面这行代码接收了用户输入 $data $_POST[data]; $user unserialize($data);这段例子里面$data我可以传O:4:User:1:{s:4:name;s:5:admin;}那__wakeup()就会自动执行输出“hello admin”。如果我传O:4:User:1:{s:4:name;s:16:admin);eval(...);}}然后__wakeup()里如果用了危险函数一样会执行。这一步看来很简单但它是整条POP链的地基——你永远要先找到那个UNSERIALIZE入口然后看入口后面有哪些魔术方法会被自动触发。排查漏洞的时候我习惯先去找代码里所有unserialize()出现的位置然后顺着这个位置逐层往后看它反序列化出来的对象类型是什么这个对象的类里定义了哪些魔术方法这些魔术方法里又有没有文件读取、命令执行、SQL拼接这种高危动作。如果没有就去看这个类里有哪些普通方法可以被间接调用到。这种“顺着入口往后摸”的思路其实就是POP链构造的最原始动力。2. 魔术方法全流程一张触发时机清单PHP里魔术方法非常多但你真正做反序列化利用的时候重点关注的就那几个。我先列一个速查表然后详细讲讲我实际利用时的体会。魔术方法触发时机典型风险点__wakeup()unserialize()还原过程中常用来初始化属性属性可控就可能造成危害__sleep()serialize()序列化之前通常用于清理资源危险较少__destruct()对象被销毁/脚本结束时常见链尾经常在里面调用文件操作或命令函数__toString()对象被当作字符串使用时常被echo、字符串拼接、文件函数触发__call()调用不存在的对象方法时可用于方法路由、动态调用__get()读取不可访问属性时和属性访问控制配合产生利用__invoke()将对象当作函数调用时常配合call_user_func使用__isset()对不可访问属性使用isset()时相对少用__set()给不可访问属性赋值时相对少用__autoload()尝试使用未定义的类时可能加载恶意文件实际构造POP链的过程里我最喜欢把魔术方法分成两类看待入口类魔术方法__wakeup、__destruct这两个是“自动触发”的不需要任何额外条件。__wakeup在反序列化一发生就调__destruct在对象生命周期结束通常是脚本跑完的时候调。它们对于攻击者来说就是“白嫖”的起点你只要能把一个序列化字符串送到unserialize()里这两个方法一定会执行没有任何前置条件。所以找链条的时候第一条原则就是——先看入口类的魔术方法里能做什么。被动触发类魔术方法__toString、__call、__get、__invoke这些方法本身不会凭空调用需要某个先导条件。比如__toString需要对象被放在字符串上下文中__invoke需要对象被当成函数。它们一般出现在链条中段用来做“中转跳板”。构造的时候要想清楚我怎么才能让代码执行到那个“把它当字符串”或“把它当函数”的地方通常是通过另一个对象里现成的代码逻辑比如某个方法里直接echo $this-obj那这个$this-obj就是一个__toString跳板。我记得有一次做题一条链子是这样的unserialize - __wakeup - 某个方法里file_put_contents - 第二个对象的__toString - 第二个对象的__destruct。整个过程就是魔术方法接魔术方法像接力棒一样往下传。构造POP链最核心的思维方式就是找到这些接力棒节点的顺序以及确认每段接力棒的触发条件可控。在分析真实系统的时候不要一上来就想着“有没有一条现成的链子”而是把代码里所有魔术方法列出来分类标注然后思考它们如何互相触发。你可以把__toString想象成一个饵看看哪里会把对象放入字符串上下文而这个对象能否被你控制成另一个类的实例——这就是典型的类型混淆跳板。3. 漏洞触发条件不只是有个unserialize就完事很多新手有个误区以为看到unserialize()就是漏洞。其实unserialize()本身只是入口漏洞能不能成立取决于三件事3.1 参数是否可控这是最基础的一条。参数如果来自用户输入GET、POST、Cookie、上传文件内容那就是可控如果来自数据库自己拼的通常不那么可控但也不能绝对排除因为数据库里存的可能是之前用户提交的恶意数据。我见过不止一次某系统把用户提交的内容原样存数据库然后下次登录unserialize(数据库里的内容)——这本质上还是可控的只是绕了一道弯。3.2 反序列化后的对象是否有操作反序列化本身只是“还原对象”如果还原出来的对象就是一个纯数据类没有任何方法那就算你传一万个恶意payload也最多触发一个__wakeup或者__destruct。关键是看还原出来的对象会不会被后续代码使用比如传给某个函数、当作字符串拼接、调用它的方法等。这些后续代码才是执行伤害的地方。// 伪代码示例危险但形象 if ($obj instanceof Logger) { echo 日志内容 . $obj-getContent(); } // 如果$obj是恶意构造的类且getContent里有__toString或__call就会成为可利用点3.3 链尾是否有危险函数所谓链尾就是POP链最后到达的那个函数常见的有eval()、system()、call_user_func()、file_put_contents()、include()、unlink()等。POP链构造本质上是从链尾的危险函数倒推我要让某个对象属性变成什么值才能让链尾函数执行我想要的操作。这个倒推的过程就是一个“找可控参数路径”的过程。我常用的分析法是画一个简单的因果表层级对象方法动作可控点链尾Taskrun()system($this-cmd)$cmd属性可控中间TaskManagerexecute()$this-task-run()$task对象可控入口TaskManager__wakeup()$this-initialize()自动触发你会发现从入口到链尾每一级的触发关系都依赖于下一级对象属性而属性又可以通过序列化字符串精确控制。这就是为什么属性修改在POP链里如此重要——因为整条链子的传参都靠属性来传递。做这类分析的时候一定要养成“把类看作一个黑盒把属性看作它的输入参数”的习惯。一个类的功能越强方法越多、操作越猛它作为中间跳板的价值就越大一个类的属性越可控这条链的可利用性就越高。我们真正要做的就是找到一条从unserialize到危险函数之间所有可控属性能串起来的路径。4. POP链构造实战从链尾逆推到入口说了这么多理论来点实操。我在模拟器里遇到的题大概是这样的有三个类A、B、C它们之间的调用关系是class C { public $cmd; public function run() { system($this-cmd); } } class B { public $objC; public function __wakeup() { $this-objC-run(); } } class A { public $objB; public function __destruct() { $this-objB-__wakeup(); } }先别急着写payload按我上面的逻辑来逆推链尾是C::run()里面直接system($this-cmd)这是最理想的危险函数。B::__wakeup()会调用$this-objC-run()所以B对象需要一个objC属性类型最好是C。A::__destruct()会显式调用$this-objB-__wakeup()所以A对象需要一个objB属性类型最好是B。入口是unserialize($payload)反序列化的对象是A。当A对象在脚本结束时被销毁自动触发__destruct。这条链的触发流程就是unserialize - A::__destruct - B::__wakeup - C::run - system。那我构造的payload就应该是O:1:A:1:{s:4:objB;O:1:B:1:{s:4:objC;O:1:C:1:{s:3:cmd;s:5:id;}}}这段字符串的含义最外层是A有一个objB属性值是B对象B对象里有一个objC属性值是C对象C对象里有一个cmd属性值是id。反序列化完成后A对象被销毁时触发__destruct于是objB-__wakeup()被调用接着objC-run()被调用最后执行system(id)。一次典型的攻防就这样形成了。你可能会问A::__destruct怎么知道要调用B::__wakeup不应该是自动触发吗对这里就是一个关键知识点__destruct会在这个对象销毁时自动触发它的函数体可以是任意代码它本身也可以去主动调用别的对象的方法。很多POP链的起手式就是在__destruct里写了一行$this-someObj-someMethod()把这个someObj替换成攻击者可控对象从而实现链式跳转。这里有几个我踩过的坑印象特别深属性名序列化格式PHP序列化字符串中public属性直接写属性名protected属性写成\0*\0属性名private属性写成\0类名\0属性名。很多人复制payload的时候漏了\0结果属性识别不上链子死活不走。这个\0在代码里是空字节但你在文本编辑器里看不到所以最好用能显示十六进制或者有个“显示十六进制”功能的编辑器。一开始老手带我的时候经常提醒我“你看这个public/private的格式”当时觉得烦后来踩过坑才知道真是血泪教训。成员变量类型不匹配有的POP链里方法会做类型判断比如if ($this-objC instanceof C)那你的objC属性就必须传C类的对象传别的直接绕过去就会被拦下。当然反过来说如果代码里没有类型限制你可以传任意类对象进去这正好是原生类利用的入口点。__wakeup和__destruct的触发时机差异__wakeup在unserialize过程中立即触发但__destruct要等到对象引用计数归零或者脚本结束时。如果payload构造后A对象被某些代码复制引用它可能不会立刻销毁导致__destruct迟迟不来。这种时候可以试着在payload里嵌套更多层让最内层对象先在脚本过程中被销毁外层对象最后再销毁凑出正确的执行时序。5. 属性修改与属性类型特征绕过访问控制的套路标题里专门提到了属性修改和属性类型特征这块在真实利用里非常重要因为构造payload时经常要突破访问修饰符的限制。5.1 属性的访问修饰符序列化格式和访问修饰符是绑定的。我用下面的例子说明class Test { public $pub p; protected $pro pro; private $pri pri; }serialize(new Test())的结果是O:4:Test:3:{s:3:pub;s:1:p;s:6:\0*\0pro;s:3:pro;s:9:\0Test\0pri;s:3:pri;}注意看pub是s:3:pubpro是s:6:\0*\0propri是s:9:\0Test\0pri。如果你手动写payload时没注意这个格式直接把protected当成public写那对象的这个属性根本不会被正确赋值目标方法执行时看到的属性值就是默认初始值甚至可能是null链子直接就断了。我以前的习惯是拿到一个目标类先用本地PHP跑一下serialize看看属性名序列化之后长什么样然后照着改。这个办法可以避免90%的格式错误。还有个偷懒技巧写个小脚本把目标类代码复制进去然后用ReflectionProperty强制改访问权限生成恶意对象再serialize输出非常省事。5.2 类型约束与类型混淆PHP 7以后类方法可以声明参数类型比如function setObj(C $obj)那传进去的就必须是C类型。这在防御侧是个天然过滤器但在利用侧也是可以绕的方法就是——让目标类本身继承同一个接口或者父类。比如代码里写的是if ($this-handler instanceof LoggerInterface)那我可以自己定义一个类继承LoggerInterface然后把我那个类的一个对象塞进handler属性里。只要我的类里的方法满足了后续调用所需那条POP链照样走得通。这就叫“类型混淆”它的核心逻辑不是骗过PHP的类型系统而是让我的对象属于同一个类型家族从而通过检查。5.3 属性类型特征从数量到索引还有一个容易忽视的细节PHP对象属性在序列化的时候不要求顺序和原始定义一致也不要求数量和定义完全一致。你可以增加额外属性不影响反序列化但可能影响__wakeup里的初始化逻辑有时反而有用把属性顺序打乱序列化结果只是一堆键值对顺序无所谓利用数字属性名PHP的数组和stdClass属性名可以是数字有些漏洞利用需要特殊键名有一次做一道__wakeup里有array_merge之类操作的题我发现利用者可以塞一个键名冲突的数组进去直接覆盖关键数据。这类“属性数量特征”虽然冷门但在绕过特定检查时非常有用。5.4 一个实际绕过的案例CVE-2016-7124说到CVE绕过最经典的就是CVE-2016-7124——它针对PHP 7.x早期版本中__wakeup的绕过当序列化字符串中表示属性数量的值大于实际属性数量时__wakeup不会被执行但对象的属性仍会按实际值还原。这个漏洞本身就是因为PHP在反序列化时先根据数量分配存储空间然后逐项还原如果在还原过程中发现真实属性比声明的数量少本应触发异常但某些版本里异常被吞掉了直接跳过__wakeup。利用方法是在payload中把O:4:Test:1:改成O:4:Test:2:让__wakeup不触发。我当时试了一下构造payloadO:4:Test:2:{s:4:name;s:5:admin;}这里声明了两个属性实际只给了一个namePHP 5.6到7.0的某些版本会跳过__wakeup。这道题的后续利用是某个类的__wakeup里有个过滤逻辑比如把cmd属性给重置掉绕过之后cmd属性就能保住了。这种绕过思路不是单独存在的它往往给POP链的入口扫清障碍。这种绕过CVE的实用意义在于很多修复类机制本身就建立在__wakeup这类魔术方法上只要你能绕过它就等于绕过了整个防御逻辑。现在很多老的PHP 7版本还在跑这个绕过漏洞依然实战可用。6. 字符串逃逸把属性名或值“挤出”原来位置的技巧字符串逃逸是反序列化里比较精妙的一个点它本质上利用的是preg_replace这类函数在替换后导致序列化字符串长度失真的特性。6.1 变短逃逸吃掉后续内容先看一个典型场景class User { public $username; public $password; public function __wakeup() { $this-username preg_replace(/admin/, a, $this-username); } }这段代码会把username里的admin替换成a导致序列化字符串中这个值的实际长度比原始长度短。序列化字符串里username对应s:5:admin表示“长度为5的字符串admin”。如果我的username设置为admin经过preg_replace后变成a实际长度是1但序列化字符串里写的还是5。PHP反序列化器在解析时会按照字符串里标识的长度去截取后面的内容。也就是说它不会重新计算实际长度而是直接按s:5去读5个字节。那多出来的4个字节是什么就是从后面“吃掉”的内容。这就造成一种现象我可以在username里写admin实际上变成了a但多出来的4个字符长度可以用来“顺带”吃掉后面的属性分隔符和属性结构从而改变后续属性的内容。一个典型利用思路是原始序列化字符串O:4:User:2:{s:8:username;s:5:admin;s:8:password;s:3:123;}我把username设成adminxxxxxxxx是一个精心构造的字符串替换后变成axxxx但序列化字符串里s:5告诉PHP后面的5个字节都属于username。我需要让xxxx正好是一段能闭合当前结构的字符串把原本后面的;s:8:password;s:3:123;}整体“并吞”掉然后再构造一个新的password属性值。说白了这个技巧就是让字符串的实际长度短于声明长度从而借用差量去改变后文的解析结果。这个差量可以是一字节、几字节也可以是我们自己写的完整属性结构。6.2 变长逃逸把后续内容“挤出”反过来如果替换函数是变长的比如preg_replace(/a/, bbb, $this-username)那实际长度会比声明长度长。这时PHP在解析时同样按声明长度截取后面剩下的内容会成为多余的“尾巴”。这个“尾巴”不能被正常解析进当前属性但如果你设计的顺序恰好让后面的属性能够被这些尾巴“喂饱”那也能构造出可控的新属性。变长逃逸比变短逃逸要常见一些因为很多过滤是短词替换成长词。利用时需要精确计算替换次数和差量长度让新增的字节恰好填满某个属性的预期长度把后续属性“挤出”原位置重新排列解析顺序。6.3 实际案例拆解我曾经处理过一道题场景类似这样class Order { public $code; public $info; public $amount; } // 入口代码 $order unserialize(preg_replace(/x/, xx, $payload));preg_replace(/x/, xx, $payload)会让payload里每个x都翻倍。我拿到一个原始payload它原本的amount是111我想改成999。由于替换变长序列化字符串里amount字段之前的长度标记会和实际长度不一致我只要精确控制x的数量就能让多出来的字节正好占据amount长度的空间把999“挤”进该读的位置。具体计算假设原始序列化字符串为O:5:Order:3:{s:4:code;s:1:x;s:4:info;s:1:a;s:6:amount;s:3:111;}经过preg_replace后x变成xx整体长度多了1。那么amount字段的值111会整体后移1字节PHP按原始的s:6amount去定位时读到的就不是111而是偏移后的内容。如果我精心计算多个x的数量可以让后续解析时恰好把amount的值位置替换为新字符串。这个计算过程比较机械但很考验耐心。提示字符串逃逸最核心的一点是先找替换函数的具体行为变长还是变短、替换次数是否限制然后在本地用一个临时脚本逐步调试不要凭空写payload。我每次都是本地写个简化版本把替换后的字符串打印出来肉眼对照长度标记和内容偏移确认无误后再搬到目标环境里。7. 绕过CVE实战一个典型场景的完整利用CVE在标题里占了很大比重这里我专门用一个虚构但不失典型性的场景把绕过CVE和前面的技术点串起来。某个产品自带了这么一段代码原型来自老版本PHP这里做了脱敏处理class Connector { protected $host; protected $command; public function __wakeup() { if (isset($this-command)) { $this-command ; } } public function __destruct() { system($this-command); } }safe_unserialize函数先把payload中所有尝试的类名用白名单过滤然后再unserialize同时调用了__wakeup。看起来很好的防御但利用CVE-2016-7124就能绕过__wakeup。payload构造O:9:Connector:3:{s:6:\0*\0host;s:1:h;s:13:\0*\0command;s:2:id;}关键在于属性数量声明为3但实际只给了2个属性。在PHP 7.x早期版本里unserialize处理时会发现数量不一致但异常被吞掉直接跳过__wakeup然后正常还原属性并创建对象。等到脚本结束对象销毁时__destruct执行system(id)成功。这个例子连起来看正好覆盖了属性类型特征protected的\0*\0、属性修改command的值、魔术方法流程__wakeup被绕过__destruct触发、CVE绕过属性数量差异。不过现在很多环境已经修了这个洞要想绕过__wakeup就得看别的CVE或者更底层的行为差异。比如有一种思路是基于__wakeup被定义但类不完整加载时的行为——不过这更罕见现实中我们还是优先找已经被公开分析和验证过的CVE然后用同样思路去匹配目标代码。8. 原生类利用不写新类也能打通链路原生类是PHP内置的那些类比如SoapClient、Error、Exception、SimpleXMLElement、DateTime、FilesystemIterator等等。利用它们当POP链节点好处是不需要目标代码里存在某个特定的、可利用的自定义类只要入口unserialize接得住就行。8.1Error和Exception类的__toStringError和Exception在被当作字符串时__toString()会输出它们的消息栈包括文件路径和行号。在某些场景下如果反序列化对象最后被echo了那可以借此做信息泄露。但更有价值的用法是它们自带一个__toString()方法如果你能把它们放到某个echo $this-xxx的位置就可以接入一条以字符串操作为跳板的自定义链。8.2SoapClient的SSRF场景SoapClient是个非常经典的利用对象。它有一个__call魔术方法当调用一个不存在的方法时会根据location和uri属性发起SOAP请求。这意味着如果你能把一个SoapClient对象放到某个$this-obj-call()的位置而这个call()方法不存在那么PHP会触发SoapClient::__call发出一个网络请求到location指定的地址。这就是SSRF服务端请求伪造的典型构造。$soap new SoapClient(null, [ location http://127.0.0.1:8080/, uri http://target/, ]); $payload serialize($soap);构造payload后如果目标代码里存在可控的任意对象方法调用这条原生链就能打出去。注意这种利用对版本有要求老版本PHP里SOAP扩展可能需要额外开启。8.3SimpleXMLElement的XXE延伸SimpleXMLElement很特殊。它的构造参数可以是一个XML字符串而且它本身是Iterator/ArrayAccess的实现如果后续代码把它当作数组遍历、当作字符串处理它的__toString返回的是XML内容。如果把SimpleXMLElement和文件读取类函数结合可能延伸出XXE。不过这个相对少见重点还是理解“原生类也是类它的魔术方法同样可以被POP链借用”这个思路。8.4 原生类的实战意义在真实渗透测试里目标应用的代码库可能很小不存在可利用的自定义类是常态。这时候原生类就是“无中生有”的利器。前提是你要对PHP内置类的魔术方法足够熟悉并且知道它们的触发条件。我建议把PHP官方文档里关于魔术方法和内置类列表的那几页存成书签实战时随手翻。9. 常用排查清单与三种实操速查法最后把我多年实操积累的几个排查方法整理成清单方便你拿到新目标时按图索骥。9.1 入口排查三步法第一步全局搜索unserialize(把每一个入口点记下来标注参数来源。第二步看每个入口点的上下文确认反序列化结果会不会被某个类的方法使用如$obj-xxx()或echo $obj。第三步配合__wakeup、__destruct的自动触发画出从入口到危险函数的方法调用路径。9.2 属性格式速查表属性类型序列化格式示例注意要点publics:3:pub;直接写属性名protecteds:6:\0*\0pro;注意\0*\0三字节privates:9:\0Test\0pri;注意\0类名\0静态属性serialize不包含不用考虑实际测试大量问题都出在这个\0上。建议构造完payload后用十六进制查看器确认别凭肉眼。9.3 本地调试建议强烈建议在本地搭建一个同样PHP版本的测试环境。把目标类代码复制进来然后写一个test.php直接unserialize(payload)打印结果看对象属性再手动触发关键方法验证链子是否真的走通。本地环境跑通后再去打靶场能省下大量试错时间。注意调试时不要使用var_dump($obj-command)这种方式去“强行”查看private属性因为这样会触发访问控制错误。用ReflectionProperty或者get_object_vars($obj)配合print_r否则容易误导你的排查。10. 实战收尾我踩过的几个坑和心得说了这么多最后分享几个我实际编码和调试中反复踩的坑希望你看了能少走点弯路。第一个坑嵌套层次一不小心就写错括号。当payload嵌套了四五层对象时花括号特别容易多一个少一个。我后来习惯是先分层写好然后再合成一行。比如先写内层C的payload再写中层B的最后包上外层A的一层层拼拼完再整体验证。第二个坑序列化字符串里不能出现非法UTF-8或者特殊字符。如果目标值里有中文、换行、单引号双引号等需要先确认PHP版本对字符串转义的规则。老版本对\r\n的处理很微妙我遇到过一次目标环境的换行符把payload的}给吃了导致整条链失效。第三个坑不要忽略__wakeup里有重置属性值之类的逻辑。有些代码会在__wakeup里把关键属性重新赋值为默认值比如把$cmd重置为。这时候哪怕你payload里写了cmdsystem反序列化后cmd也会变成空。解决办法就是绕__wakeup用CVE类技巧或者换一条不经过__wakeup的路径。第四个坑动手之前先确认目标环境的PHP版本和已加载的扩展。不同版本的序列化格式几乎相同但魔术方法的触发细节比如__wakeup的绕过条件有所区别。扩展会影响原生类的可用性SoapClient这种依赖PHP SOAP扩展没开扩展怎么构造都没用。版本决定了哪些CVE可用、哪些过时。我自己后来的工作习惯是拿到一个反序列化相关的任务先花30分钟整理输入输出和版本信息再花1小时快速搭一个本地最小复现环境最后才是正式构造和利用。这套流程不说百发百中至少能把大量低级问题挡在前面。反序列化这个东西看着是玄学其实本质就是理清对象属性怎么流、魔术方法怎么跳、构造结果怎么被还原只要这三条线不糊什么POP链都只是时间问题。希望这篇实战拆讲能帮你把那层窗户纸捅破下次在靶场里再看到unserialize时候脑子里能第一时间浮现出一条清晰的调用链。
阅读完成 · 觉得有帮助?