跳转到帖子

一次 Web 任意文件读取漏洞的代码审计与复现思路

精选回复

发布于

背景

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 映射的业务设计。

实际项目中,建议把文件下载能力封装成统一组件,统一做目录限制、权限判断、日志记录和响应头处理,避免每个业务模块各写一套下载逻辑,后续也更容易审计。

网络流量与边界分析示意图
网络请求、日志与边界流量分析示意
任意文件读取这类问题审计时,建议不要只盯着 `../`,很多实际漏洞是“路径拼接 + 规范化顺序错误”触发的。 可以重点看这几类入口: 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 状态码差异判断是否存在文件探测能力。
补一个审计时比较容易漏掉的点:很多任意文件读取不是直接出现在 `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
排查影响面时也建议看下返回内容是否会进入缓存、日志或异常页面。有些接口读取失败会把绝对路径、根目录、拼接后的真实路径打到响应里,这类信息对后续扩大利用很关键,审计报告里最好单独记录。
补一个我实际审计里经常会用的校验点:不要只看“有没有过滤 `../`”,还要看最终打开文件前有没有做“基准目录约束”。很多代码看起来做了 `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。

创建帐户或登录后发表意见

最近浏览 0

  • 没有会员查看此页面。

SiteMap HACKHAT © 2026 All rights reserved.