ARTICLE DETAIL

资讯详情

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

从ez_unserialize学PHP反序列化:__wakeup绕过与Payload构造实战

从ez_unserialize学PHP反序列化:__wakeup绕过与Payload构造实战 SWPUCTF 2021 新生赛的 Web 题里ez_unserialize是我最早被安利的 PHP 反序列化入门题。题目名字已经把考点写在脸上unserialize。但真上手做的时候我发现问题从来不在于“知不知道考反序列化”而在于序列化字符串里那堆s:4:name、O:4:User:3:{...}到底在说什么、怎么改、怎么传。这篇文章我不打算只丢一个最终 payload而是按拿到题目后的完整思考路径走一遍入口在哪、漏洞代码长什么样、为什么这样构造 payload、新手通常卡在哪个环节。想学 PHP 反序列化的朋友这道题是很标准的练手样本。1. 题目定位一张网页和一个可以传参的 data1.1 页面入口与源码获取打开题目环境通常就是一个非常朴素的页面可能有个输入框可能只有一行输出甚至什么交互都没有。SWPUCTF 新生赛的题一般不会把源码直接铺在首页而是通过一些常规手段让你拿到源码。常见的开关有?source1、?show1、?actionview也有直接在页面 HTML 注释里给你留提示的做法。我这边的环境就是首页有一行字“Hello!输入data查看结果”。右键查看源代码注释里写着!-- ?source1 可以查看源码提示一点点 --访问index.php?source1得到一段典型的题目代码。这个题的源码和我见过的多个版本都很接近核心结构类似这样?php class User{ public $name; public $is_admin false; public $flag null; public function __wakeup(){ $this-is_admin false; } public function __destruct(){ if($this-is_admin){ echo file_get_contents(/flag); } else { echo Hello, . $this-name; } } } if(isset($_GET[data])){ $data $_GET[data]; unserialize($data); } else { highlight_file(__FILE__); } ?看到unserialize($data)的那一刻考点就非常明确了。这里没有任何过滤用户传入的字符串会被 PHP 直接还原成对象。题目想让你做的是构造一个反序列化字符串让程序读取/flag文件。1.2 代码里的三个关键点读这段代码不能只看unserialize要抓住三个关键点。第一个是User类的__destruct。对象脚本运行结束后会执行析构函数在这个方法里做了一个判断如果is_admin为真就读取 flag。那么思路就变成拿到一个is_admin true的 User 对象。第二个是__wakeup。这个魔术方法在unserialize之后、对象真正可用之前被调用。类里写了__wakeup会把is_admin重置为false所以如果只是老老实实序列化一个is_admin true的对象传过去反序列化一结束__wakeup就会把变量改回去__destruct判断时已经是 false。第三个是unserialize的入口完全可控。这意味着我们不仅能控制对象属性还能尝试控制魔术方法的调用节奏。题目故意把__wakeup放在这里实际上是在引导新手学习“绕过__wakeup”这个经典知识点。2. 反序列化漏洞是如何被利用的2.1 序列化格式与对象还原要构造 payload先得明白 PHP 序列化到底是什么。我用一个快递包裹来类比serialize相当于把对象拍平成一张物品清单这张清单记录对象的所有属性和值unserialize就是收到清单后重新组装回实体对象。问题在于如果你把这个“清单”的输入权交给用户用户就可以在清单里改写任意属性等于自己往包裹里塞了不该有的东西。一个简单的类class User{ public $name test; }执行echo serialize(new User());输出O:4:User:1:{s:4:name;s:4:test;}拆开看O:4:User表示一个对象类名长度是 4类名是 User。1表示有 1 个属性。s:4:name表示属性名字符串类型长度 4。s:4:test表示属性值。如果属性是布尔值、空值就会对应b:1、N。比如O:4:User:3:{s:4:name;N;s:8:is_admin;b:1;s:4:flag;N;}这个字符串意味着一个 User 对象包含三个属性name 为空is_admin 为 trueflag 为空。理论上直接传入这段__destruct就应该读到 flag但问题就出在__wakeup会插一脚。2.2 魔术方法的触发顺序PHP 对象生命周期里魔术方法是在特定时机自动触发的。这里有两个关键方法__wakeup()unserialize还原对象后立即调用常用来重新初始化资源。__destruct()对象被销毁时调用常用来做清理工作。题目代码的执行顺序是unserialize(data) - 创建 User 对象 - 自动调用 __wakeup() - 脚本结束 - 自动调用 __destruct()所以__wakeup会先执行把is_admin改成 false然后__destruct才判断。从攻击者的角度看__wakeup就像一道自动重置阀必须在它执行之前或者让它不执行才能保住我们设置的is_admin true。于是题目考点变成有没有办法让__wakeup不执行有。PHP 历史上存在一个绕过方法当反序列化字符串中声明的属性数量大于对象实际属性数量时__wakeup会被跳过。这是 CVE-2016-7124 放过的一个问题在旧版 PHP尤其是 PHP 5 到 PHP 7 早期里广泛存在。CTF 靶场为了保留这个知识点经常会把环境定在 PHP 7.0 左右。2.3 属性可见性的序列化差异很多人直接手动改 payload 时会栽在属性名上因为 PHP 序列化对public、protected、private的处理方式不一样。这个题目里三个属性都是public比较简单但考试中经常会有私有属性所以有必要一起理清。可见性示例类序列化后的属性名写法实际长度计算publicpublic $name;s:4:namename长度 4protectedprotected $name;s:7:\0*\0name实际字节\0*\0name长度 7privateprivate $name;类名 Users:10:\0User\0name实际字节\0User\0name长度 10注意这里的\0不是两个字符而是 ASCII 码为 0 的单个空字节。如果手动写 payload很容易把\0User\0name的长度算错导致整个反序列化失败。最稳妥的办法不是手写而是用 PHP 脚本自动生成后面我会详细演示。3. 构造 payload 的完整流程3.1 本地脚本生成标准序列化字符串既然题目代码里有__wakeup会重置is_admin我们先把目标定清楚生成的序列化字符串要让反序列化后得到is_admin true并且不让__wakeup执行。第一步先写一个本地的 PHP 脚本直接序列化一个“我们希望得到”的对象。?php class User{ public $name test; public $is_admin true; public $flag null; } $obj new User(); $obj-is_admin true; echo serialize($obj); ?执行输出O:4:User:3:{s:4:name;s:4:test;s:8:is_admin;b:1;s:4:flag;N;}这个串可以直接作为基础 payload。但直接传入会被__wakeup重置所以要手动修改一个地方把对象属性数量从 3 改成 4。修改后O:4:User:4:{s:4:name;s:4:test;s:8:is_admin;b:1;s:4:flag;N;}属性数量声明确实是 4实际只有 3 个属性此时 PHP 7 早期版本会跳过__wakeupis_admin保持在 true。3.2 为什么属性数量可以绕__wakeup这个绕过逻辑很多人不理解。原理其实很朴素PHP 反序列化时会把O:后面的数字当成“这个对象有多少属性”然后按这个数量去解析大括号里的内容。解析完后对象实际只会有 3 个属性但声明是 4 个。旧版 PHP 在解析完毕准备调用__wakeup前会判断属性数量是否匹配如果不匹配就认为“这个对象不完整”从而不执行__wakeup。你可以把这个过程理解成快递单上写着“内含 4 件物品”但打开箱子只有 3 件。系统一核对发现数量对不上就拒绝执行“开箱检查”这个步骤直接进入“货到即毁”析构。漏洞利用利用的正是这个逻辑错误。如果题目环境是 PHP 8这个绕过通常不生效因为新版本已经修复。但 SWPUCTF 2021 新生赛的题目环境基本都保留了这个特性所以具体靶场能成。3.3 发送请求的三种姿势拿到 payload 后怎么传进data参数也有讲究。payload 里包含双引号、花括号、冒号直接在浏览器地址栏粘贴多数情况下会被解析得很乱所以我推荐三种方式。第一种最简单直观用 Burp Suite。打开 Burp在请求里手动拼接GET /?source1dataO:4:%22User%22:4:{s:4:%22name%22;s:4:%22test%22;s:8:%22is_admin%22;b:1;s:4:%22flag%22;N;} HTTP/1.1 Host: 你的靶机地址如果能接受 URL 编码Burp 的 CtrlU 可以自动 URL 编码选中的内容。payload 中的双引号会被编码成%22百分号不会被二次处理。第二种使用 curl。curl 有一个--data-urlencode参数专门处理 URL 编码问题curl http://目标地址/index.php --data-urlencode dataO:4:User:4:{s:4:name;s:4:test;s:8:is_admin;b:1;s:4:flag;N;}注意--data-urlencode会把 POST 请求体里的 data 编码好。如果题目入口用的是$_GET就需要把?keyvalue全部放进 URL 里这时可以这样payloadO:4:User:4:{s:4:name;s:4:test;s:8:is_admin;b:1;s:4:flag;N;} curl http://目标地址/index.php?data --globoff --data-urlencode data$payload--globoff是为了防止 curl 把花括号当通配符。如果直接放在 URL 的data后面务必提前用--data-urlencode编码或者手动将{}和转成百分号形式。第三种如果题目页面本身是一个表单直接在 HTML 表单里提交大概率会失败。因为 payload 里的空字节和特殊字符经过浏览器解析后会被破坏。所以我不推荐表单直接粘更推荐 Burp 或 curl 发送原始请求。4. 实战中踩过的坑与排查方法4.1\0空字节不可见这个是我当年卡得最久的地方。如果题目里的属性不是public而是private比如class User{ private $flag; }serialize后的结果里会出现\0User\0flag这种显示状态特别诡异的键名。文本编辑器里可能显示成空格直接复制粘贴会丢失空字节导致长度对不上。比如\0User\0flag实际字节数是 10但肉眼看到的Userflag只有 7 个字符如果你手动写s:10:UserflagPHP 解析到长度 10 时根本找不到足够的字节反序列化直接报错。这就是为什么我强烈建议不要手写序列化字符串而是用本地 PHP 脚本生成然后把脚本输出的二进制内容原样发送。尤其在处理protected和private属性时脚本生成是唯一可靠的路径。4.2 PHP 版本差异导致__wakeup绕过无效我在本地用 PHP 8 测试发现同样的 payload 传过去__wakeup照常执行。这并不代表网上所有 WP 都是错的而是 CVE-2016-7124 的绕过在 PHP 7.4 之后被逐步修复到了 PHP 8 基本不可用。CTF 靶场通常使用旧版 PHP所以这个题能成功。建议的做法是先用题目环境本身测试找不到口子再本地搭镜像。如果你本地 PHP 版本过高构造 payload 时会遇到一种奇怪的现象序列化字符串明明正确__wakeup却死活不跳过。遇到这个问题先别怀疑思路先检查版本。4.3 URL 编码中的花括号、引号和空格这类 payload 传入 URL 时最常遇到的坑有三个。第一个是双引号在 URL 里需要编码为%22否则很多 Web 中间件会直接丢弃后面的内容。第二个是花括号{}如果使用 curl 而没加--globoff花括号会被 curl 当成通配符请求直接出错。第三个是加号在 URL 里的会被解析成空格。如果 payload 里出现号要编码成%2B。好在布尔类型序列化后是b:1字符串是s:...很少出现加号算是幸运。4.4 常见报错与排查速查表现象可能原因处理方法页面输出false或空白反序列化失败字符串被破坏检查 payload 是否用脚本生成是否被 URL 编码输出Hello, test而不是 flag__wakeup没有被绕过确认属性数量是否多加 1PHP 版本是否支持输出Warning: unserialize()报错序列化格式错误常见于空字节或被截断换成 Burp 发送原始字符串不要经过表单和地址栏总是被重定向或 404source1或data参数位置不对回到源码确认入口参数名有些题是 POST 传参本地测试通过线上失败环境 PHP 版本差异调低本地 PHP 版本或直接接受线上环境特性4.5 一个特别容易忽略的细节说个实战中的细节unserialize之后对象不一定立刻销毁。如果脚本里$data变量一直存在__destruct可能等到脚本末尾才执行。这本身不影响利用但会影响你对“什么时候出 flag”的判断。有些新手看到请求没返回 flag以为 payload 不对实际上 flag 可能在响应头尾才输出。还有一种情况目标页面输出多段内容前面有Hello后面跟着 flag直接用burp响应面板看完整内容最靠谱。5. 从这道题延伸出去5.1 后续反序列化考点的大致方向ez_unserialize只用了__wakeup和__destruct但这只是 PHP 反序列化的入场券。往后你会遇到更多魔术方法比如__toString、__get、__call、__invoke它们组合起来可以形成所谓的“POP 链”。一道典型的中等难度题会给你若干个类类与类之间通过方法调用关系串联起来一个类的__destruct调用另一个类的__get__get又调用第三个类的__toString最后__toString里执行文件读取或命令执行。构造这类 payload 的关键不再是手改序列化字符串而是先画出对象关系图再把所有属性值都控制在“攻击者希望的状态”。SWPUCTF 新生赛里后面的 Web 题目比如sql、ezez_include本质上是同一个方法论先找到可控参数再去找这个参数最终流入的敏感执行点最后构造一段能让执行点误解的输入。unserialize只不过是把执行从 SQL 语句、文件包含换成了 PHP 对象生命周期里的魔术方法。有了这层方法论再遇到类似题目就不会慌。5.2 建议养成的三个习惯第一拿到源码后先在本地搭一个和题目 PHP 版本一致的环境。反序列化题的版本敏感度极高本地环境决定调试效率。第二所有序列化串一律用 PHP 脚本生成不要手写。生成后立刻用bin2hex或直接输出原始内容检查有没有看不见的字符。第三把发送请求的工具固定为 Burp 或 curl不要过度依赖浏览器地址栏。这三个习惯能帮你节省大量试错时间。从我个人做题的体感来说ez_unserialize是一道非常标准的“第一个烧杯”型的题它不考复杂的利用链却把 PHP 反序列化里最容易出错的三个点——魔术方法触发时机、属性序列化格式、URL 编码传输——全部串了一遍。我当年在这道题上卡住不是不理解unserialize而是没有用脚本生成 payload在这个基础步骤上损失了太多时间。如果你完全照着这篇文章操作却在最后一步卡住先想想是不是 PHP 版本的问题再去检查 URL 编码最后再看一遍O:后面的属性数量。把握住这三个点反序列化题目的大门基本就推开了。
返回列表