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是语言结构,不是函数,不依赖括号调用函数。
例如,构造Payload:c=foreach(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可能就是:?c=eval(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请求的原始数据。我们可以这样构造:?c=include(‘php://input’);然后在POST Body里写:<?php system(‘ls’);?>但这里include有括号和参数。如果括号被禁,此路不通。
3.3 第三步:针对web41的实战Payload构造与测试
根据多方解题记录和ctfshow系列题目的连贯性,web41的典型环境是:代码执行点存在,但过滤了所有括号()和分号;(或者分号允许,但括号不允许)。同时,可能还过滤了常见的命令执行函数名。
在这种情况下,上面提到的eval(end(getallheaders()))因为包含括号而无法使用。我们需要找到完全不使用括号的代码执行方式。
突破口:echo ‘something’;这种语句不需要括号。但如何动态执行代码?在PHP中,有一个鲜为人知但极其强大的特性:反引号`操作符。它用于执行Shell命令,并返回输出。它的使用就像echols;。注意,这里的反引号是Shell命令执行,它本身是语言操作符,不是函数调用,所以不依赖括号!
那么,一个可能的Payload是:?c=echols;但这里有个问题:URL中反引号可能需要URL编码(%60),并且ls命令本身在Web服务器环境下可能没有权限,或者被禁用。更常见的是用cat或tac、more、less等命令读取文件。
但题目要求通常是在网站根目录或当前目录下找flag文件。我们先列出文件:?c=echols;如果成功,页面会显示当前目录的文件列表。
假设我们看到一个文件叫flag.php,那么直接读取:?c=echocat flag.php;
但是,这里有一个巨大的“坑”:命令执行后的输出,会被直接嵌入到PHP页面中。如果flag.php内容包含<?php ... ?>标签,它们会被当作PHP代码解析,而不会显示源码。我们需要查看源码,所以应该用读取纯文本文件的方式,或者使用能显示源码的命令。
更好的命令是tac(反向cat,可以避免某些文件开头特殊字符问题)或者cat后通过管道传递给base64编码:?c=echocat flag.php | base64;这样,输出是一段base64编码,我们解码后就能得到文件内容,包括<?php标签。
实操步骤:
- 使用Burp Suite或浏览器开发者工具的网络选项卡,发送GET请求:
http://目标地址/?c=echols;- 注意:反引号在URL中需编码为
%60,所以实际请求是:?c=echo %60ls%60;
- 注意:反引号在URL中需编码为
- 查看响应,假设看到文件列表包含
flag.php。 - 发送请求读取文件:
?c=echo %60cat flag.php%60; - 如果返回空白或异常,尝试
tac或base64编码:?c=echo %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:?c=eval($_SERVER[‘QUERY_STRING’]);但这里包含了$_SERVER、括号和引号,很可能被过滤。
但我们可以尝试利用$_SERVER[‘HTTP_REFERER’]或$_SERVER[‘HTTP_USER_AGENT’],这些是客户端发送的HTTP头,我们可以控制。例如:?c=eval($_SERVER[‘HTTP_REFERER’]);然后在请求中设置Referer头为:http://example.com/?phpinfo();但这同样需要括号和引号。
5.2 利用文件包含与伪协议
如果代码执行点是include($_GET[‘c’])而不是eval,那么我们可以利用PHP伪协议。
?c=php://filter/convert.base64-encode/resource=flag.php:将flag.php的内容base64编码后输出,避免被当作PHP执行。?c=data://text/plain,<?php system(‘ls’);?>:使用data协议直接包含代码。但这需要allow_url_include设置为On,且可能被过滤。
对于web41,如果是eval,这些协议可能不适用,因为eval期望的是字符串代码,而不是文件名。
5.3 利用PHP的“可变函数”与字符串拼接
如果过滤了反引号,但允许美元符号$和花括号{}呢?我们可以尝试动态构造函数名。 例如:?c=$_=’sys’.’tem’;$_($_GET[‘a’]);&a=ls这里,$_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命令行可用)的方式。更简单的是在浏览器中直接查看网页源代码(Ctrl+U),有时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语言本身和服务器安全配置的深度理解。真正的安全攻防,就是在这样一层层的限制与突破中不断演进的。