暴露的 .env 文件与泄露密钥

暴露的 .env 文件是一份配置文件——装着你的数据库密码、邮件凭据和 API 密钥的那一份——你的 Web 服务器会把名字报出来的人都发一份;暴露的 .git 文件夹是同一种事故,只是还附带了你全部的源代码历史。两者都不需要攻击者攻破任何东西:文件只是放在服务器会发布的位置上,一次普通的 GET 请求就能把它下载走。本文解释这种情况如何发生——文档根目录失误、把仓库本身当作站点服务的静态托管、没人审计过的构建产物——发现者会在第一小时拿密钥做什么,以及为什么事后删除文件只是整个响应中最小的一部分。想现在就检查自己的网站,运行泄露密钥扫描器,对照结果阅读。

私有文件如何变成公开:文档根目录

每台 Web 服务器都有一个文档根目录:其内容按定义即公开的目录。放进其中的任何东西——有意或无意——都可按 URL 请求,无论是否有页面链接到它。经典事故是把整个工作目录复制到服务器完成部署:本该在那里的应用代码,与它旁边的 .env 文件、它里面的 .git 文件夹、编辑器备份副本、某人在迁移前做的数据库转储、上个月版本的压缩包一同抵达。服务器分不清这些。它只负责发文件;而私有的那些也是文件。

框架惯例让这个陷阱很容易踩。.env 文件本应位于文档根目录上一层,或完全在部署树之外,由应用从磁盘读取——永远不经过 Web 服务器。但开发服务器乐于服务项目根目录,小型部署把一切拖进 public_html,从教程抄来的配置可能把文档根目录指向项目本身。发生这种事时表面上一切都不坏,而这正是问题所在:站点运行完美,文件却是公开的,第一个注意到的是请求 /.env 的人——自动化扫描器对互联网上的每个站点持续发出的正是这个请求。

静态托管、被服务的仓库与构建产物

静态托管把服务器配置从等式中去掉,换成一条更简单的规则:发布目录里有什么就发布什么。用输出目录设为项目根目录的方式把仓库部署到静态主机,主机便会忠实地服务这个仓库——如果 .git 被复制进了输出,它也在其中。暴露的 .git 比看上去更糟:HEAD 文件和配置证明了布局,对象文件包含每个曾提交的 blob,用普通 Git 工具就能重建整个历史——包括那些曾被提交一次、又在后来的提交里“删除”的密钥;那种删除只把它们从工作树里移除,其他任何地方都没有。

构建产物造成第二类更安静的暴露。JavaScript 打包器会把代码引用的东西内联,所以在构建时从环境变量读取的 API 密钥可能以字面字符串结束在交付的包里——按设计公开、每个访客可读——而且不会被任何文件权限检查标出,因为这个文件本就该公开。源映射(source map)又把原始源码放在包旁边,使情况更糟。这一切都不需要配置错误的服务器;只需要的是一次从未从外部检查过的构建。

第三种模式是残留物:部署脚本把压缩包解到 Web 根目录并把压缩包留在那里,备份插件把转储写进可经 Web 访问的文件夹,编辑器存下的 config.php.bak——许多服务器恰恰因为扩展名不再匹配 PHP 处理器而把它当纯文本发出。每件残留物都是一件私有之物的完整副本,放在一个可预测的地址上。

攻击者在第一小时做什么

发现是自动且持续的。扫描器在大范围地址上枚举常见路径——env 文件、Git 内部文件、配置名、备份扩展名——新的暴露通常在数小时内被找到,往往更快。到达你服务器的是一个不起眼的、返回 200 的 GET 请求;没有警报,因为从服务器的角度看什么异常都没发生。因此应当假定:任何公开过一天的文件都已被下载,举证责任的方向是反过来的。

