发布于14小时前14小时 背景 Web 安全里,任意文件读取属于比较常见但容易被低估的问题。它不像 RCE 那样直观,但在真实环境中经常能读取到配置文件、源码、密钥、数据库连接串,进一步扩大影响面。 这类漏洞常见于以下场景: 文件下载接口:/download?file=xxx 图片预览接口:/preview?path=xxx 日志查看接口:/log?name=xxx 模板、附件、导出文件读取 本文以一次常见的 Java Web 文件下载接口为例,说明如何从代码审计到本地复现,再到修复验证。 技术分析 典型问题代码通常是把用户输入直接拼接到服务器文件路径中,然后通过 FileInputStream 读取返回。 例如业务设计是只允许下载 /data/upload/ 目录下的附件,但代码中没有限制最终路径是否仍然位于该目录。 @GetMapping("/download") public void download(@RequestParam String file, HttpServletResponse response) throws IOException { String baseDir = "/data/upload/"; File target = new File(baseDir + file); response.setHeader("Content-Disposition", "attachment; filename=" + target.getName()); try (InputStream in = new FileInputStream(target); OutputStream out = response.getOutputStream()) { byte[] buffer = new byte[4096]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } } 如果传入: ../../../../etc/passwd 最终拼接路径会变成: /data/upload/../../../../etc/passwd 文件系统解析后,实际访问的就是: /etc/passwd Windows 环境下也类似,例如: ..\..\..\..\Windows\win.ini 漏洞成因 直接信任用户输入的文件名或路径 仅做字符串黑名单过滤,例如替换 ../,容易绕过 没有使用规范化路径校验 允许用户控制完整路径,而不是只控制文件 ID 下载接口没有做权限校验,导致越权读取附件 操作步骤:审计与复现 1. 定位文件读取入口 代码审计时可以重点搜索以下关键字: FileInputStream Files.readAllBytes Files.newInputStream ServletOutputStream getResourceAsStream ResponseEntity<Resource> Content-Disposition attachment 如果是 Spring 项目,也可以搜索: @GetMapping @RequestMapping download preview export fileName filePath path 2. 观察参数是否可控 重点确认用户输入是否参与了文件路径拼接: String fileName = request.getParameter("file"); File file = new File(basePath + fileName); 或者: Path path = Paths.get(uploadDir, userInput); 这里并不是说使用 Paths.get 就安全,关键是后续有没有做 normalize 和目录边界校验。 3. 构造基础 Payload Linux 常见测试目标: ../../../../etc/passwd ..%2f..%2f..%2f..%2fetc%2fpasswd ....//....//....//etc/passwd Windows 常见测试目标: ..\..\..\Windows\win.ini ..%5c..%5c..%5cWindows%5cwin.ini 如果服务部署在容器中,读取 /etc/passwd 不一定有高危影响,但如果能读取应用配置,例如: ../../../../app/application.yml ../../../../app/config/application-prod.yml ../../../../WEB-INF/web.xml ../../../../WEB-INF/classes/application.properties 风险会明显提高。 4. 使用 curl 验证 curl -i " 如果响应中出现类似下面内容,基本可以确认问题存在: root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin 代码示例:安全修复方式 修复的核心不是简单替换 ../,而是要做“规范化后的路径边界校验”。也就是说:无论用户输入怎么编码或绕行,最终解析后的真实路径必须仍然位于允许目录内。 @GetMapping("/download") public void safeDownload(@RequestParam String file, HttpServletResponse response) throws IOException { Path baseDir = Paths.get("/data/upload").toRealPath(LinkOption.NOFOLLOW_LINKS); // 只接收相对文件名,不允许用户传绝对路径 Path targetPath = baseDir.resolve(file).normalize(); // 边界校验:规范化后的路径必须仍在 baseDir 下 if (!targetPath.startsWith(baseDir)) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "Invalid file path"); return; } // 避免符号链接绕过 if (!Files.exists(targetPath, LinkOption.NOFOLLOW_LINKS) || !Files.isRegularFile(targetPath, LinkOption.NOFOLLOW_LINKS)) { response.sendError(HttpServletResponse.SC_NOT_FOUND, "File not found"); return; } String downloadName = targetPath.getFileName().toString(); response.setHeader("Content-Disposition", "attachment; filename=\"" + downloadName.replace("\"", "") + "\""); response.setContentType("application/octet-stream"); try (InputStream in = Files.newInputStream(targetPath); OutputStream out = response.getOutputStream()) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } } 上面代码有几个关键点: baseDir.toRealPath() 获取真实基础目录 resolve(file).normalize() 对目标路径做规范化 startsWith(baseDir) 判断是否越界 NOFOLLOW_LINKS 降低符号链接绕过风险 不把用户输入直接放入响应头,避免响应头注入 更推荐的设计:文件 ID 映射 从工程角度看,最稳妥的方式是不让用户直接传路径,而是传文件 ID,由后端根据数据库记录找到文件。 GET /download?id=10086 后端逻辑应类似: 1. 根据 id 查询附件记录 2. 判断当前用户是否有权限访问该附件 3. 从数据库中取服务端保存的 file_path 4. 拼接固定存储目录并做路径边界校验 5. 返回文件流 这样可以同时解决路径穿越和水平越权问题。 注意事项 不要只用黑名单过滤 ../,容易被编码、双写、混合分隔符绕过。 不要相信前端传来的文件名、路径、扩展名。 如果支持 URL 编码参数,要确认框架解码次数,避免双重编码绕过。 Linux 和 Windows 路径分隔符不同,跨平台项目要同时处理 / 和 \。 容器环境下也要重视配置泄露,很多密钥会通过挂载文件或环境配置进入应用。 下载接口除了路径校验,还应补充身份认证和文件归属校验。 如果目录内允许用户上传文件,需要关注符号链接、硬链接以及覆盖已有文件的问题。 总结 任意文件读取漏洞的本质是“用户输入影响了服务端文件路径,并且缺少边界校验”。代码审计时重点看文件流读取点、路径拼接点和权限判断点。修复时不要依赖简单字符串替换,而应使用规范化路径校验,并尽量采用文件 ID 映射的业务设计。 实际项目中,建议把文件下载能力封装成统一组件,统一做目录限制、权限判断、日志记录和响应头处理,避免每个业务模块各写一套下载逻辑,后续也更容易审计。 网络请求、日志与边界流量分析示意
14小时前14小时 任意文件读取这类问题审计时,建议不要只盯着 `../`,很多实际漏洞是“路径拼接 + 规范化顺序错误”触发的。 可以重点看这几类入口: 1. 下载、预览、导出接口 例如参数名是 `file`、`path`、`url`、`template`、`name`、`filename` 的接口。 2. 静态资源代理或图片读取 常见代码类似: String filePath = baseDir + request.getParameter("path"); return Files.readAllBytes(Paths.get(filePath)); 3. 模板、日志、附件读取 特别是“根据用户传入名称读取本地文件”的逻辑。 复现时我一般会先判断后端是否做了 URL 解码、二次解码以及路径规范化。可以按这个顺序测: ../../../../etc/passwd ..%2f..%2f..%2f..%2fetc%2fpasswd ..%252f..%252f..%252f..%252fetc%252fpasswd ....//....//....//etc/passwd /../../../../etc/passwd Windows 环境还要单独测: ..\..\..\..\Windows\win.ini ..%5c..%5c..%5cWindows%5cwin.ini C:\Windows\win.ini 代码审计里比较容易漏的是这种“看似做了校验,但校验点在 normalize 之前”的写法: Path base = Paths.get("/data/upload"); Path target = Paths.get(base.toString(), userInput); if (!target.toString().contains("..")) { return Files.readAllBytes(target); } 更稳的写法应该是先解析真实路径,再判断是否仍在允许目录下: Path base = Paths.get("/data/upload").toRealPath(); Path target = base.resolve(userInput).normalize(); if (!target.startsWith(base)) { throw new SecurityException("invalid path"); } return Files.readAllBytes(target); 如果业务允许软链接,还需要特别注意 `toRealPath()`,否则攻击者可能通过上传目录里的 symlink 跳到外部目录。修复时最好同时做: // 1. 固定根目录 Path base = Paths.get("/data/upload").toRealPath(); // 2. 禁止绝对路径 if (Paths.get(userInput).isAbsolute()) { throw new SecurityException("absolute path not allowed"); } // 3. normalize 后校验前缀 Path target = base.resolve(userInput).normalize(); // 4. 读取前确认真实路径仍在 base 内 Path realTarget = target.toRealPath(); if (!realTarget.startsWith(base)) { throw new SecurityException("path traversal"); } 如果是 Spring 项目,还可以全局搜这些点: grep -R "getParameter(\".*file" -n . grep -R "readAllBytes\|FileInputStream\|ResourceUtils\|getResourceAsStream" -n src/ grep -R "download\|preview\|export\|template\|attachment" -n src/ 另外复现时建议不要一上来读敏感文件,先用无破坏的探针确认路径穿越是否成立,比如: /etc/hostname /etc/hosts C:\Windows\win.ini 最后看回显特征: 如果接口返回长度变化、文件类型变化、异常栈里出现真实路径,基本就能辅助确认读取链路。即便没有直接回显,也可以结合错误信息、响应时间、HTTP 状态码差异判断是否存在文件探测能力。
6小时前6小时 补一个审计时比较容易漏掉的点:很多任意文件读取不是直接出现在 `download?file=` 这种入口,而是藏在“模板渲染 / 静态资源代理 / 日志查看 / 附件预览”里,参数经过了几层封装,最后才落到 `FileInputStream`、`Files.readAllBytes`、`send_file` 这类 API。 我一般会按“污点流”反查,先搜危险文件读取点,再往上追参数来源: // Java 常见 sink new FileInputStream(...) Files.readAllBytes(...) Files.newInputStream(...) ResourceUtils.getFile(...) ServletContext.getResourceAsStream(...) // Python open(...) send_file(...) FileResponse(...) // Node.js fs.readFile(...) fs.createReadStream(...) res.sendFile(...) // PHP file_get_contents(...) readfile(...) include/require 复现时建议不要只测 `../../../../etc/passwd`,有些代码会过滤 `../`,但没有做规范化路径校验,可以试几类变体: ..%2f..%2f..%2fetc%2fpasswd ..%252f..%252f..%252fetc%252fpasswd ....//....//etc/passwd /var/log/nginx/access.log file:///etc/passwd 如果是 Java 项目,重点看是否只是做了字符串判断,比如: if (path.contains("../")) { throw new RuntimeException("illegal path"); } File file = new File(baseDir + "/" + path); 这种修复并不可靠。更稳妥的方式是对最终路径做 `canonicalPath` 或 `normalize` 后,确认仍然在允许目录下: Path base = Paths.get("/data/upload").toRealPath(); Path target = base.resolve(userInput).normalize(); if (!target.startsWith(base)) { throw new SecurityException("非法路径"); } byte[] data = Files.readAllBytes(target); 另外复现环境里如果读不到 `/etc/passwd`,不一定代表没漏洞,可能是容器权限或运行用户限制。可以换读应用自身文件验证,比如: pom.xml package.json WEB-INF/web.xml application.yml .env logs/app.log 排查影响面时也建议看下返回内容是否会进入缓存、日志或异常页面。有些接口读取失败会把绝对路径、根目录、拼接后的真实路径打到响应里,这类信息对后续扩大利用很关键,审计报告里最好单独记录。
5小时前5小时 补一个我实际审计里经常会用的校验点:不要只看“有没有过滤 `../`”,还要看最终打开文件前有没有做“基准目录约束”。很多代码看起来做了 `normalize()`,但没有确认结果仍在允许目录下,本质上还是可绕。 Java 里建议重点排这种写法: Path p = Paths.get(baseDir, userInput).normalize(); byte[] data = Files.readAllBytes(p); 这里 `normalize()` 只是折叠路径,不会限制目录。比较稳的写法应该是: Path base = Paths.get(baseDir).toRealPath(); Path target = base.resolve(userInput).normalize(); if (!target.toRealPath().startsWith(base)) { throw new SecurityException("invalid path"); } return Files.readAllBytes(target); 注意两个细节: 1. `base` 最好用 `toRealPath()` 避免 `baseDir` 本身带软链接导致判断失真。 2. 判断要在最终真实路径上做 如果只对字符串或 `normalize()` 后路径判断,遇到软链接目录仍可能绕出去。 复现时除了读 `/etc/passwd`,也可以顺手测一下符号链接场景。比如应用允许访问 `/var/www/upload/`,但里面存在一个软链接: ln -s /etc /var/www/upload/link 然后请求: /preview?file=link/passwd 如果代码只做了: target.normalize().startsWith(base) 这类检查,很可能会被绕过。 另外 Windows 环境也别忽略,路径判断经常写得只兼容 Linux。可以测这些形式: ..\..\..\Windows\win.ini ..%5c..%5c..%5cWindows%5cwin.ini C:\Windows\win.ini \\127.0.0.1\c$\Windows\win.ini 尤其是 Java / Node 跨平台项目,本地开发是 Windows、线上是 Linux,或者反过来,路径分隔符处理很容易出现差异。 审计时我一般会在 sink 点附近确认三件事: - 用户输入是否参与 `resolve` / 字符串拼接; - 是否在读取前做了 `toRealPath()` 后的目录边界校验; - 是否允许跟随软链接,业务上不需要的话可以考虑禁用或单独拦截。 如果是修复建议,核心不是黑名单过滤,而是“白名单文件名 + 固定目录 + realpath 边界校验”。像日志查看、附件预览这类接口,最好让前端传文件 ID,后端从数据库取真实路径,不要直接让用户传 path。
创建帐户或登录后发表意见