CTFshow Web37-40 PHP代码审计:伪协议与命令执行绕过实战

📅 2026/7/4 7:08:00 👁️ 阅读次数 📝 编程学习
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://inputdata://就成为了更优的选择,因为它们允许我们“携带”自己的代码去执行,从而间接操作目标文件。

2.2 命令执行中的“借壳上市”策略

在web38-web40中,题目意图让我们执行系统命令(如cat flag.php),但通常会对systemcatflag等关键词进行过滤。此时,直接拼接命令字符串的方法(如sy‘.’stem)可能因evalsystem的嵌套关系而失效。这时,一个更高阶的策略是:参数嵌套逃逸

其核心逻辑是:利用代码中已存在的、且能接受动态参数的函数,将过滤检查的责任“转移”到另一个我们可控的参数上。例如,如果代码是eval($_GET[‘c’]),并且对$c的内容进行了严格过滤。我们可以构造$c为一个不触发过滤的“载体”函数,比如include($_GET[‘a’])。此时,对$c的检查通过了,因为include$_GET[‘a’]可能都不在黑名单里。而真正的攻击载荷(如php://filter/...或包含命令执行代码的data URI)则放在另一个参数a中传递。这样,我们就成功绕过了对主参数c的过滤。

2.3 黑名单过滤的常见弱点

出题人常用的preg_match黑名单过滤,有几个天然弱点:

  1. 大小写敏感:默认的preg_match是大小写敏感的,除非使用i修饰符。有时可以通过大小写变种(如SyStEm)绕过。
  2. 字符串拼接:PHP中,字符串可以用.连接,也可以用双引号内插值。‘sys’.‘tem’最终会被解释为system,但正则表达式可能只匹配完整的system
  3. 利用超全局变量$_GET$_POST$_REQUEST等是数组,可以通过$_GET[‘a’]的方式引用。当过滤逻辑只检查了初始参数,而没有递归检查这些参数中的内容时,就可能产生嵌套逃逸。
  4. 命令执行替代函数:除了system,还有passthru()exec()shell_exec()、反引号“`”等。当其中一个被禁,可以尝试另一个。
  5. 空格绕过:在系统命令中,空格是参数分隔符。过滤空格时,可以用${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__); }

代码分析

  1. 获取参数c
  2. 检查c中是否包含flag(不区分大小写)。如果没有,则执行include($c)
  3. 最后输出一个变量$flag(注意,这个$flag变量很可能是在被包含的文件中定义的,或者是个烟雾弹)。

目标:显然,我们需要通过include($c)来包含某个文件,从而让$flag变量被定义或有输出。但直接包含flag.php会被过滤。

绕过策略:既然不能直接包含带flag的文件路径,我们就用伪协议来“包含”一段能帮我们获取flag的代码。

方法一:使用php://input这是最直接的方法。

  1. 将请求方法改为POST
  2. 在GET参数中传递:?c=php://input
  3. 在POST Body中写入我们要执行的PHP代码:<?php system('cat flag.php');?>
  4. 原理: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__); }

过滤分析:这次黑名单增加了phpfile。这意味着:

  1. php://inputphp://filter因为包含php而被禁止。
  2. 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_includeallow_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参数会被当作一个不带后缀的文件名来处理。

影响分析

  1. 我们不能再直接使用php://inputdata://text/plain,…这样的完整协议字符串了,因为后面会被加上.php,变成php://input.php,这显然不是一个合法的流包装器,会导致包含失败。
  2. 我们需要让$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
  • 一大堆特殊符号:~@ # % ^ & * ( ) - + = { [ ] } : ‘ “ , < . > / ? \
  • 注意,它没有过滤美元符号$、方括号[](虽然[]被过滤了,但这里列表里是\{\},指的是花括号?这里需要仔细看。原题正则中|\{|\[|\]|\}|,实际上过滤了{[]}。所以方括号和花括号都被过滤了。但$和字母、下划线_没有被过滤。

这意味着什么?

  1. 我们不能直接写任何数字(包括在字符串中)。
  2. 我们不能使用常见的字符串定义方式(单引号、双引号被禁)。
  3. 我们不能使用常见的代码结构符号,如括号()、点号.(用于字符串连接或属性访问)、比较运算符等。
  4. 我们不能使用includesystem等函数调用,因为调用函数需要括号()
  5. 我们甚至不能使用数组访问的方括号[](被过滤了)。

突破口分析:在如此严格的过滤下,我们几乎无法直接构造出任何有意义的代码字符串。但是,请注意两个关键点:

  1. eval($c)仍然存在。
  2. 过滤列表中没有$_(下划线)。这意味着变量名本身是允许的。
  3. 虽然[]被过滤,但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())))));

原理拆解

  1. localeconv():返回包含本地数字和货币格式信息的数组。该数组的第一个元素([0])是小数点字符.(在大多数环境下)。
  2. pos():是current()的别名,获取数组的当前元素(第一个元素)。所以pos(localeconv())得到.
  3. scandir(‘.’):扫描当前目录,返回文件列表数组(如[‘.’, ‘..’, ‘flag.php’, ‘index.php’])。
  4. array_reverse():将数组反转,得到[‘index.php’, ‘flag.php’, ‘..’, ‘.’]
  5. next():将数组内部指针向前移动一位,并返回该元素的值。反转后第一个是’index.php’next()后得到第二个元素’flag.php’
  6. 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())))));

或者使用readfilefile_get_contents等,但需要构造文件名参数,不如上面的直接。

核心技巧总结:当特殊字符被严格过滤时,我们的武器库包括:

  1. 利用返回特定字符的函数:如localeconv()返回.chr()函数可以构造字符但需要数字参数(数字被过滤则不可用),phpversion()返回的字符串中可能包含数字等。
  2. 利用目录遍历函数scandir()列出文件,current()next()end()prev()等操作数组指针获取特定文件名。
  3. 利用文件读取/显示函数show_source()highlight_file()readfile()file_get_contents()(后者需要echo输出)。
  4. 避免使用引号和点号:通过函数嵌套直接传递参数,而不是拼接字符串。

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编码)过滤phpflagresource等关键词时不可用
data://?c=data://text/plain,<?php code ?>执行任意PHP代码过滤dataphp标签时可能不可用;可利用//注释后缀
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<<>%09cat${IFS}flag.php
关键字单引号、双引号、反斜线分割c‘a’tc”a”tc\at
关键字通配符?*cat fl?g.phpcat fl*g.php
关键字变量拼接a=c;b=at; $a$b flag.php
命令使用替代命令cat->tacmorelessheadtailnlsortuniqrev
读取文件不使用cat,用其他语言php -r ‘echo file_get_contents(“flag.php”);’
禁止数字字母利用.(当前目录)、..(上级目录)scandir(‘.’)
禁止数字字母利用内置函数返回特定字符localeconv()[0]->.

7.3 常见问题与排查实录

在实战中,你可能会遇到以下问题:

  1. php://input返回空白或错误

    • 原因allow_url_include未开启;请求方法不是POST;POST数据格式不正确。
    • 排查:使用phpinfo()确认allow_url_include状态;确保使用POST请求,并在Body中发送有效的PHP代码(如<?php system(‘ls’);?>)。
  2. data://协议执行失败

    • 原因allow_url_include未开启;代码中包含特殊字符未URL编码;服务器配置限制。
    • 排查:尝试对data://之后的所有逗号、尖括号等进行URL编码。例如,<编码为%3C>编码为%3E,空格编码为%20
  3. 使用函数嵌套时提示未定义函数

    • 原因:某些函数可能在目标环境中被禁用(通过disable_functions配置)。
    • 排查:先用phpinfo()print_r(get_defined_functions())查看可用函数列表。优先使用最常见且通常不禁用的函数,如scandircurrentnextfile_get_contentsreadfile
  4. Payload构造后语法错误

    • 原因:字符串拼接或闭合不正确。
    • 排查:在本地PHP环境中模拟执行你的payload,查看错误信息。特别注意引号的匹配、分号的添加、注释符的使用。对于include($c.”.php”)这种,善用//?>来闭合多余部分。
  5. 过滤规则判断失误

    • 原因:正则表达式理解有误,或者环境实际过滤与描述不符(如web40的括号)。
    • 排查:提交简单的测试payload,如?c=echo ‘test’;,观察返回。逐步增加特殊字符,确定具体的过滤边界。有时,过滤是“黑名单”模式,可能存在遗漏。

7.4 从CTF到实战的思考

CTF题目是理想化的漏洞模型,而真实世界更复杂。但原理相通:

  • 代码审计:永远是发现漏洞的第一步。像审计web37-web40一样,寻找代码中不安全的函数(eval,include,system等)和未经验证的用户输入。
  • 防御思维:作为开发者,应使用白名单而非黑名单进行过滤。对文件包含,应固定后缀或限定可包含的目录范围。对命令执行,应尽量避免直接拼接用户输入,或使用严格的转义函数(如escapeshellarg())。
  • 绕过是永久的博弈:没有绝对的安全。黑名单总有可能被绕过。因此,安全的核心在于最小权限原则输入验证与输出编码