CTF PHP代码审计与漏洞利用实战指南
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:无过滤本地包含
// 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(): 访问不存在的属性时触发。
审计与利用思路 :
- 找入口 :全局搜索
unserialize(),找到用户输入点(如$_GET['data'])。 - 找类 :在源码中寻找包含魔法方法的类定义。
- 构造POP链 :分析各个类的属性和方法,寻找一条从“入口魔法方法”(通常是
__destruct或__wakeup)到“危险函数”(如file_put_contents、system)的调用链。这就像拼乐高,把不同的类和方法连接起来。 - 编写利用代码 :根据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题目的源码压缩包。以下是我的标准操作流程:
第一步:环境搭建与初步浏览
- 使用Docker或本地PHP环境快速搭建。我常用
docker run -d -p 8080:80 -v $(pwd):/var/www/html php:5.6-apache来创建一个PHP5.6环境,因为很多老题目的特性在新版本PHP中已失效。 - 解压源码,用代码编辑器(如VSCode)打开整个项目。
- 首先看
index.php,了解程序入口和主要逻辑。 - 快速浏览项目结构,注意是否有
flag.php、config.php、admin.php等常见文件。
第二步:危险函数全局搜索 使用编辑器的全局搜索功能(快捷键通常是Ctrl+Shift+F),依次搜索:
unserializeevalassertinclude,requiresystem,exec,shell_exec,passthru,popenfile_get_contents,file_put_contentspreg_replace.*/eextract,parse_str$$
将搜索结果记录下来,形成初步的“可疑点清单”。
第三步:追踪用户输入 从 $_GET , $_POST , $_REQUEST , $_COOKIE , $_SERVER (某些头部如 HTTP_X_FORWARDED_FOR 也可能可控)入手,在代码中追踪这些变量的流动。特别注意那些经过了一些“安全过滤”函数(如 addslashes , htmlspecialchars , 自定义的 waf 函数)的输入,思考过滤规则是否可被绕过。
第四步:静态分析与动态调试结合
- 静态分析 :根据第二步和第三步的结果,画出关键函数的数据流图。对于反序列化,画出类图,分析属性与方法间的调用关系。
- 动态调试 :在浏览器中访问网站,使用Burp Suite或浏览器开发者工具拦截请求。尝试在找到的输入点注入简单的测试payload,如
'、"、../、php://filter等,观察响应。 - 查看
phpinfo():如果可能,想办法触发phpinfo()(例如,找到代码执行点执行phpinfo()),查看关键配置(allow_url_include,disable_functions等)。
第五步:构造并发送Payload 根据分析结果,编写漏洞利用代码。
- 文件包含/读取 :直接构造URL或POST数据。
- 代码执行 :编写一句话Webshell或直接执行命令。如果被过滤,尝试使用上一节的高级绕过技巧。
- 反序列化 :编写一个PHP脚本,根据POP链构造对象并序列化,生成payload字符串,然后通过Cookie或POST发送。
- 数据库操作 :如果是SQL注入,使用Sqlmap或手工构造Union查询、布尔盲注、时间盲注等payload。
第六步:获取Flag并验证
- 执行命令查找Flag:通常命令是
find / -name '*flag*' 2>/dev/null或grep -r 'flag{' /var/www 2>/dev/null。 - 直接读取Flag文件:如果知道路径,用
cat或file_get_contents读取。 - 在数据库里查找:通过SQL注入查询数据库。
- 验证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,逐渐你就会形成自己的“漏洞嗅觉”。
更多推荐


所有评论(0)