1. 项目概述:为什么CTF中的PHP是必争之地?

如果你玩过CTF,尤其是Web方向,那你肯定对PHP又爱又恨。爱的是,它几乎是Web题目的“钉子户”,从新手村到决赛圈,无处不在;恨的是,它那些“特性”和“历史遗留问题”总能以意想不到的方式让你卡关。这个项目,就是要把这些让人头疼的PHP知识点,从零散的技巧变成一套系统的“作战地图”。它不是一本PHP语法教科书,而是一份聚焦于CTF攻防场景的实战指南,核心目标就一个:让你看到一道PHP题目时,能快速定位审计方向,并形成有效的漏洞利用链。

为什么PHP在CTF里这么重要?简单说,历史包袱重、灵活到“诡异”、以及庞大的存量市场。很多校内赛、初赛的题目为了快速搭建,依然会选择经典的PHP环境。从简单的代码执行、文件包含,到复杂的反序列化、POP链构造,PHP提供了丰富的“考点”。对于参赛者而言,掌握PHP漏洞挖掘,就等于掌握了Web题目的一大半得分点。本指南将围绕“代码审计”和“漏洞利用”两个核心动作展开,前者教你如何像侦探一样在源码中发现蛛丝马迹,后者教你如何像工匠一样将发现的线索打造成一击必杀的武器。

2. 核心思路拆解:审计与利用的双螺旋

面对一个PHP的CTF题目,高手的思路是并行的“双螺旋”结构:一边是自上而下的功能点审计,另一边是自下而上的危险函数回溯。两者交织,才能高效解题。

2.1 功能点审计:用户输入追踪

