CTF实战:PHP无参数RCE绕过技巧与代码执行漏洞分析
1. 项目概述从一道CTF题看PHP代码执行与无参数RCE的攻防博弈最近在带新人入门CTF的Web安全方向发现很多朋友卡在了ctfshow web41这道题上。这道题本身并不复杂但它非常经典地展示了PHP代码执行漏洞的一个进阶利用场景——无参数RCE远程代码执行。很多刚接触安全的朋友一看到代码执行第一反应就是直接上system(‘ls’)或者echo file_get_contents(‘/flag’)但现实中的漏洞利用往往没这么直接。这道题就像一道精心设计的“锁”你需要找到正确的“钥匙”而不是用蛮力去砸。它考察的不仅仅是知道某个函数能执行命令更是对PHP语言特性、内置函数以及绕过限制的创造性思维的深度理解。如果你正在学习Web安全或者对PHP代码审计感兴趣那么通过这道题的完整拆解你能清晰地看到一条从漏洞发现到利用构造的完整路径以及防守方是如何通过层层过滤来增加攻击难度的。这道题的核心场景是你面对一个存在代码执行点的Web应用但常用的命令执行函数和参数传递方式被过滤或限制你需要在不直接传递参数的情况下读取到服务器上的目标文件通常是/flag。这不仅仅是CTF比赛中的题目其背后反映的“无参数RCE”思想在真实的代码审计和渗透测试中同样具有参考价值。接下来我将以这道题为蓝本带你一步步拆解其背后的技术原理、可能的过滤规则并详细演示如何利用PHP的“黑魔法”来构造出有效的利用链。2. 核心思路拆解为什么常规的“一句话木马”行不通了在深入解题之前我们必须先理解出题人设置的“障碍”是什么。根据ctfshow web41的常见设定以及相关搜索词如php 正则、ctfshow命令执行的提示我们可以合理推断题目环境对用户的输入进行了严格的过滤。2.1 常见过滤机制与我们的应对策略通常这类题目的PHP代码核心逻辑可能如下所示?php if(isset($_GET[c])){ $c $_GET[c]; // 过滤1检查是否包含括号( if(!preg_match(/[a-zA-Z0-9\(\)]/, $c)){ eval($c); } } else { highlight_file(__FILE__); } ?当然实际的过滤规则可能更复杂。但从解题经验和相关热词php 正则 变量金额 大于0虽然不直接相关但反映了对正则过滤的关注来看常见的过滤点包括禁止括号()这是最致命的一招。没有括号你就无法调用函数因为函数调用function_name(argument)的语法依赖括号。这直接封杀了system(‘ls’)、eval($_POST[‘a’])等直接调用。黑名单关键字过滤过滤system、exec、shell_exec、passthru、eval、assert等明显的危险函数名。限制参数传递禁止或转义单双引号、$、$_GET/$_POST等超全局变量让你无法直接传入外部数据作为参数。无字母数字限制有时甚至会过滤所有字母和数字将利用限制在极小的字符集内这道题41关通常还没到这么极端。面对这些限制我们的核心思路必须转变从“直接调用函数执行命令”转变为“利用现有环境和不带括号的语法结构间接达成代码执行或文件读取的目的”。2.2 无参数RCE的突破口PHP的语言特性PHP提供了许多独特的语言特性和内置函数它们成为了我们在“紧身衣”里跳舞的工具字符串获取函数getallheaders()、get_defined_vars()、session_id()等可以从HTTP请求或服务器环境中获取数据且通常调用时不需要括号或括号内可为空。数组操作与当前作用域current()、end()、next()、prev()、reset()等数组指针函数可以操作$_GET、$_POST等数组。配合localeconv()、phpversion()等返回数组的函数可以构造出我们需要的字符。文件系统遍历scandir()、readdir()、opendir()等用于列出目录。highlight_file()、show_source()、readfile()、file_get_contents()用于读取文件内容但它们通常需要参数。“无参”函数调用技巧利用??php标签闭合与开启或者通过include/require包含由我们控制输入的文件如$_FILES但这道题可能不提供文件上传点。对于web41根据其难度定位过滤很可能集中在禁止括号和黑名单函数上。因此我们的主攻方向是找到那些可以不使用括号或使用()空括号调用的函数并且能让它们返回我们所需的数据如目录列表、文件内容。3. 关键利用链构造从信息收集到获取Flag假设我们面对的就是一个过滤了括号和危险函数名的环境。我们的攻击链可以分三步走信息收集、定位目标、读取内容。3.1 第一步探测环境与获取目录列表既然不能直接用system(‘ls’)我们就需要用PHP内置的函数来实现类似功能。这里scandir()函数是一个绝佳的选择它返回指定目录的文件和目录的数组。但问题来了scandir(‘.’)需要括号和参数。技巧1利用scandir()的无参数调用与.的构造在PHP中scandir()可以不带参数调用默认扫描当前目录.。但我们的过滤可能禁止了括号。然而有一个关键特性如果函数定义时参数有默认值调用时可以省略该参数。但scandir()的参数并非可选。不过我们可以利用另一个特性scandir(current(localeconv()))。这看起来复杂我们拆解一下localeconv()返回包含本地化数字和货币格式信息的数组。它不需要参数或者使用空括号()。这个数组的第一项current位置通常是一个点.。current()返回数组中的当前元素第一个元素。current(localeconv())就得到了字符串.。scandir(‘.’)这样就构造出了参数。但这里依然有括号。如果括号被完全禁止这条路就走不通。这时我们需要更“纯粹”的无参函数。技巧2使用getcwd()与print_r(scandir(getcwd()))的变体getcwd()获取当前工作目录也不需要参数。但同样面临括号问题。如果环境是eval($_GET[‘c’])且只过滤了括号本身但我们可以通过拼接来绕过对完整函数名的过滤吗例如$a’scandir’;$a(‘.’);但这需要分号和变量赋值也可能被过滤。实际上对于web41更经典的解法是直接利用一个返回数组且无需括号的函数然后去读取这个数组。但读取数组需要索引如$_GET[1]这又涉及括号[ ]和数字。如果[ ]也被过滤了呢让我们换个思路如果scandir(‘.’)不可行我们能不能直接读取已知文件但题目要求通常是找到/flag或flag.php位置未知。所以目录列表是必须的。经过对多种绕过技术的排查在web41的常见解法中session_id()配合session_start()是一条可行的路径但需要session支持。而更通用、更常见的解法是使用DirectoryIterator类或FilesystemIterator类。它们可以用foreach进行遍历而foreach是语言结构不是函数不依赖括号调用函数。例如构造Payloadcforeach(new DirectoryIterator(‘.’)){echo $it-getFilename().” “;}。这里new DirectoryIterator(‘.’)是一个对象实例化括号是类实例化语法的一部分可能未被过滤或者我们可以用chr(46)来构造点号。但这仍然不够“无参”。3.2 第二步真正的无参数RCE核心技巧经过对题目常见解法的回溯web41最核心的考点是一种称为“无参数函数RCE”的技巧其核心是利用函数返回值作为另一个函数参数的特性且全程不使用括号来传递我们手写的参数。核心Payload模型eval(end(getallheaders()));我们来拆解这个经典的利用链getallheaders()这是一个非常关键的PHP函数。它返回所有HTTP请求头的关联数组并且调用时不需要任何参数。这意味着我们可以完全控制这个数组的一部分内容——通过发送HTTP请求时自定义请求头。end()将数组的内部指针移动到最后一个元素并返回其值。我们通过添加一个自定义HTTP头例如X-Forwarded-For: phpinfo();并确保它是最后一个头end(getallheaders())就能返回我们注入的代码字符串。eval()执行该字符串。那么在题目中我们的Payload可能就是?ceval(end(getallheaders()));然后在发送这个HTTP请求时我们额外添加一个请求头比如X-Forwarded-For: system(‘ls’);但是注意这里出现了system(‘ls’)如果题目过滤了system等函数名这个Payload会失效。所以我们需要替换为其他未被过滤的函数比如用反引号执行命令ls或者用shell_exec(‘ls’)如果它不在黑名单。更稳妥的方式是进行文件读取因为目标很明确是获取flag。因此改进的利用链是首先我们需要知道文件名。所以先列目录。我们可以构造一个能执行代码的请求把目录列表打印出来。但eval(end(getallheaders()))本身需要eval如果eval被关键字过滤了呢我们可以尝试用assert但assert也可能被过滤。这时我们可以利用include或require。但include需要一个文件名参数。这就引出了另一个技巧利用php://伪协议配合输入流php://input。php://input可以读取POST请求的原始数据。我们可以这样构造?cinclude(‘php://input’);然后在POST Body里写?php system(‘ls’);?但这里include有括号和参数。如果括号被禁此路不通。3.3 第三步针对web41的实战Payload构造与测试根据多方解题记录和ctfshow系列题目的连贯性web41的典型环境是代码执行点存在但过滤了所有括号()和分号;或者分号允许但括号不允许。同时可能还过滤了常见的命令执行函数名。在这种情况下上面提到的eval(end(getallheaders()))因为包含括号而无法使用。我们需要找到完全不使用括号的代码执行方式。突破口echo ‘something’;这种语句不需要括号。但如何动态执行代码在PHP中有一个鲜为人知但极其强大的特性反引号操作符。它用于执行Shell命令并返回输出。它的使用就像echols;。注意这里的反引号是Shell命令执行它本身是语言操作符不是函数调用所以不依赖括号那么一个可能的Payload是?cechols;但这里有个问题URL中反引号可能需要URL编码%60并且ls命令本身在Web服务器环境下可能没有权限或者被禁用。更常见的是用cat或tac、more、less等命令读取文件。但题目要求通常是在网站根目录或当前目录下找flag文件。我们先列出文件?cechols;如果成功页面会显示当前目录的文件列表。假设我们看到一个文件叫flag.php那么直接读取?cechocat flag.php;但是这里有一个巨大的“坑”命令执行后的输出会被直接嵌入到PHP页面中。如果flag.php内容包含?php ... ?标签它们会被当作PHP代码解析而不会显示源码。我们需要查看源码所以应该用读取纯文本文件的方式或者使用能显示源码的命令。更好的命令是tac反向cat可以避免某些文件开头特殊字符问题或者cat后通过管道传递给base64编码?cechocat flag.php | base64;这样输出是一段base64编码我们解码后就能得到文件内容包括?php标签。实操步骤使用Burp Suite或浏览器开发者工具的网络选项卡发送GET请求http://目标地址/?cechols;注意反引号在URL中需编码为%60所以实际请求是?cecho %60ls%60;查看响应假设看到文件列表包含flag.php。发送请求读取文件?cecho %60cat flag.php%60;如果返回空白或异常尝试tac或base64编码?cecho %60cat flag.php | base64%60;将返回的base64字符串解码即可得到flag。重要注意事项这种方法成功的前提是服务器操作系统是Linux/Unix并且cat、ls、base64等命令可用且Web进程有执行这些命令的权限。在CTF环境中这通常是满足的。但在实际渗透测试中需要具体情况具体分析。4. 深入原理为什么反引号能绕过过滤理解原理能帮助我们举一反三。反引号操作符在PHP中相当于调用了shell_exec()函数但它是以语言操作符的形式存在的。这意味着它在词法分析阶段就被识别为执行操作符而不是一个函数名。因此基于函数名黑名单的过滤如preg_match(‘/system|exec|shell_exec/’, $c)对它无效。因为它不是函数调用所以不违反“禁止括号”的规则。它的语法是 command完全不需要括号。然而这种方法的局限性也很明显依赖系统Shell只能在类Unix系统上使用Windows系统上不适用尽管CTF题大多用Linux。可能被禁用PHP配置中可以通过disable_functions禁用shell_exec()这同样会禁用反引号操作符。但CTF题目为了考察这个知识点通常不会禁用。特殊字符转义如果命令中包含空格、管道|、重定向等Shell元字符需要确保它们能正确传递。在URL中空格可以用%20或加号表示但最好整个命令用反引号包起来即可。5. 其他可能的绕过路径与备用方案虽然反引号是解决web41的一种直接方法但作为学习者我们应该探索更多可能性以应对题目变种或实际环境中更复杂的情况。5.1 利用$_SERVER超全局变量$_SERVER是一个包含服务器和执行环境信息的数组。其中$_SERVER[‘QUERY_STRING’]包含了URL中?之后的所有查询字符串。如果我们能控制它并让eval执行它的一部分呢 例如Payload?ceval($_SERVER[‘QUERY_STRING’]);但这里包含了$_SERVER、括号和引号很可能被过滤。但我们可以尝试利用$_SERVER[‘HTTP_REFERER’]或$_SERVER[‘HTTP_USER_AGENT’]这些是客户端发送的HTTP头我们可以控制。例如?ceval($_SERVER[‘HTTP_REFERER’]);然后在请求中设置Referer头为http://example.com/?phpinfo();但这同样需要括号和引号。5.2 利用文件包含与伪协议如果代码执行点是include($_GET[‘c’])而不是eval那么我们可以利用PHP伪协议。?cphp://filter/convert.base64-encode/resourceflag.php将flag.php的内容base64编码后输出避免被当作PHP执行。?cdata://text/plain,?php system(‘ls’);?使用data协议直接包含代码。但这需要allow_url_include设置为On且可能被过滤。对于web41如果是eval这些协议可能不适用因为eval期望的是字符串代码而不是文件名。5.3 利用PHP的“可变函数”与字符串拼接如果过滤了反引号但允许美元符号$和花括号{}呢我们可以尝试动态构造函数名。 例如?c$_’sys’.’tem’;$_($_GET[‘a’]);als这里$_GET[‘a’]可能被过滤。我们可以尝试用其他方式获取参数比如用${‘_GET’}[‘a’]这种可变变量形式但同样复杂。6. 常见问题排查与实战心得在实际操作中你可能会遇到各种问题。这里我总结几个常见坑点和排查技巧Payload发送后无回显或报错检查命令语法确保反引号是成对的命令本身正确。在Linux中可以先用本地终端测试命令。检查URL编码特殊字符如反引号%60、空格%20、管道%7c必须正确编码。使用Burp Suite的Repeater模块可以方便地修改和发送请求避免浏览器自动编码带来的问题。查看服务器响应用Burp或浏览器开发者工具查看原始HTTP响应而不是只看页面渲染。可能命令执行的结果在HTML注释里或者被错误信息掩盖了。尝试简单的命令先试试echopwd;打印当前目录或echoid;查看当前用户确认命令执行是否生效。命令执行了但找不到flag文件扩大搜索范围ls -la查看所有文件包括隐藏文件。find / -name ‘*flag*‘ 2/dev/null在全盘搜索包含flag的文件名2/dev/null是为了屏蔽权限错误产生的噪音。检查常见位置/flag、/flag.txt、/home/ctf/flag、/var/www/html/flag.php、./flag。读取环境变量有时flag在环境变量里可以试试echoenv;。反引号被过滤了怎么办如果反引号也被加入黑名单那么情况就更复杂了。可能需要回归到最原始的无参数RCE研究利用localeconv()、current()、array_reverse()、scandir()等函数进行复杂的嵌套最终用readfile(end(scandir(current(localeconv()))));这样的形式来读取当前目录最后一个文件假设flag是最后一个文件。这需要极强的耐心和对PHP数组函数的深刻理解。我得到的是一串代码不是flag 如果flag在PHP文件里直接cat可能会因为?php ... ?标签被解析而看不到内容。一定要用cat flag.php | base64或者php -r ‘highlight_file(“flag.php”);’如果PHP命令行可用的方式。更简单的是在浏览器中直接查看网页源代码CtrlU有时flag就以注释形式!-- flag{xxx} --藏在页面里。个人实操心得工具至上手工构造Payload容易出错尤其涉及复杂编码时。Burp Suite是Web安全测试的“瑞士军刀”它的Repeater、Decoder、Intruder模块能极大提升效率。把Payload放在Repeater里方便修改和重放。思维要活不要被题目描述或自己的第一印象困住。如果一种方法不行立刻回溯过滤规则思考哪些字符可用哪些不可用然后组合可用的“积木”去构建Payload。信息收集是第一步在尝试RCE之前先用phpinfo();如果允许看看环境用echopwd;和echols -la;看看目录结构。信息越多后续利用越有针对性。注意细节Linux命令区分大小写Flag和flag是不同的文件。文件名后面可能有换行符使用cat flag比cat flag.php更稳妥因为Tab键补全可能会出错。这道ctfshow web41就像一把钥匙它打开的不是一道简单的门而是通往PHP代码安全世界的一扇窗。通过它你看到的不仅仅是绕过技巧更是对PHP语言本身和服务器安全配置的深度理解。真正的安全攻防就是在这样一层层的限制与突破中不断演进的。