跳转到帖子

背景

文件上传一直是 Web 安全里比较容易被低估的功能点。很多系统在设计时只把它当作“业务附件”处理,比如头像、合同、工单图片、日志压缩包等,但从攻击面角度看,上传功能同时涉及文件类型识别、存储路径、访问权限、解析行为、内容安全、后续处理链路等多个环节。

我在做代码审计和漏洞复现时,经常遇到一种情况:单点看上传接口似乎做了扩展名校验,也限制了大小,但结合部署环境、中间件解析规则、对象存储策略、异步解压任务之后,风险会被放大。本文整理一套偏长期价值的审计思路,重点是如何发现问题、如何验证风险、如何修复治理,不涉及恶意利用引导。

典型场景

常见的上传链路大致如下:

  • 前端选择文件并提交到后端接口。
  • 后端检查文件名、扩展名、MIME、大小。
  • 服务端生成存储路径,并将文件保存到本地目录、NFS 或对象存储。
  • 数据库记录文件 URL、原始文件名、上传人、业务 ID。
  • 后续由下载接口、预览接口、图片处理服务、解压任务或杀毒引擎处理。

真正需要关注的不是“能不能上传一个特殊后缀”,而是上传后的文件是否会被不安全地解析、访问、包含、执行或二次处理。

问题拆解

审计上传功能时,我一般按以下几个问题拆解:

  • 是否只依赖前端限制。
  • 后端是否使用白名单,而不是黑名单。
  • 是否只判断扩展名,没有判断文件内容。
  • 存储目录是否位于 Web 可访问路径下。
  • 上传后的文件是否可能被脚本引擎解析。
  • 是否存在路径穿越导致覆盖任意文件的风险。
  • 是否支持压缩包上传和自动解压。
  • 文件下载、预览接口是否存在越权。
  • 原始文件名是否进入 HTML、日志、命令行或文件系统。
  • 对象存储桶策略是否过宽。

代码审计重点

以下以常见 Java/Spring 项目为例,上传接口通常会出现 MultipartFile、Commons FileUpload、Servlet Part 等关键字。审计时可以先全局搜索:

MultipartFile
CommonsMultipartFile
@RequestParam("file")
request.getPart
transferTo
Files.copy
FileOutputStream
upload
attachment
avatar
importFile

发现入口后,重点看三个点:校验逻辑、文件名处理、保存位置。

1. 扩展名校验

不推荐使用黑名单,比如只禁止 jsp、php、asp 这类后缀。黑名单维护成本高,也容易遗漏大小写、双后缀、特殊解析规则等情况。更合适的是按业务建立白名单。

private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "pdf", "docx");

private boolean isAllowedExt(String filename) {
    String ext = FilenameUtils.getExtension(filename);
    if (ext == null) {
        return false;
    }
    return ALLOWED_EXT.contains(ext.toLowerCase(Locale.ROOT));
}

注意,扩展名白名单只能作为第一层过滤,不能单独作为可信依据。

2. MIME 与文件头识别

很多代码会直接使用用户提交的 Content-Type,例如 image/png、application/pdf。这个值来自客户端,不能作为安全边界。更稳妥的做法是结合文件头和解析库判断。

private static final Map<String, List<String>> ALLOWED_MIME = Map.of(
    "jpg", List.of("image/jpeg"),
    "jpeg", List.of("image/jpeg"),
    "png", List.of("image/png"),
    "pdf", List.of("application/pdf")
);

private boolean checkMime(Path path, String ext) throws IOException {
    String mime = Files.probeContentType(path);
    List<String> allowed = ALLOWED_MIME.get(ext.toLowerCase(Locale.ROOT));
    return allowed != null && allowed.contains(mime);
}

在生产环境中,Files.probeContentType 的结果与系统环境有关,建议结合 Apache Tika、图片解码库或业务专用解析器做更稳定的检测。对于图片类文件,可以尝试实际解码并重新编码,这能降低伪造文件头带来的风险。

3. 文件名处理

直接使用用户上传的原始文件名保存,是上传漏洞里很常见的根因。风险包括路径穿越、覆盖文件、特殊字符污染日志、在下载响应头中引发问题等。

建议服务端生成随机文件名,原始文件名仅作为元数据保存,并在展示和下载时做输出编码。

String originalName = file.getOriginalFilename();
String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT);
String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path target = uploadRoot.resolve(safeName).normalize();

if (!target.startsWith(uploadRoot)) {
    throw new SecurityException("invalid upload path");
}

file.transferTo(target.toFile());

这里的 normalize 和 startsWith 判断很关键,能减少路径穿越导致写入非预期目录的风险。

4. 存储位置与执行权限

上传目录最好不要放在 Web 容器可直接解析的目录下。例如不要将用户文件保存到 webapps、static、public 这类可直接访问且可能被特殊解析的路径中。

更推荐的方式是:文件保存在应用目录之外,通过受控下载接口或对象存储签名 URL 访问。对于本地文件目录,应明确禁用脚本执行权限。

# Nginx 示例:上传目录仅作为静态文件访问时,禁止脚本解释
location /uploads/ {
    alias /data/app/uploads/;
    autoindex off;
    default_type application/octet-stream;
    add_header X-Content-Type-Options nosniff;
}

