上个礼拜帮一家做了八年私有化系统的客户做代码审计,前后台加起来不到三十个接口,我却在小半天里接连定位了三类同源问题:文件包含、文件下载和文件读取漏洞。它们长得都很像——一个从 URL 或 POST 过来的文件名参数,被直接拼进了 include、readfile、file_get_contents 这类函数。标题里那行"件包含与下载读取漏洞解析",大概率是某份安全笔记里手滑漏了个"文"字,但"件包含与下载读取"这七个字凑在一起,恰好点中了 Web 安全里一个特别常见、又特别容易被低估的组合。
这篇文章我不打算只贴几个 payload。我想从漏洞为什么产生、为什么总长在"文件引用"这条逻辑上,到怎么用代码审计把它们一个个揪出来,再到最后怎么修得干净,完整地过一遍。无论你是刚入行的安全测试、写业务代码的研发,还是偶尔要处理扫描报告的安全运维,这套思路都能直接用到日常排查里。
1. 先从一场安全应急说起:三类漏洞为何常被放在一起
1.1 一条URL背后的"文件引用"逻辑
绝大多数 Web 应用都有这样的功能:下载附件、导出报表、预览用户头像、加载语言包、渲染模板。这些功能落到代码层面,几乎都是同一个模式:拿到前端传过来的一个文件名或 ID,拼出一个服务器路径,再交给语言内置的函数去读取或包含。
如果传的是 report_20250101.pdf 这种普通文件名,一切正常。但如果这个值来自 URL 的 query 参数、来自表单、甚至来自请求头,而且后端没有对它做任何边界校验,那么这里就埋下了三类常见漏洞的种子:文件包含、文件下载、文件读取。
我排查过的项目里,十次有七八次能在同一个后台系统里同时看到这三种写法。它们看起来是三个不同漏洞,实际上共享同一个根因:应用信任了用户提供的路径信息,并且没有限制这个路径能落在文件系统的哪个范围。
1.2 三种漏洞的共性:可控路径与缺失的边界
先把概念摆清楚,后面讲原理和修复时大家不容易绕晕。我把三类的核心差异整理成了一张表:
| 漏洞类型 | 典型触发函数/组件 | 直接后果 | 危害链条 |
|---|---|---|---|
| 文件包含(LFI/RFI) | include、require、include_once | 被包含的文件内容被当作代码执行 | 代码执行、Getshell、内网横向 |
| 文件下载(任意文件下载) | readfile、download、Attachment 响应 | 服务器任意文件被下载 | 敏感信息泄露、信息收集扩大 |
| 文件读取(任意文件读取) | file_get_contents、fopen、回显内容 | 文件内容直接输出在页面或响应里 | 源码泄露、二次审计、进一步攻击 |
为什么这三类经常放在一起讲?因为它们的入口经常是同一个"文件参数",只是因为业务写法不同,导致最后一步的处置方式不同。include 那边把文件当代码执行了,所以叫文件包含;readfile 那边给了个下载响应头,所以叫任意文件下载;file_get_contents 那边直接输出了内容,所以叫任意文件读取。
1.3 这篇文章能帮你解决什么问题
我不预设读者都是安全老手。如果你是研发,读完你应该能明白为什么 include 一个参数会出事;如果你是测试或运维,读完你应该懂得在黑盒扫描之外,怎么用白盒代码审计把这类问题系统性找出来;如果你在写代码评审规范,后面的场景拆解和修复样例可以直接抄走。
2. 文件包含漏洞解析:从 include 的机制到致命后果
2.1 include 的"代码拼接"效应
PHP 里 include 和 require 当初设计出来是为了复用代码:把一个文件的内容"粘贴"到当前脚本里,并且如果其中有 PHP 标签,就会被当作 PHP 代码执行。这种机制在正常开发里非常好用,但当被包含的文件名来自用户输入时,它就变成了一个非常危险的"拼接器"。
最常见的错误写法是这种:
$page = $_GET['page']; $template = "templates/" . $page . ".html"; include($template);开发者的本意是让页面根据 page 参数切换不同的模板文件。但攻击者一旦能够控制 page 的值,就可能让 include 去加载一个完全超出"模板目录"的文件。这段代码还拼了 .html 后缀,表面看起来能挡住许多路径,但在 PHP 的某些配置和函数组合下,扩展名并不能真正限制住加载路径。
为什么说文件包含的杀伤力在三个漏洞里最大?因为 include 不是"读取",是"执行"。包含一个正常的 HTML 模板没问题,但如果包含的文件内容里带有 PHP 代码——比如攻击者上传的图片马、被污染的 session 文件、包含用户输入的日志——这些内容会直接以脚本权限执行。一条看起来只是"加载模板有问题"的漏洞,很快就可能变成服务器被完全控制。
2.2 LFI 与 RFI:本地包含和远程包含的差别
文件包含漏洞按"被包含文件在哪"分成两种:
- 本地文件包含(LFI):被包含的文件在目标服务器本地。攻击方向是读源码、读配置、包含本地的可执行文件。
- 远程文件包含(RFI):被包含的文件来自攻击者指定的外部地址,PHP 会主动拉取并执行。这个需要 PHP 的 allow_url_include 开启,而新版 PHP 默认是关闭的,所以现在公网 RFI 比当年少了很多,但内网和私有化系统里依然偶发。
这里有个容易混淆的知识点:LFI 并不只是"读一下文件"。日常排查里 LFI 经常被拿去配合伪协议读取源码,但更危险的是,如果日志、session、上传目录里的内容可控,LFI 就能进一步变成代码执行。这也是为什么我在审计时遇到 include 拼参几乎都会标记为高危——就算我只在授权测试里读到几个配置文件,也说明这个入口已经具备了往代码执行方向走的条件。
对付这类问题,只靠过滤"../"或者"php://"这类关键字是走不通的,原因我下面单独说。
2.3 为什么黑名单过滤总在文件包含面前失灵
很多团队早期也做过防护,最常见的是黑名单思路:过滤 ..、过滤斜杠、过滤伪协议关键字。看起来已经很努力了,但问题在于黑名单永远要面对"攻击者用了你没见过的姿势"。
举个例子,光是一个路径表达,就有普通穿越、多重编码穿越、长路径变形好几种变体。过滤清单如果没覆盖到伪协议,源码泄露就只是时间问题。更本质的原因在于 include 的输入空间太大了——函数本身接收的就是一个文件路径,而文件系统的命名规则又极其复杂,空格、点、斜杠、反斜杠、Windows 盘符、Unicode 混淆全都能参与进来,想用几条正则堵死所有入口,几乎不可能。
所以我在评审建议里第一条永远是:不要跟黑名单较劲,把思路换到"白名单 + 路径规范化"上。具体怎么做,我会在防御章节展开。
3. 文件下载与读取漏洞:download.php?file= 的历史遗留病
3.1 任意文件下载的成因:路径参数直接拼接
如果文件包含是"把文件拿进来执行",那么任意文件下载就是"把文件拿出去给你"。很多老系统里都能看到类似这种下载接口:
$file = $_GET['file']; $path = "uploads/" . $file; header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename=' . basename($file)); readfile($path);这段代码看着挺合理:限制了 uploads 目录、给了下载响应头、文件名也做了处理。但问题恰恰出在 $file 是直接拼进路径的。攻击者传一个 ../ 序列,比如 ../../config/database.php,最终拼出来的路径就变成 uploads/../../config/database.php,操作系统解析后,完全可以跳出 uploads 目录。
download.php?file= 这种形态我基本只在老项目里见到了,但它死而不僵——最新框架里依然有大量导出功能用类似逻辑实现,只是参数名从 file 换成了 name、path、filename、flowId。名字变了好几个,本质还是用户可控路径。
3.2 文件读取与下载:同源同根的不同出口
文件读取漏洞和下载漏洞的成因几乎一样,只是出口不同。下载接口最后给浏览器一个 attachment,告诉浏览器"这是文件,不要直接显示";读取接口则可能直接把内容塞进响应体,比如用一个 JSON 接口返回文件内容,或者一个帮助页面里嵌入了"查看配置"的功能。
从代码审计视角,我基本不把这两类分开看。搜索函数时会统一搜 readfile、file_get_contents、fopen、FileInputStream、Files.readAllBytes 这些,然后先问两个问题:
- 文件路径是否包含用户可控参数?
- 读取之后的内容去了哪,是下载响应、页面回显,还是写到了另一个文件?
如果内容直接回显到页面,危害往往比"下载"更大。普通下载还得先拿到文件再看,而回显型读取接口相当于把文件内容直接展示给攻击者,甚至可能被浏览器解析逻辑二次利用,变成 XSS 入口。审计时一旦看到某个参数出现在文件操作里,不管它叫读取还是下载,都应该走高危评审流程。
3.3 泄露什么最致命:不只是 /etc/passwd
刚学这类漏洞的时候,很多资料都拿 /etc/passwd 当例子。但现实中真正让我后怕的泄露事件,泄露的往往是这几类文件:
- 应用配置:.env、config.php、application.properties,里面有数据库账号、Redis 密码、第三方密钥。
- 备份压缩包:整站代码备份可以直接拖下来,接着做源码审计,等于完成了信息收集的"一步到位"。
- 权限密钥:SSH 私钥、云服务 token、JWT 签名证书。
- 用户数据:订单导出、身份证照片这类业务文件如果路径可控,影响面直接就是合规级事故。
我印象很深的一次授权测试里,目标是一个"导出年度报表"的功能,报表文件名由前端传参控制。我尝试把文件名换成配置文件名,直接就把数据库连接信息取了出来。整个过程不到两分钟,但造成的风险比常规 SQL 注入还直观。
4. 文档与编辑器场景中的文件引用风险
4.1 从"该文档包含的域可能引用了其他文件"说起
办公软件在打开某些包含外部引用、数据连接、域代码的文档时,会弹出一个提示,大意是"该文档包含的域可能引用了其他文件"。这个提示是客户端软件的安全机制——它在提醒用户:这个文档里藏了"去别处取数据"的指令。
对普通用户,这个提示最多是点个"否"。但如果你是一个开发在线预览、文档转换、富文本导出功能的工程师,这个提示背后的事远没有这么简单。
想象一下:你的系统接收用户上传的 docx、xlsx、pdf、xml 文件,然后调用第三方解析库把它转成网页预览。解析库看到文档里声明了一个外部引用,它会不会主动去加载?如果会,那么攻击者上传一个精心构造的文档,你就帮他完成了一次"服务器发起的文件读取或文件下载"。这不是危言耸听,不少在线预览和转换服务就因此出过问题。
4.2 XML 外部实体解析:指向本地的"文件引用"
这类问题在安全圈最著名的形态是 XXE,全称 XML 外部实体注入。XML 规范允许文档里声明一个"外部实体",并给出一个地址。解析器如果允许加载外部实体,就可以把本机文件、内网地址当作实体内容读取出来,甚至带回显到页面。这正是"文件包含 + 文件读取"在文档解析场景里的翻版。
防御上其实并不复杂,关键是不能偷懒:
- 解析 XML 时关闭外部实体和 DTD。
- 不给解析器访问文件系统或网络的权限。
- 对上传文档做真实类型识别,不能只看扩展名,因为扩展名完全可以伪造。
- 转换和预览尽量用独立隔离环境,避免把生产环境的文件系统直接暴露给解析库。
4.3 不止 PHP:Java、Python、前端都会遇到"引用劫持"
文件下载读取漏洞的形态远比 PHP 里的 include 丰富。Java 代码里 FileInputStream 直接拼接用户参数,Python 里 open() 接收了请求参数,甚至前端里一个被解码的内容当成 URL 处理,都可能造成"间接文件引入"。
判断标准其实一句话就能说清:你的应用是否允许用户输入的内容成为某个"文件来源或加载地址"?如果是,有没有告诉系统这个地址必须在哪个范围内?没有的话,不管是哪种语言,逻辑上都是同一个洞。
5. 从代码审计视角排查三类漏洞
5.1 先搜危险函数,再查参数来源
白盒审计最有效率的方法不是漫无目的地翻代码,而是先通过搜索把"可能操作文件"的语句全部拉出来。以 PHP 项目为例,起步命令可以是这样:
grep -rn "include\|require\|readfile\|file_get_contents\|fopen" --include="*.php" app/ modules/ | grep -E '\$_(GET|POST|REQUEST|COOKIE)'这条命令有两层过滤:第一层找到所有操作文件的地方,第二层筛出参数来自超全局变量的行。这样能快速定位到"用户可控 + 文件操作"的交集。Java 项目里就是把 FileInputStream、Files.readAllBytes、ClassPathResource 这类类名和 request.getParameter 交叉着看。
需要提醒的是,这个交集只是候选点,不一定就是漏洞。接下来要顺着代码往上走,看参数在进入文件操作函数之前有没有经过校验,有没有被强制映射到固定目录,有没有做路径规范化。
5.2 一个 download 接口的完整排查记录
讲一个我实际做过的排查流程,大家可以直接照着套。目标是一个老系统的附件下载功能,接口长这样:
GET /attachment.php?name=report_20250101.pdf第一步,先看接口用途。它负责从 downloads 目录取附件并返回下载响应,接口文档也明确写"只允许下载 downloads 目录下的文件"。
第二步,搜索 attachment.php 的源码,核心逻辑是:
$name = $_GET['name']; $path = '/var/www/downloads/' . $name; readfile($path);第三步,查看 name 参数在拼进 $path 之前有没有任何过滤和校验。注意,这不只是看当前代码,还要看公共请求清洗函数、路由层有没有统一校验。我翻了一圈,没有任何校验。
第四步,做最小验证。在授权范围内,我用一个无害路径做测试,比如把 name 传成一个不存在但格式化上会跳出 downloads 目录的值,观察响应状态和错误信息,确认路径拼接真的生效了。
第五步,评估影响并出具修复建议。当时我给的结论是:这个接口根本不是只读 downloads 目录,而是把整个文件系统暴露了。建议是"不要传文件名,改成传附件 ID,由服务端查库映射真实路径;同时对所有输出做 realpath 白名单校验。"
这是一个非常典型的案例,五个步骤走下来,问题链条清楚,修复方向明确。
5.3 授权测试下怎么做最小化验证
做安全验证,第一步永远是确认授权边界。我自己的最小化验证思路是:不碰数据库备份、不碰用户数据、不碰高敏配置,只验证"路径是否可控"这个事实。比如造一个测试文件放到临时目录,通过漏洞尝试读取它;或者请求一个不存在的跳转路径,观察是否出现"文件不存在"的报错。只要能让证据链闭合就够了,完全没必要真把生产配置拖出来。
提示:所有验证都必须在授权范围内进行,优先使用 GET 请求和无副作用的路径,避免对业务数据做任何写操作。
还有一个经验:验证时尽量选不会触发额外业务逻辑的路径。有一次为了验证一个文件读取点,差点触发了下载接口的日志落地逻辑,虽然最后还是拿到了证据,但动静明显比预期大。后来我想想,完全可以用更轻量的方式,这个教训也顺带分享出来。
6. 防御与加固:给文件路径加上"边界"
6.1 真实路径规范化:让系统帮你判断
路径校验的正确姿势不是"判断字符串里有没有 ..",而是让操作系统帮忙把路径解析成真实路径,再判断真实路径是否落在允许的范围内。PHP 里用 realpath,Java 里用 File.getCanonicalPath,Python 里用 os.path.realpath,原理都一样。
PHP 的推荐写法:
$base = realpath(__DIR__ . '/../../downloads'); $target = realpath($base . '/' . $_GET['name']); if ($target === false || strpos($target, $base . DIRECTORY_SEPARATOR) !== 0) { exit('非法路径'); } readfile($target);这里有个非常关键的细节:判断前缀时,一定要在 $base 后面拼上 DIRECTORY_SEPARATOR。否则,当白名单目录是 /var/data 时,/var/database 也会通过校验,因为它的前缀也是 /var/data。这是我见过最容易被忽略的绕过点。
6.2 ID 映射法和白名单:把"文件名"从用户手里收走
比路径校验更稳的方案,是根本不接收文件名。用户传一个附件 ID、一个业务编号,服务端拿着这个 ID 去数据库查出真实路径,再执行文件操作。这样就算用户把参数改出花来,服务端也只会查不到记录。
这个方法在工程上实施起来并不难,但需要业务层配合。如果接手的是老系统,短时间内改不了表结构,那退而求其次也要在配置里维护一份"允许下载目录"的白名单,用户传的文件名只允许是白名单里某个目录下的相对路径。
PHP 层面还有两个建议顺手做掉:php.ini 里 allow_url_include 保持 Off,open_basedir 限定 PHP 只能访问指定目录。这两项属于纵深防御,不能当主防线,因为 open_basedir 在某些场景下也存在被绕过的案例,但总比裸奔强。
6.3 部署层加固:WAF、存储分离与日志监控
应用代码修完之后,部署层还能做不少事:
- WAF 可以拦截明显的路径穿越和伪协议请求,但不要把希望全押在它身上。WAF 规则覆盖不了所有编码变体,也拦不住已经藏在正常参数里的攻击流量。
- 如果附件和导出文件能迁到对象存储或独立静态服务,应用代码对本地文件系统的读写路径就会大幅收窄,这是根治这类问题的架构级方案。
- 日志记录里一定要把 filename/path 这类参数单独打出来。很多扫描器在探测漏洞时会产生大量异常路径请求,日志里有这个字段,应急时定位攻击者试过哪些路径会快得多。
提示:判断目录前缀时,务必在根目录后补上目录分隔符,避免 /var/data 与 /var/database 这种前缀污染。
7. 落地经验与常见误区:修复文件类漏洞时我踩过的坑
7.1 看上去过滤到位,实际上可以绕过的三个坑
第一个坑是只过滤 "../" 这串字符串。攻击者把点换成 URL 编码、把斜杠换成反斜杠、甚至利用长路径截断,过滤规则就失效了。所以后来我一律不用字符串过滤,只认解析后的真实路径。
第二个坑是用了 basename() 就觉得安全。basename 确实能去掉路径里的目录部分,但它在处理 Windows 风格路径、某些特殊字符时会和你预期不一致;更麻烦的是,如果业务逻辑还需要保留子目录,basename 会把子目录也一起剥掉,导致正常功能直接坏掉。修复这类问题时不能只改一个函数,要把路径的来源场景一并想明白。
第三个坑是修复了一个入口、漏了同逻辑的另一个入口。同一个 $file 参数经常同时出现在下载、预览、导出三个功能里,研发只改了下载那处,预览那处还是老代码。所以做验收时我都会要求研发把同一个参数的所有引用点全部列出来,逐个确认。
7.2 修复之后必须做的回归验证
修复不是改完代码就结束,回归验证至少要覆盖这几条:
- 正常场景还能用:普通附件还能下载、预览还能打开。
- 越权路径被拦住:跨目录的路径访问返回 403 或业务层面的错误。
- 特殊文件名不崩:带中文、空格、URL 编码的合法文件名能正常解析。
- 请求变形也能拦住:简单的编码绕过、拼接绕过试一遍。
我个人在做完回归之后,还会习惯性看一眼错误日志,确认没有因为新加的路径校验产生大量误报。毕竟安全加固最怕的不是没防住,而是把正常用户也拦在门外。
每一次修完这类漏洞,我都会在验收记录里补一行备注:攻击路径确实断了,正常路径也还通着。这一行字看起来不起眼,但在后续周报、复测、甚至事故复盘的时候,它能让所有人一眼判断出本次修复是否真正有效。