PHP反序列化漏洞实战:绕过正则过滤读取敏感文件

发布时间:2026/7/11 6:23:21
PHP反序列化漏洞实战:绕过正则过滤读取敏感文件 1. 项目概述一次典型的PHP反序列化漏洞利用实战在CTFCapture The Flag夺旗赛的Web安全赛道上PHP反序列化漏洞是一个经久不衰的考点它完美地结合了代码审计、逻辑绕过和利用链构造。今天要拆解的这个场景——“利用PHP序列化漏洞绕过正则过滤读取敏感文件”就是一个非常经典的案例。它模拟了一个真实环境中开发者虽然意识到了危险并尝试用正则表达式进行过滤但由于对PHP反序列化机制和字符串处理的底层逻辑理解不够深入导致防御被轻易绕过的情形。简单来说这个挑战的核心是我们向一个存在漏洞的Web应用提交一段精心构造的序列化字符串Payload目标是让应用在反序列化这个字符串后能够执行我们预设的恶意操作比如读取服务器上的/etc/passwd或flag.php等敏感文件。而难点在于应用层对我们提交的数据进行了某种正则匹配过滤试图拦截危险的Payload。我们的任务就是深入理解序列化数据的结构找到正则过滤的“盲点”构造一个既能通过检查又能成功触发漏洞的“变形”Payload。Pikachu靶场作为国内知名的Web漏洞练习平台其“PHP反序列化漏洞”关卡正是为此类学习量身定做的。它不仅仅是一个漏洞点更是一个完整的教学场景让我们能在安全的环境里从漏洞原理、代码审计到Payload构造、绕过技巧进行全链条的实战演练。接下来我将带你一步步拆解这个漏洞从原理到实操从常规利用到高级绕过让你彻底掌握这类问题的攻防精髓。2. 漏洞原理深度剖析为什么序列化会变得危险要理解如何利用必须先明白漏洞为何会产生。这不能停留在“用户输入可控”的表面而要深入到PHP对象在内存中的生命周期与魔法函数的交互机制。2.1 序列化与反序列化对象的“休眠”与“唤醒”想象一下你有一个复杂的PHP对象里面包含各种属性、资源句柄。你想把这个对象完整地保存到数据库的一条记录里或者通过网络发送给另一个服务。直接存对象或发送内存地址是行不通的。这时就需要序列化serialize它将对象的状态即其属性值转换成一个可存储或传输的字符串格式。这个过程就像把一辆汽车拆解成标准化的零件清单和装配图。反之反序列化unserialize则是根据这份“零件清单和装配图”在内存中重新组装出一辆功能完全相同的汽车。关键在于反序列化过程会重新创建对象并恢复其属性。class VulnerableClass { public $cmd whoami; public $output_file /tmp/result.txt; public function __destruct() { // 当对象被销毁时执行系统命令并将结果写入文件 system($this-cmd . . $this-output_file); } } // 序列化创建对象并转换为字符串 $obj new VulnerableClass(); $serialized_str serialize($obj); // 输出O:15:VulnerableClass:2:{s:3:cmd;s:6:whoami;s:11:output_file;s:14:/tmp/result.txt;} // 反序列化从字符串还原对象 $restored_obj unserialize($serialized_str); // 脚本结束时$restored_obj 会被销毁触发 __destruct() 方法执行系统命令。上面这个例子清晰地展示了序列化字符串的结构O:15:VulnerableClass:2:{s:3:cmd;s:6:whoami;s:11:output_file;s:14:/tmp/result.txt;}。其中O代表对象15是类名长度2是属性个数后面跟着每个属性的类型、长度、名称和值。2.2 漏洞产生的核心可控的输入与危险的魔法函数漏洞产生的条件是两个“如果”同时成立如果应用接受了用户输入并将其直接传递给unserialize()函数。如果被反序列化的类定义中存在一些特殊的“魔法方法”Magic Methods并且这些方法的逻辑是危险的。常见的危险魔法函数包括__destruct(): 对象被销毁时自动调用。常用于写入文件、删除资源如果其逻辑依赖对象属性而属性被我们控制就可能执行任意命令。__wakeup(): 对象在反序列化完成后立即调用。常用来重新初始化资源或连接如果其中有eval()、system()等危险函数且参数可控则直接构成漏洞。__toString(): 对象被当作字符串使用时调用如echo $obj。如果其中包含文件操作或代码执行也可能被利用。__call(),__get(),__set(): 在访问不存在的属性或方法时触发有时会被用于构造复杂的“POP链”Property-Oriented Programming。关键理解反序列化漏洞的本质是“代码注入”的一种特殊形式。我们注入的不是直接的PHP代码而是一个对象的“状态描述”。当这个描述被还原成对象并触发其内置的通常是开发者无意留下的危险代码路径时攻击就成功了。攻击的入口是数据序列化字符串但执行的却是对象类中已存在的代码。2.3 Pikachu靶场漏洞点模拟分析根据Pikachu靶场和常见CTF题目的设计我们可以推测后端漏洞代码可能简化如下// vuln.php class ReadFile { public $filename help.txt; public function __destruct() { // 危险操作直接读取文件内容并输出未对文件名做路径限制 echo file_get_contents($this-filename); } } // 用户输入点通常来自GET/POST参数这里假设来自$_GET[data] $user_input $_GET[data]; // 第一层有缺陷的防御简单的正则过滤 if (preg_match(/flag|etc|passwd|\.\.\//i, $user_input)) { die(Hacker detected!); } // 反序列化操作 $obj unserialize($user_input); // 脚本结束$obj析构__destruct()被调用这段代码的致命问题在于__destruct方法直接使用对象属性$filename作为file_get_contents的参数且未做任何安全校验。虽然对用户输入的原始字符串做了正则过滤试图拦截包含flag、etc、passwd或目录遍历../的输入但过滤发生在反序列化之前。正则匹配的是序列化字符串而我们作为攻击者可以通过精心构造序列化字符串的格式让最终还原出的对象属性值包含敏感路径但原始的序列化字符串却能绕过正则检查。3. 绕过正则过滤的核心技巧序列化字符串的“视觉欺骗”正则过滤通常针对的是人类可读的字符串模式。但序列化字符串是一种有严格格式的、对机器友好的数据表示法。这就给我们提供了多种“欺骗”正则引擎的途径。3.1 技巧一利用序列化格式中的长度声明这是最经典、最有效的绕过方式。回顾序列化格式s:7:pikachu这里的s表示字符串类型7表示字符串的长度。PHP的unserialize()函数在解析时严格按照长度声明来读取字符串内容而不是寻找终止符如引号。假设过滤规则是preg_match(/flag/i, $input)我们想让$filename的值为flag.php。原始PayloadO:8:ReadFile:1:{s:8:filename;s:8:flag.php;}这个肯定会被拦截因为字符串里明明白白写着flag.php。绕过PayloadO:8:ReadFile:1:{s:8:filename;s:8:flaflagg.php;}等等长度是8但字符串flaflagg.php长度是12不对。我们再看O:8:ReadFile:1:{s:8:filename;s:12:flaflagg.php;}这样长度对了但内容还是被正则匹配到了flag。真正的技巧在于修改长度声明O:8:ReadFile:1:{s:8:filename;s:9:flag.php;}我们声明字符串长度为9但实际内容flag.php只有8个字符。会发生什么在旧版本PHP5.6.25, 7.0.10中unserialize()存在一个特性它会忠实地读取9个字符作为字符串值。如果序列化字符串在flag.php后面恰好有一个字符比如闭合的}或;它会被一并读入。例如如果字符串是flag.php后紧跟一个空格或不可见字符可能凑够9个。但在我们的构造中后面是“;}读取9个字符就是flag.php;这可能导致语法错误或非预期行为但有时也能利用。更稳定的是利用溢出读取如果长度声明大于实际字符串长度加闭合引号后的字符PHP可能会继续读取后面的内存行为不确定不稳定。更实用的方法添加无关字符O:8:ReadFile:1:{s:8:filename;s:12:flaflagg.php”;}我们声明长度是12。我们提供的字符串是flaflagg.php。注意我们在中间插入了flag但正则匹配的是/flag/它匹配flaflagg.php中的flag吗匹配因为正则/flag/会在字符串flaflagg.php中找到子串flag从第4个字符开始。所以这个不行。正确的绕过思路让最终属性值包含flag但序列化字符串中不包含连续的flag子串。我们可以利用PHP反序列化另一个特性它不关心字符串内容中的引号是否转义它只认长度和起始结束位置。构造PayloadO:8:ReadFile:1:{s:8:filename;s:10:fl\ag.php;}长度声明为10。字符串内容是fl\ag.php。注意这里有反斜杠\和引号。对于正则表达式/flag/它看到的字符串是fl\ag.php其中包含\这不是连续的flag四个字母所以绕过成功。当unserialize()解析时它读取10个字符f,l,\,,a,g,.,p,h,p。它会将\解析为一个转义后的引号字符但最终存储在对象属性$filename中的值是什么这取决于PHP版本和具体实现。在某些情况下它可能被存储为flag.php一个字面量引号这通常不是一个有效的文件名攻击失败。由此可见单纯修改长度或插入转义符可能因环境而异我们需要更可靠的技巧。3.2 技巧二利用正则表达式的模式局限性实战最常用正则过滤往往只匹配常见的敏感词且可能忽略大小写。但我们可以使用以下方法大小写变换如果过滤是/flag/i不区分大小写则大小写无效。但如果过滤是/flag/区分大小写则Flag.php、FLAG.PHP可以绕过。使用空字节、制表符、换行符等不可见字符PHP在反序列化后这些字符可能被忽略或处理但正则表达式.通常不匹配换行符且敏感词匹配可能被中断。构造s:11:fl\0ag.php”;(使用空字节\0)。在序列化字符串中空字节是\0。正则/flag/无法匹配fl\0ag。关键点在URL中传递时空字节需要编码为%00。Payload变为O:8:ReadFile:1:{s:8:filename;s:11:fl%00ag.php”;}。当PHP接收到%00会将其解码为空字节然后进行反序列化。正则引擎在处理fl%00ag.php时可能不将其识别为连续的flag。双写绕过s:12:flflagag.php”;。正则如果只匹配一次可能匹配到中间的flag而放过我们。但如果正则使用了全局匹配/flag/依然会匹配到。这取决于正则实现。利用PHP的字符串解析特性有时unserialize()对字符串的处理和preg_match不同。例如unserialize()可能会忽略字符串末尾的某些空白字符而正则不会。实操心得在真实测试中最稳健的绕过方法通常是结合不可见字符。例如将敏感文件名转换为URL编码、Unicode编码或者插入空字节。但需要注意unserialize()本身可能对某些编码字符的处理方式与预期不同需要多次尝试。在Pikachu靶场中为了教学目的其过滤逻辑通常设计得比较简单可能直接匹配flag字符串使用空字节%00插入中间是常见解法。3.3 技巧三操纵对象属性数量进行畸形构造序列化字符串中对象属性数量声明O:8:ReadFile:1:{...}中的1也是一个可操纵点。如果我们声明有2个属性但只提供1个会怎样畸形PayloadO:8:ReadFile:2:{s:8:filename;s:8:flag.php”;}这里声明了2个属性但只提供了1个。在PHP 7中这通常会引发警告并导致反序列化失败。但在某些旧版本或特定配置下可能会触发__wakeup()魔法函数而__wakeup()中可能存在其他漏洞利用点。这属于更高级的“PHP反序列化字符逃逸”或“对象注入”技巧的范畴在基础绕过中不常用但需要了解。4. Pikachu靶场实战复现步步为营拿到Flag理论讲完我们进入实战。假设Pikachu靶场已搭建在http://192.168.1.100/pikachu/。4.1 环境准备与信息收集启动靶场确保你的Pikachu靶场通常基于PHPStudy、Docker或LAMP环境正常运行。访问漏洞页面找到“PHP反序列化漏洞”模块。URL可能类似http://192.168.1.100/pikachu/vul/unserilization/unser.php。查看前端通常是一个简单的表单让你输入一段序列化数据或者是一个链接点击后传递参数。查看页面源代码看是否有注释提示后端类结构。Pikachu通常会给出漏洞的源代码或提示。探测过滤规则这是关键一步。先提交一个明显的Payload试探。提交O:1:A:1:{s:4:test;s:10:flag.php;}观察返回。如果返回“Hacker detected!”或类似信息说明存在过滤。再提交O:1:A:1:{s:4:test;s:10:test.txt;}如果正常则确认过滤针对flag等关键词。4.2 构造绕过Payload假设我们通过信息收集或代码审计Pikachu通常提供源码链接得知后端类如下// 后端源码可能暴露的类 class ExploitMe { public $file hello.txt; public function __destruct() { include($this-file); // 危险使用include } }目标是让include包含flag.php。过滤规则是preg_match(/flag|\.\.\//, $_GET[data])。方案A使用空字节绕过我们想让$file的值为flag.php但序列化字符串中不能出现连续flag。 构造属性值fl%00ag.php。注意%00是一个字节所以字符串长度是9f,l,\0,a,g,.,p,h,p。 序列化字符串应为O:10:ExploitMe:1:{s:4:file;s:9:fl%00ag.php;}但是这里有个陷阱。s:9:fl%00ag.php中的%00在URL传输后服务器端的PHP接收到的是已经解码的空字节字符。在序列化字符串的文本表示中空字节就是\0。所以我们真正需要构造的字符串在生成时就要包含空字节。我们可以写一个PHP脚本生成Payload?php class ExploitMe { public $file hello.txt; } $obj new ExploitMe(); $obj-file fl\0ag.php; // 注意这里双引号内直接写\0表示空字节 echo serialize($obj); // 输出O:10:ExploitMe:1:{s:4:file;s:9:fl ag.php;} // 注意输出中空字节可能显示为空格或不可见但其二进制值是\0。 ?输出的字符串中fl和ag.php之间是一个空字节。这个序列化字符串中不包含连续的flag字母因此绕过了正则过滤。当我们把这个字符串进行URL编码因为要通过GET传递时空字节\0会被编码为%00。最终用于URL的PayloadO:10:%22ExploitMe%22:1:%7Bs:4:%22file%22%3Bs:9:%22fl%00ag.php%22%3B%7D或者更常见的是只对关键部分编码确保可读性http://192.168.1.100/pikachu/vul/unserilization/unser.php?dataO:10:ExploitMe:1:{s:4:file;s:9:fl%00ag.php;}4.3 发起攻击与结果验证将构造好的Payload填入靶场输入框或直接修改URL参数。提交请求。观察响应。如果漏洞存在且绕过成功可能会发生以下几种情况直接输出flag.php的内容如果__destruct里用的是echo file_get_contents。页面布局发生变化显示了flag.php的执行结果因为include了该文件。页面报错但错误信息中泄露了flag.php的部分内容或路径。最理想的情况是页面上清晰地显示出了Flag例如flag{th1s_1s_y0ur_fl4g}。注意事项在实际操作中空字节%00的利用受PHP版本和magic_quotes_gpc等配置影响。在PHP 5.3.4之后unserialize()对包含空字节的属性名处理更严格但属性值中的空字节有时仍可利用。如果空字节绕过失败可以尝试其他方法如使用.点号fl.ag.php如果后端有str_replace过滤点号可能被还原。使用/斜杠如果目标是读取/etc/passwd过滤了etc和passwd可以尝试/etc/./passwd、/etc/passwd\0空字节结尾或利用Windows路径特征如果服务器是Windows但CTF通常为Linux。终极方法直接读取源码如果include或file_get_contents支持PHP伪协议可以尝试php://filter/convert.base64-encode/resourceflag.php。这样读取到的是Base64编码的源码解码即可。但伪协议字符串php://filter本身也可能被过滤需要测试。4.4 扩展利用从文件读取到代码执行在更复杂的场景中反序列化漏洞的终点可能不是文件读取而是远程代码执行RCE。这通常需要利用现有类库中的“POP链”将多个类的魔法函数像齿轮一样咬合起来最终触发危险函数如eval()、system()等。例如一个类A的__destruct会调用$this-cleanup而cleanup是另一个类B的对象B的__toString方法会执行eval($this-code)。通过精心设置属性我们就可以在反序列化后形成A-cleanup(B) - B-__toString() - eval($this-code)的调用链。Pikachu靶场的初级关卡可能不涉及复杂的POP链但了解这个方向至关重要。在CTF中遇到反序列化题目时第一步就是审计给出的源码画出所有类的魔法方法调用图寻找可能的链式调用路径。5. 防御之道与CTF解题心法5.1 如何从根本上防御PHP反序列化漏洞作为开发者应遵循以下原则永远不要反序列化不可信的数据这是铁律。如果必须接受序列化数据应使用数字签名或加密来验证其完整性和来源。使用安全的替代方案对于简单的数据存储使用json_encode()/json_decode()或serialize()/unserialize()但仅限内部使用。对于对象持久化考虑使用数据库ORM。实施严格的输入过滤与白名单如果无法避免应对反序列化前的字符串进行严格检查。但注意基于黑名单过滤关键词的防御极易被绕过。应尽可能使用白名单只允许预期的类名。PHP提供了unserialize()的第二个参数$allowed_classes可以限制允许反序列化的类名。// 只允许反序列化 MySafeClass 和 AnotherSafeClass $data unserialize($user_input, [allowed_classes [MySafeClass, AnotherSafeClass]]);避免在魔法函数中实现危险逻辑仔细审查__destruct、__wakeup、__toString等魔法函数确保它们不执行包含用户可控参数的危险操作如系统命令执行、文件包含、数据库查询等。保持PHP环境更新及时升级PHP版本修复已知的反序列化相关漏洞。5.2 CTF解题思路与排查技巧当你在CTF中遇到一个疑似反序列化漏洞的入口时可以按以下步骤进行识别入口点寻找接收参数并可能调用unserialize()的功能点。如“导入配置”、“恢复会话”、“数据传输”等。获取源码通过.git泄露、phpinfo()、文件包含漏洞等获取后端源码。这是最关键的一步因为你需要知道类结构。代码审计在源码中搜索unserialize调用点。然后分析其参数是否用户可控。接着全局搜索__destruct、__wakeup、__toString等魔法方法分析其逻辑绘制可能的调用关系图POP链。构造POP链如果存在多个类寻找一条从反序列化入口类起点到危险函数终点的属性调用链。这需要理解每个类的属性和方法。处理过滤如果存在WAF或正则过滤分析过滤规则。尝试使用空字节%00大小写变换双写、插入特殊字符编码URL编码、Unicode编码利用phar://伪协议如果条件允许phar反序列化是另一个强大载体生成与编码Payload使用PHP脚本本地生成序列化字符串。注意属性名的可见性public、private、protected会影响序列化字符串格式private属性会包含类名前缀格式如s:11:\0Class\0name。发送与调试将Payload发送到目标。如果失败查看错误信息。可能的原因类不存在需要触发自动加载、属性不匹配、过滤规则更复杂、PHP版本差异等。常见问题排查表问题现象可能原因排查方向提交Payload后页面空白或500错误1. 类未定义或未自动加载。2. 序列化字符串格式错误。3. 属性数量/类型不匹配。4.__wakeup()方法中有退出逻辑。1. 检查源码中类定义和自动加载机制。2. 使用serialize()本地生成标准Payload对比。3. 检查__wakeup()方法。返回“Hacker detected”等拦截信息触发了WAF或过滤规则。1. 分析拦截关键词如flag、system、eval。2. 尝试使用空字节、编码等方式绕过。3. 测试过滤是检查原始字符串还是检查反序列化后的对象属性值。反序列化成功但未触发预期效果1. POP链构造错误调用未到达危险函数。2. 属性值在魔法函数中被覆盖或修改。3. 依赖的环境条件不满足如特定文件不存在。1. 重新审计代码确认调用链。2. 在__destruct或__wakeup开头添加echo或写日志调试执行流程。3. 检查文件路径、函数可用性。只能读取文件无法执行代码漏洞点最终是文件包含或读取没有直接代码执行函数。尝试利用文件包含向服务器写入Webshell需有写权限或利用php://filter读取源码寻找其他漏洞。5.3 个人实战心得在我打过的CTF和渗透测试项目中PHP反序列化漏洞的利用成功率很高但也很考验耐心和细心。有几点深刻体会信息收集是王道一半的时间花在找源码、分析代码上。.git、.svn、composer.json、phpinfo.php、备份文件.bak、.swp都是宝藏。注意PHP版本差异unserialize()的行为、魔法函数的调用顺序、对私有/受保护属性的处理在不同PHP版本间可能有细微差别。测试环境尽量与目标一致。善用工具但不依赖工具像phpggc这样的工具可以生成通用POP链Payload但在CTF或定制化漏洞中往往需要手动审计和构造。理解原理比会用工具更重要。绕过过滤的本质是“混淆”让机器PHP解析器能正确理解而人眼或简单正则难以识别。多从二进制、编码、字符串解析的角度思考。从漏洞到利用链一个简单的反序列化入口背后可能连接着一个复杂的POP链。要有“链式思维”把各个类和方法看成拼图找到连接它们的那条线。最后在Pikachu这类靶场练习时不要满足于一种Payload通关。尝试用不同的方法空字节、点号、路径遍历、伪协议去绕过思考每种方法成功或失败的原因。这样当你在真实世界遇到更复杂的过滤时你的工具箱里才会有更多的选择。安全之路始于对细节的深刻理解成于无数次的实战锤炼。