目录
[二、第一次试探:传入普通字符串 ABC](#二、第一次试探:传入普通字符串 ABC)
[三、strpos 的关键:严格不相等比较了什么](#三、strpos 的关键:严格不相等比较了什么)
[4.1 先看题目有没有禁止数组](#4.1 先看题目有没有禁止数组)
[4.2 数组如何通过第一道正则判断](#4.2 数组如何通过第一道正则判断)
[4.3 数组如何影响 strpos](#4.3 数组如何影响 strpos)
[五、.swp 临时文件:为什么题目要求先恢复文件](#五、.swp 临时文件:为什么题目要求先恢复文件)
[六、提交工具的选择:HackBar 适合临时验证](#六、提交工具的选择:HackBar 适合临时验证)
[7.1 Xdebug 版本不能想当然](#7.1 Xdebug 版本不能想当然)
[7.2 通过 phpinfo 验证模块是否启用](#7.2 通过 phpinfo 验证模块是否启用)
[7.3 VS Code 中创建 launch.json](#7.3 VS Code 中创建 launch.json)
[8.1 跳过当前函数](#8.1 跳过当前函数)
[8.2 单步进入函数](#8.2 单步进入函数)
[8.3 从函数中跳出](#8.3 从函数中跳出)
[8.4 跳到下一个断点](#8.4 跳到下一个断点)
[十一、正则表达式中的 .*:为什么它会"吃掉"输入](#十一、正则表达式中的 .*:为什么它会“吃掉”输入)
[11.1 贪婪不是"匹配一次就停"](#11.1 贪婪不是“匹配一次就停”)
[11.2 后续匹配失败后,正则会回退](#11.2 后续匹配失败后,正则会回退)
[13.1 字符串开头保留目标文本](#13.1 字符串开头保留目标文本)
[13.2 让正则陷入持续回退](#13.2 让正则陷入持续回退)
[13.3 三个条件如何依次成立](#13.3 三个条件如何依次成立)
[17.1 先区分两个版本,而不是马上套用旧 payload](#17.1 先区分两个版本,而不是马上套用旧 payload)
[17.2 通过浏览器结果判断是否真的走到了目标分支](#17.2 通过浏览器结果判断是否真的走到了目标分支)
[17.3 为什么讲师没有一开始就给出数组格式](#17.3 为什么讲师没有一开始就给出数组格式)
[17.4 断点里的变量比页面文字更可靠](#17.4 断点里的变量比页面文字更可靠)
[17.5 调试环境本身也可能成为问题](#17.5 调试环境本身也可能成为问题)
[18.1 点号为什么可以吞掉大量字符](#18.1 点号为什么可以吞掉大量字符)
[18.2 为什么一百万个 a 不是随便增加的](#18.2 为什么一百万个 a 不是随便增加的)
[18.3 为什么输入还要保留 Merry Christmas](#18.3 为什么输入还要保留 Merry Christmas)
[二十、从本题过渡到后续 SQL 注入绕过](#二十、从本题过渡到后续 SQL 注入绕过)
[21.1 不要把页面 warning 当成唯一结论](#21.1 不要把页面 warning 当成唯一结论)
[21.2 不要把 ABC 当成最终答案](#21.2 不要把 ABC 当成最终答案)
[21.3 数组写法必须和字符串写法区分](#21.3 数组写法必须和字符串写法区分)
[21.4 第二题不能继续提交数组](#21.4 第二题不能继续提交数组)
[21.5 Xdebug 版本要以检测结果为准](#21.5 Xdebug 版本要以检测结果为准)
[21.6 代理可能影响断点连接](#21.6 代理可能影响断点连接)
[21.7 回溯数量不是越长越好这么简单](#21.7 回溯数量不是越长越好这么简单)
[21.8 最后用断点验证,而不是凭感觉收尾](#21.8 最后用断点验证,而不是凭感觉收尾)
导读:同一道题的两条利用链
这次课堂围绕一组 PHP CTF 练习展开,核心问题是:程序用正则表达式过滤 greeting,随后又用字符串查找函数检查同一个参数,攻击者如何在不改变源码的情况下,让程序沿着出题者没有预料到的分支继续执行?
课堂内容并不是只给出一个 payload,而是按"读代码 - 找判断 - 试输入 - 看返回值 - 用断点验证 - 理解正则机制"的顺序推进。第一题利用的是数组和字符串函数之间的类型边界 ;第二题修复了数组入口后,才真正进入正则贪婪匹配与回溯上限。
本文保留这条讲解脉络,对讲师的经验判断和观点完整转述;新增的零基础说明统一放在"【小白通俗注解】"中,不把它们伪装成课堂原生结论。
一、先从题目目标倒推执行路径
讲师开场先提醒,相关题型在较早期的 CTF 中已经出现过。面对这类题,目标不是"把所有代码都看懂后再动手",而是先确认程序最终要我们绕过什么。题目给出的函数和条件并不多,但每个判断的真假都会决定后续代码是否执行。
源码中首先定义了一个检查函数,内部调用的正则模式是 /Merry.*Christmas/is:
php
function areyouok($greeting){
return preg_match('/Merry.*Christmas/is', $greeting);
}
随后程序从 POST 参数中取出 greeting,先执行外层条件,再执行 strpos 检查:
php
$greeting = @$_POST['greeting'];
if (!areyouok($greeting)) {
if (strpos($greeting, 'Merry Christmas') !== false) {
echo 'Merry Christmas. ' . 'flag{****}';
} else {
echo 'Do you know .swp file?';
}
} else {
echo 'Do you know PHP?';
}
从程序结构看,想走到 flag 分支,至少要满足两件事:
-
areyouok($greeting)返回假,让程序进入外层if; -
strpos($greeting, 'Merry Christmas') !== false返回真,让程序进入内层输出分支。
讲师在这里反复强调的是分析顺序:如果第一道判断没有绕过,程序根本不会执行到第二道判断,因此不能一开始就只研究 strpos。
讲师观点:做绕过时,先找程序入口和最先执行的过滤点。前一个判断没有过,后面再精巧的输入也不会生效。

【小白通俗注解】
可以把程序想成两扇门。第一扇门问"输入是否符合正则",第二扇门问"输入里是否出现指定文字"。必须先打开第一扇门,才有机会面对第二扇门。
二、第一次试探:传入普通字符串 ABC
课堂先用最直观的方式尝试:给 greeting 传入 ABC。这个字符串既没有 Merry,也没有 Christmas,所以不可能匹配 /Merry.*Christmas/is。
在 PHP 中,preg_match 匹配失败时会返回 0,在条件判断中可视作假。这样一来,!areyouok($greeting) 成立,程序确实能从外层 else 进入外层 if。这一步说明 ABC 成功绕过了第一关。
然而,程序不会因为进入外层 if 就立即输出 flag。它还要执行:
php
strpos($greeting, 'Merry Christmas') !== false
ABC 中没有 Merry Christmas,所以字符串查找失败。课堂展示了 strpos 手册中的返回值说明:找不到目标时返回 false;找到目标时返回目标第一次出现的位置,而且位置从 0 开始。
这带来一个容易忽视的细节:如果目标刚好出现在字符串开头,返回值可能是整数 0。因此,不能简单地把返回值直接当作布尔值判断,而应该使用严格比较。题目使用了 !== false,目的就是区分"返回 0"和"返回 false"。


普通字符串的执行结果可以整理成这样:
| 检查点 | ABC 的结果 |
是否通过 |
|---|---|---|
preg_match('/Merry.*Christmas/is', 'ABC') |
0/假 |
通过外层否定判断 |
strpos('ABC', 'Merry Christmas') |
找不到 | 返回 false |
false !== false |
假 | 无法进入 flag 分支 |
所以,ABC 只是一个有用的试探输入。它证明第一道正则过滤可以被普通不匹配字符串绕过,却同时暴露出第二个判断仍然存在。
讲师观点:第一关和第二关必须分别处理。不能因为输入让正则返回假,就误以为整个题目已经绕过。
【小白通俗注解】
strpos 返回的是"位置",而不是简单的"有/没有"。"没找到"是 false,"找到了但在第一个字符"可能是 0。这就是为什么代码需要严格判断。
三、strpos 的关键:严格不相等比较了什么
为了让第二道门成立,课堂专门停下来解释 != 和 !== 的区别。
-
!=只做宽松的不相等比较,PHP 可能在比较前进行类型转换; -
!==同时比较值和类型,只有值不同且类型也不同,结果才为真。
在题目中,内层条件明确写成了:
php
strpos($greeting, 'Merry Christmas') !== false
如果 strpos 返回 false,那么条件自然不成立;如果它返回整数 0,则 0 !== false 为真;如果它返回某个其他整数,也为真。课堂接下来要寻找的,就是一种能让该函数返回既不是 false、又能与输入类型问题产生联系的情况。


源码截图里的注释也提示了这一思路:strpos 预期接收字符串,但参数类型可能影响它的返回结果。于是问题自然变成:如果把本来应该是字符串的 greeting 改成数组,函数会怎样处理?
四、第一题真正的突破口:字符串函数收到数组
4.1 先看题目有没有禁止数组
第二个版本的代码后来增加了 is_array 判断,但第一题没有这层保护。讲师因此提出一个反向思考:出题者为什么会在加强版里专门加数组判断?通常意味着,数组正是前一版的非预期输入。
在第一题中,可以把 POST 参数写成数组形式,例如:
greeting[]=ABC
PHP 会把 greeting 解析成数组,数组索引 0 对应的值是 ABC。这与直接提交 greeting=ABC 完全不同:前者在服务端是数组,后者是字符串。

4.2 数组如何通过第一道正则判断
数组元素可以放入一个不包含 Merry Christmas 的值,例如 ABC。课堂的执行分析是:
-
greeting的顶层类型是数组; -
preg_match期待字符串,却收到了数组; -
PHP 页面出现参数类型警告,函数结果表现为匹配失败;
-
areyouok($greeting)在外层否定判断中被当作假; -
程序继续向内执行。
这里的重点不是警告本身,而是程序没有因为警告终止,而是继续使用函数返回值判断分支。课堂演示页面也保留了这些 warning,说明输入已经到达了函数调用处。

4.3 数组如何影响 strpos
进入内层以后,程序执行 strpos($greeting, 'Merry Christmas')。此时第一个参数依旧是数组,而 strpos 的设计目标是处理字符串。
在课堂使用的 PHP 环境中,传入数组后页面提示"expects parameter 1 to be string, array given",函数返回了 null。由于题目使用严格不相等判断:
null !== false
这个条件成立,程序继续进入 flag 分支。


第一题的利用链因此完整闭合:
greeting[]=ABC | +--> preg_match:参数类型不符合,结果按假处理 | +--> strpos:数组参数导致返回 null | +--> null !== false:条件成立 | +--> 进入 flag 分支
讲师观点:第一题完全不需要正则回溯。真正的问题是,代码把面向字符串的函数用于数组,却没有在入口处先拒绝数组。
【小白通俗注解】
这里不是"数组能被正则正常识别",而是"程序没有认真处理类型错误"。同一个参数先后被两个只适合字符串的函数使用,错误返回值恰好组成了可以继续向下执行的条件。
五、.swp 临时文件:为什么题目要求先恢复文件
拿到题目时,除了 PHP 源码,还会遇到一个 .swp 文件。讲师先解释了它的来源:在 Linux 上用 Vim 打开文件后,如果编辑过程没有正常保存退出,Vim 可能留下临时交换文件,用来保存编辑中的中间状态。
课堂演示的情境是:用户通过 vim 1.txt 打开文件,写入了一些内容,却没有完成保存退出。此时磁盘上会出现一个与原文件相关的临时文件。其他人看到的不是一个完整提交后的源文件,而是这份尚未收尾的编辑中间产物。

因此,题目最初的操作是恢复临时文件,再根据恢复出的代码做审计。讲师认为,这个恢复步骤是题目设计中的前置条件;如果已经完成恢复,再重复处理 .swp 就没有必要。
【小白通俗注解】
.swp 可以理解成 Vim 的"草稿缓存"。它有时会包含原文件还没有正式保存的内容,所以 CTF 会把它作为代码审计的入口。
六、提交工具的选择:HackBar 适合临时验证
恢复源码后,要通过 POST 请求提交 greeting。课堂使用 Firefox 的 HackBar,在 URL 区域填入目标地址,在 POST data 区域写入参数,再执行请求。
第一题的请求可以写成数组格式:
greeting[]=ABC


演示中也比较了 HackBar 和 Burp Suite。HackBar 适合快速改参数、观察一次响应,操作成本低;但它有时会失效,或者受到浏览器、代理和页面状态影响。讲师的日常经验是,临时分析可以用 HackBar,稳定的抓包、重放和参数管理更应该依赖 Burp Suite。
讲师经验判断:HackBar 不是不能用,而是不应该成为唯一依赖。遇到复杂请求或工具状态不稳定时,要及时换到更可靠的代理工具。
提交成功后,页面前部可能同时显示 PHP warning,但只要下方出现目标输出,就说明程序已经按照预期的非正常路径进入 flag 分支。课堂提醒不要被 warning 文字带偏,应该关注最终分支结果。
七、为什么要上断点:从"拿到结果"走向"看懂过程"
仅仅看到 flag,还不能说明已经完全理解漏洞。下一步是用 Xdebug 在程序执行过程中暂停,查看每个变量到底是什么类型、每个函数返回了什么值。
讲师把断点解释为一种程序追踪工具:程序运行到断点会暂时停下来,调试器展示当前行、调用栈、变量和返回值,分析者可以决定是进入函数、跳过函数,还是直接继续到下一个断点。
讲师观点:断点像一个侦探,会持续追踪 PHP 代码的每一步。复杂漏洞往往不是一个函数就能说明白的,而是一个函数套着另一个函数,入口数据经过很多层以后才到达最终执行点。没有断点,很容易不知道数据在中途变成了什么。


7.1 Xdebug 版本不能想当然
课堂以 Windows 的 PHP 7.3 环境为例,指出 PHP 环境里自带的 Xdebug 版本不一定适配当前 PHP。PHP 5.6 常见的是 Xdebug 2;升级到 PHP 7 后,如果仍然加载旧版本,VS Code 可能无法正常下断点。
处理方式是重新检测当前 PHP 对应的 Xdebug 版本,而不是凭经验随便下载一个扩展。
具体流程如下:
-
新建一个
phpinfo.php文件,只写<?php phpinfo();; -
在浏览器访问该文件,得到完整 PHP 配置信息;
-
查看页面源码,把 HTML 源码完整复制出来;
-
粘贴到 Xdebug 官方安装向导;
-
点击分析,让向导根据 PHP 版本、编译参数和系统架构推荐扩展;
-
下载指定的 DLL 文件,放入 PHP 扩展目录;
-
在 PHP 配置中启用 Xdebug,并重启 Web 服务。



课堂截图中,安装向导给出的版本是 3.1.6。讲师强调,Windows 和 Linux 的下载文件不同,必须以当前系统和当前 PHP 版本的检测结果为准。

7.2 通过 phpinfo 验证模块是否启用
安装完后不能只看文件是否放进目录,还要重新访问 phpinfo,搜索 xdebug:
-
如果能看到 Xdebug 模块和版本信息,说明扩展已经加载;
-
如果只看到零散配置,或者搜索不到模块,说明启用失败;
-
调试配置中需要确认监听端口为
9003。

7.3 VS Code 中创建 launch.json
在 VS Code 的 Run and Debug 面板中,选择创建调试配置,生成 launch.json。课堂演示的配置名称是 Listen for Xdebug,类型为 PHP,端口设置为 9003。

只要 Xdebug 和 VS Code 的端口保持一致,调试器就能接收 PHP 发来的调试连接。讲师也提醒,代理模式可能影响调试连接:全局代理、规则代理和本地端口设置都可能让断点表现异常。出现"代码明明执行了但没有停下"时,需要排查代理、端口、扩展版本和服务重启状态。
八、断点按钮分别解决什么问题
课堂逐个解释了调试器上的几种动作。它们并不是名称不同的同一个按钮,而是对应不同的阅读策略。
8.1 跳过当前函数
如果当前函数只是一个已经熟悉的封装,只想知道它最终返回什么,就可以跳过函数内部。调试器会执行函数,然后把返回值交回当前代码。
8.2 单步进入函数
如果需要确认函数的具体实现,就进入函数内部。此时可以看到参数类型、局部变量、正则调用和实际返回值。
8.3 从函数中跳出
有时已经进入一个很长的函数,读了几行后发现它与当前漏洞无关,就可以直接跳出,回到调用位置,避免在无关代码里浪费时间。
8.4 跳到下一个断点
如果已经知道中间代码不会影响分析,可以从当前断点直接运行到下一个断点。这样既保留了关键观察点,又不用逐行查看所有普通代码。

【小白通俗注解】
单步调试像是在地图上走路:进入函数是走进一栋楼,跳出函数是回到街道,继续执行是直接走到下一个地标。不同操作只是为了控制观察范围。
九、用断点重新验证第一题
断点停在 greeting 读取位置时,变量面板可以直接看到:
-
greeting的类型是数组; -
数组中有一个元素;
-
数组索引为
0; -
元素值为
ABC。

继续执行到 areyouok 调用处,如果选择跳过函数,调试器会直接显示结果。课堂演示结果为 false,所以程序进入外层 if。

再向下执行到 strpos,可以看到函数返回 null。这一步把前面页面上的 warning 和最终 flag 联系起来:数组并没有被转换成一个正常字符串,而是在错误类型下让函数返回了空值。

随后条件 null !== false 成立,程序到达输出位置。这个断点过程说明,绕过并不是偶然的页面现象,而是每一个判断都按特定返回值走到了下一步。
讲师观点:当漏洞涉及多个函数和多个返回值时,断点是必需的训练工具。只看最终页面,很容易把真正的原因和表面现象混在一起。
十、第二题的变化:先禁止数组,再谈回溯
第一题完成后,课堂切换到加强版代码。新版在调用正则前加了数组判断:
php
if (!is_array($greeting)) {
if (!areyouok($greeting)) {
if (strpos($greeting, 'Merry Christmas') !== false) {
echo 'Merry Christmas. ' . 'flag{xxxxx}';
}
}
}
新版的关键变化只有一个:提交值不能是数组。如果是数组,就不会再进入后面的字符串处理逻辑,第一题的类型绕过被直接截断。

讲师据此回看两个版本:加强版防护更严格,差异点就是数组检查;而数组检查之所以被补上,正说明第一版确实被数组输入绕过过。
讲师判断:修补了数组入口后,第一题的办法不再适用。第二题必须回到正则本身,利用回溯行为制造新的返回结果。
十一、正则表达式中的 .*:为什么它会"吃掉"输入
第二天课程开始系统解释正则回溯。讲师将 PHP 的正则匹配概括为 NFA 的有效状态机,并指出,如果看不懂这道题,根本原因往往不是 PHP 语法不会,而是没有把正则的匹配顺序想清楚。
正则表达式并不只用于安全。程序开发、运维和安全工作中都经常需要用它。课堂继续使用 /Merry.*Christmas/is 作为例子,重点拆解 .*:
-
.表示匹配一个字符,但默认不匹配换行符; -
*表示前面的模式可以出现零次或多次; -
.*合起来就表示匹配任意数量的普通字符; -
这个组合默认是贪婪的,会尽量多地匹配输入内容。

11.1 贪婪不是"匹配一次就停"
假设输入里有很多个 a,而正则的 .* 后面还有一个固定字符。贪婪匹配不会在第一个满足可能性的地方立即停下,而是继续向后消耗字符,直到不能再匹配为止。
可以把它想成一条流水线:前面的 .* 先把能拿到的字符尽量全部拿走,后面的固定字符则等着接收自己的那一段。当 .* 拿得太多时,后面的固定字符就会发现"当前位置不是我需要的字符"。

11.2 后续匹配失败后,正则会回退
关键在于,正则引擎不会因为后面的固定字符第一次匹配失败就立刻结束。它会尝试调整前面 .* 的匹配长度:
-
.*先吃掉尽可能多的字符; -
后面的固定字符在当前位置匹配失败;
-
.*退回一个字符; -
再次尝试匹配固定字符;
-
仍然失败就继续退回;
-
直到找到可匹配的位置,或者所有可能位置都试完。

这就是课堂所说的"回溯":不是程序主动随机尝试,而是贪婪量词先做出最大匹配,后续条件失败以后,匹配引擎沿着之前的路径逐步撤销选择。
讲师观点 :理解回溯的关键,是先理解贪婪匹配。前面的
.*把字符全部吞掉,后面发现目标字符没有出现在当前位置,正则才会向后退,重新寻找匹配点。
【小白通俗注解】
"回溯"就是走错路以后往回退。.* 先走得太远,后面的字符对不上,于是它只能一步步往回走,直到后面的字符能接上。
十二、为什么要构造大量字符:让回溯次数达到上限
如果只放几个 a,正则最多退回几次,很快就会结束。为了把这条路径放大,课堂把 a 的数量扩展到接近一百万。
假设正则先吞掉一百万个字符,而后续需要匹配的字符始终不存在,那么它可能从接近输入末尾的位置逐个退回。字符越多,可能尝试的匹配位置越多,回溯次数也越大。
讲师在这里引入 PHP 的保护限制:为了避免恶意构造的正则请求拖垮服务,PHP 对回溯次数设置了上限。课堂依据官方文档按一百万次理解;当回溯超过限制时,preg_match 返回 false,匹配过程结束。

为什么需要这个限制?讲师用更大的数量说明风险:如果攻击者让每次请求都触发一亿次回溯,服务器就会在每个请求上消耗大量计算资源,带宽、缓存、内存和整体响应能力都会受到拖累。大量请求叠加后,就可能形成拒绝服务效果。

讲师观点:回溯上限原本是为了防止资源消耗型攻击,但在 CTF 中,这个"超过上限就返回 false"的行为反过来成为可利用条件。
十三、第二题的完整构造思路
第二题的输入不能再是数组,所以必须构造一个字符串,同时满足外层和内层两个判断。
13.1 字符串开头保留目标文本
内层 strpos 需要找到 Merry Christmas。因此,输入不能像第一题那样只使用 ABC,而要在字符串中保留目标文本。课堂演示采用了"目标文本 + 大量无关字符"的结构。
示意形式如下:
Merry Christmasaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
这样做的目的有两个:
-
让
strpos在开头找到Merry Christmas,返回位置值; -
为前面的正则匹配准备足够长的回溯输入。
13.2 让正则陷入持续回退
如果字符串只是 Merry Christmas,正则可能直接完成匹配,不会触发上限。必须让输入结构导致 .* 先吞掉大量字符,再在后续位置遇到无法匹配的条件。
课堂中的脚本思路是用 Python 自动生成并发送请求,不手工输入一百万个字符:
php
import requests
url = 'http://127.0.0.1/gift_plus.php'
payloads = {
'greeting': 'Merry Christmas' + 'a' * 1000000
}
response = requests.post(url, data=payloads)
print(response.text)
这里的代码作用不是"随便发一个长请求",而是把字符数量精确地交给程序拼接,减少手工输入错误。课堂截图中还尝试了略多于一百万的字符,意图避免刚好卡在边界上。


13.3 三个条件如何依次成立
讲师按照程序执行顺序把结果拆成三步:
第一步:通过数组检查。
输入是普通字符串,因此 is_array($greeting) 返回假,程序进入真正的正则检查。这里不能使用 greeting[]=...,否则会在入口处被挡住。
第二步:让正则返回 false。
.* 先尽量匹配大量字符,后续条件无法立即匹配,正则不断回退。回溯次数超过 PHP 设置的上限后,preg_match 返回 false,于是 !areyouok($greeting) 成立。
第三步:让 strpos 找到目标文本。
字符串开头保留了 Merry Christmas,所以 strpos 返回它的首次位置,而不是 false。无论返回位置是 0 还是其他整数,!== false 都成立。
这三步组合起来就是:
字符串类型 | +--> is_array 检查通过 | +--> 正则回溯超过上限,preg_match 返回 false | +--> strpos 找到 Merry Christmas,返回位置 | +--> 两个条件同时满足,进入 flag 分支


讲师还建议,如果仍然看不清程序为何进入分支,可以在 preg_match 和 strpos 附近再次下断点,直接观察:第一处是 false,第二处是一个位置值,最后程序才会走到输出语句。


十四、两道题放在一起比较
| 对比项 | 第一题 | 第二题 |
|---|---|---|
| 输入类型 | 数组 | 必须是字符串 |
| 主要突破点 | 字符串函数处理数组 | 正则贪婪匹配与回溯上限 |
preg_match 结果 |
因参数类型和不匹配表现为假 | 因超过回溯限制返回 false |
strpos 结果 |
数组参数导致课堂环境返回 null |
找到开头的 Merry Christmas,返回位置 |
| 是否需要回溯 | 不需要 | 需要 |
| 出题者修补点 | 原版本未限制数组 | 增加 is_array 检查 |
第一题体现的是输入类型与函数预期不一致,第二题体现的是正则引擎资源限制被反向利用。两题看起来都在围绕同一段 PHP 代码做绕过,但思考方式不同:第一题先看类型,第二题先看匹配机制。
十五、讲师对难度和学习方式的评价
课堂最后,讲师对这组练习的难度做了明确判断:它不需要非常深的 PHP 基础,也不要求一开始就精通正则。代码中真正起核心作用的函数并不多,主要是正则匹配和字符串位置查找,再配合几层 if 判断。
讲师观点:这类题的难点不在代码量,而在于能否把函数的参数类型、返回值和条件判断串起来。看似只有一两个函数,组合起来仍然可能产生非预期路径。
他还要求学习者不要满足于"听懂了课堂例子"。至少要做到举一反一:把课堂讲过的同类思路真正独立复现,才能在题目换一个变量名、换一层判断后继续找出突破口。
从训练角度看,可以把这次复盘沉淀成四个检查问题:
-
这个参数在 PHP 中实际是什么类型?是字符串、数组,还是空值?
-
当前函数对参数类型有什么预期?异常类型时会报错、返回空值,还是继续执行?
-
条件判断比较的是宽松值、严格值,还是同时比较类型?
-
正则中的贪婪量词会不会让匹配引擎回退?回溯上限是否会改变返回结果?
十六、复盘收束:从"能绕过"到"能解释"
这次课程的完整价值,不只是拿到一次 flag,而是建立了一条可复用的代码审计路径:
首先,沿着程序真实执行顺序列出每个判断,不要被题目表面的提示带着走。然后,检查每个输入点是否有类型限制,尤其注意数组、字符串、空值之间的转换和错误处理。接着,记录函数的准确返回值,区分 false、null、0 和普通整数。遇到多层函数调用时,用断点确认变量在每一步的变化,而不是只凭最终页面猜原因。
当题目补上类型检查后,再回到正则表达式本身:先理解 .* 的贪婪行为,再理解后续匹配失败时为什么会回退,最后观察回溯上限如何改变 preg_match 的结果。这样,第一题的数组绕过和第二题的正则回溯就不再是两个孤立技巧,而是同一条分析方法在不同防护条件下的两次应用。
讲师总结性观点:真正需要练习的,是把"输入 - 函数 - 返回值 - 分支 - 最终输出"连成完整链路。只有能解释每一步为什么成立,才算真正掌握了这道题。
十七、按课堂现场还原一次完整排查
为了避免把这道题写成只剩结论的"技巧清单",这里把课堂现场的排查动作再按原顺序展开一次。这个过程也是实际做题时最容易遗漏的部分:很多人知道数组可以绕过,却没有确认数组究竟在哪一步改变了返回值;也有人知道正则存在回溯,却没有确认它是否真的触发了题目需要的 false。
17.1 先区分两个版本,而不是马上套用旧 payload
课堂先展示了早上的练习和当前的两个代码版本。两份代码看起来非常接近,正则函数也保持不变,但加强版多了一处数组判断。讲师让大家先自己观察差别,而不是直接公布答案。
差异看起来很小,影响却很大。没有数组判断的版本允许输入以数组形态进入函数;增加 is_array 以后,数组会在更外层被挡住。也就是说,同样的 greeting[]=ABC,在第一题里可以继续执行,在第二题里会在入口附近结束。
讲师借此强调,审计代码时不能只看正则表达式本身。过滤规则是否安全,还要看调用前有没有做类型验证、调用失败后返回什么、后续条件是否严格比较了返回值。很多非预期绕过都藏在"参数类型没有限制"这种看似普通的细节里。
17.2 通过浏览器结果判断是否真的走到了目标分支
第一次使用 HackBar 提交时,页面可能同时出现两类信息:上面是 preg_match 或 strpos 的类型警告,下面是题目自己输出的文字。讲师提醒,不能只看浏览器最上方的 warning 就认为请求失败,因为 PHP 在这个环境中会继续执行后面的代码。
判断是否成功的标准,是看程序最后进入了哪一条 echo。如果仍然显示 Do you know .swp file?,说明第二个 strpos 条件没有成立;如果显示 Merry Christmas 和 flag,才说明两道判断都满足。
这种"带 warning 但仍然有业务输出"的现象很容易迷惑初学者。它告诉我们:错误提示和控制流结果是两件事。错误提示说明函数收到的参数不符合预期,控制流结果则取决于函数返回值如何被后面的 if 使用。
17.3 为什么讲师没有一开始就给出数组格式
课堂前半段先让大家尝试 ABC,是为了把第二个判断暴露出来。如果直接给出数组 payload,虽然很快可以拿到 flag,但学习者可能只记住"加方括号",却不理解它为什么有效。
先传普通字符串,可以得到一个完整的失败样本:正则检查为假,程序进入内层;strpos 找不到目标,返回 false,程序停在内层 else。有了这个样本,后面再把字符串换成数组,才能明确看出变化发生在参数类型和函数返回值上,而不是误以为数组只是另一种字符串写法。
17.4 断点里的变量比页面文字更可靠
课堂使用断点重新提交时,讲师先观察当前请求的 greeting。变量面板显示它是数组,索引 0 下面挂着 ABC。这一步确认了浏览器里的 greeting[]=ABC 并不是普通文本,而是被 PHP 解析成了数组结构。
随后,调试器停在 areyouok 调用处。选择跳过函数可以快速获得结果,面板显示返回值为 false。如果选择单步进入,则可以看到调用链进入 preg_match,但当前题目的重点并不是继续阅读正则内部实现,而是确认返回值如何影响外层 if。
走到 strpos 后,面板显示返回值为空。课堂用 null 描述这个结果,并把它与 false 放在一起比较:两者的值不同,类型也不同,所以严格不相等判断成立。最终,调试器停到输出 flag 的行,完整验证了第一题的控制流。
17.5 调试环境本身也可能成为问题
讲师在配置断点时遇到过代理和环境状态的干扰,因此提醒大家:断点没有命中,不一定代表代码没有执行。需要逐项排查:
-
Xdebug 是否真的被 PHP 加载;
-
PHP 使用的扩展版本是否与当前 PHP 版本匹配;
-
php.ini是否启用了正确的扩展; -
Web 服务是否在修改配置后重启;
-
Xdebug 和 VS Code 是否都监听
9003; -
浏览器或系统代理是否改变了调试连接路径。
课堂里也提到,如果 Xdebug 一时无法工作,可以先用打印变量的方法辅助分析,再回头修复断点配置。这里的重点不是依赖某一种工具,而是确保每个关键返回值都能被观察到。
十八、从"回溯是什么"到"为什么刚好能绕过"
第二天的讲解并没有直接跳到一百万个字符,而是先用一个短字符串把回溯过程拆开。假设正则结构里有一个贪婪的 .*,后面紧接着一个固定字符。输入中如果出现多个相似字符,.* 会优先匹配到更靠后的位置,固定字符却可能因为当前位置不符合而失败。
这时,正则引擎不是重新从字符串开头盲目搜索,而是沿着已经走过的匹配路径往回退。每退一步,就释放一个原来被 .* 吞掉的字符,然后重新尝试后面的固定字符。只要某一步对上,匹配就会继续向后推进;如果所有位置都对不上,才会报告匹配失败。
讲师用"全吃掉"来形容贪婪匹配,用"往回退"来形容回溯。这样的说法虽然口语化,但它对应了两个不同阶段:前者是量词选择尽可能大的匹配范围,后者是后续条件失败后撤销之前的选择。
18.1 点号为什么可以吞掉大量字符
课堂专门解释了 . 的边界:在默认模式下,它可以匹配大多数普通字符,但不能匹配换行符。当前题目的输入没有换行,所以连续的 a、普通字母和其他无关字符,都可能被 . 消耗。
接着是 *。它允许前面的模式出现零次、一次或很多次。正因为次数没有固定上限,.* 才能覆盖从空字符串到整段输入的多种长度。再加上它默认偏向最大匹配范围,才会产生后续的回退成本。
正则工具的英文提示中也写着 zero or more,也就是零次或多次;另一个词说明它会尽量多地获取字符。讲师借此提醒,阅读正则工具和官方文档时,英文术语本身已经提供了重要线索,不要只看示例而忽略说明文字。
18.2 为什么一百万个 a 不是随便增加的
增加字符数量的目的不是让请求看起来复杂,而是让正则拥有更多可以尝试的回退位置。若只有十几个字符,回溯很快就结束,函数返回值不会因为资源限制而改变;只有当可尝试的路径足够多,才有机会触发 PHP 的上限。
课堂演示中,讲师先用较短的字符串观察正则步骤,再逐步增加 a 的数量。这样做可以看出:输入变长以后,正则调试器记录的步骤也随之增加。最终构造时,脚本通过字符串重复操作一次生成约一百万个 a,避免人工复制长文本。
18.3 为什么输入还要保留 Merry Christmas
如果只发送一百万个 a,即便成功让正则返回 false,后面的 strpos 也找不到目标文本,仍然无法进入 flag 分支。于是,第二题的输入必须同时服务于两个函数:
-
给正则制造"前面匹配过多、后面无法接上"的回溯条件;
-
给
strpos提供一个确实存在的Merry Christmas。
课堂最终采用的结构是把目标文本放在输入开头,再拼接大量无关字符。这样 strpos 可以在开头找到目标,返回位置;正则则有足够长的后续内容可以触发回溯。
这也是第二题比第一题更需要整体思考的原因:第一题可以分别利用两个错误类型返回值,第二题则必须让同一段字符串同时满足正则和字符串查找的相反要求。
十九、讲师对"举一反一"的具体要求
课堂中,讲师没有把第二题的全部构造过程都当作现成答案交给大家,而是要求大家把早上的正则回溯练习迁移过来。两个题目的题干、参数名和过滤位置可能不同,但"让贪婪匹配回退到限制"的思路是一致的。
讲师的要求不是让大家马上举一反三,至少先做到举一反一:把课堂刚讲过的回溯例子独立复现一次,能自己写出自动提交脚本,能解释为什么要放一百万个无关字符,能说明为什么输入开头还要保留目标文本。
如果只把 payload 当成一串神秘字符复制,换一个固定词、换一个参数名或增加一道数组检查后就很难继续。真正的练习是重新画出条件链,然后为每个条件寻找所需的返回值:外层需要 false,内层需要一个不是 false 的位置值。
【小白通俗注解】
"举一反一"可以理解成先把同一道题的逻辑自己重做一遍。只有能解释每个字符为什么放在那里,才不是机械复制答案。
二十、从本题过渡到后续 SQL 注入绕过
课程末尾提到,这次练习是第一次尝试做一些 SQL 注入过滤的绕过。虽然当前题目直接使用的是 PHP 函数和正则,不是 SQL 查询,但分析方法是一致的:
-
找到应用程序真正执行的过滤代码;
-
明确过滤函数接受什么类型的数据;
-
观察失败时是抛出异常、返回空值,还是返回布尔值;
-
检查开发者用宽松比较还是严格比较;
-
把输入构造成能影响控制流的形式;
-
用调试或重复请求确认每一步,而不是只看最后结果。
在 SQL 注入场景中,过滤器可能同样使用正则、字符串查找或黑名单判断。只要开发者错误地假设了输入类型,或者忽略了函数失败返回值,类似的非预期路径仍然可能出现。因此,这道题的训练重点并不局限于 preg_match 和 strpos 两个函数。
但讲师也没有把本题延伸成新的知识结论。这里能保留的只有课堂明确表达的学习方向:先掌握本题中的参数类型、返回值和正则回溯,再把这种审计思路迁移到后续题目。
二十一、课堂操作中的几个踩坑点
21.1 不要把页面 warning 当成唯一结论
数组提交后出现 warning,是因为函数收到的参数类型不符合预期。课堂环境下,warning 并没有阻止后续代码继续运行,所以需要同时观察页面最终输出。只看第一行错误文字,容易误判为"请求彻底失败";只看 flag,又容易忽略真正的类型问题。
21.2 不要把 ABC 当成最终答案
ABC 的作用是证明正则过滤可以被不匹配字符串绕过,它不是完整 payload。后面的 strpos 仍然需要单独处理。这个试探输入非常重要,因为它把"第一关能过、第二关不能过"的状态展示得很清楚。
21.3 数组写法必须和字符串写法区分
greeting=ABC 在服务端是字符串,greeting[]=ABC 才会被解析为数组。两者在浏览器的 POST 区域里看起来只差一对方括号,进入 PHP 后却会导致完全不同的参数类型。第一题利用的正是这种差异。
21.4 第二题不能继续提交数组
加强版增加 is_array 后,数组会被提前挡住。即便数组仍然能够触发函数 warning,也不会再走到后面的 strpos 条件。因此第二题的构造必须回到普通字符串,并通过回溯改变正则函数的结果。
21.5 Xdebug 版本要以检测结果为准
课程现场多次提到,PHP 环境自带的 Xdebug 版本可能太旧。把 DLL 放进目录并不等于调试器已经可用,必须用 phpinfo 检查模块是否加载,再看 VS Code 是否能在 9003 端口接到连接。版本不兼容时,最明显的表现就是断点始终不命中。
21.6 代理可能影响断点连接
课堂中切换过代理模式,因为代理有时会让网络访问变慢,也可能影响调试连接。遇到断点表现异常时,讲师建议先排除代理因素,再确认端口和服务状态。这里的排查顺序很实用:先看环境连接,再怀疑代码逻辑。
21.7 回溯数量不是越长越好这么简单
大量字符只是为了提供足够的回退位置,真正目标是让后续匹配失败并触发限制。如果字符串结构没有让正则进入回溯路径,单纯把输入变长也没有意义。同样,如果删掉开头的 Merry Christmas,正则也许能得到需要的结果,但 strpos 会失败,程序仍然到不了 flag。
21.8 最后用断点验证,而不是凭感觉收尾
第二题成功后,仍然可以在三个位置下断点:数组检查处、正则调用处、strpos 条件处。理想的观察结果是:输入不是数组;正则函数由于回溯限制返回 false;strpos 返回目标文本的位置。三个值都能解释清楚,复盘才算闭环。
【小白通俗注解】
这些踩坑点可以归纳成一句话:不要只记住"某个输入能成功",要记住输入在服务端变成了什么、每个函数返回了什么,以及程序为什么继续往下走。
附:本篇图片说明
文中图片均来自附件课堂材料,按原文出现顺序整理,包含:题目源码、strpos 手册页面、数组提交结果、.swp 文件、HackBar、Xdebug 安装与端口配置、断点调试、正则贪婪匹配与大量字符回溯演示。