分诊也是自动的。下载到的 .env 在几秒内被解析:数据库凭据会被拿去试任何可达的数据库端口,云密钥用一次无害的 API 调用验证是否有效,邮件凭据被试着用来发信,支付密钥被检查其模式——生产环境的支付密钥和生产数据库凭据是头奖,因为每一个都能直接换成钱或数据。有效的密钥会被迅速使用、交易或持有,因为它们的主人随时可能轮换它们;暴露的密钥在第一小时最值钱,而这一个小时被高效地花掉。

为什么删除文件不够

删除文件只能停止新的下载。它对已经存在的副本毫无作用:显然有发现者的那一份,也有你自己制造的副本——被你的 CDN 或公司代理缓存的响应、搜索引擎或存档服务抓取里的快照、浏览器已经下载的包。在每个缓存被清除并过期之前,这个“已删除”的文件可能仍被你控制却忘了清理的基础设施发出,而你控制之外的那个版本则永远在你控制之外。

这就是为什么对已确认暴露的第一反应是轮换而不是删除:吊销已暴露的凭据并签发新的,使全世界的每一份副本在同一时刻变得毫无价值。轮换有顺序——签发替代品、部署它、验证服务正常、然后再吊销旧凭据——并且它延伸到已暴露材料能解锁的一切:一次 .git 暴露意味着轮换曾经出现在历史中的每一个密钥,而不只是当前的那些,因为被披露的正是历史本身。此后,阅读你对已暴露路径的访问日志:它们会告诉你你是否是第一个知道的人,更早的抓取会把一次配置事件变成一份清单完全不同的入侵调查。

探测清单详解——以及预防清单

像泄露密钥扫描器这样的扫描器只能按名索取文件,所以它的清单就是那组常见项:/.env 及其环境变体、/.git/HEAD 与 /.git/config、熟悉的配置文件名、备份与压缩扩展名、密钥文件名——外加页面自己的脚本,按密钥形态的字符串搜索。诚实的局限双向起作用:扫描器只找它所问的,这就是为什么它按内容而不是状态码确认发现。一个返回你首页的 200 是全捕获路由,不是泄露,任何把它计作泄露的报告都是在自我膨胀。相关的供应链风险——你刻意从他处加载的脚本——见第三方脚本与供应链风险。

预防主要是摆放位置加一层拒绝。把密钥放在文档根目录之外、仓库发布输出之外;让服务器即使在危险名字落到那里时也拒绝它们——针对点文件、env 与配置扩展名、压缩包和密钥材料的拒绝规则,并通过实际请求来测试;从部署产物中剥离 .git 或干脆封锁它。在流水线中,在 CI 里加密钥扫描——在提交时,让密钥在交付前就被拦住;也在构建产物上,让被打包的密钥在输出中被抓到,而不是被陌生人抓到。最后,按计划并在每次部署变更后亲自扫描:本文中的暴露全都是无声的,而想在别人之前了解自己的暴露,唯一的办法就是向自己的服务器问与扫描器相同的问题。

来源与延伸阅读

常见问题

我删掉了暴露的 .env 文件。现在安全了吗?

还没有。删除只能停止新的下载,但副本可能存在于发现者手里、CDN 与代理缓存里、以及抓取与存档中。请轮换该文件包含的每一份凭据——吊销旧密钥并签发新密钥——清除你的缓存,并检查该路径的访问日志。只有轮换能让副本变得毫无价值。

我的站点对 /.env 返回 200。文件暴露了吗?

不一定。许多站点对每个未知路径都以 200 和它们的首页或应用外壳作答——那是全捕获回退。重要的是内容:如果响应正文是你的页面,就没有暴露;如果是带真实值的键值行,那就是。值得信赖的扫描器按内容确认,并把裸 200 单独列为未确认。

CI 里应该跑什么来阻止这种事再次发生?

在两个时刻做两项检查:对提交做密钥扫描,阻止密钥进入仓库;并检查构建产物和已部署的输出目录,抓住构建内联进去的和部署复制进去的东西。再加上服务器对点文件和密钥材料的拒绝规则作为最后一层,并在部署变更后重新运行外部探测。