目录
[先确认入口:为什么重点落在 POST 和文件包含](#先确认入口:为什么重点落在 POST 和文件包含)
[从原始规则到 preg_replace:先分清上传头与请求体](#从原始规则到 preg_replace:先分清上传头与请求体)
[usort 失败中的参数签名问题](#usort 失败中的参数签名问题)
[第一次上传:502 说明限制可能发生在上传阶段](#第一次上传:502 说明限制可能发生在上传阶段)
[CMD 参数与命令执行的第二轮排查](#CMD 参数与命令执行的第二轮排查)
[远程复制方式与 copy 命令限制](#远程复制方式与 copy 命令限制)
[对工具和 AI 的边界判断](#对工具和 AI 的边界判断)
[安全工具识别和 PHP 解释器不是同一个系统](#安全工具识别和 PHP 解释器不是同一个系统)
[证据链五:代理、PHP 和 WAF 的责任边界](#证据链五:代理、PHP 和 WAF 的责任边界)
摘要:本文根据一次授权实验环境中的授课记录,复盘在宝塔 Nginx 防火墙和文件包含条件下分析过滤规则、寻找受限传参方式、验证图片载荷,以及处理上传拦截、命令执行、加密连接和缓存判断的完整过程。文章重点保留每次失败、报错和修正,说明问题如何从"函数被过滤"逐步转化为"规则、版本、流量和缓存状态共同影响",最后延伸到 mb_ereg_replace 与哈希碰撞实验的验证边界。
本文只讨论原记录中的授权本地环境、靶场和教学测试语境。文中出现的文件包含、图片载荷、WebShell、WAF 绕过和缓存绕过,均应在明确授权的隔离环境中复现;不要将这些步骤迁移到未知目标。
背景与复盘范围
本次复盘的起点并不是一个已经稳定运行的实验环境。安装阶段曾出现异常,处理方式是回滚系统快照后重新安装。这个细节看似与后续的正则分析无关,实际上决定了后续实验能否持续进行:防护规则、PHP 版本、上传目录和测试站点都需要在可重复的状态下验证。一旦环境被错误配置,后续看到的"拦截""无回显"或 502,就很难判断究竟来自代码逻辑、版本差异还是部署状态。
授课记录反复强调,应该在每一个关键阶段保存快照。安装完成后保存一份,防火墙启用前保存一份,规则修改和载荷验证前再保存一份。这样做并不是为了让实验看起来更顺利,而是为了让每一次失败都能回到可比较的起点。文档中实际的经验是:恢复快照后重新安装,最终得到可用环境;这个结果也构成了后续排查的前提。
随后启用宝塔面板中的 Nginx 防火墙。刚启用时统计项仍为零,因为还没有产生测试流量。记录中将其定位为实验对象,原因是这类免费或低成本的防护组件在中小型站点中较常见。它覆盖的范围包括 CC、DDoS、GET 参数、POST 参数以及 User-Agent 等方向,但本次实验的重点不在 CC 或 DDoS,而在 POST 请求过滤和文件包含链路。

图 01 展示了防火墙刚开启、尚未产生测试数据时的状态。此时看到的零值只能说明实验还没有开始,不能证明规则没有生效。

图 02 对应记录中对 GET、POST 和 User-Agent 过滤范围的查看,也是后续把注意力集中到 POST 上传流量的依据。
先确认入口:为什么重点落在 POST 和文件包含
实验中已经存在一个文件包含后门。为了保持授权靶场的语境,本文将它理解为测试站点中预先放置的、仅用于验证包含行为的入口,而不是面向真实站点的持久化手段。这个前提改变了问题的性质:目标并非上传一个可直接执行的 PHP 文件,而是上传一张表面上属于图片、随后由文件包含逻辑读取的测试文件。
图片上传通常比任意扩展名上传更容易被业务功能接受,头像、编辑器或附件模块都可能允许图片进入服务器。但上传请求本身仍然会经过 POST 过滤,图片内部写入的内容也会作为请求体的一部分被规则扫描。因此,排查重点不是单独寻找一个"允许上传"的页面,而是沿着"图片进入服务器、文件被包含、PHP 解释器处理内容"这三步逐层确认限制发生在哪里。
记录中首先查看了防火墙的规则内容。规则声称覆盖 SQL 注入和 XSS,同时对若干常见的一句话木马及命令执行函数做了关键词过滤。phpinfo、eval、file_get_contents、include 等标识出现在分析范围内;其中 include 在当前链路中不能简单当作上传阶段的突破点,因为用于测试的包含入口已经存在于服务器,真正需要解决的是图片如何通过过滤并被入口读取。
规则还涉及 var_dump、print_r 以及 $GET、$POST、$COOKIE、$SESSION、$GLOBALS、$SERVER 等变量标识,另外过滤了部分运算符和等号。记录认为,等号、括号等限制可能同时承担 SQL 注入防护职责;但把这些字符写进正则并不意味着所有场景都得到完整保护,正则回溯、规则覆盖范围和版本行为仍然需要单独验证。

图 03 展示了防火墙规则对函数、变量和常见输入符号的覆盖情况。它体现的是"静态关键词过滤"的思路,而不是对 PHP 语义进行完整解析。
在规则分析过程中还提到 information_schema。课程没有把这个问题展开成新的 SQL 注入教程,而是把它作为一个思考点:如果某个关键字被拦截,应先确认业务是否真的依赖它,再考虑是否存在原文已经提到的替代对象。这里形成的经验是,过滤一个名称并不等于消除了底层能力,真正需要核对的是应用逻辑是否还依赖该名称以及替代路径是否会改变语义。
从原始规则到 preg_replace:先分清上传头与请求体
为了避免只看面板上的摘要,记录继续定位防火墙的原始配置和代码。规则文件分别处理通用请求、规则集合以及 multipart 上传内容。multipart/form-data 处理的是文件上传,相关逻辑会查看 Content-Disposition 等文件头。这个方向与前一天的文件头测试有关,但本次复盘不把它作为首要突破口,因为实验前提已经是"服务器上存在文件包含入口"。
更重要的是 POST 请求体的过滤函数。记录中引用了一个条件:当 body 是字符串且不为空时,对请求体调用正则替换。过滤列表包括 phpinfo、$SERVER、$PHP、fputs、file_put_contents、file_get_contents、$POST、$GET、$REQUEST、assert、preg_replace 等。函数和参数被混在同一套关键词规则中处理,因而会产生两个直接后果:一方面常用的命令执行或写文件函数无法直接出现;另一方面,即使找到了未被列入名单的函数,传入它的变量名也可能先被拦截。

图 04 展示了规则源文件中不同规则段的排列方式。排查原始配置比只看面板提示更可靠,因为它能确认过滤发生在请求的哪个阶段。

图 05 对应上传请求中的文件头过滤。Content-Disposition 等字段属于上传协议层;它们与后续针对请求体内容的正则过滤不是同一层,不能把两类结果混为一谈。

图 06 展示了 body 进入正则过滤后的关键词集合。这里的关键不是记住列表有多长,而是确认过滤器是在 PHP 代码执行前扫描原始文本,因此它不会理解字符串拼接、函数调用和变量替换后的真实语义。
第一次尝试:寻找未被过滤的回调函数
在确认 eval、assert、system、preg_replace 等名称受限后,分析转向回调型函数。记录首先注意到 call_user_func 的确出现在某个规则片段中,但又发现相关配置并不完整;usort 等回调函数并没有以相同方式出现。这里的判断不是"看到一个未过滤函数就立即成功",而是先把它拆成两个问题:回调函数名称能否通过,用户可控参数能否传入。
回调函数通常需要一个回调名称和一组数据。如果第一个参数能使用未被规则列出的回调函数,但第二个参数只能通过 $GET 或 $POST 获取,而这些变量标识又被过滤,那么入口仍然不可用。记录还进一步检查了 system 的括号形式:规则可能同时匹配 system 和 system(,即使裸名称侥幸通过,带括号的调用仍然会被挡住。

图 07 对应"函数名未被覆盖不代表整个调用链可用"的初步判断。

图 08 展示了回调函数相关规则的对比。它帮助确认了本次排查的两个独立变量:函数名是否在过滤表中,以及用于传参的变量名是否在过滤表中。
因此,第一次尝试没有得到可用载荷。失败原因并不是回调机制本身不存在,而是命令执行函数和用户传参方式同时受限 。这也解释了为什么简单地把 system 换成另一个函数,不能自动解决问题。
封锁时间带来的实验成本
失败还受到一个现实限制影响:每次命中过滤规则后,测试 IP 会被封锁。记录中的时间从 300 秒开始递增,后续触发可能延长到 600 秒、900 秒,最高一次达到 2700 秒。切换代理或 IP 可以暂时恢复访问,但在未知规则的阶段盲目尝试会导致"换一个 IP、再触发一次封锁"的循环,最终仍然无法获得稳定反馈。

图 09 展示了封锁反馈和等待成本。它说明测试策略必须从"在线反复撞规则"转为"先在本地搭建同版本环境,再在目标实验站验证"。
授课记录中的转折点是一次 WAF 报错暴露了实现线索。此前并不知道防火墙内部使用了哪种过滤方式,看到报错后才推断其可能依赖 preg_replace 一类正则逻辑。于是先在本地复现规则,减少每一次试错都触发远端封锁的成本。两天后,原测试后台中的后门又被清理,原有 API 不再出现,这再次说明快照、日志和本地复现的重要性:成功与否不能只依赖一个随时会变化的远端页面。

图 10 记录了入口消失后的页面状态。它并不证明规则已经失效,只说明测试对象的服务端状态发生了变化。
把问题拆成两个独立任务
到这里,记录将问题明确拆成两部分:第一,怎样找到一个仍可用的命令执行函数;第二,怎样在不使用 $GET、$POST、$COOKIE、$REQUEST 或 $SERVER 等关键词的情况下把用户输入传进去。这个拆分非常重要,因为两部分的失败原因不同:传参可以通过 PHP 提供的输入接口寻找替代表达,命令执行则必须继续考察过滤器覆盖范围、语言版本和底层函数。
第一次询问 AI 得到的候选包括原始 POST 数据流、环境变量、服务器变量、Cookie、框架请求对象以及参数别名。记录逐项排除:原始 POST 数据流依赖两个 PHP 配置项,实际环境中其中一个默认关闭;$SERVER、$ENV、$SESSION 等已经被规则覆盖;框架请求对象属于 ThinkPHP 等特定框架,当前纯 PHP 环境不存在;即使某个别名没有被过滤,候选代码仍然包含 eval 等被拦截标识。

图 11 展示了候选传参入口的枚举过程。记录没有把 AI 给出的列表直接当作结论,而是逐项核对 PHP 配置、运行环境和过滤规则。
filter_input:传参绕过的验证过程
真正留下突破可能的是 filter_input。记录检查到它没有出现在当前规则列表中,并根据函数说明确认它可以按名称获取外部变量,同时允许通过过滤器处理输入。函数的第一个参数是输入类型,例如 INPUT_POST、INPUT_GET、INPUT_COOKIE、INPUT_ENV、INPUT_SERVER、INPUT_SESSION 和 INPUT_REQUEST;第二个参数是变量名。基于原文已经出现的内容,本次实验选择 INPUT_POST 与一个自定义变量名进行测试。

图 12 对应函数说明页,重点是确认输入类型和变量名都是函数参数,而不是直接出现 $POST 这样的变量标识。
先不把它用于完整载荷,而是写一个最小验证:读取指定输入类型中的 CMD 变量,然后把返回值输出。为减少干扰,测试阶段先切换到 GET 输入,以便快速观察是否能获得 CMD=123。第一次测试得到回显,说明这个函数确实能读取外部变量,传参问题的后半段因此得到验证。

图 13 展示了只包含输入读取和输出的验证代码。它的目的不是执行命令,而是确认输入通道是否建立。

图 14 展示了测试阶段把输入类型改为 GET 的调整,属于排除变量来源错误的步骤。

图 15 的回显证明 filter_input 成功取得了指定变量。这个结果只能证明传参通道可用,不能直接推出命令执行链路也已成功。
随后回到规则文件和页面规则中搜索 filter_input、INPUT_POST 等标识,未发现对应拦截项。于是形成阶段性结论:在当前实验规则和 PHP 版本下,filter_input(INPUT_POST, "CMD") 提供了一个不依赖 $POST 文本的参数读取方式。这一步解决了第二个问题,但第一个问题仍然没有答案。

图 16 展示了对规则文件和页面规则的二次确认,避免把"搜索结果为空"误认为整个站点必然没有其他隐藏规则。
命令执行函数仍然是主要瓶颈
传参通道建立后,剩下的问题更加集中:filter_input 能把 CMD 读出来,但它不会自动执行 CMD 的内容。记录再次回到函数过滤表,确认直接型后门、include、require、eval、assert、system 以及常见写文件函数都受到不同程度的限制。换言之,传参绕过只是把数据送到 PHP,真正的调用点仍然会在请求体扫描阶段被拒绝。
这里出现了一个容易误判的现象:某种写法可能没有触发防火墙,但这不代表它会产生有效结果。比如把两个参数都改成 filter_input 可以避开 $POST 字符串,但第二个参数仍然需要一个命令执行函数;如果这个函数在规则中,整个请求照样会失败。因此,记录把回调型、直接型、变形命令和技巧型后门逐类列出,再观察它们分别依赖什么。

图 17 是后续分类讨论的起点,用于区分直接调用、回调调用、变形命令和创建函数等思路。
直接型和回调型的差异
直接型后门依赖一个明确的危险函数。若函数名或调用形式进入过滤表,几乎没有继续调整参数的空间。回调型后门则把函数名作为参数传给回调机制,因此理论上可能避开对某个固定调用点的检查,但它同时引入了更多可控节点,也更容易被安全工具做数据流分析。

图 18:直接型、回调型和其他类型后门的分类图
回调型方向看似有机会,原因是此前已经解决了 $POST 的字面过滤;但记录很快发现,回调函数内部依然需要一个能执行命令的名称,assert、system 等名称仍然会在请求体层面出现。也就是说,传参的载体不再是问题,被传入的值本身仍可能成为规则命中点。
例如,把两个位置都改为 filter_input,分别读取两个变量,语法层面可以绕开 $POST;然而第一个变量最终仍然要表示某个可调用函数,第二个变量仍然要提供数据。只要函数名字或执行所需的关键字出现在过滤集合,实际请求就会被拦截。记录对 include、require、eval 等方式做了同样的排除,并没有把"能传进去"误写成"能执行"。

图 19 展示了参数拆分后的限制:输入通道虽然可以替换,执行节点并没有消失。
非字母数字表达式和创建函数的尝试
记录还回顾了前面课程出现过的非字母数字表达式。这类表达式试图通过字符拼接构造关键字,再交给某个执行机制处理。但在本次规则中,底层执行仍需要 eval 或等价调用作为托底;如果托底函数本身被过滤,表达式只是构造出一段字符串,并不会自动运行。

图 20:非字母数字拼接方式的分析
因此,记录将这种方向标为"可能拼接成功,但缺少最终执行点"。这是一种典型的失败分析:语法能够形成并不代表解释器会主动执行它。
另一个候选是 create_function。它的思路是根据 POST 数据动态创建函数,理论上可以把传入的函数体变成可调用对象。但检查规则后发现,create_function 与 preg_replace 都在过滤范围内,因此这个候选同样被排除。

图 21:create_function 与 preg_replace 同时被规则覆盖
到这里,记录得到一个相当明确的判断:当前可见的常见后门已经基本被覆盖,剩余突破口不能只靠替换变量名,而要回到 PHP 的运行时行为和安全工具的识别边界。
从字符串过滤转向语义分析
课程随后展示了对一段回调代码的"代码哲学家"式分析。它把代码看作一段功能不确定的程序:回调函数由用户提供,用户传入什么名称,代码就可能呈现什么功能。开发者原本可能想传入一个过滤函数,但如果输入变成 assert,代码就会表现出命令执行特征。

图 22:对用户可控回调代码进行功能判定

图 23:变量变化导致代码功能变化的说明

图 24:回调函数和参数之间的数据关系
这里的关键概念是 PHP 的动态特性。用户输入改变变量值,变量值又决定函数调用,最终使同一段源代码在不同请求下表现出不同功能。安全工具如果只做文本匹配,可能漏掉通过拼接、别名或大小写变化形成的调用;如果它把代码解析成语法树,再沿着函数调用和参数来源追踪,就能判断危险节点是否收到用户可控数据。
为了说明这个过程,记录把调用拆成树形结构:第一个节点是回调函数,例如 usort;第二个节点是回调名称,例如 assert;第三个节点是用户输入值。分析重点包括函数调用、动态创建类、include 以及函数参数是否由用户控制。

图 25:将回调调用拆分为树形节点
当分析器看到函数调用节点后,会继续追踪回调参数。如果函数名称来自用户输入,且第三个参数也来自用户输入,那么它会被视为高风险路径。记录中将这种判断归纳为:用户不可控的固定匿名函数通常更容易解释;用户可控的具名回调则可能改变功能。

图 26:安全工具沿函数调用节点追踪用户可控参数
如果过滤器不认识某个底层存在的函数,或只识别小写形式而没有统一大小写,就可能出现检测盲区。这里提出了两条思路:寻找 PHP 底层存在、但官方文档或规则作者没有覆盖的函数;检查 PHP 对函数名大小写不敏感时,防护层是否也做了大小写归一化。
记录同时保留了一个重要的学习判断:授课不应把所有答案直接交给学习者。面对过滤器时,至少可以独立提出"底层函数是否存在"和"大小写是否被归一化"两个方向,再用本地环境验证,而不是只等待现成 payload。
回调检测、匿名函数与大小写尝试
回调后门的检测过程继续沿着语法树展开。首先判断是否存在包含回调函数参数的调用,再判断回调参数是否为变量,最后检查回调是否以匿名函数定义。匿名函数没有显式名称,函数体直接写在调用位置,开发者能在源代码中看到它的具体逻辑;具名回调则可能由外部输入决定,或者由于函数体位于别处而增加分析难度。

图 27:回调型后门的检测思路

图 28:回调函数节点与用户输入节点的拆分

图 29:回调参数是否可控的判断路径
记录用一个包含 assert 和用户变量的示意调用说明检测步骤:先拆开调用,再识别回调函数名称,最后检查它是否接收用户可控数据。这个流程本质上是从字符串过滤转向数据流判断。

图 30:匿名函数和具名函数的对照
匿名函数示例中,函数体直接写在 function 后面,没有名称。记录强调,匿名函数并非因为"匿名"这个词天然安全,而是因为在当前示例里它由开发者写死,用户不能直接替换函数体;如果匿名函数内部仍然读取用户输入,依然需要进一步分析。

图 31:匿名函数体中固定处理逻辑的示例

图 32:具名回调函数可能受用户控制的示例

图 33:回调名称由外部输入决定时的风险关系

图 34:回调后门沿语法树遍历的分析流程
在大小写测试中,先提出了 PHP 函数名大小写不敏感的事实,再验证安全层是否忽略大小写。尝试把 assert 改成混合大小写形式,预期 PHP 仍能识别;但结果没有绕过,因为防护规则设置了忽略大小写的匹配方式。这个失败非常有价值:语言层面的大小写特性只有在防护层没有同步归一化时才可能形成差异。

图 35:混合大小写函数名被防护规则继续识别
记录还尝试把回调函数和参数换成 filter_input,并按 usort 所需的数组形态传递。最初写错函数名,报错提示缺少字母;修正拼写后,PHP 又提示参数必须是数组。随后把输入拆成数组元素,并继续验证回调和参数位置。

图 36:以 filter_input 读取回调相关变量的测试代码

图 37:传递数组参数时的变量结构

图 38:函数名拼写错误导致的报错

图 39:修正拼写后继续验证数组参数
经过多次改写,测试仍然没有形成稳定结果。记录进一步检查 usort 的引用参数要求,怀疑临时数组不能按引用传递,而变量数组才可以。即便用变量和引用符号修正了语法,外部输入直接形成数组仍可能与函数要求不一致。这个过程说明,PHP 的参数签名、数组结构和引用语义同样会影响验证结果,不能把 WAF 失败全部归因于过滤规则。

图 40:usort 回调测试未形成有效结果
回调方向的阶段性结论
记录随后用 filter_input 读取数组和回调参数,并把输入类型切换为 GET 以便观察。某些写法可以避开 $GET 文本,但仍无法让回调链在当前规则下稳定执行;同一张截图在原文中出现了两次,分别对应"尝试替换输入来源"和"确认替换仍未解决执行点"两个位置,本文也按原出现顺序保留两次引用。

图 41:第一次验证替代输入来源的回调写法

图 41:第二次复查替代输入来源仍无法完成回调链

图 42:回调链路最终仍受执行函数限制
这一阶段的经验可以概括为:回调函数未被过滤,只能说明回调入口存在;它不能证明回调名称、参数值和函数签名都能同时通过 。当 $GET 被过滤、函数名又必须通过请求体传入时,回调方案依然可能停在"语法可写、运行不可达"的状态。
usort 失败中的参数签名问题
回调方向的失败并不只来自防火墙。记录中使用 usort 做最小测试时,先把外部输入组织成数组,再把回调函数作为第二个参数传入。代码表面上符合"数组加回调"的结构,但实际执行仍然没有得到稳定结果。排查首先发现过函数名拼写错误,修正后又出现参数类型不符合要求的提示,说明测试样本本身还没有进入规则绕过的讨论阶段。
随后将输入拆成数组元素,并对照 usort 的函数签名检查参数位置。新的问题是,usort 的第一个参数需要数组,并且函数内部会修改这个数组;某些由表达式临时生成的数组不能像普通变量一样传递给需要引用的参数。将数组保存到变量后,语法层面的问题有所变化,但外部输入仍然不能稳定形成预期的数组结构。由此可以确认,变量来源、数组形态和引用语义是三个独立条件,缺少任何一个条件,回调链都不能成立。
这次排查也暴露了一个容易被忽略的误区:看到官方示例可以运行,并不意味着把同样的写法换成动态输入后仍然等价。官方示例中的数组通常是明确的变量,回调也在固定位置;实验代码中的值经过 filter_input 读取,可能是字符串、空值或未按预期拆分的内容。若不先在无防护的本地 PHP 环境中确认类型和引用关系,远端返回的 502、无回显或拦截页就无法归因。
因此,usort 方向最终被标记为未形成稳定结果。它提供的不是一个可直接复用的答案,而是一条排错经验:回调函数没有被关键词表覆盖,只能证明名称层面尚未命中;真正的可执行性还要经过 PHP 函数签名、输入类型、变量引用和请求规则的共同验证。
寻找文档之外的底层函数
在常见函数全部受限后,分析转向 PHP 底层存在、但官方文档或规则作者可能没有覆盖的函数。记录特别关注 mb_ereg_replace,因为它的名称和 preg_replace 相似,同样属于正则替换范畴,且在部分 PHP 版本中支持执行修饰符。这里的判断只是实验假设,必须先确认当前版本仍然可用,再确认防火墙是否过滤它。

图 43:从官方文档边界转向 PHP 内部函数的思路

图 44:mb_ereg_replace 与 preg_replace 的对照方向
记录检查了 PHP 文档和函数列表,发现 mb_ereg_replace 在相关版本中可见,但并未进入当前防火墙的关键词列表。随后查看函数说明,确认它用于多字节字符串环境下的正则替换。由于示例用法与 preg_replace 接近,下一步才是验证执行修饰符是否仍被支持。

图 45:版本和函数可用范围的核对

图 46:mb_ereg_replace 函数说明和示例

图 47:两个正则替换函数的并列示例
实验先在 PHP 7.3、7.4 等版本中测试,记录观察到该函数在 7.3.4 和 7.4 仍可使用,而 PHP 8.0 之后不可用。原记录对具体版本存在口误和不确定性,因此这里保留为"以本地实际版本测试为准",不把版本边界扩大为普遍结论。
测试中加入执行修饰符并传入一个简单的 CMD 变量。结果表明,在实验所用的旧版本里函数仍能完成预期替换和执行;这为绕过当前关键词表提供了可能,但它不代表所有 PHP 版本、所有防火墙和所有配置都存在同样行为。

图 48:旧版 PHP 中测试 mb_ereg_replace 执行行为
阶段性判断是:函数名没有出现在过滤器中,版本又支持该函数,且传参可以由 filter_input 提供,三项条件同时成立,才值得进入图片上传验证。
这一判断还需要补充一个版本边界。实验记录中曾在不同 PHP 环境之间切换,mb_ereg_replace 的行为并不完全一致;同一个测试在旧环境可以继续,换到更新环境后可能因为函数能力变化、执行修饰符行为变化或扩展状态不同而失效。原稿没有给出一份足够稳定的兼容性矩阵,因此文章只保留实测现象,不把某个小版本写成绝对分界。
验证顺序也不能反过来。先检查函数是否存在,再用不涉及复杂载荷的替换操作确认普通功能,之后才观察执行修饰符和外部参数。若直接把完整图片载荷送入上传接口,失败时无法判断是函数不可用、请求体被拦截、图片被重写,还是包含阶段没有读取文件。将测试拆开后,filter_input 负责输入,mb_ereg_replace 负责替换或执行,上传功能负责落盘,文件包含负责加载,每一层都有独立的观察点。
从防御角度看,这个案例说明静态规则表存在时效性问题。规则作者可能依据旧版本中最常见的危险函数建立名单,却没有覆盖名称相近的扩展函数;但这不等于扩展函数天然危险或在新环境中仍然可用。"未被过滤"只能作为候选条件,不能作为成功结论。
把函数验证放回上传链路
命令执行函数在本地通过验证后,记录没有立即把它写入真实后台,而是先确认图片载荷能否在文件系统中保持为有效图片。测试站点提供了无需权限的图片上传功能,早期还曾允许 PHP 后缀,后来后缀限制被恢复;但文件包含入口并未同步删除。因此,实验可以继续使用"上传图片、再由包含入口读取"的路径,而不需要直接上传 .php 文件。
第一个载体是一张普通图片。用十六进制编辑器查看后,把载荷放在图片尾部的空闲位置。记录特别强调不能随意改写中间字节:如果写入区域属于图像数据,图片可能损坏;尾部追加或明确的空闲区更适合验证。改造后先用图像查看器确认文件仍能打开,再检查编辑器能否看到追加内容。

图 49:验证过滤函数可执行后准备图片载荷

图 50:图片载荷写入前后的文件状态

图 51:实验站点中不要求权限的上传功能
原记录还提到上传目录位于编辑器或后台上传动作的目录下,例如 upload.action 一类路径。本文保留这个观察作为靶场排查方法,不把它当成任何真实站点的固定目录。实际测试时,文件地址可以从上传响应、浏览器开发者工具或图片地址中确认,不能凭名称猜测。
改造后的图片先被保存为类似 web.jpg 的图片扩展名。打开图片确认视觉内容正常,再观察十六进制内容是否包含追加的测试片段。这个顺序很重要:如果图片在上传前就已经损坏,后面的 502 或包含错误没有诊断价值。

图 52:原始图片和准备改造的上传文件

图 53:用十六进制工具定位图片尾部的可写区域
记录指出,也可以在图片中间寻找没有有效数据的区域,但要先证明该区域不会破坏图片编码。为了降低变量,实验优先选择尾部位置。这里的结论不是"任何图片都能在尾部安全追加",而是"应该选择经过验证的载体,并在每一步确认文件仍可打开"。

图 54:在图片尾部安排测试数据的位置
传参来源方面,记录曾经测试过 Cookie,也观察到某些情况下可行,但为便于复现和构造请求,最终优先选择 POST。原因是 POST 能承载更完整的测试数据,同时与当前防火墙重点扫描的请求体直接对应。

图 55:将图片载荷和 POST 输入方式组合的测试界面
完成改造后,再次确认文件扩展名、图片可读性和编辑器显示结果。只要任一项异常,就先回退到上一个快照或重新生成载体,而不是马上把错误归咎于 WAF。

图 56:修改后的图片文件和载荷位置

图 57:图片在本地仍能打开并显示追加内容的状态
第一次上传:502 说明限制可能发生在上传阶段
上传改造后的图片后,第一次返回 502。当时的判断是:请求可能被防火墙或上游服务拦截,但仅凭 502 不能判断具体位置。于是查看防火墙日志,看到一次恶意文件上传拦截,并记录了封锁状态。

图 58:上传图片载荷后返回 502 的响应

图 59:防火墙日志中的恶意文件上传拦截
下一步是对照抓到的 HTTP 流量,检查请求体中究竟包含哪些关键词。规则可能扫描文件名、表单字段、文件头、文件内容或请求参数,不能只盯着图片扩展名。记录中确认某些输入标识并没有被直接限制,于是重新设计测试:先解除本地环境中的封锁,再只修改一个变量,观察响应是否变化。

图 60:上传请求和防火墙检测字段的对照
在本地测试时解除 IP 封锁是合理的,因为站点属于授权环境,目标是验证规则而不是维持封锁状态。重新合成图片后,原先出现的额外下载文件不再出现,图片本身仍能正常打开;再次上传得到成功响应。这个结果表明,上一轮失败很可能来自特定字符串或规则命中,而不是图片格式必然不可用。

图 61:重新合成后的图片载荷和上传前检查

图 62:上传成功但仍受 $PHP 关键词限制的结果
此处形成一个重要分界:图片已经能进入服务器,但 $PHP 相关限制仍在生效。上传成功不等于 PHP 解释器会执行图片内容,图片能被包含也不等于命令参数能通过。 后续必须分别验证文件是否存在、包含入口是否读取到文件、PHP 是否解析载荷,以及命令参数是否到达执行函数。
上传链路中的每个状态都需要单独留证。第一份证据是业务层返回了上传结果,但这只能说明请求被应用接受;第二份证据是通过图片地址或开发者工具确认服务器生成了实际文件;第三份证据是浏览器或图像查看器仍能打开该文件,说明服务器没有在上传过程中完全破坏载体;第四份证据才是文件包含入口能够读到文件内容。若缺少中间证据,后续出现 No such file 或语法错误时,很容易错误地把问题归因于过滤器。
原稿中还出现过上传前后文件内容发生变化的情况。应用可能重新命名文件、改变扩展名,或者对图片进行处理;因此,本地生成的文件名并不等于服务器最终路径,本地看到的字节也不一定等于落盘后的字节。验证时需要同时保留原始文件、上传响应和服务器实际地址,必要时重新下载服务器文件,再对比大小与关键内容。这个过程没有引入新的攻击能力,却决定了后续判断是否可信。
从阶段推进看,图片载体只完成了"把测试内容带进业务上传接口"的任务。即使图片能够上传,包含入口仍可能限制路径,PHP 仍可能把图片尾部解释为无效源码,执行函数仍可能被过滤,访问连接仍可能被流量检测拦截。上传、包含、解析、执行和连接是五个不同里程碑,任何一个里程碑都不能代替下一个。
旧版本标签和短标签验证
记录首先检查了旧版 PHP 对脚本标签的支持。某些旧版本可能接受不同形式的 PHP 标签,而当前环境版本较高时,已经移除部分标签方式。因此这里先做版本判断,再做最小语法测试;不能把旧课程中的标签写法直接套到新版本。

图 63:旧版脚本标签方式及版本差异
随后验证短标签。记录回顾了此前在数字和字母受限时使用短标签的经验,并把短标签视为 PHP echo 的一种简短表达。测试阶段不直接使用完整 WebShell,而是先让一个最小表达式输出固定值,确认服务器是否接受这种语法。

图 64:PHP 短标签的语法说明
在本地执行最小测试后,短标签能够被解析。换行和图片内容中的其他字符没有立即造成语法错误,于是把同一形式放进测试图片,重新生成载荷。

图 65:把短标签放入图片载荷后的内容

图 66:短标签在本地 PHP 版本中的执行结果
这个结果仍然只是"语法可解析"。下一步必须重新检查图片可打开、上传接口接受以及文件包含能否读取,任何一步失败都可能使最终请求无法到达短标签。

图 67:短标签图片重新生成后的预览
重新上传后,图片载荷能够被接受并正常包含,说明图片上传和文件包含两层已经打通。此时命令参数仍存在问题,不能把这一步写成"已经完成 WebShell",更准确的表述是:载荷成功进入服务器,并能经由既有包含入口加载;命令执行链尚未完全验证。

图 68:图片载荷上传成功的结果
文件包含路径和文件名排查
包含测试从一个最小文件开始。先在服务器上放置简单内容,通过包含入口读取它,再逐步替换为图片文件。记录中曾使用过测试文件名和固定数字内容,目的是确认包含路径,而不是直接运行复杂载荷。

图 69:用于文件包含验证的测试入口

图 70:服务器端测试文件的准备状态

图 71:最小包含内容和固定输出值
浏览器开发者工具和图片地址可以帮助确认上传文件的实际路径。若出现 No such file,首先检查文件名、扩展名、大小写和上传目录,而不是立即修改 PHP 逻辑。记录中遇到的路径错误就是由文件名写错导致,修正路径后才进入下一步。

图 72:包含入口拼接测试文件路径的代码

图 73:通过图片地址确认上传文件实际位置

图 74:路径写错时出现 No such file 的错误
路径正确后,包含操作又出现语法错误。记录推断,图片中大量与载荷无关的字节可能被 PHP 当作源代码的一部分,导致解析在到达末尾测试语句前就失败。解决方式有两个:更换载体,或使用简单的图片头欺骗并减少可能影响解析的内容。

图 75:图片中无关字节导致包含解析异常的现象
GIF 图片的 GIF89a 头被作为另一个验证方向。做法是选择可接受的图片头,把文件改为图片扩展名,再反复测试上传和包含。原记录强调,这种方法不是万能的;图片检测严格时仍然会失败,且随意插入字节可能破坏图像结构。

图 76:使用 GIF89a 作为图片头的测试文件

图 77:将测试文件改为图片扩展名后的状态
多次测试后,图片可以被上传并被包含,说明当前图片检测较弱,或者只检查部分关键词。这个结论仍然限定在本地实验规则:不能据此推断其他版本的防火墙也存在同样缺陷。

图 78:图片经过检测后成功进入包含链路
在参数缺失时,包含页面返回了与官方文档相关的提示。这说明包含入口本身已经执行到读取参数的阶段,剩余故障集中在命令执行函数或参数值,而非图片上传。

图 79:包含成功但缺少命令参数时的提示
CMD 参数与命令执行的第二轮排查
下一步抓取请求,向已包含的图片传递 CMD 参数。最初仍然尝试使用 phpinfo 作为探针,因为它能快速显示 PHP 是否解析成功;但开发者也预料到这一习惯,规则中特意限制了 phpinfo。因此即便包含已经成功,发送这个探针仍可能触发拦截。

图 80:phpinfo 被特意加入规则后的限制
把 CMD 设置为普通数字只能证明参数被接收,不能证明执行函数工作。继续尝试直接传递被过滤的执行名称时,响应有时是 200,有时是 502,说明状态码本身不足以判断执行结果,必须同时看防火墙日志、PHP 错误和文件内容。

图 81:使用命令参数进行最小执行测试
实验过程中还出现了拼写或字段缺失问题,例如请求中少了必要字段,导致观察到的结果被误判为规则拦截。修正字段后,含有危险关键词的请求仍会返回 502,但并不总是封锁 IP。这说明拦截和封锁是两个不同状态,日志必须分别记录。

图 82:请求字段缺失造成的错误状态

图 83:补齐请求字段后的再次测试
记录回到 PHP 7 中的字符串运算特性,尝试用取反或编码表达式避免直接出现 phpinfo 文本。这个方向来自前面课程中 PHP 5、PHP 7 RCE 的对比经验:如果规则只匹配函数名和括号的连续文本,而表达式在 PHP 运行时才还原,过滤器可能看不到完整关键字。但同样需要注意,规则可能在不同阶段解码,不能把"编码后未命中"直接写成"执行成功"。

图 84:PHP 7 字符串取反或编码思路的回顾

图 85:避免函数名与括号连续出现的表达式

图 86:编码表达式经过请求过滤后的测试结果
本地测试确认,在实验所用的 PHP 7.x 版本中,这种表达式可以避开部分针对 phpinfo() 形式的文本匹配。记录同时指出,早期远端测试因为 IP 封锁而耗时数小时,本地复现成功后才能快速确认问题来自规则还是环境。这里的经验仍然是:用本地同版本环境验证语义,再把最小化结果放回授权目标测试。
从写入普通文件到分段构造内容
当执行链终于可以写入普通内容时,记录先创建一个简单文件并写入 123,通过文件是否出现及内容是否一致确认写文件能力。这里没有马上写入复杂的一句话内容,因为复杂载荷包含更多关键词,更容易在 WAF 或 PHP 解析阶段失败。

图 87:本地防火墙和 PHP 节点状态恢复后的测试

图 88:执行函数不带括号时避开部分规则限制
写入普通文件成功后,问题从"能否执行函数"变成"怎样构造完整文件"。直接一次性写入复杂内容会同时暴露文件名、函数名、参数和敏感字符串。记录因此回到 file_put_contents 的追加模式:先写入一个简单片段,再逐次追加其他片段,最后通过覆盖或追加使文件变成完整内容。

图 89:分段追加写入第一段内容
每写入一段,都先查看文件是否生成、内容是否符合预期,再决定下一段。若某个片段触发规则,就把它拆得更小,或者更换不包含敏感词的测试内容。这个过程的价值在于能定位具体触发点,而不是一次请求失败后只能猜测。

图 90:继续追加后文件内容逐步形成
当文件最终命名为带有明显 WebShell 特征的名称时,连接工具请求返回"不合法"或被管理员拦截。此时上传和写入本身可能已经成功,拦截发生在后续访问流量。记录先把文件名改成普通名称,观察错误是否随名称变化,再检查访问请求是否包含工具特征。

图 91:连接工具访问明显 Shell 文件名时被拦截

图 92:将访问内容和文件名拆开排查后的请求状态
改变文件名后仍然出现错误,说明问题不一定只在名称,也可能在流量内容、函数参数或服务端缓存。记录继续用普通文件名、最小内容和刷新操作复测,并对比防火墙命中次数。

图 93:使用普通文件名重新访问的测试

图 94:重命名后再次刷新的页面状态

图 95:排除旧缓存影响后的再次访问结果
最后,实验转向加密连接工具的编码器配置。明文流量中能直接看到 $POST、eval 等高特征字符串,即便文件已经写入成功,后续访问仍可能被 WAF 识别。因此,记录强调不能只看"文件已写入",还要验证连接阶段的流量是否被拦截。

图 96:已写入文件后准备配置连接测试
明文连接失败:先看流量,再谈编码
连接工具访问时,最初的请求仍然包含明显的参数名和执行函数。记录先尝试对数据做简单编码,例如 Base64 或 ROT13 一类转换,但测试结果没有直接绕过。失败原因并不难理解:如果只是把一部分字段编码,而服务端没有对应解码器,应用收到的内容就不是原来的命令;如果客户端和服务端虽然配置了编码,但 WAF 在解码后检查,文本变化也不会带来真正的安全边界。

图 97:连接流量包含明显关键词时的拦截现象
下一步没有继续盲目更换编码,而是抓取完整请求,观察连接工具到底发送了哪些字段。抓包结果显示,明文流量中出现 $POST、eval 等特征,这些内容足以被请求体规则识别。由此得到更可靠的结论:需要验证的是一套完整的客户端编码器和服务端解码器,而不是把单个参数做任意编码。

图 98:抓取连接工具请求的操作界面

图 99:明文连接请求中出现高特征字段
连接工具提供了编码器和解码器配置。记录中的工作方式是:先把服务端解码端放到测试站点,再在客户端选择对应编码器。客户端使用密钥对流量处理,服务端用匹配的公钥或解码配置恢复数据,恢复后才交给后门逻辑。若只把客户端配置好,而服务端没有对应文件,连接请求会变成不可理解的数据,结果仍然是失败。

图 100:连接工具中的编码器和解码器配置
记录先用分段追加的方式写入加密连接文件。第一次追加失败,第二次才成功,原因集中在写入内容长度、片段顺序和请求中出现的敏感特征。文件完整后,再选择新建的编码器连接,观察 WAF 是否还会把明文关键词当成 POST 恶意流量。

图 101:将编码器配置文件写入授权测试站点
远程复制方式与 copy 命令限制
逐段写入加密文件能够完成验证,但过程繁琐,也容易因为某个字符触发规则而中断。记录进一步想到 PHP 的 copy 能力:如果服务器能够访问实验用的公网云主机,就可以从远程测试服务器取得文件,避免在请求中传输大段内容。
这个方向仍然要在授权环境中使用,且远程服务器只放置专门用于实验的测试文件。记录先验证 copy 的 from 和 to 参数,确认来源地址、目标路径和返回值分别表示什么。最小测试先复制普通文本文件,确认文件确实到达本地,再考虑替换为编码器配置。

图 102:copy 函数的来源路径和目标路径说明

图 103:从远程实验服务器复制普通测试文件

图 104:复制成功后本地文件内容与来源一致
原记录最终确认,当前防护规则也把 copy 加入了限制,因此"远程复制"并不是自动可用的第二条路。这个失败仍然有价值:它说明规则作者不仅考虑了常见执行函数,也考虑了通过文件操作引入内容的场景。实验由此回到已经验证的字符串表达式和分段写入方式,而不是继续扩大远程操作范围。
copy 与 file_put_contents 的比较,实际反映的是两种完全不同的故障面。分段调用 file_put_contents 时,内容经过多个请求进入站点;每个片段都可能受到请求体规则影响,片段顺序、追加模式、目标文件权限和最终文件内容都要逐步确认。它的优点是单次请求较小,出现失败时能够定位到具体片段;缺点是过程较长,任何一段漏写或顺序错误都会使最终文件不可用。
远程 copy 的思路则把大部分内容移到授权测试服务器,站点只需要发起一次来源到目标的文件复制。这样可以减少请求体中的长字符串,但新的前提也随之出现:服务端必须允许该函数,网络必须能够访问测试服务器,来源路径必须真实存在,目标路径必须可写,防火墙还可能专门拦截 copy。原记录先用普通文本文件验证复制结果,确认来源与目标内容一致后才想到替换为配置文件;最终却发现当前规则已经限制 copy,因此不能把它写成可用替代方案。
这组对比说明,所谓"换一种写文件方式"并不是简单的函数替换。需要先问内容从哪里来、在哪一层被扫描、服务器是否能访问来源、目标文件是否会被重命名,以及后续连接是否还会暴露特征。即使 copy 没有被规则限制,成功复制普通文本也只能证明文件传输成立,不能自动证明配置文件能够被解析或连接工具能够使用。
记录还提到过压缩归档和 phar 相关方向。归档载体的吸引力在于,压缩后的文件中不容易出现普通文本规则直接匹配的内容;但它需要满足另一套条件:上传接口要接受归档字节,应用不能把关键文件名改成普通图片名,包含路径还要按照归档语义处理。由于自动重命名会破坏这个前提,且继续验证需要进入 C 语言底层行为,课堂没有把它推进为完整结果。对于未完成的候选,保留其限制比补写一个虚构的成功步骤更重要。
重新部署环境:把雷池和宝塔放进可比较的拓扑
后半段课程把测试扩展到雷池和宝塔的组合环境。重点不是把不同 WAF 叠加起来就能得到更安全的结果,而是让流量路径和版本固定下来,以便区分哪一层做了什么判断。记录建议先在纯净环境部署雷池和一个正常网站,确认网站自身可以访问,再接入宝塔的免费第三方 WAF,最后才加入测试规则。
雷池作为反向代理时,网站端口映射、主机地址和上游服务地址必须保持一致。记录中曾发生访问地址映射错误:请求没有经过预期的代理节点,导致看到的结果不能代表雷池是否生效。修正映射后,用普通页面和故意设置的本地测试输入分别访问,普通页面成功,命中测试规则的请求出现拦截,才说明流量确实经过雷池。

图 105:雷池与测试网站的部署关系

图 106:代理节点和上游站点的端口映射

图 107:修正映射后确认请求经过雷池

图 108:普通请求和测试请求的转发差异
接着安装宝塔防火墙。记录特别区分了官方收费应用和第三方免费应用,因为实验对象是后者的规则实现。安装前先完成雷池单独验证,安装后再逐层启用宝塔规则。这样做可以通过同一请求在不同阶段的响应差异,判断命中来自雷池、宝塔还是 PHP 代码。

图 109:宝塔第三方防火墙应用的选择界面

图 110:宝塔防火墙启用后的配置界面

图 111:雷池与宝塔同时部署后的运行状态
在组合环境中,记录使用前面验证过的输入类型差异测试:雷池把某个请求识别为文件上传,而 PHP 端把它看成普通表单提交;当测试字段最终抵达后端并获得预期响应时,说明这条特定规则链在实验版本中被绕过。这个结果必须限定在给定版本、给定配置和授权输入,不应表述为"雷池永远不会防御"。
雷池的复测还受到版本条件影响。原记录把 9.2.7 作为后续练习时参考的版本,但版本号本身不能替代环境确认。重新部署时,需要记录雷池版本、上游 PHP 版本、宝塔第三方防火墙版本以及规则更新时间;否则即使域名、端口和页面都相同,也无法确认两次测试是否使用了相同的规则实现。
代理排错应先建立两个基线。第一条基线是普通页面,目的是确认 DNS、端口、站点绑定和上游服务都正常;第二条基线是一个授权实验中的不存在路径,目的是观察返回的是雷池自己的响应、宝塔的响应,还是上游原始错误页。只有当这两条基线都符合预期,才有资格评价测试输入是否命中雷池。若普通页面访问正常但不存在路径直接落到上游,首先应修正代理映射,而不是修改测试字符串。
两层防护同时启用时,日志也必须按组件区分。请求可能在雷池处停止,根本没有到达宝塔;也可能通过雷池后被宝塔拦截;还可能抵达 PHP 后产生语法错误,再由代理包装成 502。浏览器看到的同一个状态码不能直接指出责任组件。记录中先单独验证雷池,再加入宝塔免费应用,正是为了让每一层都拥有可比较的前后状态。
输入类型差异同样不能只用"绕过"概括。雷池看到的是 HTTP 请求、字段结构和 Content-Type,PHP 看到的是解析后的变量和程序分支。一个请求可能在 WAF 中被归为文件上传,在应用端却生成普通表单变量;这种差异只说明两层使用的观察视图不同。要确认影响范围,仍然需要查看后端变量、组件日志和最终响应,不能从一次页面回显推导出所有请求都能通过。

图 112:组合防护下请求被识别为不同类型的验证结果
哈希碰撞与缓存绕过:先区分内容、文件名和缓存键
课程最后进入一个相对独立的缓存绕过案例。它依赖一个实验用的哈希碰撞项目,目标是生成两个哈希值相同、但内容不同的文件。这个案例与前面的 preg_replace 规则不同,核心不在 PHP 函数过滤,而在缓存系统如何选择文件身份。
实验环境的准备也经历了排错。编译项目时,首先需要安装 C++ 编译工具;如果只安装了 g++ 或只安装了 C++ 相关的一部分依赖,编译阶段会报错。记录的处理顺序是补齐编译环境,再根据是否有 GPU 选择是否加入 --without-cuda 一类选项,最后按项目文档生成脚本文件。这里的版本和选项必须以实际项目文档为准,不能把课堂中的命令行占位符当成任意系统都适用的固定命令。
生成脚本后,项目目录中会出现若干脚本。记录建议优先使用官方标注为较快的脚本,给定一个正常文件作为输入,让工具生成一个内容不同但哈希相同的文件。输入文件在实验中是普通 PHP 测试文件,第二个文件则让 PHP 解析器遇到不存在的函数,从而在代码执行阶段报错。
这里的设计包含两个相互配合的状态:第一份文件是"可执行的正常内容",第二份文件是"在 PHP 层面失败的内容"。先上传第二份文件,让缓存系统记录其哈希或文件身份;再把第二份文件改名为与目标文件相同的 PHP 文件名,上传第一份内容。如果缓存只依据哈希和文件名判断是否已有结果,可能继续复用此前的异常或放行状态。
碰撞结果需要同时检查哈希和内容,不能只看工具返回成功。记录中比较了两个文件的哈希值,确认它们完全相同;再打开内容,确认一个是正常测试代码,另一个包含会导致 PHP 报错的替代内容。只有"哈希相同、内容不同、名称和上传顺序符合测试设计"三项同时成立,才进入缓存行为验证。
最终实验观察到,第二份文件先被上传后,PHP 引擎在解析不存在的函数时产生错误;随后上传正常内容时,因为文件名与缓存状态相同,缓存层没有按照新的字节重新限制请求。记录把它归纳为缓存绕过的具体细节:缓存命中依据与 PHP 实际执行依据不一致,造成了缓存状态和解释器状态之间的差异。
哈希碰撞工具自身也经历了独立的准备过程。编译阶段先出现依赖不完整的问题,说明"工具目录存在"不等于"工具已经可运行";随后还要区分是否具备 GPU 条件,并根据项目实际文档选择对应编译选项。原记录提到过 --without-cuda 一类选项,但这个选项属于具体项目的构建参数,不能脱离项目文档复制到其他工具。若生成脚本失败,后面没有两个输出文件,缓存实验自然还没有开始。
工具生成文件后,验证必须同时关注内容和摘要。首先确认两个文件的字节内容确实不同;其次确认哈希值按实验使用的算法一致;再次检查文件是否被工具改名、大小是否符合预期,以及普通文本编辑器或十六进制查看结果是否显示出不同的程序内容。只看到工具打印"完成"或只比较文件名,不能证明碰撞文件已经满足测试条件。
文件满足条件后,应用层仍然存在新的检查。上传接口可能拒绝某个扩展名,可能重命名文件,也可能在保存前重新读取内容;PHP 可能在缓存判断之前就拒绝解析,或者在解析阶段直接报错。原记录把一个文件设计成正常测试内容,另一个文件设计成会触发不存在函数错误的内容,是为了让"内容差异"在解释器层留下可观察证据。若两份文件都得到完全相同的应用响应,反而需要先确认文件是否真的进入了 PHP,而不能立即归因于缓存。
缓存层的验证还需要固定上传顺序。先上传异常内容,再上传正常内容,观察同名、改名和清缓存后的结果;每次只改变文件名或内容中的一个变量。若清理缓存后两次行为恢复一致,差异更可能来自旧状态;若不同文件名始终触发重新处理,说明文件名可能参与缓存身份;若同名文件在特定顺序下复用旧结果,才有理由继续检查缓存键是否覆盖了全部内容属性。"哈希相同"只是实验入口,缓存键、文件名、顺序和解释器结果共同构成最终证据。
因此,哈希碰撞案例与前面的正则过滤案例在方法上是相通的:都不能只记录最后一个页面现象。前者要分工具、文件、应用、缓存四层,后者要分规则、输入、上传、包含、解析、流量六层。每层都需要自己的成功标准和失败证据,只有这样,所谓"绕过"才不会变成无法复核的标签。
课程中的方法论:每次失败都要留下可解释的证据
这份授课记录最有价值的部分,不只是最终找到 mb_ereg_replace 或完成哈希碰撞,而是它不断把"没有成功"拆解成更小的结果。
第一次安装失败,恢复快照后成功,说明部署状态是变量。第一次回调尝试失败,说明函数名未过滤不足以解决传参和执行函数限制。候选传参方式逐个被排除,说明配置项、框架依赖和规则覆盖必须同时核对。filter_input 最小测试成功,说明输入通道成立,但不代表执行函数成立。图片上传返回 502,日志显示恶意文件拦截,说明故障发生在上传阶段;图片被包含后出现语法错误,说明故障已经转移到 PHP 解析阶段;文件能够写入但连接工具被拦截,说明故障发生在后续流量阶段。
这种分层思考比"最终能不能跑通"更适合复盘。一次请求至少可以拆为:客户端构造、网络传输、防火墙扫描、上传落盘、文件包含、PHP 解析、函数调用、返回流量和缓存处理。每层的成功条件不同,观察证据也不同。状态码、页面回显、WAF 日志、PHP 错误日志、文件内容和缓存命中信息应该分别记录。
讲师在记录中反复强调,本地复现不是降低难度,而是把远端封锁、版本不确定和规则误判这些噪声先隔离开。只有在本地把最小行为验证清楚,回到授权实验站时才知道某个失败究竟是规则命中,还是代码本身不能运行。
另一个经验是不能把理想化项目写成"一次成功"。真实排查通常会遇到安装失败、参数拼写错误、数组类型错误、引用传递问题、函数版本差异、路径写错、图片损坏、502、文件名拦截和缓存残留。保留这些节点,才能说明为什么选择下一步,而不是只留下一个看起来顺滑的结果。
对工具和 AI 的边界判断
记录中使用 AI 询问替代传参方式,但得到的候选并没有直接解决问题。这个结果并不是 AI 无用,而是说明候选答案必须回到当前 PHP 配置、框架、版本和过滤规则中逐项验证。原稿给出的建议是:把规则文件交给 AI 分析,让它先列出过滤项,再基于具体限制提出思路;同时保留人工判断,特别是底层函数、版本差异和运行时行为。
在授权本地环境中,也可以部署一个普通网站,故意加入练习漏洞,再让 AI 在明确范围内分析 WAF 影响。这样做的价值是让 AI 面对已知边界,而不是让它对未知目标进行泛化测试。AI 可以协助整理规则、枚举候选、解释报错和比较版本,但不能代替对请求、日志和 PHP 实际结果的验证。
原稿还提醒,当前 AI 更适合作为辅助工具。面对规模很大的内部系统,自动化分析可能有帮助;但对单一目标的复杂绕过,仍然需要人工理解业务路径、防护链路和异常反馈。这个判断与全文的实验过程一致:真正推动进展的不是候选数量,而是把每个候选放回具体环境中测试。
AI 辅助的边界在这次课程中表现得很具体。输入规则文件后,模型可以帮助整理出被过滤的函数、变量和符号;看到报错后,也可以提供数组、引用或版本差异的可能解释。但模型无法仅凭候选文本知道当前站点是否启用了某个扩展、上传后文件是否被重命名、请求是否经过雷池,或者 502 究竟由哪一层产生。上述问题都必须由实验环境、日志和实际响应回答。
因此,较稳妥的工作方式是把 AI 放在"提出假设"和"整理证据"的位置。先让它根据规则列出候选,再由人工删除依赖不存在框架或错误配置的方案;之后用最小、无害的本地测试验证函数签名和版本行为;最后才把已经解释清楚的单变量测试放回授权环境。若本地测试失败,候选就应停止;若本地成功但远端失败,下一步应查规则、代理、上传处理和日志,而不是继续要求模型生成更多变体。
这种方法也适用于学习记录。保留原始请求、版本、错误和修正过程,能够让后续复查者知道结论的适用边界。相反,只保存一个所谓"可用载荷",既无法说明为什么成功,也无法说明换版本、换规则或换代理后为什么会失败。讲师关于 AI 的观点并不是拒绝工具,而是强调工具不能替代环境中的证据。
学习记录与面试表达
课程最后把技术复盘延伸到简历和面试。这里保留的是方法建议,不把它写成夸大项目的指导。一个项目如果写得过于理想化、没有错误、没有版本限制、没有调试过程,反而缺乏可信度。更好的写法是准确说明授权环境、测试边界、遇到的限制、验证过的版本、失败尝试和最终证据。
例如,可以描述"在本地复现某版本防护规则后,先解决过滤变量导致的传参失败,再区分上传阶段、包含阶段和命令执行阶段;通过日志和最小化测试定位问题,最后验证了特定版本中的函数差异"。这样的表述保留了思考过程,也不会把未经验证的普遍结论写成事实。
简历中的每个项目都应能够回答:环境是什么、授权范围是什么、最初遇到什么错误、如何排除、为什么选择下一种方法、成功凭什么证明、版本变化会不会影响结果。如果面试官继续追问,回答应与真实测试记录一致;不清楚的内容不应通过堆叠术语掩盖。
记录还把学习者分成两类:一类表达能力较强,但技术深度可能不足;另一类表达不够外向,却持续学习、能够把任务完成并做得更扎实。最终建议并不是只选择其中一种,而是同时提高两项能力:一方面把技术项目做实,另一方面练习在短时间内把背景、限制、行动和证据说清楚。
面试紧张也应被当作可训练的问题。项目准备充分、能说出失败原因和验证证据,通常比背诵一个"完美成功"的故事更有帮助。记录中的观点是,面试结果无非成功或失败,过度紧张会削弱已经掌握的表达;应把注意力放回项目事实和问题推理上。
从这次案例提炼项目经历时,表达顺序可以围绕事实展开:先说明授权范围和环境版本,再说明最初的限制;随后列出排查动作和每次结果;最后明确哪些内容已验证、哪些仍是候选。比如,mb_ereg_replace 可以描述为特定旧版本中的实测发现,不能写成所有 PHP 版本都支持;图片载荷可以描述为完成上传和包含验证,不能把它直接等同于完整连接;哈希碰撞可以描述为特定缓存实现中的状态差异,不能泛化为通用缓存绕过。
讲师还强调,真实项目通常不是一次完成。环境恢复、规则阅读、函数签名排错、路径修正、图片重建、分段写入和代理拓扑确认,每一步都可能留下失败记录。面试中能够说明"当时为什么认为失败发生在上传阶段""为什么要改用最小测试""为什么要清理缓存再比较",比只报出若干工具名称更能体现对问题的掌握。项目描述应当能够被原始日志和截图逐项支持。
按原顺序回看完整技术链
将全文压缩成一条因果链,可以看到每个阶段都承接了前一阶段的限制:
-
环境安装出现异常,恢复快照后建立可重复的宝塔和 Nginx 防火墙环境。
-
规则初步覆盖 GET、POST、User-Agent、SQL 注入、XSS 和常见一句话木马,实验范围因此集中到文件包含和图片上传。
-
原始配置显示请求体通过正则关键词过滤,
$GET、$POST、assert、preg_replace、写文件函数等都不能直接使用。 -
回调函数、非字母数字表达式和
create_function依次尝试,但分别受到参数来源、托底函数、规则覆盖和版本行为限制。 -
filter_input的最小测试成功,解决了外部变量读取问题,但命令执行函数仍未解决。 -
mb_ereg_replace在实验使用的旧版本中仍可运行,且没有出现在当前规则表,成为新的验证候选。 -
将最小载荷放入图片,先确认图片可打开,再确认上传、包含、解析和参数读取,逐层排查
502、路径错误和语法错误。 -
写入普通文件成功后,复杂内容改为分段追加;连接阶段又遇到明文流量特征,于是转向完整编码器和解码器配置。
-
雷池与宝塔组合环境单独验证代理路径和规则差异,避免把一层防护的结果误认为另一层的结果。
-
哈希碰撞实验把缓存身份和 PHP 内容执行分开,展示了缓存状态与解释器状态不一致时可能出现的异常。
这条链条没有把多个失败压缩成"最后绕过成功",而是保留每个步骤所解决的问题。它也说明,防护分析不是寻找一个万能字符串,而是围绕入口、规则、版本、流量、解析器和缓存逐层建立证据。
复盘结论
本次课程的核心对象是 PHP 请求体正则过滤与缓存判断,不是某个单独的 WebShell 字符串。preg_replace 规则的弱点在于它以文本关键词为中心,可能无法完整表达 PHP 的动态语义;但这并不意味着任何未列入名单的函数都能成功,因为函数版本、参数签名、用户输入来源和后续流量仍会决定结果。
第二个结论是,图片上传只是载体,不是结果。图片必须先通过业务上传,再被文件包含读取,还要通过 PHP 解析,最后才能观察函数和参数行为。上传成功、包含成功、写入成功和连接成功是四个不同里程碑,必须分别记录。
第三个结论是,WAF 绕过和缓存绕过都高度依赖环境。前者依赖规则实现、大小写处理、版本和流量编码,后者依赖缓存键、哈希算法、文件名和上传顺序。原稿中的成功现象只能说明指定授权实验在指定版本和配置下存在差异,不能泛化为所有站点或所有版本的固定结论。
最后,真正可迁移的能力是排查方法:保存快照,建立本地复现,拆分阶段,使用最小测试,记录错误和日志,验证版本差异,并在交付或面试中准确说明边界。对于安全学习而言,理解为什么失败,和知道最后用了什么方法同样重要。
补充复盘:从连接成功回看每个边界
在加密连接成功后,原记录又回到"还有哪些可能的路径"这一问题。这里没有继续把未验证的候选写成已完成结果,而是提到另一类压缩归档文件方案:把测试内容放进 tar.gz 或与 phar 相关的归档载体,利用文件包含和重命名行为观察解析结果。这个思路之所以没有在课堂上完整展开,是因为它需要继续调试 C 语言底层行为,难度和验证成本更高。
归档方案的关键限制是文件名。记录指出,上传压缩内容本身可能成功,但如果应用把文件自动重命名为普通图片扩展名,包含逻辑就不会按归档语义处理。只有文件名或包含路径满足解析器对 phar 标识的判断,后续行为才可能发生。因此,上传成功与归档解析成功之间仍然隔着一个"文件名和解析器语义"边界。
这段未完成案例有两个复盘价值。第一,不能把"压缩包没有明显字符串特征"理解为一定可以执行;上传过滤、自动重命名和包含解析各自会改变结果。第二,没有在课程中完整验证的方案应明确标记为候选方向,而不是为了让文章显得完整而补出不存在的成功结论。
旧站点状态与历史结果的限制
记录还回顾了一年前的测试站点。站点本身仍然可访问,但原来的木马文件可能已经被清理,宝塔和主机检测配置也可能发生过变化。页面中仍能观察到文件包含入口,说明某些应用代码未被修复;但这不代表原先的所有载荷仍然有效。尤其是 mb_ereg_replace 的可用性取决于 PHP 版本,WAF 的规则也可能在更新后加入此前没有的关键词。
这种历史结果只能作为复盘材料,不能当成当前环境的实测证据。重新验证时,应该重新确认上传接口、版本、代理、规则、日志和文件系统状态。若通过 AI 查询上传路径,也需要检查它是否遗漏了 admin、编辑器目录或站点前缀;路径缺失会导致 404,而 404 与规则拦截、文件不存在和权限错误的排查方向完全不同。
原记录提到曾从授权测试系统的后台找到编辑器上传接口。本文只保留"在拥有授权的测试系统中确认上传接口"的方法,不扩展为对外部系统寻找弱口令或未授权入口的指导。对外发布时,这个边界必须写清楚,否则容易把课堂中的内部测试经历误读成可迁移的入侵步骤。
代理拓扑的排错细节
雷池实验中,最容易出错的不是规则本身,而是流量是否真的经过雷池。宝塔网站创建完成后,如果直接把域名映射到上游 PHP 服务,访问虽然正常,却绕过了雷池;只有先把站点映射到雷池监听地址,再由雷池转发到上游,测试请求才会经过预期防护层。
排查拓扑时,普通页面可以作为基线,故意构造的测试路径作为差异样本。普通页面在正确代理链路上应正常返回;对一个不存在的测试路径,如果雷池返回统一拦截页或记录命中,才说明请求经过雷池。若两个请求都直接得到上游的原始响应,应优先检查映射地址,而不是修改规则。
宝塔与雷池的安装顺序也会影响结果。原记录建议先完成雷池和上游网站,再安装宝塔 WAF;这样可以先观察一层防护的原始行为,再加入第二层。若一开始同时打开所有规则,测试失败时无法判断是谁拦截,日志也会出现多个相似事件。
规则类型和请求类型的差异
组合测试中出现过"雷池把请求识别为文件上传,PHP 端把它识别为普通表单"的情况。这个观察值得单独解释:防护层判断的是 HTTP 请求及其字段,PHP 应用判断的是解析后的变量和代码路径,两者使用的输入视图不同。即便同一个请求在应用端产生了 CMD 变量,WAF 也可能依据 Content-Type、字段名或上传结构把它归为另一个类别。
因此,复盘不能只写"请求成功"。更准确的记录方式是:哪一层看到了什么类型、哪一层返回了什么状态、后端最终收到什么变量、日志中的命中规则是什么。原稿中将这个现象用于 SQL 注入测试的授权环境,本文将其概括为"规则分类差异",不把它推广为任何版本都能稳定利用的通用绕过。
哈希碰撞实验的验证矩阵
为了避免把哈希碰撞案例写得过于简单,可以把验证拆成四组观察。
第一组是工具层:编译器是否安装、GPU 选项是否与机器匹配、脚本是否生成、工具是否返回两个输出文件。若编译失败,不能把问题归到缓存。
第二组是文件层:两个文件的字节内容是否不同、文件名是否相同或可改名、文件大小是否符合工具输出、哈希值是否完全一致。只比较一部分摘要或只看文件名都不够。
第三组是应用层:上传接口是否接收第一份文件、是否对扩展名和内容做检查、是否会重命名文件、PHP 是否会解析该文件。第二份文件产生 PHP 报错,至少说明它已经进入了解释器;如果连解析都没有发生,缓存实验就没有开始。
第四组是缓存层:第一次上传后缓存记录的键由什么构成,第二次同名文件是否命中旧状态,修改内容后是否重新计算缓存,清理缓存后结果是否改变。原记录观察到哈希相同但内容不同的文件在特定顺序下造成差异,这个结论只覆盖该实验缓存实现。
这样拆分后,哈希碰撞不再是"工具生成两个文件就完成",而是一个需要工具、文件、应用和缓存四层共同成立的实验。任何一层不满足,最终都可能表现为上传失败、解析错误、缓存未命中或响应不变。
如何把失败写进技术复盘
原始课堂口吻里有大量重复确认和即时反应。重写时不能简单删除这些词,因为重复背后可能包含不同的技术状态。例如"请求返回 200"可能只表示 Web 层收到响应;"文件写入成功"可能只说明 file_put_contents 返回了长度;"包含页面有输出"可能只说明文件被读取;"连接工具显示成功"才涉及后续协议握手。成稿把这些状态分别写出,避免用一个"成功"覆盖多层事实。
失败原因也需要分级。语法错误通常先检查括号、逗号、中英文标点和变量类型;路径错误检查文件名、目录、扩展名和大小写;参数错误检查输入类型、变量名和请求方法;WAF 拦截检查规则、请求体、文件名和日志;502 还要检查上游服务、PHP 错误和代理超时;缓存差异则要检查是否存在旧缓存。不同故障使用不同证据,才能减少无效尝试。
记录中出现过数组引用错误。usort 需要数组,而某些写法传入的是临时数组;PHP 又要求引用参数对应可引用变量。即使调用形式看起来与官方示例相似,实际由 filter_input 动态生成的值也可能不满足引用语义。这个细节说明,安全测试不仅是规则研究,也是普通编程调试:先让代码在没有 WAF 的本地环境中符合函数签名,再进入防护验证。
记录中还出现过少一个括号、函数名少一个字母、逗号类型混写等问题。这些问题之所以重要,是因为防护测试常常在错误代码之上进行。若测试样本自身不能运行,任何"没有被拦截"的结果都不可信。最小测试应先固定语法,再只改变输入来源或函数名,并保留每个版本的代码快照。
对错误信息的阅读也应该保持层次感。函数参数数量错误,首先指向调用形式;"必须为数组"指向输入结构;"只能通过引用传递变量"指向变量生命周期和参数签名;No such file 指向路径、扩展名或重命名;502 则可能来自上游 PHP 错误、代理超时或防火墙封装。把这些错误都归类为"被 WAF 拦了",会让下一步继续修改错误位置。
记录中还出现过权限和文件名的混淆。文件已经写入,并不表示连接工具一定能够访问;文件名中包含明显的 Shell 特征,可能触发访问阶段规则,但改名后仍报错又说明名称不是唯一因素。此时需要把访问流量、文件权限、参数格式和缓存状态分别复测。权限数字的含义也要回到实际读写需求核对,不能凭记忆判断;当错误同时存在时,应先修复最小、最确定的语法或路径问题,再处理防护差异。
这类实验还需要一个明确的清理节点。每次验证结束后,应删除测试文件、清理缓存、解除临时封锁,并恢复到保存的快照;否则下一轮请求可能读到旧文件、旧响应或旧日志。原记录中多次刷新页面,正是为了排除历史缓存和残留状态。清理并不是实验之外的行政工作,而是让下一次结果具备可比性的前提。
如果需要把过程交给其他人复现,最小记录至少应包含五类信息:环境版本和代理拓扑,原始规则或规则更新时间,请求的关键字段和响应,服务器端日志与文件状态,以及每次改变的唯一变量。缺少其中任一类信息,复现者可能得到相同页面,却无法判断那是同一条路径还是偶然的缓存命中。可复现不等于再次看到相同输出,而是能够解释为什么输出相同或不同。
这也是本次授课记录适合改写成技术博客的原因。案例并没有把所有限制都变成成功结果,而是展示了从规则表、函数签名、版本行为、上传载体到代理和缓存的连续排查。对外发布时保留这些边界,既能呈现技术判断,也能避免把特定授权环境中的观察误读成对未知目标通用的操作结论。
版本差异必须单独记录
本文涉及 PHP 5.6、PHP 7.3、PHP 7.4 和 PHP 8.0 时代的行为差异,但原始讲解对个别版本数字存在不确定表述。成稿因此没有把它写成严格的兼容性表,只保留"某些旧版本可用、更新版本不可用,需要本地验证"的事实边界。对于 mb_ereg_replace、短标签、函数名大小写、动态参数和引用行为,版本差异都可能改变结论。
版本记录至少要包括 PHP 小版本、防火墙版本、反向代理版本、规则文件更新时间和相关扩展是否启用。实验若从 7.3 切换到 7.4,应重新执行最小函数测试;若从旧版雷池切换到新版,也应重新确认规则分类和日志格式。不能以"昨天能通过"代替"当前版本已验证"。
安全工具识别和 PHP 解释器不是同一个系统
全文多次出现一个核心差异:WAF 先看到的是请求文本或解析后的请求结构,PHP 解释器后看到的是已经经过变量替换、表达式计算和函数调用解析的程序。字符串过滤可能看不到运行时拼接后的结果,语义分析工具可能根据语法树追踪危险调用,而 PHP 最终还会受到版本和配置限制。
这个差异不能单方面理解成"WAF 一定弱"。安全工具可能通过大小写不敏感匹配、解码、语法树、数据流和函数黑名单组合检测;同时,PHP 自身也可能因为函数移除、配置关闭或扩展缺失而拒绝执行。只有把防护层和解释器层分开测试,才能知道某个候选到底是绕过了 WAF,还是根本没有到达 PHP。
对外发布时保留授权边界
原稿有不少第一人称现场操作和未授权语境之外的历史描述。重写后统一改为"授权实验站""本地靶场""测试系统",并删除对未知目标开展测试的暗示。具体工具名称、函数名和错误信息仍按原文保留,是为了说明技术推理;但文章不把这些内容包装成对任意站点适用的攻击流程,也不提供超出源稿的目标发现、凭证获取或持久化建议。
对于尚未完整验证的归档载体、某些历史后门和不同版本规则,成稿明确标注为候选、历史记录或需重新验证。这样既保留了讲师的探索过程,也避免把教学中的猜测改写成确定事实。
复盘后的实践清单
在同类授权实验中,可以沿用以下记录顺序:先保存快照并记录版本;再确认站点和代理拓扑;随后导出规则原文;用普通请求建立基线;用最小输入验证参数读取;用不带敏感函数的文件写入测试确认执行位置;再测试图片上传和文件包含;每次只改变一个变量;将响应、日志、文件内容和缓存状态分别记录;最后清理实验文件并恢复快照。
如果测试失败,应先问"失败发生在哪一层",再问"应该换哪个函数"。如果没有上传成功,先看请求体和防火墙日志;如果上传成功但包含失败,先看文件路径和图片字节;如果包含成功但参数无效,先看输入类型和变量名;如果参数有效但执行无结果,检查函数版本和配置;如果执行结果只在某些请求中出现,抓取完整流量;如果相同文件名得到不同结果,清理缓存并比较缓存键。
这个清单并不能保证获得某个固定结果,但能保证每次结果都可解释、可复现、可回滚。对于技术博客而言,这比单独罗列一串绕过字符串更有价值。
把一次测试拆成可审计的记录
原稿中的许多操作都可以用一张简单的实验记录表串起来。第一列记录时间、快照和版本,第二列记录本轮只改变了什么,第三列记录客户端实际发送的请求,第四列记录页面、状态码和响应体,第五列记录 WAF、PHP 和代理日志,最后一列写出基于证据形成的判断。这样整理后,200、502、无回显和连接失败不会被混成同一种结果。
例如,验证 filter_input 时,变量来源是唯一变化项,执行函数和文件内容保持最小;验证图片载荷时,普通图片与改造图片应尽量使用同一上传入口和同一文件名规则;验证代理时,先使用普通页面和不存在路径;验证缓存时,则固定文件内容和上传顺序,只改变文件名或是否清缓存。每组实验都需要一个对照,否则"这次成功"无法说明究竟是哪一个变量产生了影响。
记录还应区分观察事实与推断。事实可以是"日志出现恶意文件拦截""响应为 502""上传目录生成了文件""两个文件的哈希值一致";推断则是"过滤发生在上传阶段""缓存键可能没有覆盖内容差异"。推断需要下一轮测试继续确认,不能因为它听起来合理就直接写成结论。技术博客如果把两者混在一起,读者就无法知道哪些内容来自截图,哪些内容来自讲师当时的判断。
在授权环境中进行安全测试,还要把边界写进记录本身。目标是本地靶场、课程测试站或明确授权的服务器;使用的远程主机应是实验专用主机;生成的测试文件、配置文件和缓存状态在验证后需要清理。文章可以保留函数名、报错和工具类别来说明推理,但不应把历史站点的路径、入口发现或旧文件状态包装成面向未知目标的行动建议。
最后,复盘结论应回答三个问题:当前实验到底验证了什么,哪些现象只在特定版本成立,下一次复测最先需要重新确认什么。本案例的回答是,特定规则下存在文本过滤与运行时语义之间的差异;mb_ereg_replace、图片载荷、加密连接和哈希缓存都依赖环境;重新复测时应先确认版本、规则、拓扑、上传处理和缓存状态。这样的结论比"找到一个万能绕过"更准确,也更能指导后续学习。
证据链一:请求进入防护层之前
在请求还没有进入 PHP 之前,首先要记录的是客户端实际发送了什么。包括请求方法、URL、Content-Type、边界字符串、表单字段名、文件名、文件扩展名、请求体长度以及是否带有 Cookie。原稿中的许多失败都与"以为发送了某个值、实际请求里却没有该值"有关,例如少写字段、把 GET 测试误当成 POST 测试、或把变量名拼错。
对于图片上传,至少要保留一份普通图片请求作为对照,再保留一份加入测试数据后的请求。两份请求应尽量只在一个位置不同。这样当普通图片返回成功、测试图片返回 502 时,才可以把差异优先归因于新增内容,而不是目录、权限或上传接口本身。
在请求体过滤场景中,字段名和字段值必须分开观察。防火墙可能只匹配字段名,也可能只匹配文件内容,还可能先把 multipart 数据解析后再扫描。filter_input 的验证之所以采用最小变量名,是为了把"输入来源"与"危险函数"拆开;如果一开始就把完整载荷放进请求,失败时无法知道是哪个部分触发。
证据链二:防护规则实际做了什么
规则文件的阅读需要同时关注匹配方式和替换方式。一个规则可能通过 preg_replace 删除字符串,也可能通过条件判断直接返回拦截结果;同样的关键词出现在不同规则段中,作用范围可能不同。原稿里先查看面板,再查看原始文档,目的就是确定页面展示的摘要是否与实际代码一致。
如果规则使用大小写不敏感匹配,混合大小写尝试自然不会改变结果;如果规则先做 URL 解码或请求解析,客户端编码也可能在扫描前恢复;如果规则只对原始文本匹配,运行时拼接才可能产生差异。每一种假设都需要用一个可控、无害、不会产生副作用的测试字符串验证。
规则中出现函数名并不代表函数在当前 PHP 版本中可用,规则中没有出现函数名也不代表调用一定能成功。前者可能是历史遗留或防护作者的经验列表,后者可能被 PHP 配置、扩展加载、函数签名和版本移除所限制。因此,文章把"过滤状态"和"运行状态"分别描述,避免把规则表当成 PHP 能力清单。
证据链三:上传、落盘与包含
上传成功的证据应来自文件系统或应用返回的明确路径。只看到页面提示"上传成功"还不够,因为应用可能把文件放到临时目录、改写文件名或在后台队列中异步处理。记录中通过浏览器图片地址和开发者工具确认实际位置,这一步也帮助修正了 No such file 错误。
落盘后,需要验证文件字节是否与本地准备的文件一致。图片可能被重新编码、压缩、裁剪或追加安全标记;如果服务器重写了文件内容,原先的图片载荷不一定还存在。对比文件大小、图片是否可打开和包含后的输出,比单看扩展名更可靠。
包含测试应从固定文本开始。先放入一个只输出固定数字的普通文件,再放入图片,再放入带有最小 PHP 语句的图片。若固定文本能够包含而图片不能,问题集中在文件内容或解析方式;若文件路径错误,两者都会失败;若图片能包含但 PHP 语句报语法错误,则需要检查尾部数据、图片头和 PHP 版本。
原记录多次尝试重新合成图片,正是因为图片文件中存在大量对 PHP 没有意义、但对 PHP 解析器可能有意义的字节。图片格式的"浏览器可打开"与 PHP 的"源代码可解析"不是同一标准。一张图片在图像查看器中完全正常,并不等于它被文件包含后能作为 PHP 源码顺利走到末尾。
证据链四:从写入测试到连接测试
写入测试和连接测试的判定标准也不同。写入一个固定值时,可以通过文件存在、内容等于预期、文件权限满足读取条件来确认;但连接工具还需要服务端文件包含逻辑、客户端协议、编码器、解码器、密钥和请求流量共同匹配。一个普通文件写入成功,不能证明编码连接已经建立。
原稿中的分段追加策略,实际上是在缩小每一次请求的影响面。先写入一小段无害内容,如果文件出现,再追加第二段;某一段失败时,只需定位当前片段,而不必重新解释整个文件。这个方法也能帮助区分 WAF 关键词触发和 PHP 字符串语法错误:前者通常表现为请求被拦截或封锁,后者可能表现为 PHP 错误或上游 502。
当最终文件名包含明显的 Shell 特征时,访问流量可能触发另一套规则。记录中改名后仍然出错,说明单一名称不是完整原因,于是又检查了请求参数和连接流量。这样的回退很重要:第一次猜测被证伪后,不应继续围绕同一猜测堆叠变体,而要改变观察层。
证据链五:代理、PHP 和 WAF 的责任边界
在雷池与宝塔的组合部署中,至少存在三个可能产生响应的组件:雷池、宝塔 WAF 和上游 PHP 应用。若域名直接指向上游,雷池没有机会记录请求;若域名经过雷池但宝塔仍在上游,宝塔日志与雷池日志会分别出现;若上游返回 PHP 错误,代理可能把它包装成 502。同一个浏览器现象可能来自不同组件,必须结合日志和拓扑判断。
因此,部署完成后要先访问一个普通页面,确认 DNS、端口和反向代理;再访问一个不存在的路径,确认代理是否返回自己的错误页;最后才使用授权的测试输入观察规则命中。若第二步就得到上游原始页面,说明代理没有接管,不应把第三步的结果用来评价雷池。
原稿中把上游地址设为 PHP 服务,再让雷池对外提供入口,随后在宝塔中创建网站。这个顺序的意义是固定责任边界:雷池负责入口与转发,宝塔负责站点和规则,PHP 负责解释和响应。若要测试宝塔 WAF,应先保持雷池规则不变;若要测试雷池,应暂时不安装或不启用宝塔规则。否则一次请求可能被两层同时改变,复盘会失去可比性。
证据链六:为什么缓存绕过不能只写"哈希相同"
哈希碰撞案例中,"哈希相同"只是必要条件。缓存系统可能根本不使用该哈希作为唯一键,也可能同时考虑文件名、路径、大小、修改时间、上传用户或内容类型。即使两个文件摘要一致,应用重新读取文件后也可能发现内容变化并清理缓存。因此,必须通过上传顺序和清缓存对照实验确定真正的影响因素。
一个完整的对照至少包括三次:第一次上传异常内容,记录响应和缓存状态;第二次上传正常内容但使用不同文件名,观察是否重新处理;第三次上传正常内容并复用同一文件名,观察是否出现与第一次不同的执行结果。再清理缓存重复第二、三次,才能判断差异来自缓存还是来自 PHP 解析。
原记录的碰撞文件一个能被 PHP 正常解释,另一个让不存在的函数产生错误。这个设计把缓存层和解释器层的结果拉开:如果两者内容确实不同,且异常内容的错误可以被观察到,说明文件不是简单被服务器拒绝;如果第二次上传后缓存直接返回旧状态,则说明缓存键可能没有覆盖全部内容差异。文章因此把该案例描述为"特定条件下的缓存状态差异",没有扩展成所有缓存系统都存在的结论。
对"绕过"一词的精确使用
课程中"绕过"一词覆盖了多个不同含义:绕过关键词过滤、绕过输入变量限制、绕过图片上传限制、绕过连接流量检测、绕过代理规则,以及利用缓存身份差异。它们的前提、证据和影响范围都不相同。成稿在各章节中尽量说明具体绕过的是哪一层,避免把所有结果统称为"WAF 已被完全绕过"。
例如,filter_input 只改变了传参表达方式;它没有绕过函数执行限制。mb_ereg_replace 只在特定旧版本和规则列表下提供候选执行点;它没有证明最新版本仍可用。图片载荷成功上传只绕过了扩展名或请求体的一部分限制;它没有说明文件一定能被包含。加密连接隐藏了明文特征;它没有说明防守方不能基于协议异常、证书或行为检测。
哈希碰撞也只是缓存身份与内容判断之间的差异;它不等价于绕过所有内容安全检查。用词越精确,复盘越能帮助读者理解限制,也越不容易把实验观察误解为普遍攻击能力。
从文章结构看,前半部分呈现的是问题如何逐层收窄:先从面板规则进入原始配置,再从常见函数转向输入接口和扩展函数;中段把候选放入图片上传、文件包含和参数读取链路;后半段继续追踪写入、连接、代理和缓存。每次转折都由前一次失败触发,而不是由作者预先知道答案后倒推过程。这样的顺序保留了原稿的复盘价值,也让读者能够区分"已验证结果"和"下一步候选"。
因此,全文反复出现的"不能直接推出"并不是刻意保守,而是技术结论的必要边界。函数未被规则列出,不代表版本允许;图片被接受,不代表解析成功;普通文件写入,不代表连接流量通过;代理页面正常,不代表请求经过目标 WAF;哈希相同,也不代表缓存只使用哈希。每一个否定都对应原稿中的一次失败或一次排除,不能在压缩文章时删除。
复盘的最终价值也在这里:它把一个看似单一的"绕过问题"还原成多条可验证的工程链。规则阅读解决的是观察范围,版本核对解决的是运行条件,最小测试解决的是变量隔离,日志和抓包解决的是责任定位,快照和清理解决的是复现基础。即使某个候选在更新环境中不再成立,这套证据链仍然可以继续使用,并帮助解释结论为何发生变化。
最终验证不应只看一张"成功截图"。规则分析至少要能回到原始配置,输入测试至少要能回到请求字段,文件测试至少要能回到服务器路径,连接测试至少要能回到编码器和解码器的对应关系,缓存测试至少要能回到上传顺序、文件名和清缓存操作。图片标题也应说明它展示的是哪一步证据,而不是只写"测试结果"。这样,图片、正文和结论之间才形成互相支持的关系。
如果某一步没有达到这个证据标准,文章应明确写成"未完成验证""历史观察"或"候选方向"。归档载体就是如此:原记录说明了压缩、重命名和 phar 文件名之间的限制,但没有完成底层调试,因此不能在复盘中用确定语气描述执行结果。类似地,旧站点仍可访问,也不能证明一年前的文件、规则和版本今天仍然相同。技术文章的可信度来自边界清楚,而不是来自把每条路径都写成成功。
从读者使用文章的角度看,最有价值的不是把每个函数名背下来,而是理解验证顺序。先确认入口和规则,再确认版本和函数能力;先用固定值确认输入,再用普通文件确认落盘;先让图片保持可打开,再确认包含和解析;先让明文请求可观察,再分析编码器与解码器;先证明碰撞文件成立,再研究缓存。顺序一旦被打乱,后面的错误就可能掩盖前面的缺陷。
这套顺序也解释了为什么课程中需要多次暂停、恢复快照和重新抓包。每次恢复都把变量数量降下来,每次抓包都把"页面看起来失败"转换成可分析的请求事实,每次查看日志都把责任从猜测转成证据。即使最终只完成了其中一部分,记录仍然可以回答问题发生在哪一层,以及下一步应该验证什么。对技术复盘而言,这比一个没有上下文的成功页面更可靠。
最终判定还需要把"成功"拆成可复核的层级。规则层的成功,意味着测试字符串经过规则文件和日志比对后,能够确认是否命中;输入层的成功,意味着后端确实收到了预期变量,而不是只看到一个状态码;文件层的成功,意味着目标文件已经在预期目录生成且内容可核对;解析层的成功,意味着 PHP 实际读取了文件并给出与测试代码一致的结果;连接层的成功,才意味着客户端与服务端的编码配置匹配并完成握手。缓存案例也一样,需要同时确认碰撞文件、上传顺序、缓存命中和解释器反馈。
如果其中一层缺少证据,结论就只能停留在更低一级。例如,日志显示请求未命中规则,只能说明它通过了这一层,不能说明 PHP 会执行;页面返回 200,只能说明 HTTP 请求完成,不能说明文件写入内容正确;两个哈希值相同,只能说明摘要相同,不能说明应用缓存一定使用该摘要。把这些边界写清楚,读者才能根据自己的授权环境重新验证,而不会把课堂中的一次结果误当成固定答案。