尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

文件包含+任意文件上传组合链:从LFI到RCE应急加固

文件包含+任意文件上传组合链:从LFI到RCE应急加固 如果你在搜索引擎里敲下文件包含这四个字大概率会撞见两种完全不相干的结果一种是 C/C 编译时报出的#include找不到头文件、提示你去检查 IncludePath 的报错另一种是安全圈里说的文件包含漏洞也就是常被写成 LFI / RFI 的那一类问题。名字撞车性质却差着十万八千里——前者是编译期的静态文本替换找不到就报错全程可预测后者是运行期的动态加载加载什么完全取决于运行时拿到的那个字符串。很多刚入门的朋友搜文件包含搜到一半就迷糊了就是因为没先把这两个语境分开。这篇要聊的是后者而且不是孤立的文件包含。单独一个文件包含很多时候只能做到读文件离真正的 RCE远程代码执行还差一层窗户纸而当它和任意文件上传凑到一起这层窗户纸就被捅破了。以通达OA这类协同办公系统为代表的一批内网应用历史上出现过的典型问题正是这条组合链上传点负责把内容送进去包含点负责把内容当代码执行两段拼起来就是完整的 RCE。这篇文章站在防守和应急的视角把这条链从头到尾拆一遍——它为什么成立、条件是什么、告警长什么样、怎么排查、怎么从根上剪断。做运维的、做应急响应的、写后端代码的同学都能从中找到对得上自己活儿的部分。1. 先把文件包含这个词拆开编译期包含与运行时包含1.1 从 #include 报错说起C/C 里的#include是预处理指令它的工作发生在编译之前做的是纯文本层面的事把指定文件的内容原样搬到当前位置然后交给编译器。所以一旦路径写错、目录没配、头文件缺失编译器会立刻甩给你一句检测到 #include 错误请更新你的 IncludePath因为在这个阶段所有依赖关系都是静态可见的缺什么、在哪缺一清二楚。构建系统甚至会把每个头文件的依赖关系画成一张有向图用来做增量编译。这类包含是确定的、可枚举的、能被工具完整分析的。PHP、JSP、ASP 这类脚本语言里的include/require则完全是另一码事。它们发生在运行期而且最要命的一点是被包含的路径可以是一个变量变量的值可以来自请求参数。这一下就把静态可分析的依赖图变成了运行时才知道的字符串。你没法在部署前画出一张完整的包含关系图因为这张图取决于每一个请求带来了什么。用个不太严谨但很好懂的类比编译期的 include 像装订一本手册附录在装订时就已经按目录订进去了缺页当场就能发现运行期的 include 像读到某一页时书上写着请现在翻开另一个文件夹里的第 N 页继续读——翻哪本、第几页是读的时候才决定的。如果这句话是编辑写死的没事如果这句话是读者自己填的那就出事了。1.2 运行时包含为什么危险include 本质是把文件当代码执行很多人对包含漏洞的理解停在能读文件这一层比如读到/etc/passwd就算胜利。这其实是低估了它。include的语义不是打印文件内容而是把文件内容当作当前语言的代码参与解析执行。也就是说被包含的文件只要落在 PHP 标签内内容就会被当成 PHP 代码跑起来而不是被当成文本输出。这个区别决定了漏洞的严重程度上限。如果能读文件但不会执行最坏情况是敏感信息泄露如果能执行那就是服务器权限范围内的任意代码执行。所以判断一个包含点到底有多大危害第一个要问的不是能包含什么路径而是包含进去之后文件里的内容会不会被解析。在某个固定的目标环境里这又取决于几个具体的配置目标文件的后缀是否在解析范围内、内容里是否带了脚本起始标签、allow_url_include这类开关是否打开。这也是为什么同一条利用思路在 A 环境一次就通到了 B 环境怎么试都不行——不是思路错了是环境不满足。1.3 单点包含为什么不够为什么要配上上传纯粹的文件包含攻击者能控制的只有路径控制不了内容。他能指向服务器上已有的文件但没法往服务器上写新文件。而服务器上现成的文件内容基本都是业务代码或系统数据带脚本标签的极少。所以孤立地看它通常只能做到信息读取或者包含一些本来就存在但设计者没打算让人直接访问的脚本。要突破这一层就需要一个能把攻击者可控内容落到服务器磁盘上的通道。任意文件上传正好提供了这个通道。上传负责落内容包含负责给这份内容授予执行权——两段链条各司其职缺一不可。这也是为什么在应急响应里看到包含告警一定要顺手去翻上传接口的日志两者往往是成对出现的。2. 一条链的成立条件上传与包含是怎么被焊在一起的2.1 任意文件上传真正的问题不是能上传而是上传后能被执行先把一个常见的认知偏差纠正掉上传漏洞的危害不取决于能不能把文件传上去而取决于传上去之后这个文件处于什么位置、以什么身份存在。把一张图片传到 Web 根目录下的静态资源目录那个目录没有脚本解析权限传上去一万个文件也只是占硬盘反过来如果上传目录恰好是脚本可解析的而且文件名和后缀没被约束那一个文件就足以掀桌子。所以评估上传点时我一般按这个顺序问三个问题这份文件最终落在哪个物理路径这个路径是否在 Web 服务器的可访问根目录内这个目录是否配置了脚本解析还是只当作纯静态资源文件名、扩展名、内容三者里服务端实际校验了哪几个很多老系统的上传逻辑只做了最省事的那一层——查扩展名。而扩展名校验一旦做成黑名单就天然存在漏项问题因为它需要穷举所有危险后缀而服务器能解析的后缀组合、不同中间件的映射关系永远比开发者写进黑名单的那几行长。2.2 文件包含在下半场的角色给静态文件注入执行权上传把内容放到了磁盘上但这些内容此刻还是一堆躺在文件系统里的字节没人去读它。这时候包含点就登场了。它做的事情是把某个路径的字符串交给解析器让解析器去读这个文件并且把它当作代码处理。这里有个特别值得玩味的点很多场景下上传上来的文件本身扩展名是被改造过的、或者根本不是什么脚本后缀从静态检查的角度看毫无威胁。但只要包含点愿意去读它后缀是什么就完全不重要了——解析器不看后缀只看内容。一句话概括这条链的本质上传负责把数据放进来包含负责把数据提拔成代码。数据本身是无害的代码才有杀伤力。中间的这次身份转换就是整条链最关键的铰链。2.3 链条成立的三个隐性前提把这条链拆开看它的成立需要三个前提同时满足。做防守的时候把这三个前提里任意一个掐掉链条就断了——这也是后面加固思路的来源。前提具体含义常见失效表现防守侧的抓手路径可达包含点能通过可控字符串定位到目标文件参数直接拼进 include且未做白名单拒绝外部输入参与路径构造位置可控上传的文件落在包含点能到达的目录上传目录固定、路径可穿越上传目录移出 Web 根、目录隔离权限足够Web 进程有读取该文件的权限运行账户权限过大最小权限、open_basedir 类限制这三个前提里最容易被忽视的是第一个和第三个。第一个是代码问题写代码的人未必意识到风险第三个是配置问题装机器的人未必意识到影响。两者叠加才让链条从理论可行变成实际打通。3. 上传点常见校验失效的几种形态3.1 只查后缀不查内容黑名单的天然缺陷后缀黑名单的问题不在于实现得多烂而在于它的思路本身是枚举坏东西。任何枚举都面临两个不可控变量一是漏项二是变体。漏项指的是开发者没想到的后缀比如某些中间件会把一些冷门扩展名也交给脚本引擎解析变体指的是同一个后缀能写出的多种等价形式比如大小写、多后缀叠加、结尾带特殊字符等等。这两种问题在实战里几乎必然出现。原因很简单写黑名单的人和做中间件配置的人往往不是同一批人而中间件到底把哪些后缀交给了哪个解析器只有翻配置文档才知道。所以从工程角度黑名单可以作为一道辅助手段但它绝不能是唯一的防线。我在实际排查里发现过一个很典型的规律凡是只做了后缀黑名单的上传接口出问题的概率远高于平均值。因为它的安全性完全依赖开发者记得全而人是不可能记得全的。3.2 只信前端和 Content-Type服务端信任链断裂第二类失效形态更隐蔽一些它的问题不在校验规则而在校验的位置。常见的有两种一种是校验写在前端 JS 里。用户在页面上选文件JS 拦住不合规的文件名。这套逻辑对正常用户是有用的因为它能提前给出提示但对攻击者毫无意义因为绕过前端校验只需要构造一个请求就行浏览器的 JS 根本不在链路上。前端校验的唯一定位是提升用户体验不是安全防护。另一种是把Content-Type当证据。这个字段是请求体里的一个普通头部客户端想写什么就写什么服务端拿它当判断依据等于自欺欺人。更常见的是代码里用文件扩展名反推 MIME 类型或者反过来用 MIME 类型决定扩展名这两种逻辑都容易被构造出不一致的结果。判断一个上传接口是否可靠看一个点就够了它的校验逻辑是否完全在服务端且是否同时校验了扩展名、内容类型和文件内容特征这三者。三者缺一就等于给攻击者留了一道侧门。3.3 路径与文件名拼接目录穿越与覆盖第三类问题出在保存路径这一步。很多老代码的写法是把上传目录和文件名拼起来$savePath $uploadDir . $_POST[filename]; move_uploaded_file($tmp, $savePath);这段代码里filename完全由客户端提供。如果这个字段里带了../这样的相对路径片段保存位置就可能跑到上传目录之外——跑到 Web 根目录、跑到配置文件目录、甚至跑到可以覆盖已有文件的位置。目录穿越的后果比单纯上传更严重因为它把写入一个受限目录变成了写入任意位置。还有一类变体是覆盖。如果目标路径存在同名文件某些实现会直接覆盖于是攻击者可以用上传来覆盖一个本就在预期内的文件从而改变系统行为。严格来说这已经不算上传漏洞而是任意文件写入了危害等级更高。3.4 上传目录被当成静态资源站最容易被忽略的一环前面三点都是代码层面的事这一点是配置层面的而且特别容易被忽略上传目录被直接暴露在 Web 根目录下且该目录具备脚本解析能力。这两件事单独看都不算大问题——目录暴露只是让人能下载文件脚本解析是服务器的正常能力。但当上传的文件和可解析的目录重叠在一起上传这个动作就等价于在服务器上部署一段可执行代码。我在做加固方案时对上传目录通常有三个硬性要求物理路径移出 Web 根、或者至少在 Web 服务器层面单独配一段 location 禁止脚本解析上传的文件一律随机重命名不保留用户提供的文件名文件访问统一走一个下载脚本做权限校验而不是让 Web 服务器直接吐文件。这三条做完即使上传接口的校验出了纰漏攻击者拿到手的也只是一个能在服务器上存文件的能力而不是能在服务器上执行代码的能力。4. 包含点的三类典型写法从变量拼接到日志落点4.1 直接拼接 $_GET / $_POST 的经典写法最直白的写法长这样include $_GET[page] . .php;这种代码在早期项目里非常常见思路是用一个参数切换页面。写法本身简洁但把路径构造权完全交了出去。攻击者要做的事只有一件构造一个能指向目标文件的字符串。注意末尾那个.php拼上去并不意味着安全——它是前缀还是后缀、能不能被路径穿越消化掉、服务器对特殊路径片段的处理方式都会影响最终指向哪里。这里我想强调一个经验不要用这个拼接看起来很难绕来论证安全。路径的规范化、软链接、相对路径的层级、不同文件系统的差异组合起来的情况多得超出直觉。稳妥的判断标准只有一条外部输入有没有参与路径构造。参与了就当它不安全。4.2 白名单没做全前缀、后缀、截断的擦边比裸拼高一级的写法是加了个白名单但加得不彻底。典型形态有三种第一种是前缀拼接形如$baseDir . $_GET[f]。前缀在左边看似把范围限制在了$baseDir里但只要参数里带上返回上一级的相对路径片段就能一层层爬出去。限制范围靠的是过滤掉向上跳的片段而不是加个前缀。第二种是后缀拼接形如$_GET[f] . .php。后缀的限制力比前缀还弱因为末尾拼接的东西通常可以用路径片段消化掉或者被当成目录名的一部分处理。第三种是字符串截断类问题在特定历史环境下靠超长或特殊字符让校验逻辑与解析逻辑看到不同的字符串。这类问题的现代版本基本都修掉了但老系统里依然存在做审计时不要因为这个是老问题就跳过。真正可靠的白名单是键值映射不是字符串过滤。这两者的差别等下在加固那节会给出具体代码。4.3 日志文件包含不是漏洞的错是日志被当代码读的错日志文件包含业内常简称日志包含是这条链里最有趣的一个玩法也是最容易被误判为这不是漏洞的一类。它的逻辑是Web 服务器会把每个请求的 URL、User-Agent 之类的信息原样写进访问日志如果攻击者把这些信息构造得带上了脚本特征那么日志文件里就混进了看起来像代码的内容再通过一个包含点去读这个日志文件日志里的内容就被当代码解析了。注意这个链条里没有任何一个环节是设计缺陷日志记录请求信息是它的本职工作包含点是代码自己写的日志文件是正常存在的。问题出在这三件事同时成立的时候——一份原本只应该被人看的文本文件被程序当成了代码来读。这个案例有个很实用的推论排查系统里的包含风险时不要只看业务模板目录。日志目录、缓存目录、上传目录、临时文件目录、会话文件目录这些都是内容可能被外部影响的地方只要包含点能指过去就有戏。4.4 无参数与短标签依赖为什么有些环境打通了有些打不通经常有朋友问同样的思路为什么我在测试环境怎么都通不了 这个问题背后一般有几个具体变量在作祟值得单独列一下变量影响排查方式脚本起始标签配置短标签是否启用决定某些载荷是否会被解析查 php.ini 相关开关远程包含开关决定能否直接从远端地址加载内容查远程包含相关配置项目录访问限制限制脚本能读写的目录范围越界直接失败查目录限制类配置Web 运行账户权限决定能不能读到目标文件看进程属主与文件权限日志格式与落盘位置决定日志里内容的具体形态直接看日志文件样本还有一个现象叫无参数执行指的是在某些特定写法下攻击者不需要传入自己构造的参数而是借助系统里已有的函数组合完成执行。这类技巧对环境的依赖更强可移植性很差经常是在这个版本能用、换个版本就不行。从防守角度看这类技巧的存在提醒我们不能因为某个具体载荷在当前环境跑不通就判定这里没有风险。判断风险的依据是代码逻辑不是某一次尝试的结果。5. 应急响应实战从告警到定位落地文件5.1 先看什么Web 日志里的三个可疑信号一旦收到相关告警或者怀疑被打了这条链第一步永远是看日志不要急着删文件。访问日志里这几类信号出现的组合度很高第一类是包含点的异常参数。业务正常的包含参数值通常是固定的几个短字符串突然出现带有路径特征、带有多层相对路径、或者是绝对路径的取值就是强烈信号。第二类是上传接口的异常请求。重点看请求体的类型和大小、上传后的响应状态、以及紧接着对上传路径的访问。正常用户上传完会看到成功提示然后继续操作攻击者的行为模式是上传 → 立刻访问 → 立刻带参数调用包含点时间间隔往往在秒级。第三类是状态码的异常跳变。比如连续几个 404 之后突然出现 200或者某个平时从不被访问的路径突然被高频请求。这类模式在日志里画成折线图会非常显眼。我的习惯是先把日志按可疑时间窗口切出来把那个窗口内所有对上传接口和包含点的请求列出来按时间排序基本上整条链的动作序列就摆在眼前了。5.2 文件系统排查时间线、体积、权限三个维度日志看完接下来是落地文件的排查。这里给一个我自己常用的排查顺序按修改时间筛。从可疑时间窗口起把该时间段内新增或修改的文件列出来。命令层面可以用find配合时间参数注意时区要和日志对齐否则会错开一段。看体积。攻击者放上去的文件通常很小几百字节到几 KB因为它们只需要装一小段代码。而正常业务文件的大小分布是相对固定的极小文件本身就是异常特征。看权限和属主。正常上传的文件属主应该是 Web 运行账户权限通常是常规的读写权限。如果出现可执行位、或者属主不对就要重点看。看扩展名与实际内容是否一致。这是最关键的一步。把文件头读出来对比一个声称是图片的文件内容里却是脚本标签这就是明证。检查 Web 根目录之外的路径。有余力的话把整个 Web 根的外层目录也扫一遍因为目录穿越类的写入很可能落在外面。排查的时候有个原则要守住先记录、再处置。把文件的路径、大小、修改时间、哈希、内容摘要全部记下来拷贝一份样本留存然后再动手清理。跳过这一步后面复盘就无从谈起。5.3 进程与网络确认是否已进入持久化阶段文件找到不代表事情结束了还要确认攻击者有没有留下来。重点看这几个地方进程列表里找那些由 Web 运行账户启动的、非常规的进程。正常情况下一台 Web 服务器上Web 账户不该起命令行解释器、不该起下载工具、不该起任何交互式的程序。看到就是问题。网络连接方面看有没有从这台机器主动外连到陌生地址的长连接特别是非常规端口的。持久化往往需要回连通道。再往下是几个容易被忽略的落点计划任务、开机启动项、Web 应用自身的定时脚本、以及账号体系里有没有被新增的账户。这几个位置不查很可能出现清理完了过两天又活了的情况。还有一个常被忽视的点是二次落脚点。攻击者进来之后通常会在多个位置放东西删掉其中一个并不影响其他。所以清理要以完整梳理为前提而不是看到什么删什么。5.4 处置顺序为什么不能先删文件应急响应的处置顺序我总结成一句话先隔离再取证后清理最后验证。先隔离指的是把受影响的机器从网络里摘出来或者至少把对外访问切断防止攻击者继续操作、也防止横向扩散。这一步要快。再取证就是前面说的记录和留存。内存里的东西进程、连接、临时文件一旦重启就没了所以取证要抢在重启之前。后清理才是删文件、清任务、改配置。这里有个特别容易犯的错先改密码。很多人的第一反应是赶紧把密码都换了但如果后门还在改密码对攻击者毫无影响反而打草惊蛇而且新密码可能直接被后门抓到。正确的顺序是先把后门清干净再改凭据。最后验证指的是清理完要重新做一轮排查确认没有遗漏并且在接下来一段时间里保持监控。经验上被打过的系统在清理后的一两周内是最需要盯着的窗口。6. 加固方案把这条链从三个位置同时剪断6.1 上传侧白名单 重命名 隔离存储上传侧的加固我一般按四步走来落地。第一步扩展名白名单而且要基于业务实际需要来定。图片业务就只允许图片格式附件业务就只允许文档格式绝不出现允许上传任意文件这种需求。白名单的写法是判断是否在允许集合内而不是是否在禁止集合外。第二步服务端强制重命名。文件名由服务端生成用户提供的名字只作为展示用途存在元数据表里不参与磁盘路径构造。名字的生成方式建议用随机值加时间戳避免可预测。第三步校验文件内容不只是看扩展名和 MIME。对图片类可以用图像库尝试解析解析失败直接拒对文档类可以校验文件头魔数。这一步能挡住大量改后缀的尝试。第四步存储隔离。上传目录移出 Web 根或者至少在 Web 服务器配置里明确禁止该目录下的脚本解析。这是最后一道保险即使前面三道都被绕过攻击者拿到的也只是一个存文件的能力。6.2 包含侧拒绝动态路径只允许映射表包含侧的加固思路很干脆外部输入永远不参与路径构造。具体做法是把参数值到文件路径的映射关系写死在代码里?php // 只允许映射表里存在的键路径完全由服务端决定 $views [ detail __DIR__ . /tpl/detail.php, list __DIR__ . /tpl/list.php, edit __DIR__ . /tpl/edit.php, ]; $key $_GET[view] ?? list; if (!isset($views[$key])) { http_response_code(400); exit(invalid view); } include $views[$key];这段代码和拼字符串的写法最大的区别在于攻击者能控制的只有$key这个键名而键名的取值必须存在于映射表里路径本身一个字节都动不了。就算攻击者传了带路径片段的键名isset直接把它挡掉。如果业务确实需要动态加载那就退一步做一个严格的字符集校验加上目录固定只允许特定字符、不允许路径分隔符、不允许上级目录片段并且最终路径要经过规范化之后再确认它还在允许的目录内。这个逻辑写起来不复杂但千万别省。6.3 运行环境侧最小权限与目录隔离代码层面的修复之外环境层面能做的事其实更多而且收益往往更大。几个我认为性价比最高的措施限制脚本可访问的目录范围。通过配置把脚本的读写范围圈定在业务目录内越界的访问直接失败。这样即使包含点被利用能指过去的位置也极其有限。Web 进程用低权限账户运行。不要图省事用高权限跑 Web 服务。低权限意味着即使代码被执行攻击者拿到的东西也少得多。上传目录、缓存目录、日志目录单独分区或单独挂载并去掉执行属性。这几个目录是内容可能被外部影响的重灾区给它们最严格的待遇是值得的。禁用不必要的危险函数。把业务用不到的、容易被利用的函数在配置里禁掉。这一步要注意别把业务搞崩所以上线前一定要在测试环境完整回归。6.4 检测侧用规则兜住漏网的场景前面三节是不让它发生这一节是发生了要能发现。检测规则可以从三个维度建请求维度对包含点和上传接口做参数基线。正常参数值会形成一个很小的集合偏离基线的取值就告警。这类规则误报率低、收益高是很划算的一步。文件维度做上传目录的文件完整性监控。这个目录里除了业务正常写入的文件任何新增都值得看一眼特别是体积小于某个阈值、或者扩展名不在白名单内的。行为维度监控上传后短时间内访问该文件和访问包含点时携带路径特征这两类行为序列。单独看每个请求都可能是正常的但组合起来就是典型的攻击动作序列。规则建好之后要有一个调整期。一开始误报多是正常的花一两周把白名单补全规则的可用性会上一个台阶。千万不要因为误报就整个关掉——那就等于把检测能力一起关掉了。7. 几个容易走偏的认知误区7.1 改了后缀就安全了这是最普遍的一个误区。把上传后缀白名单做严确实能挡住一大批尝试但它挡不住内容被执行这条路径——因为执行的判定依据是内容不是后缀。我见过一些系统上传校验做得很严只允许特定几种图片后缀MIME 也查了但包含点依然用参数拼路径结果攻击者靠包含一份本来就存在的日志文件就把事情办了。所以上传加固和包含加固是两件事必须分别做不能拿一个替代另一个。7.2 内网系统不用管协同办公系统这类应用绝大多数部署在内网于是很多团队默认外网进不来就没风险。这个假设在今天的网络环境下基本站不住。内网不是一个安全域而是一个信任前提被过度放大的区域。一旦有任何一台终端被控攻击者就在内网里了而这些系统的默认配置、弱口令、老旧版本恰好是横向移动最省力的抓手。把内网系统按迟早会被访问到来加固是更符合实际的思路。7.3 打了补丁就万事大吉补丁必须打但打完不等于结束。协同办公系统这类产品通常会被二次开发官方补丁覆盖不到自定义的代码而且升级过程中老版本的文件经常因为各种原因被保留下来成了没人管的影子入口。所以打完补丁之后建议做两件事一是把升级时保留下来的历史文件清理干净二是对二次开发的代码做一次针对性的审计重点看上传和包含这两类逻辑。这两件事做完补丁的实际效果才落地。7.4 有 WAF 就够了WAF 是一层有效的缓冲它能拦住大量自动化扫描和已知特征的攻击。但它的定位是降低攻击效率不是消除风险。绕过的本质是让请求看起来不像攻击而这件事在 HTTP 协议的自由度下并不困难。所以正确的姿势是WAF 继续用但把它当最后一道而不是第一道。第一道永远是代码里的校验逻辑和环境里的权限约束。我从这条链上得到的最实际的一条体会是真正稳固的防护从来不是靠某一个点的严防死守而是靠多个位置上即便这个点失守下一个点也能兜住。上传做白名单、包含用映射表、目录做隔离、账户给最小权限、日志建规则——单看每一条都不复杂但它们叠在一起的时候攻击者要穿透的成本是指数级上升的。反过来只要其中任意一环想着反正前面已经拦住了整条链的强度就取决于最弱的那一环。这些年我复盘过的案例里绝大多数不是被多高深的手法打穿的而是一个不起眼的拼接、一个懒得改的配置、一句内网应该没事累积出来的。
返回列表