1. 题目初探与环境搭建
拿到这道“[BJDCTF2020]ZJCTF,不过如此1”,一看就是典型的CTF Web题目。这类题目通常不会上来就给你一个复杂的漏洞链,而是考验你对PHP代码审计、函数特性以及一些基础Web漏洞的灵活运用。题目名字叫“不过如此”,往往意味着出题人设置了一些看似简单、实则容易忽略的陷阱,或者需要你跳出常规思路去解题。
首先,我们需要一个解题环境。BUUCTF是一个在线的CTF靶场平台,我们直接访问题目给出的链接即可。但为了后续的代码审计和本地测试,我习惯在本地用Docker快速搭建一个PHP环境。这里我用的是php:7.4-apache的镜像,把题目源码(如果有的话)或者我们审计出的关键代码片段放进去测试,这样调试起来更方便,尤其是涉及到需要发送特定HTTP请求或者查看服务器响应细节的时候。
注意:在CTF解题中,尤其是Web题,浏览器开发者工具(F12)是你的核心武器。Network标签页用来查看每一次请求和响应的原始内容,包括Headers、Post Data,这对于分析服务端逻辑至关重要。Console标签页有时也会输出一些有用的错误信息。
2. 代码审计与核心逻辑分析
访问题目链接,我们通常会看到一个简单的网页。对于这类题目,第一步永远是查看源代码。在源代码中,我们发现了本题的核心——一段PHP代码。这是CTF Web题的经典套路,把漏洞点直接放在前端代码里让你审计。
让我们来仔细分析这段代码:
<?php error_reporting(0); $text = $_GET["text"]; $file = $_GET["file"]; if(isset($text)&&(file_get_contents($text,'r')==="I have a dream")){ echo "<br><h1>".file_get_contents($text,'r')."</h1></br>"; if(preg_match("/flag/",$file)){ die("Not now!"); } include($file); //next.php } else{ highlight_file(__FILE__); } ?>代码逻辑非常清晰,我们逐行拆解:
error_reporting(0);:关闭所有错误报告,这会让我们的调试变得困难,因为即使代码有错误(比如包含文件失败),页面上也不会显示任何提示。- 它通过
$_GET超全局数组获取两个参数:text和file。 - 第一个条件判断:
if(isset($text)&&(file_get_contents($text,'r')==="I have a dream"))isset($text)检查text参数是否存在。file_get_contents($text, 'r')会尝试读取text参数所指向的“文件”内容,并判断其是否严格等于(===)字符串"I have a dream"。- 如果条件满足,则首先用
echo输出这个内容(第5行),然后进入第二个if判断。
- 第二个条件判断:
if(preg_match("/flag/",$file))。它检查file参数中是否包含子字符串"flag"。如果包含,则直接调用die("Not now!")结束脚本,这是我们获取flag的第一个障碍。 - 如果
file参数中不包含"flag",则执行include($file);,并且后面有个注释//next.php,这强烈暗示了我们最终需要包含的文件是next.php。 - 如果第一个条件不满足,则执行
highlight_file(__FILE__);,也就是高亮显示当前文件(即这段代码本身)的源代码。这就是我们一开始能看到这段代码的原因。
解题思路瞬间清晰了:我们需要同时控制text和file两个GET参数。
- 对于
text,要让file_get_contents($text)读取的内容等于"I have a dream"。 - 对于
file,要让它最终能包含next.php文件,但又不能直接包含flag(因为会被正则拦截)。
这里就引出了两个关键的技术考点:如何让file_get_contents读取我们指定的内容,以及如何绕过preg_match对flag关键词的过滤。
3. 关键技术点突破:PHP伪协议与死亡代码
3.1 利用php://input满足第一个条件
第一个难点是file_get_contents($text, 'r')==="I have a dream"。file_get_contents()函数不仅可以读取本地文件,还可以读取很多“流”(Stream),这得益于PHP丰富的伪协议(Wrapper)。
我们常规的思路是传一个文件路径,比如text=./1.txt,然后让1.txt的内容是I have a dream。但在CTF题目中,我们通常没有上传文件的权限。这时,php://input伪协议就派上用场了。
php://input是一个只读流,允许你读取原始POST数据。这意味着,当我们将text参数设置为php://input时,file_get_contents(“php://input”)读取的将是我们在HTTP请求体(Body)中以POST形式发送的数据。
所以,突破第一个条件的Payload构造如下:
GET: ?text=php://input POST Body: I have a dream这样,服务器端的file_get_contents($_GET[‘text’])实际上读取的就是我们POST过去的I have a dream,从而完美匹配条件。
实操心得:在使用
php://input时,务必确保你的请求是POST方法,并且Content-Type最好不是application/x-www-form-urlencoded(这种格式会对参数进行URL编码处理)。直接使用原始文本(Raw)或者text/plain即可。在Burp Suite中,我们通常在Repeater模块,将请求方法改为POST,然后在最下方的请求体部分直接写入字符串。
3.2 利用php://filter绕过死亡代码读取next.php
第二个难点是包含next.php。代码写明了include($file);,我们的目标是$file = “next.php”。但是,如果我们直接传递?file=next.php,虽然能包含,但我们看不到内容。因为include会将目标文件当作PHP代码来执行,如果next.php里只有一段PHP代码(比如定义了变量或函数),或者直接输出了flag,我们可能在页面上看不到任何回显。
更高级的做法是利用php://filter伪协议。php://filter是一种元封装器,设计用于数据流打开时的筛选过滤应用。在文件包含漏洞中,它有一个经典用法:读取文件源码。
我们可以这样构造:file=php://filter/read=convert.base64-encode/resource=next.php
php://filter/:使用过滤器伪协议。read=convert.base64-encode:对资源进行读取过滤,这里使用的是base64-encode过滤器,它会将文件内容进行base64编码。/resource=next.php:指定要读取的资源是next.php。
为什么这样做?因为include函数会尝试将file参数指向的内容作为PHP代码执行。当我们传入一个php://filter流,并且对其应用了base64-encode过滤器后,include实际上接收到的是next.php文件经过base64编码后的字符串。PHP引擎会尝试去执行这段base64编码的字符串,而这通常不是有效的PHP代码,因此会导致执行失败。但是,关键点在于,当include执行失败时,如果服务器没有严格配置(本题error_reporting(0)只是不显示错误,但警告和通知可能仍会导致包含失败并可能将内容输出),或者我们利用其他技巧,有时编码后的内容会以警告、错误信息的形式被打印出来。然而,更通用、更可靠的方法是,我们并不指望include执行它,而是希望file_get_contents或者highlight_file这类函数来处理它。不过在这道题里,我们走通了第一个条件后,代码逻辑是include($file)。
这里有一个精妙的点:我们传file=php://filter/read=convert.base64-encode/resource=next.php,代码会尝试包含这个经过base64编码的“文件流”。PHP在包含时,会先“读取”这个流的内容。因为内容是base64编码的,不是有效的PHP标签开头(如<?php),所以PHP很可能不会去执行它,而是可能因为包含失败,但在某些环境下,这个被读取的base64字符串会“泄露”出来。但更常见的做法是,我们通过这种方式先看到next.php的源代码,然后分析其源码,找到下一步的漏洞点。
所以,我们现在的思路是:先利用php://input通过第一个检查,然后利用php://filter去读取next.php的源码。
但是,这里遇到了代码中的“死亡关卡”:if(preg_match(“/flag/”,$file)){ die(“Not now!”); }。我们的Payloadphp://filter/read=convert.base64-encode/resource=next.php里面没有flag,所以可以通过检查。
让我们构造完整的Payload请求:
请求方法: POST URL: http://靶场地址/?text=php://input&file=php://filter/read=convert.base64-encode/resource=next.php 请求头: Content-Type: application/x-www-form-urlencoded (或直接不用管,用Raw) 请求体: I have a dream发送这个请求后,我们观察响应。如果成功,响应体中除了开头的<br><h1>I have a dream</h1></br>外,很可能会包含一串base64编码的字符串。我们将其复制出来,进行base64解码。
4. 解码next.php与二次审计
假设我们从服务器响应中获取到了如下base64字符串(此处为模拟):PD9waHAKJ...(很长一串)...==
使用在线工具或命令行echo “PD9waHAKJ...==” | base64 -d进行解码。解码后,我们得到了next.php的源代码:
<?php $id = $_GET['id']; $_SESSION['id'] = $id; function complex($re, $str) { return preg_replace( '/(' . $re . ')/ei', 'strtolower("\\1")', $str ); } foreach($_GET as $re => $str) { echo complex($re, $str). "\n"; } function getFlag(){ @eval($_GET['cmd']); }第二段代码审计开始:
- 代码获取GET参数
id,并存入$_SESSION[‘id’],但后续没用到,可能是个干扰项。 - 定义了一个名为
complex的函数,这是核心漏洞函数。它接受两个参数$re和$str,然后调用preg_replace函数。preg_replace用于执行正则表达式的搜索和替换。- 模式(pattern)是
‘/(‘ . $re . ‘)/ei’。注意这个/e修饰符!这是PHP中一个极其危险的修饰符,在PHP 5.x时代存在,它会让preg_replace将替换字符串(第二个参数)当作PHP代码来执行。本题环境显然是PHP 5.x(因为后续解题需要这个特性)。在PHP 7.0以后,/e修饰符已被移除。 - 替换字符串是
‘strtolower(“\\1”)’。这里的\\1是正则匹配中的反向引用,代表第一个捕获组(即$re匹配到的内容)。它被包裹在strtolower()函数里。
- 接下来是一个
foreach循环:foreach($_GET as $re => $str)。它遍历$_GET数组,将每个元素的键(key)作为$re(正则表达式),将元素的值(value)作为$str(被处理的字符串),然后调用complex($re, $str)并输出结果。 - 最后定义了一个
getFlag()函数,里面是危险的@eval($_GET[‘cmd’])。这明显是我们的目标,但函数没有被调用。我们需要找到方法调用它或者利用上面的漏洞来执行任意代码。
突破口分析:漏洞核心在于preg_replace的/e模式。当模式中使用/e时,preg_replace在完成替换后,会将替换后的结果字符串作为PHP代码来执行。
在我们的complex函数中,替换字符串是固定的‘strtolower(“\\1”)’。执行流程是:
- 用
$re去匹配$str。 - 如果匹配到,就用
strtolower(“\\1”)去替换匹配到的部分。注意,这里的\\1在双引号字符串中会被解释为\1(即反向引用)。 - 由于有
/e修饰符,替换后的字符串strtolower(“匹配到的内容”)会被当作PHP代码执行。
那么,我们如何利用这个固定的strtolower(“\\1”)来执行任意代码呢?这需要用到PHP可变函数和字符串解析的特性。
5. preg_replace /e修饰符的代码执行利用
我们的目标是让最终被执行的代码是getFlag(),从而触发eval($_GET[‘cmd’]),这样我们就可以通过cmd参数执行任意命令来读取flag了。
观察strtolower(“\\1”),它执行时相当于eval(‘strtolower(“匹配到的内容”)’)。如果我们能让“匹配到的内容”闭合双引号,并拼接上我们想要的函数调用,不就行了吗?
但这里有个问题,匹配到的内容是$re正则表达式从$str中捕获的。我们控制$re(GET参数的键)和$str(GET参数的值)。我们需要构造一个$re和$str,使得:
$re能在$str中匹配到内容。- 匹配到的内容,在被放入
strtolower(“匹配内容”)并执行时,能变成我们想要的代码。
一个经典的Payload是利用PHP花括号{}的特性。在PHP中,${php代码}这种结构中的代码会被执行。例如,${phpinfo()}会执行phpinfo()函数。
我们可以这样构造:
$str的值设置为:{${getFlag()}}$re的键(即正则表达式)设置为:.*(匹配任意字符,确保能捕获到{${getFlag()}})
那么,执行过程如下:
preg_replace(‘/(.*)/ei’, ‘strtolower(“\\1”)’, ‘{${getFlag()}}’)- 正则
(.*)匹配了整个字符串{${getFlag()}},所以\\1就是{${getFlag()}}。 - 替换字符串变为
strtolower(“{${getFlag()}}”)。 - 由于
/e修饰符,这串字符被当作代码执行:eval(‘strtolower(“{${getFlag()}}”)’)。 - PHP执行
strtolower(“{${getFlag()}}”)。在将字符串“{${getFlag()}}”传给strtolower之前,PHP会先对字符串中的变量进行解析。双引号中的${getFlag()}会被执行,即调用getFlag()函数。 getFlag()函数被调用,执行了eval($_GET[‘cmd’])。
至此,我们成功通过/e修饰符和双引号字符串解析,间接调用了getFlag()函数。
6. 最终Payload构造与Flag获取
现在,我们知道了如何利用next.php中的漏洞。我们需要向next.php发送一个GET请求,并且利用foreach($_GET as $re => $str)这个循环。
我们的Payload需要作为一个GET参数传递,其中键名是正则表达式$re,键值是待处理的字符串$str。
根据上面的分析,我们构造如下:
请求URL: http://靶场地址/next.php?.*={${getFlag()}}或者,更明确地,使用一个不会和其他参数冲突的键名:
请求URL: http://靶场地址/next.php?.*={${getFlag()}}&cmd=system(‘cat /flag’);这里有两个细节:
.*作为参数名(键),其值{${getFlag()}}作为$str。- 我们额外传递了一个参数
cmd,因为getFlag()函数执行的是eval($_GET[‘cmd’])。所以我们需要通过cmd参数传递要执行的系统命令,比如system(‘cat /flag’);来读取flag文件。
但是,这里有一个巨坑:PHP会将.点号作为参数名中的特殊字符处理。在查询字符串?.*=...中,点号会被转换成下划线_。所以服务器端收到的$_GET数组的键名可能是_*,而不是我们预期的.*。这会导致正则表达式不合法,匹配失败。
为了解决这个问题,我们需要对参数名进行URL编码,或者使用其他方法。一个更可靠的方法是,我们利用foreach遍历所有GET参数的特性,直接用一个合法的正则表达式作为键名。例如,我们可以用/(.*)/e本身作为键名的一部分?不,这不行,因为键名就是正则模式$re,我们不能在键名里包含/e,因为/e是修饰符,是写在模式外面的。
实际上,最简单的绕过方法是:我们不需要让键名是一个复杂的正则,只要它能匹配到值中的特定部分即可。我们可以让$re匹配一个我们设定的特定标记。
例如,我们构造:
请求URL: http://靶场地址/next.php?\S*={${getFlag()}}&cmd=system(‘cat /flag’);\S*匹配任意非空白字符。我们的值{${getFlag()}}里没有空白字符,所以能匹配上。
或者,更稳妥地,我们让$re匹配值开头的一个固定字符:
请求URL: http://靶场地址/next.php?^a={${getFlag()}}&cmd=system(‘cat /flag’);^a作为正则,意思是匹配以字母a开头的字符串。但我们的值以{开头,匹配不上。所以不行。
我们需要一个能匹配{${getFlag()}}这个字符串的正则。这个字符串以{开头。所以我们可以用^{。但{在正则中有特殊含义(表示量词开始),需要转义。所以^\{匹配以左花括号开头的字符串。但参数名中的{和}也可能被特殊处理。最省事的方法是使用.或.*,但点号会被转换。
经过测试和查阅经验,一个经典的、可用的Payload是:我们不对参数名(键)做复杂编码,而是利用PHP的一个特性:当参数名中包含[时,它会被转换为数组。但这里我们不需要数组。另一个方法是,我们直接在Burp Suite的Repeater中修改原始请求,将参数名写成.*,但确保HTTP请求的原始查询字符串就是?.*=...。这需要直接编辑Raw请求。
在Burp Suite中,我们可以这样操作:
- 在Repeater中,原始请求行可能是
GET /next.php?.*={${getFlag()}}&cmd=system(‘cat+/flag’); HTTP/1.1 - 确保它没有被自动编码。有时Burp会帮你编码,但我们需要点号保持为点号。
如果点号被转换的问题无法解决,我们可以换一种思路:既然键名$re可以是任何正则,我们何不直接让它匹配整个字符串的起始^呢?正则^匹配字符串的开始位置。但它匹配的是一个“位置”,而不是字符,所以反向引用\1会是空字符串。这不行。
最终,经过多次尝试和查阅WP(Writeup,解题报告),对于这道题,一个广泛验证可用的Payload是:不对$re(键名)做特殊字符处理,而是利用PHP的全局变量覆盖特性?不,这里不是。实际上,正确的姿势是:
我们回顾一下,complex函数被这样调用:complex($re, $str)。其中$re来自GET参数的键,$str来自GET参数的值。 函数内部是:preg_replace(‘/(‘ . $re . ‘)/ei’, ‘strtolower(“\\1”)’, $str)
如果我们让$re为空字符串会怎样?模式就变成了‘/()/ei’,这会匹配空字符串。空字符串在$str的任何位置(包括开始、结束、字符之间)都会出现无数次,这可能导致无限循环或错误。但这不是我们想要的。
我们需要$re匹配$str中的{${getFlag()}}。所以$re可以就是{${getFlag()}}本身吗?不行,因为{和}在正则中是特殊字符,需要转义。而且$也是特殊字符(匹配行尾)。
所以,我们需要对{${getFlag()}}进行正则转义,或者使用一个能匹配它的简单正则。最简单的就是.*,匹配任意字符。
解决点号转换的终极方法:使用URL编码。在HTTP请求中,我们可以对参数名进行URL编码。点号.的URL编码是%2E,星号*的编码是%2A。
所以,最终的Payload构造如下(在Burp Suite的Repeater中,使用Raw模式编辑):
POST /?text=php://input&file=next.php HTTP/1.1 Host: node4.buuoj.cn:xxxxx Content-Type: application/x-www-form-urlencoded Content-Length: 15 I have a dream首先这个请求获取next.php的源码(base64编码),解码后我们知道了漏洞点。
然后我们向next.php本身发起攻击请求:
GET /next.php?%2E%2A={${getFlag()}}&cmd=system(‘cat /flag’); HTTP/1.1 Host: node4.buuoj.cn:xxxxx这里,%2E%2A就是.*。服务器端PHP在解析查询字符串时,会将其解码为.*,从而$_GET[‘.*’]的值为{${getFlag()}}。
当foreach循环处理这个参数时:
$re = ‘.*’$str = ‘{${getFlag()}}’- 调用
complex(‘.*’, ‘{${getFlag()}}’) - 执行
preg_replace(‘/(.*)/ei’, ‘strtolower(“\\1”)’, ‘{${getFlag()}}’) - 匹配成功,替换字符串为
strtolower(“{${getFlag()}}”),由于/e修饰符,该字符串被当作代码执行。 - 执行过程中,双引号内的
${getFlag()}被解析执行,调用getFlag()函数。 getFlag()函数执行eval($_GET[‘cmd’]),即eval(“system(‘cat /flag’);”)。- 最终执行系统命令
cat /flag,并将flag输出到网页上。
发送这个请求,你应该就能在响应体中看到最终的flag了,格式通常为flag{xxxx-xxxx-xxxx}或BJD{xxxxxx}。
7. 总结与核心考点复盘
这道题虽然叫“不过如此”,但串联了多个经典的PHP漏洞知识点,是一道非常好的代码审计入门题。我们来梳理一下整个流程和考察点:
第一阶段:文件包含与伪协议(初级)
- 考察对
file_get_contents()函数和include()函数的理解。 - 熟练运用
php://input伪协议来满足内容读取条件。 - 了解
php://filter伪协议在读取文件源码(特别是源码泄露)方面的应用。 - 绕过简单的字符串过滤(
preg_match(“/flag/”))。
- 考察对
第二阶段:preg_replace /e修饰符代码执行(中级)
- 考察对PHP
preg_replace()函数/e修饰符危险性的深刻理解。这是PHP 5.x时代的一个典型高危漏洞,现代PHP版本已移除。 - 考察利用双引号字符串解析执行PHP代码的特性(
${}结构)。 - 考察如何通过精心构造的GET参数(键值对)来触发漏洞函数。这里的关键是理解
foreach($_GET as $re => $str)这行代码将请求参数直接映射到了漏洞函数的两个参数上。
- 考察对PHP
第三阶段:综合构造与绕过技巧(实战)
- 考察实战中HTTP请求的构造能力,包括GET/POST参数的处理、URL编码的使用。
- 考察使用工具(如Burp Suite)手动修改和发送请求的能力。
- 考察对正则表达式基本语法的了解,以及如何构造一个能匹配目标字符串的简单模式。
避坑指南与心得:
- 伪协议的选择:看到
file_get_contents(),要立刻想到php://input和data://协议。看到include()/require(),要立刻想到php://filter读取源码和可能的文件包含漏洞。 - 正则修饰符:在审计代码时,看到
preg_replace一定要检查模式字符串的结尾是否有/e、/i等修饰符。/e是“执行”(Execute)的标志,是命令执行漏洞的明确信号。 - 双引号与变量解析:PHP中双引号字符串会解析其中的变量和
${}结构,单引号则不会。这个特性在代码执行利用中经常用到。 - 参数名中的特殊字符:在Web开发中,
.、[、]等字符在作为GET/POST参数名时,可能会被PHP自动转换(例如.转为_)。在构造Payload时,如果遇到奇怪的问题,可以考虑使用URL编码来传递原始字符。 - 步步为营:CTF解题就像闯关,每一步都要验证。先确保第一个条件
file_get_contents($text)==="I have a dream"能通过,看到回显。再尝试用filter协议读取next.php源码。解码源码后,本地搭建环境分析漏洞,构造Payload,最后在真实靶场上测试。不要试图一步到位构造出最终Payload,拆解步骤能帮你快速定位问题所在。
这道题完美地展示了从简单的参数控制到危险的代码执行漏洞的完整链条,理解它对于掌握PHP Web安全的基础至关重要。