如果业务不需要直接公开访问,建议不要暴露 /uploads/ 静态路径,而是统一走鉴权后的下载接口。

5. 下载接口的越权问题

上传风险不只在“上传”本身,下载接口也很关键。常见问题是接口通过 fileId 获取文件路径后直接返回,没有校验当前用户是否有访问该业务附件的权限。

GET /api/file/download?id=12345

审计时要沿着 fileId 查数据库,看是否能关联到业务对象,例如工单、合同、订单、项目空间等,并确认当前用户是否对该业务对象具备访问权限。只校验“用户已登录”是不够的。

6. 压缩包上传与解压风险

很多后台支持导入 zip、jar、xlsx、docx 等文件。这里要特别注意两类问题:路径穿越和解压炸弹。

路径穿越的典型表现是压缩包内文件名包含 ../ 或绝对路径。如果解压逻辑没有校验目标路径,就可能覆盖非预期文件。

Path destDir = Paths.get("/data/app/imports").toAbsolutePath().normalize();
Path out = destDir.resolve(entry.getName()).normalize();

if (!out.startsWith(destDir)) {
    throw new SecurityException("zip entry path traversal detected");
}

同时要限制解压后的总大小、文件数量、递归层级和单文件大小,避免资源耗尽。

7. 图片处理链路

图片上传后常见操作包括生成缩略图、裁剪、水印、格式转换。这里要注意两点:

  • 图片解析库是否存在历史漏洞,是否长期未升级。
  • 是否把用户可控参数拼接到系统命令里调用 ImageMagick、ffmpeg 等工具。

如果确实需要调用外部命令,建议使用参数数组方式,不要拼接 shell 字符串,并对尺寸、格式、超时时间做限制。

ProcessBuilder pb = new ProcessBuilder(
    "convert",
    inputPath.toString(),
    "-resize", "300x300",
    outputPath.toString()
);
pb.redirectErrorStream(true);
Process p = pb.start();
boolean finished = p.waitFor(5, TimeUnit.SECONDS);
if (!finished) {
    p.destroyForcibly();
}

风险验证方法

在授权测试或内部自查中,不建议一上来就构造高危 payload。更稳妥的验证方式是围绕安全边界做观察:

  • 上传非白名单扩展名,确认是否被后端拒绝。
  • 修改 Content-Type,确认服务端是否只信任请求头。
  • 上传异常文件头,观察解析和错误处理。
  • 构造包含特殊文件名的普通文本文件,验证路径归一化是否正确。
  • 上传超大文件或大量小文件,验证大小和频率限制。
  • 在不同账号间访问附件 ID,验证是否存在越权。
  • 上传压缩包样本,验证解压目录边界和资源限制。

验证重点是确认“控制措施是否真实生效”,不是追求破坏性结果。

推荐防护基线

控制点建议做法
文件类型按业务使用白名单,结合扩展名、MIME、文件头和解析结果判断。
文件名服务端生成随机文件名,原始文件名仅作为展示字段保存。
存储路径上传目录放在 Web 根目录之外,路径 normalize 后校验边界。
访问控制下载和预览接口必须校验业务权限,不能只校验登录态。
解析权限上传文件不应被脚本引擎解析,静态访问加 nosniff。
资源限制限制大小、数量、频率、解压总量、处理超时。
内容处理图片、文档、压缩包处理组件及时更新,外部命令禁止字符串拼接。
审计日志记录上传人、业务对象、文件类型、大小、存储位置、检测结果。

代码审计清单

  • 上传接口是否全部集中到统一组件处理。
  • 是否存在绕过统一上传组件的历史接口。
  • 是否有后台导入、富文本编辑器、客服附件等隐蔽上传点。
  • 是否存在 Base64 文件上传接口。
  • 是否支持远程 URL 拉取文件,若支持需防 SSRF。
  • 是否有异步任务对上传文件执行解压、转换、扫描。
  • 是否将上传路径、原始文件名拼接进命令行。
  • 是否把附件 URL 直接返回给无权限用户。
  • 对象存储是否存在 public-read 误配置。
  • 历史附件是否按新策略重新评估。

事件复盘中的常见根因

从实际复盘看,上传问题往往不是某一行代码导致的,而是多个小缺口叠加:

  • 开发认为前端限制已经足够。
  • 后端做了扩展名校验,但存储在可解析目录。
  • 下载接口没有业务级鉴权。
  • 历史接口没有纳入统一安全组件。
  • 对象存储权限配置过宽。
  • 上线后缺少附件行为审计和告警。

因此修复时不要只补一个 if 判断,最好把上传能力收敛成公共服务,由统一策略负责校验、存储、鉴权、审计。

总结

文件上传功能的安全治理,核心不是简单判断后缀,而是建立完整链路的边界控制:上传前限制类型和大小,上传时规范文件名和路径,上传后控制访问权限和解析行为,后续处理过程限制资源和命令调用。

对代码审计来说,可以从 MultipartFile、transferTo、Files.copy、解压和下载接口入手,沿着文件从进入系统到被访问、被处理的完整路径追踪。只要把这些链路梳理清楚,大多数上传相关风险都能比较稳定地发现和修掉。

建议在每个项目里沉淀一份上传安全基线,并把它做成公共组件和自动化测试用例。上传功能越多,统一治理的收益越明显。
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…