PHP文件包含漏洞深度解析:从LFI到临时文件竞争攻击实战

📅 2026/8/15 2:15:45
PHP文件包含漏洞深度解析:从LFI到临时文件竞争攻击实战
1. 从一道CTF题看文件包含漏洞的“进化”最近在复盘一些经典的CTF题目特别是Web安全方向的发现很多题目虽然年份不算久远但其中蕴含的思路和技巧至今仍有很强的学习价值。今天想和大家深入聊聊一道来自NPUCTF2020的题目名字叫“ezinclude”。光看名字就知道它肯定和文件包含漏洞有关而且“ez”似乎暗示着简单。但做过CTF的朋友都知道出题人嘴里的“简单”往往意味着你需要绕过好几层意想不到的过滤或者对某个知识点有非常深刻的理解。这道题就是一个典型的例子。它没有复杂的代码审计没有眼花缭乱的加密算法核心就是考察对PHP文件包含漏洞的利用深度。但它的“简单”背后却串联起了从基础LFI本地文件包含到利用PHP封装协议、临时文件竞争、乃至结合目录穿越和哈希长度扩展攻击的完整攻击链。很多人卡在这道题不是因为不知道文件包含而是因为对文件包含能“包含”什么以及在不同限制条件下如何“创造”出可包含的内容缺乏系统性的认知。所以这篇文章我不打算只给出这道题的答案。我更想以这道题为引子带大家重新梳理一遍PHP文件包含漏洞的“武器库”看看一个看似简单的漏洞点如何在不同场景下被玩出花来。我们会从最基础的include($_GET[‘file’])开始一步步探讨如何读取源码、如何利用日志、如何通过封装协议执行代码最后再聚焦到ezinclude这道题的核心难点——如何在无法直接上传文件的情况下通过PHP的输入流和临时文件机制“无中生有”地写入一个Webshell。这个过程也是理解现代Web应用安全中攻击者如何将多个低危漏洞或特性组合形成高危攻击路径的绝佳案例。2. 文件包含漏洞基础不只是包含文件那么简单在深入题目之前我们有必要统一一下对文件包含漏洞的基本认识。很多新手会有一个误解文件包含漏洞就是能读取服务器上的任意文件。这个说法对但不全对。读取文件只是它最直接的一种利用方式它的真正威力在于“将任意文件内容作为PHP代码来解析执行”。2.1 PHP包含函数的行为本质PHP中常用的包含函数有四个include,include_once,require,require_once。它们的主要区别在于处理包含失败的方式require会报致命错误并停止include只会报警告以及是否重复包含。但从安全角度看它们的行为核心是一致的当PHP引擎执行到包含语句时它会尝试打开参数指定的文件读取其内容然后将这些内容插入到当前脚本的该位置并作为PHP代码进行解析执行。关键在于“作为PHP代码解析”。这意味着只要我们能控制包含函数的参数并能让服务器访问到我们指定的文件那么该文件的内容就会被执行。如果这个文件的内容是?php phpinfo(); ?那么就会执行phpinfo()。2.2 常见利用场景与分类根据包含的文件来源文件包含漏洞通常分为两类本地文件包含LFI, Local File Inclusion包含的参数是服务器本地的文件路径。例如include(‘./uploads/’ . $_GET[‘file’]);。如果过滤不严攻击者可能通过目录遍历../../../etc/passwd读取系统敏感文件。远程文件包含RFI, Remote File Inclusion包含的参数是一个远程URL。例如include($_GET[‘url’]);。如果allow_url_include配置为OnPHP会去获取这个URL的内容并将其作为本地文件包含执行。这使得攻击者可以直接包含托管在远程服务器上的恶意脚本。由于RFI的危害极大且利用条件苛刻需要allow_url_includeOn在现代PHP版本和安全意识下已较少见。CTF和实际渗透中更常见的是LFI以及如何将LFI升级为代码执行。2.3 基础利用技巧读取源码与敏感信息即使不能直接执行代码LFI也极具价值。首先就是源码泄露。在CTF中这往往是解题的第一步。假设存在漏洞的代码位于/var/www/html/index.php?php $page $_GET[‘page’]; include(‘/pages/’ . $page . ‘.php’); ?如果我们传入page../../index服务器实际执行的语句是include(‘/pages/../../index.php’)即include(‘/var/www/html/index.php’)。这看起来是包含自身似乎没什么用不这里有一个关键技巧使用PHP封装协议。我们可以传入pagephp://filter/readconvert.base64-encode/resource../../index此时include的参数变成了一个php://filter流。这个流的作用不是让PHP去执行index.php而是读取index.php的内容并对其进行base64编码后输出。因为include会执行读取操作并将读取到的已编码的内容直接输出到页面上。这样我们就得到了index.php经过base64编码后的源码解码即可查看。为什么需要base64编码因为如果直接读取包含PHP标签的源码这些标签会被当前脚本的PHP引擎尝试解析可能导致语法错误或看不到完整源码。编码后所有字符都变成安全文本完美绕过这个问题。除了源码经典的/etc/passwd、Web服务器日志/var/log/apache2/access.log、/proc/self/environ环境变量等都是LFI读取敏感信息的常见目标。3. 进阶利用将LFI转化为代码执行读取信息固然有用但获得代码执行能力才是攻击者的终极目标。如何通过LFI执行任意PHP代码核心思路是让一个我们可控内容的文件被包含。这个文件不一定需要是.php后缀只要其内容能被当作PHP代码解析即可。有以下几种主要方式3.1 利用服务器日志文件这是非常经典的一种方法。Web服务器如Apache、Nginx会记录所有访问日志包括请求的URL、User-Agent头等信息。如果我们可以将PHP代码注入到这些日志字段中然后再通过LFI去包含这个日志文件代码就会被执行。例如我们访问目标网站时在User-Agent中携带PayloadUser-Agent: ?php system($_GET[‘cmd’]); ?然后我们利用LFI漏洞去包含Apache的访问日志文件常见路径如/var/log/apache2/access.loginclude(‘../../../var/log/apache2/access.log’)当服务器执行这个包含语句时日志文件的内容被读取。由于日志是纯文本文件其中的?php system($_GET[‘cmd’]); ?字符串会被当作PHP代码解析从而执行我们通过cmd参数传递的命令。注意这种方法成功需要几个条件1. 我们知道日志文件的绝对路径2. 我们有权限读取该日志文件3. 日志文件中没有被转义的特殊字符如、。在实际中路径猜解和权限是主要障碍。3.2 利用PHP会话文件Session FilePHP默认会将session数据存储在服务器上的临时文件中如/tmp/sess_[sessionid]。文件内容通常是序列化的键值对。如果我们能控制存入session的某个变量的值并且知道session文件的存储路径和名称就可以通过LFI包含它来执行代码。例如一个设置用户昵称的功能$_SESSION[‘username’] $_GET[‘name’];如果我们传入name?php phpinfo();?//那么序列化后的session文件内容可能包含这段PHP代码。然后我们通过包含/tmp/sess_abc123其中abc123是我们的session id就有可能执行phpinfo()。实操心得利用session文件通常需要结合其他漏洞如注入点来写入恶意内容并且需要精确获取session id。在CTF中有时题目会特意提供一个写入session的端点。3.3 利用PHP封装协议的写入功能php://input是一个只读流用于访问请求的原始数据。php://filter除了用于读取编码其write过滤器链理论上也能用于处理数据但更常用于读取。另一个强大的协议是zip://或phar://它们可以包含压缩包中的文件。如果允许上传一个包含恶意PHP脚本的zip文件然后通过zip://archive.zip#shell.php的方式包含即可执行代码。但这通常需要先上传文件。3.4 利用临时文件与竞争条件这是ezinclude这道题的核心考点也是比较高级的一种技巧。当PHP处理文件上传multipart/form-data或php://input流时如果数据量较大会先在系统的临时目录如/tmp创建一个临时文件将数据写入其中处理完后再删除。这个临时文件的存在时间极短但确实存在。攻击思路是利用文件包含漏洞在临时文件被删除前抢先包含它。这需要精确的时间竞争Race Condition。更巧妙的一种方式与php://input和allow_url_include的设置有关。但即使allow_url_include为Off我们仍然可以利用php://filter的某些特性配合特殊的请求体来“写入”一个文件。这涉及到PHP底层对数据流处理的细节。ezinclude题目正是利用了这一点它设置了一个“死亡”目录让你无法上传文件也无法直接访问临时目录但却通过文件包含和精心构造的php://filter载荷绕过了这些限制。4. 题目“ezinclude”场景还原与核心难点拆解现在让我们把目光聚焦到[NPUCTF2020]ezinclude这道题。根据题目名和常见考点我们可以模拟还原它的核心代码逻辑。通常这类题目的入口点是一个明显的文件包含参数比如?php // index.php error_reporting(0); $file $_GET[“file”]; if(stristr($file, “../”) ! FALSE || stristr($file, “tp”) ! FALSE || stristr($file, “input”) ! FALSE || stristr($file, “data”) ! FALSE || stristr($file, “zip”) ! FALSE){ die(“Hacker!”); } include($file); echo “Welcome to NPUCTF2020!”; ?可以看到它过滤了../目录穿越、tp可能针对ThinkPHP、input、data、zip常见危险协议。这基本上堵死了常规的日志包含、php://input和压缩包包含的路。题目可能还有一个文件上传点但上传目录不可执行比如是/upload/但目录没有执行权限或者被重命名为.jpg或者像一些变种题上传后文件被立即删除或存放在一个无法通过Web访问的路径。那么突破口在哪里关键就在于对php://filter协议的深度利用。题目只过滤了input、data等但没有过滤filter本身。php://filter除了用于读取其过滤器链有一个特性当与resource结合时如果resource指向的是一个不可读的文件或一个不存在的“文件”同时请求体POST data中有内容PHP会如何处理这里涉及到一个知识点php://filter的resource参数如果指向一个类似php://temp或php://memory这样的流或者一个特殊的路径并且以write过滤器结束那么整个php://filter流会变成一个可写的流写入的数据会经过过滤器处理。但更关键的是在某些PHP版本和配置下当你尝试以包含include的方式去“读取”一个php://filter/read.../resourcephp://temp并且同时POST大量数据时PHP可能会因为处理multipart请求在临时目录生成一个包含POST数据的临时文件而这个临时文件的路径可能会以某种形式与resource参数关联起来。另一种更直接的利用方式与php://filter的string.strip_tags过滤器有关。这个过滤器会去除流中的所有PHP和HTML标签。如果我们构造这样一个包含index.php?filephp://filter/string.strip_tags/resourcephp://input然后POST body里发送PHP代码?php system(‘ls’);?会发生什么string.strip_tags会尝试去除?php ?标签。但这里有一个历史悠久的漏洞PHP CVE-2018-19518等变体在某些情况下当string.strip_tags过滤器处理包含PHP标签的流时如果流不是普通的文件而是php://inputPHP底层在解析时可能会发生错误导致临时文件不被正常清除。而这个临时文件里就保存着我们POST过去的原始数据包括PHP代码。如果此时我们能猜到或获取到这个临时文件的路径就可以用文件包含去包含它从而执行代码。核心难点临时文件名是随机的如/tmp/phpXXXXXX我们如何知道它的名字这就需要利用到另一个特性PHP在处理包含php://filter的请求时如果发生错误有时会在错误信息中泄露临时文件的完整路径。或者我们可以通过爆破、或者利用/proc/self/fd/目录Linux下指向进程打开的文件描述符来尝试访问。在ezinclude这道题中出题人很可能设置了一个“死亡”目录让/tmp不可达但通过精心构造的filter链和巨大的POST数据触发PHP生成临时文件并利用包含点本身通过多次请求和错误信息泄露最终定位并包含这个临时文件获得RCE。5. 实战解题步骤推演与深度分析基于以上分析我们可以尝试推演解这道题的步骤。请注意由于没有真实环境以下步骤是基于常见考点和漏洞原理的逻辑推演实际解题可能需要微调。5.1 第一步信息收集与基础探测首先访问目标发现只有一个index.php通过参数file进行包含。尝试基础Payloadfilephp://filter/readconvert.base64-encode/resourceindex.php– 查看源码确认过滤逻辑。file./index.php– 尝试相对路径包含自身观察反应。尝试读取/etc/passwd发现../被过滤无法目录穿越。尝试filephp://input并用POST发送?php phpinfo();?发现input被过滤。通过第一步我们确认了过滤规则也知道了php://filter的read功能是可用的这给了我们读取源码的能力。读取index.php的base64编码后解码我们得到了第4节中模拟的过滤代码。5.2 第二步利用filter写入临时文件的Payload构造既然php://input被禁我们考虑利用php://filter的write过滤器链和resourcephp://temp来尝试写入数据。但单纯的write在include的上下文中可能不会触发写入。我们需要一个能触发PHP创建临时文件的场景。查阅资料和过往WP一个有效的Payload是index.php?filephp://filter/writeconvert.base64-decode|convert.base64-decode|convert.base64-decode/resourceaaa然后在POST Body中发送经过多次base64编码的Webshell代码。原理深度分析 这个Payload看起来有些奇怪。resourceaaa指向一个不存在的文件。write过滤器链表示这是一个用于写入的流。当我们向这个URL发送POST请求时PHP会尝试将POST数据写入到resource指定的“文件”中。由于aaa不是一个已存在的可写文件PHP可能会在临时目录创建一个临时文件来处理这个流操作。convert.base64-decode过滤器会将POST数据视为base64进行解码。我们发送多次编码的数据是为了让解码后的内容是我们想要的原始PHP代码。关键在于include()函数会去“读取”file参数指定的文件。当file参数是一个php://filter/write...的流时PHP会先尝试执行“写入”操作将POST数据解码后写入临时文件然后include再尝试去“读取”这个流或关联的临时文件。如果整个流程中临时文件被创建且未被立即删除并且include最终成功读取到了这个临时文件的内容那么我们的代码就可能被执行。5.3 第三步触发临时文件残留与路径获取上一步的Payload可能不会一次成功。因为临时文件可能很快被删除。我们需要让PHP在处理过程中“出错”从而让临时文件残留。这就是string.strip_tags过滤器出场的时候。尝试Payloadindex.php?filephp://filter/string.strip_tags/resourcephp://input尽管input被过滤但有时过滤是大小写敏感的可以尝试PHP://INPUT或Php://Input等变种。如果过滤是stristr不区分大小写则此路不通。如果input被彻底封死那么resourcephp://temp或resourceAAA一个不存在的协议可能也能触发类似行为。我们发送一个非常大的POST body比如几MB的数据里面包含?php ... ?代码。巨大的数据量会增加PHP使用临时文件的可能性。string.strip_tags在处理这些数据时可能会因为内存或处理异常导致临时文件句柄没有正确关闭。此时观察服务器的错误响应。如果题目配置了display_errorsOn我们可能在错误信息中看到类似failed to open stream: No such file or directory in /tmp/phpLf9w8Q on line ...的提示。这个/tmp/phpLf9w8Q就是泄露的临时文件路径5.4 第四步竞争包含与最终RCE获取到临时文件名例如/tmp/phpLf9w8Q后我们需要在它被删除前包含它。由于文件名是随机的我们需要写脚本进行自动化竞争。攻击流程如下启动一个线程/进程不断发送创建临时文件的请求使用上述string.strip_tags大POST数据的Payload。同时启动另一个线程/进程不断尝试用文件包含去访问/tmp/phpXXXXXX可以暴力枚举常见的模式或者如果错误信息泄露了部分名字就更简单。一旦包含成功就会执行我们写在POST数据里的PHP代码。通常我们会在代码里执行命令比如system(‘ls /’);或者直接写一个Webshell到Web目录。在ezinclude这道题的实际解中可能不需要复杂的多线程竞争。因为出题人可能调整了PHP配置或代码逻辑使得临时文件在被include读取后才会被删除或者根本不删除。这样只要我们在一个请求里完成了“创建临时文件”和“包含临时文件”两个动作通过精心构造的Payload和请求顺序就能成功。一个经典的单一请求利用链是请求index.php?filephp://filter/string.strip_tags/resourcephp://tempPOST body为?php eval($_POST[‘a’]);?这个请求会创建临时文件/tmp/phpxxx其中包含我们的代码并且由于strip_tags的处理文件被残留。立即或通过脚本请求index.php?file/tmp/phpxxx包含该文件从而执行eval我们可以通过POST参数a传递任意PHP代码如asystem(‘cat /flag’);获得flag。6. 漏洞防御与安全开发启示通过这道题我们看到了一个被严格过滤的文件包含点如何通过PHP底层特性被利用。这对我们开发中的安全防护有重要启示永远不要信任用户输入这是铁律。对于文件包含最根本的解决方法是使用白名单机制。如果必须动态包含则只允许包含预定义好的、安全的文件列表中的项。$allowed_pages [‘home’, ‘about’, ‘contact’]; $page $_GET[‘page’]; if (in_array($page, $allowed_pages)) { include(‘./templates/’ . $page . ‘.php’); } else { include(‘./templates/404.php’); }避免动态包含尽可能重构代码避免使用include/require包含由用户直接控制的变量。使用路由映射或前端控制器模式。设置PHP安全配置allow_url_include和allow_url_fopen务必设置为Off。open_basedir限制PHP可访问的目录范围。display_errors在生产环境设置为Off防止路径等敏感信息泄露。及时更新PHP版本修复已知的底层漏洞如string.strip_tags相关的问题。严格的输入过滤与验证黑名单永远有被绕过的一天。如题目所示过滤了../、input等但filter协议依然打开了突破口。如果业务必须使用包含应对输入进行严格的路径规范化检查并确保最终路径在预期的安全目录内。安全代码审查在代码审查中要特别警惕任何将用户输入直接传递给文件操作函数include,require,file_get_contents,fopen等的地方。文件包含漏洞就像一个“漏洞放大器”它可以将一个简单的信息泄露如日志文件读取或一个难以利用的上传点如无执行权限的目录转变为一个严重的远程代码执行漏洞。理解其原理和多种利用方式无论是对于攻击方渗透测试、CTF还是防御方安全开发、运维都至关重要。ezinclude这道题的价值就在于它逼迫我们去思考过滤的边界和PHP语言的深层特性而不仅仅是套用常见的Payload。