这是最直接的审计方法。你的目标是找到所有与用户交互的“入口”。通常,这些入口就是GET、POST、COOKIE等超全局变量。审计时,我会习惯性地在代码编辑器中全局搜索 $_GET[ $_POST[ $_REQUEST[ 。找到这些入口后,就像画流程图一样,追踪这些数据流向了哪里。

注意:不要只看变量名,很多题目会进行变量覆盖或动态变量,比如 $$a 这种形式。这时需要结合上下文理解其最终指向。

例如,发现 $file = $_GET['page']; ,你的大脑就应该立刻拉响警报:文件包含漏洞的潜在风险。接着追踪 $file 是否被拼接进了 include() require() 。如果发现了 include($file . '.php'); ,就要思考如何绕过 .php 的后缀限制,或许可以利用截断(PHP版本<5.3.4)或利用协议( php://filter )。

2.2 危险函数回溯:从“炸弹”找“引线”

这是另一种高效方法。PHP有很多“著名”的危险函数,它们是漏洞的“爆炸点”。你的任务就是找到引爆炸弹的“引线”(用户可控输入)。我会建立一个自己的危险函数清单,审计时直接全局搜索:

  • 代码执行类 eval() , assert() , system() , exec() , shell_exec() , passthru() , popen() , 反引号 `
  • 文件操作类 include() , require() , include_once() , require_once() , file_get_contents() , file_put_contents() , unlink() , copy()
  • 命令执行类 :上面系统命令执行的也属于此类,特别注意 preg_replace() /e 修饰符(在PHP5中)。
  • 反序列化类 unserialize() ,这是审计的重中之重,往往伴随着复杂的POP链。

找到这些函数后,向上回溯它们的参数。如果参数的值(哪怕是经过了一些字符串处理)最终来源于用户输入,那么这里就很可能存在漏洞。

2.3 环境与配置审计:隐藏的突破口

很多漏洞的利用成功与否,高度依赖于PHP的环境配置。在CTF中,出题人有时会故意设置或提示某些非常规配置,这就是突破口。

  • magic_quotes_gpc :如果为On(已废弃,但老题目会有),它会自动转义GET/POST/COOKIE中的单引号、双引号等,可能影响SQL注入的利用。
  • allow_url_include :如果为On,则 include() 可以包含远程文件(RFI漏洞),否则只能本地文件包含(LFI)。
  • open_basedir :限制PHP可访问的目录范围,是文件包含和目录遍历的重要障碍。
  • disable_functions :禁用了哪些危险函数。如果 system 被禁,就要找其他未被禁的函数,如 proc_open() 或利用LD_PRELOAD等技巧进行绕过。

在实战中,我通常会先想办法查看 phpinfo() ,这页信息是“黄金情报”。很多题目的Flag或解题关键就藏在某个配置项或注释里。

3. 核心漏洞类型与审计实战详解

下面我们深入几种CTF中最常见的PHP漏洞类型,结合代码片段,讲解审计技巧和利用方法。

3.1 文件包含漏洞:一扇通往服务器内部的门

文件包含漏洞的核心函数是 include() , require() , include_once() , require_once() 。漏洞产生的原因是动态包含的文件路径用户可控。

审计点

  1. 查找上述四个函数。
  2. 检查其参数是否为变量,且该变量是否用户可控。
  3. 检查是否有后缀拼接、前缀拼接或过滤。

实战案例1:无过滤本地包含

// index.php
$page = $_GET['p'];
include($page);

这是最理想的情况。利用方式:

  • 目录遍历: ?p=../../../../etc/passwd ,读取系统文件。
  • 包含日志文件:如果知道网站绝对路径,可以包含Apache的access.log或error.log,在User-Agent中插入PHP代码,然后包含该日志文件以执行代码。
  • 包含Session文件:如果Session文件路径和名称可预测(如 /tmp/sess_[你的PHPSESSID] ),可以在Session中写入PHP代码然后包含。
  • 利用 php://filter 协议: ?p=php://filter/read=convert.base64-encode/resource=index.php 。这能将目标文件(如index.php本身)以base64编码形式读取出来,常用于绕过代码显示限制,获取源码。

实战案例2:后缀拼接绕过

$file = $_GET['file'];
include($file . '.php');

题目强制添加了 .php 后缀。绕过思路:

  • %00截断(PHP<5.3.4) :需要 magic_quotes_gpc=off 。构造 ?file=../../etc/passwd%00 %00 是空字符,拼接后变成 ../../etc/passwd\0.php ,在遇到空字符时,PHP底层会认为字符串结束,从而成功包含 /etc/passwd
  • 长度截断(Windows下) :Windows路径最大长度256字节,Linux下4096字节。可以构造超长的 file 参数,使 .php 被系统截断。但此法在现代CTF中较少见。
  • 利用协议忽略后缀 ?file=php://filter/read=convert.base64-encode/resource=flag 。这里 resource=flag ,拼接后变成 resource=flag.php ,但 php://filter 协议处理时, resource 参数后的 .php 会被当作文件名的一部分去查找,如果服务器上存在一个叫 flag.php 的文件,就能读取。更常见的是直接读取已知源码文件。
  • 点号截断 :在特定环境下(如Windows) ?file=../../etc/passwd. ?file=../../etc/passwd... 可能有效。

实操心得:遇到文件包含,第一个尝试的就是 php://filter 读源码。这不仅能绕过很多过滤,还能帮你理解整个题目的逻辑,是“以战养战”的关键一步。

3.2 代码执行漏洞:将字符串变成命令

这类漏洞允许攻击者将任意代码注入到服务器端执行。

审计点 :搜索 eval() , assert() 。特别注意 preg_replace() /e 修饰符。

实战案例1: eval() 直接执行

$code = $_GET['c'];
eval($code);

直接利用: ?c=system('ls'); ?c=phpinfo(); 。但通常题目会有过滤。

实战案例2: assert() 的陷阱 assert() 在PHP中是一个语言构造器,其参数会被当作PHP代码执行。一个经典陷阱:

$input = $_GET['input'];
assert("strpos('$input', 'flag') === false");

用户传入 ?input=') or phpinfo();// 。拼接后assert的语句变为:

assert("strpos('') or phpinfo();//', 'flag') === false");

// 注释掉了后面的内容。 strpos('') 返回 false false or phpinfo() 会执行 phpinfo() 以判断整个表达式是否为真。这里利用了字符串拼接和逻辑运算符的短路特性。

实战案例3: preg_replace() /e 修饰符

echo preg_replace('/(' . $_GET['pattern'] . ')/ei', 'strtoupper("\\1")', $_GET['text']);

/e 修饰符使得替换字符串 strtoupper("\\1") 会被当作PHP代码执行。在PHP中, \\1 是对第一个捕获组的引用。但这里存在一个奇妙的特性:在 /e 模式下,PHP会将捕获组的内容先进行变量解析。如果传入 ?pattern=.*&text=${phpinfo()} phpinfo() 会被执行。更常见的利用是利用PHP可变函数的特性,后面会提到。

3.3 反序列化漏洞:对象的重生与魔法

这是PHP Web题目的难点和重点。漏洞函数是 unserialize() 。当用户可控的序列化字符串被反序列化时,如果类中定义了“魔法方法”,这些方法会在反序列化的生命周期中自动调用,从而可能触发危险操作。

核心魔法方法

  • __wakeup() : 在反序列化后立即调用。
  • __destruct() : 对象被销毁时调用(脚本结束或对象unset)。
  • __toString() : 对象被当作字符串使用时调用(如 echo $obj )。
  • __call() : 调用对象中不存在的方法时触发。
  • __get() , __set() : 访问不存在的属性时触发。

审计与利用思路

  1. 找入口 :全局搜索 unserialize() ,找到用户输入点(如 $_GET['data'] )。
  2. 找类 :在源码中寻找包含魔法方法的类定义。
  3. 构造POP链 :分析各个类的属性和方法,寻找一条从“入口魔法方法”(通常是 __destruct __wakeup )到“危险函数”(如 file_put_contents system )的调用链。这就像拼乐高,把不同的类和方法连接起来。
  4. 编写利用代码 :根据POP链,实例化对象并设置好属性,然后序列化,将生成的字符串作为payload提交。

实战案例:一个简单的POP链 假设有以下源码:

class FileHandler {
    public $filename;
    public $data;
    function __destruct() {
        file_put_contents($this->filename, $this->data);
    }
}
class User {
    public $username;
    public $isAdmin;
    function __toString() {
        if ($this->isAdmin) {
            return $this->username . " is admin";
        }
        return $this->username;
    }
}
// 入口
$data = unserialize($_COOKIE['userdata']);

看起来 FileHandler __destruct 很危险,但用户输入反序列化的是 User 对象。如何触发 FileHandler ?我们需要构造一条链: User 对象被销毁时(或许在脚本末尾)会调用 __destruct 吗?不, User 没有 __destruct 。但注意,如果 User 对象在某个上下文中被当作字符串使用(比如被 echo ,或者被拼接进 file_put_contents 的文件名里),就会触发 __toString 。然而,这里没有明显的触发点。

让我们修改一下 FileHandler

class FileHandler {
    public $filename;
    public $data;
    function __destruct() {
        // 假设这里会echo $this->filename
        echo "Writing to: " . $this->filename;
        file_put_contents($this->filename, $this->data);
    }
}

现在,如果 $this->filename 是一个 User 对象,那么在 echo 时就会触发 User::__toString() 。但我们的目标还是写文件。真正的链可能需要更复杂。假设 User::__toString() 中有一行代码: global $handler; $handler->log($this->username); ,而 $handler 是另一个 Logger 类的对象, Logger::log() 方法中调用了 system() ... 这就是POP链的复杂性所在。

实操心得:审计反序列化时,我习惯先用工具(如 PHPGGC )扫描已知框架(ThinkPHP、Laravel等)的通用链。如果是原生代码,则手动绘制类图,重点关注 __destruct __wakeup ,因为它们触发点最直接。属性类型是对象时,要特别留意,这往往是连接两个类的“桥梁”。

3.4 变量覆盖与动态函数:PHP的“灵活”之殇

PHP的灵活语法有时会成为安全噩梦。

变量覆盖

  • extract() : 从数组中将变量导入当前符号表。如果数组中有 key flag ,值为 flag.php ,那么 extract($_POST) 后,变量 $flag 就被覆盖为 flag.php
  • parse_str() : 类似 extract() ,用于解析查询字符串。
  • $$ (可变变量): $a = 'flag'; $$a = 'hacked'; 相当于 $flag = 'hacked'; 。如果 $a 用户可控,就可以覆盖任意变量。

动态函数/变量函数

$func = $_GET['action'];
$func(); // 如果$func是'phpinfo',则执行phpinfo()

这非常危险,因为它可以直接调用任何已定义的函数。

实战案例:变量覆盖+文件包含

$file = 'default.php';
if (isset($_REQUEST['file'])) {
    $file = $_REQUEST['file'];
}
include($file);

看起来正常。但如果前面有一段被忽略的代码:

// config.php 可能被包含进来
$allow_include = false;
// ... 其他代码

如果存在 extract($_POST) ,攻击者可以POST allow_include=1 ,从而覆盖 $allow_include 变量,可能绕过某些检查。更直接的,如果存在 $$ 覆盖:

$var = $_GET['var'];
$value = $_GET['value'];
$$var = $value; // 危险!

传入 ?var=file&value=php://filter/read=convert.base64-encode/resource=/flag ,就可以覆盖 $file 变量,实现文件包含。

4. 高级利用技巧与绕过手段

当简单的漏洞点被基础过滤阻挡时,就需要一些“骚操作”。

4.1 命令执行的无字母数字Webshell

当代码执行点过滤了所有字母和数字,如何构造payload?这需要利用PHP的一些奇特特性。

  • 利用异或 :在PHP中,两个字符串进行异或( ^ )运算,可以得到新的字符串。例如, '{' ^ '~' 的结果是 'G' (因为ASCII码异或)。通过精心构造,可以用一堆特殊字符异或出我们想要的函数名,如 assert
  • 利用取反 ~'assert' 会得到一串乱码,但 (~'assert') 再执行取反操作就能变回 assert 。但如何执行呢?PHP中, $_='assert'; $__=~$_[$_]; 这种形式可以构造。
  • 利用自增运算符 :PHP中, 'a'++ 会变成 'b' 。可以从一个字符(如 'a' )开始,通过多次自增得到其他字母。
  • 利用PHP的弱类型和字符串解析 :例如, $_=[].''; 可以得到 'Array' 字符串,从中提取出字母 'A' 'r' 等。

一个经典的无字母数字webshell构造如下(需要开启短标签 <?= ):

<?php
$_=~«á»^«?»; // 通过异或得到字符,最终构造出`_GET`等变量
$__=~«??»^«?»;
// ... 一系列复杂操作后,最终动态执行 $_GET[1] 的参数
?>

在CTF中,这类题目往往考察对PHP语言深层次特性的理解。实战中,可以搜索现成的工具或脚本来自动生成此类payload。

4.2 利用PHP协议进行信息收集与攻击

php:// 协议族是PHP审计中的瑞士军刀。

  • php://filter :如前所述,用于读写数据流时的过滤。 read=convert.base64-encode 用于读取源码, write=convert.base64-decode 可以用于解码写入,有时绕过 die()
  • php://input :可以访问请求的原始数据。如果 allow_url_include=On 且存在文件包含,可以POST PHP代码到 php://input ,然后包含它,实现无文件写入的代码执行。
  • data:// :数据流封装器。可以直接在URL中嵌入Base64编码的代码。例如: include('data://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8+') 会执行 phpinfo() 。前提是 allow_url_include=On
  • phar:// :这个协议常用于反序列化漏洞的利用。可以将序列化后的payload和POP链代码打包进一个PHAR文件中,然后通过 phar://archive.zip/内部文件 的方式触发反序列化。这在某些限制上传文件内容但能上传文件(如图片)的场景下特别有用,因为可以将PHAR伪装成图片上传,然后通过 phar:// 包含触发。

4.3 正则绕过与代码注入

preg_replace() /e 模式被过滤,或者 eval() 被正则检查时,需要绕过。

  • 利用 . 连接符和变量函数 :PHP中, $a='sys';$b='tem';$c=$a.$b; $c('ls'); 可以执行 system('ls') 。如果过滤了 system 关键字,可以尝试拆分。
  • 利用 ${} 执行 ${phpinfo()} 这种写法在某些上下文下会被执行。特别是在双引号字符串中, ${ 会被解析。
  • 利用反引号执行命令 :反引号( ` )等同于 shell_exec()
  • 利用 create_function() :这个已废弃的函数会创建一个匿名函数,其函数体由字符串传入。如果用户输入进入了函数体字符串,就可能造成代码注入。例如: $func = create_function('$a', 'echo $a . $_GET["cmd"];'); ,如果 $_GET["cmd"] 可控,就能注入代码。

5. 实战全流程:从拿到源码到获取Flag

假设我们拿到一个CTF题目的源码压缩包。以下是我的标准操作流程:

第一步:环境搭建与初步浏览

  1. 使用Docker或本地PHP环境快速搭建。我常用 docker run -d -p 8080:80 -v $(pwd):/var/www/html php:5.6-apache 来创建一个PHP5.6环境,因为很多老题目的特性在新版本PHP中已失效。
  2. 解压源码,用代码编辑器(如VSCode)打开整个项目。
  3. 首先看 index.php ,了解程序入口和主要逻辑。
  4. 快速浏览项目结构,注意是否有 flag.php config.php admin.php 等常见文件。

第二步:危险函数全局搜索 使用编辑器的全局搜索功能(快捷键通常是Ctrl+Shift+F),依次搜索:

  • unserialize
  • eval
  • assert
  • include , require
  • system , exec , shell_exec , passthru , popen
  • file_get_contents , file_put_contents
  • preg_replace.*/e
  • extract , parse_str
  • $$

将搜索结果记录下来,形成初步的“可疑点清单”。

第三步:追踪用户输入 $_GET , $_POST , $_REQUEST , $_COOKIE , $_SERVER (某些头部如 HTTP_X_FORWARDED_FOR 也可能可控)入手,在代码中追踪这些变量的流动。特别注意那些经过了一些“安全过滤”函数(如 addslashes , htmlspecialchars , 自定义的 waf 函数)的输入,思考过滤规则是否可被绕过。

第四步:静态分析与动态调试结合

  1. 静态分析 :根据第二步和第三步的结果,画出关键函数的数据流图。对于反序列化,画出类图,分析属性与方法间的调用关系。
  2. 动态调试 :在浏览器中访问网站,使用Burp Suite或浏览器开发者工具拦截请求。尝试在找到的输入点注入简单的测试payload,如 ' " ../ php://filter 等,观察响应。
  3. 查看 phpinfo() :如果可能,想办法触发 phpinfo() (例如,找到代码执行点执行 phpinfo() ),查看关键配置( allow_url_include , disable_functions 等)。

第五步:构造并发送Payload 根据分析结果,编写漏洞利用代码。

  • 文件包含/读取 :直接构造URL或POST数据。
  • 代码执行 :编写一句话Webshell或直接执行命令。如果被过滤,尝试使用上一节的高级绕过技巧。
  • 反序列化 :编写一个PHP脚本,根据POP链构造对象并序列化,生成payload字符串,然后通过Cookie或POST发送。
  • 数据库操作 :如果是SQL注入,使用Sqlmap或手工构造Union查询、布尔盲注、时间盲注等payload。

第六步:获取Flag并验证

  1. 执行命令查找Flag:通常命令是 find / -name '*flag*' 2>/dev/null grep -r 'flag{' /var/www 2>/dev/null
  2. 直接读取Flag文件:如果知道路径,用 cat file_get_contents 读取。
  3. 在数据库里查找:通过SQL注入查询数据库。
  4. 验证Flag格式:CTF的Flag通常有固定格式,如 flag{xxx-xxx-xxx} CTF{...} ,确保提取完整。

6. 常见问题排查与避坑指南

在实战中,你肯定会遇到各种“坑”。这里记录一些我踩过的雷和解决方法。

问题1:Payload明明正确,为什么没反应?

  • 检查PHP版本 :很多技巧(如 %00 截断)对PHP版本有严格要求。确认环境版本是否匹配。
  • 检查 magic_quotes_gpc :如果为On,单引号、双引号会被转义,影响字符串型注入。可以使用宽字节注入(GBK编码下)或寻找数字型注入点。
  • 检查 disable_functions :你的命令执行函数可能被禁用了。尝试其他函数,如 proc_open() popen() ,或者使用LD_PRELOAD等绕过方式。
  • 检查WAF或自定义过滤 :题目可能部署了简单的WAF。尝试大小写混淆、双写关键字、插入注释 /**/ 、使用编码(如URL编码、Hex编码)来绕过。
  • 检查输出点 :你的代码执行了,但结果没有回显到页面上。尝试将输出写入一个文件: system('ls > /tmp/result.txt'); ,或者使用DNS外带、HTTP请求外带( curl )的方式将结果发送到你的服务器。

问题2:反序列化Payload提示 __wakeup() 执行了,但链没走通?

  • __wakeup() 的干扰 __wakeup() 方法在反序列化时最先执行,如果它里面有一些“净化”操作(如重置属性),可能会破坏你精心构造的POP链。需要研究如何绕过 __wakeup() 。在PHP早期版本中,如果序列化字符串中对象属性个数大于实际个数,可以绕过 __wakeup() 的执行(CVE-2016-7124)。Payload类似: O:7:"Example":2:{s:3:"var";s:1:"x";} ,即使 Example 类只有一个属性 var ,这里写成2也可能绕过。
  • 属性访问权限 :序列化字符串中对 private protected 属性的表示方式不同。 private 属性格式为 %00类名%00属性名 protected 属性为 %00*%00属性名 。在手动构造或复制Payload时,这些不可见字符容易出错,最好用PHP脚本生成。
  • 自动加载问题 :如果POP链涉及多个类,且题目使用了 spl_autoload_register ,确保所有用到的类都能被正确加载,否则反序列化时会因找不到类而失败。

问题3:文件包含包含不到想要的文件?

  • 路径问题 :使用绝对路径还是相对路径? include 的相对路径是基于当前执行脚本的路径,还是基于 include_path ?多试试 ./ ../ / 开头的路径。
  • 文件权限 :目标文件是否有读权限?Web进程用户(如 www-data )可能无权读取某些系统文件。
  • 文件后缀 :是否被强制添加或删除了后缀?利用协议或截断技术绕过。
  • 文件不存在导致警告 include 一个不存在的文件会产生警告,但脚本可能继续执行。 require 则会产生致命错误。注意区分。

问题4:拿到一个CMS或框架的代码审计题,无从下手?

  • 先找版本信息 :查看 README.md composer.json 、框架入口文件的注释,确定CMS/框架名称和版本。
  • 搜索公开漏洞 :在Exploit-DB、GitHub、安全社区搜索该版本已知的漏洞。很多CTF题就是这些公开漏洞的简化复现。
  • 关注路由和控制器 :现代MVC框架的入口通常是 index.php ,然后根据路由调用对应的Controller和Action。审计用户输入如何从路由传递到控制器方法。
  • 关注核心过滤函数 :框架通常有全局的输入过滤或中间件。找到它,理解其规则,寻找绕过方法。
  • 关注数据库操作层 :查看ORM或Active Record的使用方式,是否存在SQL注入的可能。

问题5:命令执行被空格或特殊字符过滤?

  • 空格绕过 :在Bash中,空格可以用以下字符替代: ${IFS} $IFS$9 < > %09 (Tab的URL编码)。
  • 命令分隔 ; && || | %0a (换行)都可以用来分隔命令。
  • 通配符 /???/??t /???/p?ss?? 可能代表 /bin/cat /etc/passwd * 通配符在读取文件时很有用。
  • 内联执行 `命令` $(命令) 可以将命令的输出作为参数。
  • 编码绕过 :将命令Base64编码后执行: echo 'bHM=' | base64 -d | bash 执行 ls

最后,保持耐心和细心是CTF Web攻防,尤其是PHP审计的关键。每一个警告、每一个异常回显都可能是突破口。多练、多总结、多分析别人的Writeup,逐渐你就会形成自己的“漏洞嗅觉”。

更多推荐