ARTICLE DETAIL

资讯详情

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

PHP反序列化漏洞深度解析:从CTF技巧到实战防御

PHP反序列化漏洞深度解析:从CTF技巧到实战防御 1. 从一道CTF题看PHP反序列化的“奇技淫巧”最近在复盘一些老题目特别是2020年全国大学生信息安全竞赛国赛的Web赛题发现其中一道关于“trick”的题目很有意思。它没有复杂的多层套娃也没有生僻的冷门知识点核心就是PHP反序列化。但恰恰是这种最基础的考点在出题人精心设计的“trick”技巧/陷阱下变得极具迷惑性和挑战性。这道题完美地诠释了CTF比赛中“思路”往往比“知识面”更重要。今天我就结合这道题以及这些年打比赛、做审计的经验来深挖一下PHP反序列化中那些容易被忽略的“边界”与“特性”希望能帮你建立起更立体的漏洞利用思维。很多刚接触Web安全的同学一提到PHP反序列化脑子里立刻蹦出来的可能就是__wakeup()、__destruct()、__toString()这些魔术方法以及利用phar://伪协议触发反序列化。这没错这是基础。但国赛级别的题目往往不会直接考你“如何构造一个POP链”。它会假设你已经掌握了这些基础然后在这个基础上设置一些障碍或者利用一些PHP语言本身不那么“显眼”的特性让你在看似正确的道路上走入死胡同。这道“trick”题就是一个经典的例子它考察的不是你会不会反序列化而是你够不够了解反序列化过程中的一些“细枝末节”。2. 场景复现一个“简单”的登录与反序列化我们先来还原一下题目的基本场景。题目通常是一个简单的Web应用可能有一个登录框。源码经过混淆或精简但核心逻辑清晰。你通过审计源码发现了一个类似这样的关键代码片段?php highlight_file(__FILE__); class User { public $username; public $password; private $is_admin false; public function __construct($u, $p) { $this-username $u; $this-password $p; } public function __wakeup() { if ($this-username admin $this-password a_very_strong_password_you_dont_know) { $this-is_admin true; } } public function login() { return $this-is_admin; } } if (isset($_POST[data])) { $data $_POST[data]; // 关键点1这里可能存在一个替换或过滤 $data str_replace(something, anotherthing, $data); $obj unserialize($data); if ($obj-login()) { echo Flag is here: . $flag; } else { echo Login failed!; } } ?初看之下这是一个非常标准的反序列化题目。我们的目标是构造一个序列化字符串使得反序列化后的对象$obj调用login()方法时返回true即让$is_admin属性为true。常规思路是我们需要让__wakeup()方法中的条件成立。但是条件里要求密码是一个未知的强密码。直接赋值显然不行。那么有没有可能绕过__wakeup()或者有没有其他方法让$is_admin变成true这里就引入了第一个“trick”属性数量与__wakeup()。在PHP 5.6.25以下版本及PHP 7 7.0.10中存在一个著名的__wakeup()绕过漏洞CVE-2016-7124。当序列化字符串中表示对象属性个数的值大于真实属性个数时__wakeup()方法将不会被执行。例如我们的User类有3个属性$username,$password,$is_admin。如果我们构造的序列化字符串中声明有4个或更多属性__wakeup()就会被跳过。但是请注意题目年份是2020年。到了2020年比赛环境通常使用较新的PHP版本如7.2这个经典的CVE基本已经失效。出题人不会考一个在比赛环境里无法利用的漏洞。所以我们需要寻找其他路径。仔细看__wakeup()里的逻辑它是在反序列化完成后立即执行的目的是根据用户名和密码来“重置”$is_admin的状态。如果我们无法阻止它执行那么似乎只能满足它的条件。但密码未知怎么办注意这里有一个关键思维转换。我们真的需要让__wakeup()里的条件为真吗不一定。我们的终极目标是让login()返回true即$is_admin true。__wakeup()只是设置$is_admin的一种方式但不是唯一方式。3. 核心Trick利用反序列化过程中的属性赋值特性这才是本题的精髓所在。我们需要深入了解unserialize()的内部过程。一个对象的反序列化大致分为几步根据序列化字符串中的类名找到对应的类需要类已定义或开启unserialize_callback_func。创建这个类的一个空实例。按照序列化字符串中声明的属性顺序和值直接对对象的属性进行赋值。注意这个赋值过程是“粗暴”的它不会调用任何__set()方法如果属性是public它会直接修改对象的属性表。所有属性赋值完成后最后才调用__wakeup()魔术方法。关键在于第3步。在__wakeup()被调用之前对象的属性已经被序列化字符串里的值完全初始化了。这包括private和protected属性让我们再看一眼User类private $is_admin false; // 默认值是false$is_admin是一个私有属性。在序列化字符串中私有属性的表示格式是%00类名%00属性名。例如User类的私有属性is_admin在序列化中表现为\0User\0is_admin。那么如果我们直接在序列化字符串中将\0User\0is_admin的值设置为true或1会发生什么反序列化开始创建User对象。属性赋值阶段直接将$is_admin赋值为true。这一步发生在__wakeup()之前。执行__wakeup()。__wakeup()里的代码逻辑是如果用户名密码对就把$is_admin设为true。但现在$is_admin已经是true了。除非__wakeup()里显式地将其设置为false例如$this-is_admin false;否则它不会改变。__wakeup()执行完毕$is_admin的值依然是true。调用login()返回true拿到flag。这就是一个典型的“属性注入”或“状态污染”trick。我们绕过了__wakeup()中的验证逻辑不是通过阻止它执行而是通过在它执行之前就已经把关键属性设置成了我们想要的状态。__wakeup()的代码逻辑没有考虑到属性可能已经被预先设置的情况或者说出题人正是利用了这个特性来设置陷阱。构造的Payload核心部分如下?php class User { public $username hacker; public $password whatever; private $is_admin true; // 直接设置为true } echo serialize(new User()); // 输出类似O:4:User:3:{s:8:username;s:5:hacker;s:8:password;s:8:whatever;s:16:\0User\0is_admin;b:1;} ?你需要将s:16:\0User\0is_admin;b:1;这部分进行URL编码后再作为POST参数data提交因为空字符\0在HTTP传输中可能会被截断。通常使用%00来表示。4. 进阶障碍字符串替换与十六进制绕过如果题目仅仅是这样那还谈不上“trick”。真正的比赛中往往还会增加一层障碍。回顾我们最初看到的代码片段里有一行注释$data str_replace(something, anotherthing, $data);这行代码代表在反序列化之前对用户输入的序列化字符串进行了一次简单的替换。这是CTF中常见的考点目的是破坏你精心构造的序列化字符串结构。假设替换规则是$data str_replace(admin, hacker, $data);。这意味着如果你在序列化字符串里包含了admin这个单词它会被替换成hacker。这会导致什么问题序列化字符串是高度结构化的它依靠严格的格式定义O:长度:类名:属性数量:{属性定义}。属性定义中属性名和属性值都以s:长度:值;的格式定义。如果其中的某个“值”被替换改变了它的长度就会导致整个序列化字符串格式错误unserialize()会失败并返回false。例如你构造的用户名是admin序列化后是s:5:admin;。经过str_replace(admin, hacker)后变成了s:5:hacker;。字符串内容从5个字符的admin变成了6个字符的hacker但前面的长度标识s:5却没有变。这就会引发反序列化错误。如何绕过这种字符串过滤这里就需要用到PHP序列化的另一个特性用十六进制\xXX或Unicode\uXXXX表示字符串。在PHP的序列化字符串中字符串值可以用双引号包裹的普通字符也可以用大写S标识的“已编码”字符串。例如字符串admin可以表示为普通方式s:5:admin;十六进制方式S:5:\x61\x64\x6d\x69\x6e;\x61是a的十六进制ASCII码以此类推关键点在于str_replace()函数查找和替换的是文本字符admin。它不会去解析字符串中的十六进制转义符。对于S:5:\x61\x64\x6d\x69\x6e;str_replace()找不到admin这个文本因此不会进行替换。而在反序列化时PHP引擎能够正确识别S类型和其中的十六进制编码将其还原为字符串admin。所以我们的Payload构造需要升级将需要绕过过滤的关键字符串如admin用十六进制表示。确保整个序列化字符串的长度、结构依然正确。假设我们需要用户名是admin密码随意并且要注入私有属性$is_admin为true。同时要绕过对admin的过滤。构造过程如下?php class User { public $username; public $password; private $is_admin; } $obj new User(); // 用户名使用十六进制编码绕过过滤 $obj-username “admin”; // 注意这里我们最终要的是它的十六进制表示 $obj-password “anything”; $obj-is_admin true; $serialized serialize($obj); echo “原始序列化\n” . $serialized . “\n\n”; // 手动将 username 值部分的 “admin” 替换为其十六进制表示 // 原始s:8:”username”;s:5:”admin”; // 目标s:8:”username”;S:5:”\x61\x64\x6d\x69\x6e”; // 注意属性名”username”本身不需要编码因为过滤的是值”admin”。 $target ‘s:5:”admin”;’; $replacement ‘S:5:”\x61\x64\x6d\x69\x6e”;’; $final_payload str_replace($target, $replacement, $serialized); echo “最终Payload已编码\n” . $final_payload . “\n\n”; echo “URL编码后\n” . urlencode($final_payload); ?这样即使用户输入经过了str_replace(‘admin’, ‘xxx’, $data)我们的Payload中的admin因为是以\x61\x64\x6d\x69\x6e的形式存在所以得以幸存从而成功反序列化出我们预期的对象状态。5. 实战中的深度排查与联想在实际比赛或渗透测试中情况可能比上述例子更复杂。题目可能不会直接把源码给你或者过滤规则不止一层。这时系统的排查思路就至关重要。5.1 信息收集与源码审计首先确定反序列化入口。查找所有接收用户输入的参数$_GET,$_POST,$_COOKIE特别是$_SESSION的序列化处理器以及像unserialize()、maybe_unserialize()、接收phar://、php://等流包装器的函数。题目中“trick”可能藏在任意一处。5.2 魔术方法链POP链构造如果简单的属性注入无法达到目的例如__wakeup()里强制设置了$is_adminfalse就需要寻找完整的POP链。本题可能只是更复杂链条的一环。你需要审计所有可用的类画出它们的方法和属性关系寻找从某个魔术方法如__destruct(),__toString()到危险函数如file_put_contents(),system(),eval()的调用路径。2020年的国赛题很可能需要这种更深度的链式利用。5.3 过滤与编码的对抗除了简单的str_replace还可能遇到preg_replace正则表达式过滤可能更严格。思考能否利用正则的局限性比如是否用了/…/e修饰符已废弃但老题目可能有造成代码执行或者能否通过超长字符串、递归嵌套耗尽资源。addslashes()/mysql_real_escape_string()这类转义函数通常是为了防SQL注入但如果它们错误地应用于序列化字符串会破坏其结构。需要测试转义后的字符串是否仍能被正确反序列化。多步替换先替换a为b再替换b为c。这需要你动态跟踪字符串变化构造一个在最终替换后恰好正确的“中间态”Payload。长度限制substr($data, 0, 100)。如果你的Payload过长会被截断。这时需要精简Payload或者利用PHP反序列化“遇到}即结束”的特性将有效载荷放在前面。5.4 利用PHP原生类PHP Native Class这是近年来CTF Web题尤其是反序列化的高频率考点。题目可能不定义任何自己的类或者定义的类无法利用。这时就需要在PHP内置的类中寻找具有危险魔术方法的类。例如SplFileObject 可以用于读取文件。SoapClientCRLF注入 可以发起SSRF请求。SimpleXMLElement 配合XXE读取文件。Error/Exception 它们的__toString方法会打印调用栈和错误信息有时会泄露路径或敏感数据。 在本题的上下文中如果User类无法利用或许就需要转向寻找诸如GlobIterator列目录之类的内置类来达到信息收集的目的为下一步攻击铺路。5.5 Session反序列化问题另一个常见的“trick”点是Session反序列化。PHP默认的Session处理器files会使用php_serialize或php格式存储数据。如果网站使用session_start()前设置了session.serialize_handler为php而存储的Session数据是php_serialize格式或反之在反序列化时就会产生解析差异可能导致对象注入。此外如果Session数据可以被用户部分控制例如通过PHPSESSID或某些框架的特性也可能成为反序列化的入口。6. 从CTF到实战企业级Web开发中的反序列化防御打CTF是为了锻炼技术但最终目的是为了应用到实际的安全工作中。PHP反序列化漏洞在真实的CMS、框架、插件中屡见不鲜例如著名的ThinkPHP、FastJSONJava、Python的Pickle模块等都出现过相关漏洞。防御的核心思想是不要反序列化不可信的数据。如果业务必须使用序列化请遵循以下原则严格的数据源白名单 确保反序列化的数据来自可信的、受控的源头如经过严格验证和签名的服务器端存储而非直接来自用户输入。使用安全的替代方案 对于数据存储和传输优先考虑使用JSON、XML等纯数据格式而非对象序列化。PHP的json_encode()/json_decode()是更安全的选择。魔术方法的安全实现 在__wakeup()和__destruct()等魔术方法中避免执行敏感操作或进行状态重置。如果必须执行应进行严格的权限和状态检查。例如在__wakeup()中可以重新计算关键属性的值而不是依赖反序列化来的值。public function __wakeup() { // 不安全的做法依赖反序列化来的 $this-is_admin // 安全的做法根据当前会话或其他可信源重新判定 $this-is_admin $this-checkAdminStatusFromSession(); }签名与验证 如果序列化数据必须在网络中传输或不可信环境中存储应对序列化后的字符串进行签名如HMAC。在反序列化前先验证签名是否有效确保数据未被篡改。限制可用类 在PHP 7.0中可以使用unserialize()的第二个参数$options指定allowed_classes为一个白名单数组只允许反序列化特定的、安全的类。这是最有效的防护措施之一。// 只允许反序列化 MySafeClass1 和 MySafeClass2 $data unserialize($input, [‘allowed_classes’ [‘MySafeClass1’, ‘MySafeClass2’]]);代码审计与依赖管理 定期对自身代码和第三方依赖库进行安全审计关注是否有不安全的反序列化操作。使用工具如PHP反序列化漏洞扫描器进行辅助检查。WAF与运行时监控 在Web应用防火墙WAF中部署规则拦截含有可疑序列化字符串特征的请求如O:、C:、a:等开头包含大量数字和花括号。同时监控服务器日志对反序列化错误或异常进行告警。回过头看这道2020年的“trick”题它考察的正是对反序列化机制最细微处的理解。它告诉我们在安全领域尤其是代码审计和漏洞挖掘中对技术原理的掌握必须深入到“骨髓”里。不仅仅是知道有哪些函数、有哪些漏洞更要理解数据在系统中的流动、转换的每一个环节理解编程语言特性在特定上下文中的真实表现。这种深度才是区分普通爱好者和专业工程师的关键。在下次遇到反序列化相关的挑战时不妨多问自己几个问题数据从哪里来经过了哪些处理语言在这里会有什么特殊行为对象的生命周期是怎样的只有这样才能看穿那些精心设计的“trick”直指漏洞核心。
返回列表