跳转到帖子

背景

文件上传是 Java Web 项目里很常见的功能,头像、附件、工单截图、导入文件都会用到。它看起来只是业务能力,但在实际代码审计和应急排查中,上传点经常是高风险入口:一旦校验不严,可能引入 WebShell、恶意脚本、存储型 XSS、任意文件覆盖,甚至配合解析缺陷形成远程代码执行。

这篇文章整理一次典型的上传漏洞复盘思路,重点放在防守侧:如何识别风险代码、如何复现安全问题、如何设计加固方案。文中示例均为本地靶场或脱敏代码,不涉及真实目标攻击。

问题场景

某 Java Web 系统提供附件上传功能,后端使用 Spring MVC 接收 MultipartFile,上传后把文件保存到 Web 根目录下的 upload 目录,并将访问路径返回给前端。

@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
    String fileName = file.getOriginalFilename();
    File dest = new File(servletContext.getRealPath("/upload/") + fileName);
    file.transferTo(dest);
    return "/upload/" + fileName;
}

从业务角度看,这段代码能正常运行;从安全角度看,问题比较集中:

  • 直接信任原始文件名,存在路径穿越和文件覆盖风险。
  • 文件保存目录位于 Web 可访问目录下。
  • 未限制文件类型、大小、内容。
  • 未做文件名规范化处理。
  • 返回了可直接访问的文件路径。

技术分析

文件上传风险并不只取决于“扩展名是否允许”。很多系统只做了简单判断,例如:

if (!fileName.endsWith(".jpg") && !fileName.endsWith(".png")) {
    throw new RuntimeException("invalid file type");
}

这种方式容易遗漏大小写、双后缀、特殊字符、服务端解析差异等问题。更关键的是,上传后的文件如果进入 Web 可解析目录,风险会被明显放大。

常见风险点可以拆成几类:

风险点说明影响
扩展名校验不足只基于字符串后缀判断上传非预期文件
Content-Type 可信任HTTP 头可由客户端伪造绕过简单 MIME 判断
保存到 Web 目录文件可被直接访问或解析扩大利用面
使用原始文件名未处理 ../、路径分隔符、特殊字符路径穿越、覆盖文件
无大小限制可上传超大文件磁盘耗尽、服务异常

本地复现思路

复现时不建议直接使用真实业务环境。可以在本地搭一个最小 Spring Boot 项目,保留上传接口和静态资源映射,用于验证风险点。

首先观察上传后的落盘位置和访问路径。如果文件进入应用静态目录,例如 src/main/resources/static/upload 或部署后的 Web 根目录,就需要重点关注。

其次测试文件名处理逻辑。重点不是构造攻击,而是确认服务端是否会原样使用客户端文件名。可以用正常文本文件进行测试,例如带空格、中文、重复文件名、长文件名、特殊符号的文件,观察是否产生异常或覆盖。

# 仅用于本地环境验证上传行为
curl -F "[email protected];filename=normal.txt" 
curl -F "[email protected];filename=repeat.txt"  是否直接接收 MultipartFile、Base64、远程 URL。
  • 校验:是否限制大小、扩展名、MIME、文件魔数。
  • 文件名:是否使用 getOriginalFilename,是否做规范化和随机化。
  • 路径:是否允许用户控制目录、是否拼接字符串生成路径。
  • 存储:是否保存到 Web 可访问目录,是否有执行权限。
  • 访问:是否通过下载接口鉴权访问,还是直接暴露静态路径。
  • 后处理:是否调用图片压缩、Office 解析、PDF 预览等组件。
  • 其中“后处理组件”很容易被忽略。比如图片处理库、文档转换服务、解压缩逻辑都可能引入新的攻击面。上传功能不只是保存文件,还包括后续解析链路。

    推荐加固方案

    比较稳妥的做法是采用“白名单校验 + 随机文件名 + 非 Web 目录存储 + 下载接口鉴权”的组合。不要依赖单一校验点。

    1. 限制文件大小

    Spring Boot 可以先在配置层限制请求大小,避免大文件直接打到业务逻辑。

    spring.servlet.multipart.max-file-size=5MB
    spring.servlet.multipart.max-request-size=10MB

    业务层仍然建议再判断一次,配置限制和代码限制是两道不同的防线。

    2. 规范化文件名

    不要直接使用客户端传入的原始文件名作为落盘名称。原始文件名只适合作为展示字段保存到数据库。

    String originalName = file.getOriginalFilename();
    String ext = StringUtils.getFilenameExtension(originalName);
    if (ext == null) {
        throw new IllegalArgumentException("missing extension");
    }
    ext = ext.toLowerCase(Locale.ROOT);
    
    Set<String> allowExt = Set.of("jpg", "jpeg", "png", "pdf");
    if (!allowExt.contains(ext)) {
        throw new IllegalArgumentException("unsupported file type");
    }
    
    String savedName = UUID.randomUUID().toString().replace("-", "") + "." + ext;

    3. 校验文件魔数

    扩展名只能作为第一层判断。对于图片、PDF 等常见文件,建议读取文件头做魔数校验。

    private boolean isPng(byte[] header) {
        return header.length >= 8
            && header[0] == (byte) 0x89
            && header[1] == 0x50
            && header[2] == 0x4E
            && header[3] == 0x47
            && header[4] == 0x0D
            && header[5] == 0x0A
            && header[6] == 0x1A
            && header[7] == 0x0A;
    }

    魔数校验也不是绝对安全,但能过滤掉大量明显不符合预期的文件。对于复杂格式,建议使用成熟库解析并重新生成安全版本,例如图片重新编码。

    4. 保存到非 Web 目录

    上传文件不应放在应用可直接解析的目录里。推荐保存到独立数据目录,通过受控下载接口读取。

    Path baseDir = Paths.get("/data/app-upload").toAbsolutePath().normalize();
    Path target = baseDir.resolve(savedName).normalize();
    
    if (!target.startsWith(baseDir)) {
        throw new SecurityException("invalid path");
    }
    
    Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);

    这里的 normalize 和 startsWith 检查很重要,可以避免路径拼接过程中出现意外目录跳转。

    5. 下载接口做权限控制

    不要把真实文件路径直接暴露给前端。前端拿到的应是文件 ID,后端根据文件 ID 查询元数据、校验权限,再读取文件流返回。

    @GetMapping("/files/{id}")
    public ResponseEntity<Resource> download(@PathVariable Long id, Principal principal) {
        FileMeta meta = fileService.findById(id);
        fileService.checkPermission(meta, principal);
    
        Resource resource = fileService.loadAsResource(meta.getSavedName());
        return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION,
                "attachment; filename=\"" + meta.getOriginalName() + "\"")
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(resource);
    }

    如果是图片预览,也建议通过接口返回,并设置合理的 Content-Type、Content-Disposition、缓存策略和鉴权逻辑。

    6. 去除执行权限和解析能力

    从部署角度看,上传目录应当不可执行。Nginx 可对上传目录禁用脚本解析,Java 应用则尽量不要把上传目录映射为静态资源目录。

    location /upload/ {
        alias /data/app-upload/;
        default_type application/octet-stream;
        add_header Content-Disposition "attachment";
    }

    如果确实需要 Nginx 提供静态访问,也要注意不要和 PHP、CGI、脚本解释器等解析规则混在一起。静态文件服务和脚本解析服务最好隔离。

    注意点

    • 不要只相信 Content-Type,它来自客户端请求头,可被伪造。
    • 不要只做前端限制,前端限制只能改善体验,不能作为安全控制。
    • 不要把上传目录放到 classpath、static、public、webapp 等可直接访问路径。
    • 不要使用原始文件名落盘,原始文件名应仅用于展示。
    • 不要在上传后立即调用高权限解析器处理未知文件。
    • 对压缩包上传要额外处理解压路径、文件数量、压缩比,防止 Zip Slip 和压缩炸弹。
    • 日志中记录文件 ID、上传用户、大小、类型、校验结果,避免记录敏感业务内容。

    应急排查建议

    如果怀疑系统存在上传风险,可以优先排查以下位置:

    • 最近一段时间上传目录内新增的异常后缀文件。
    • 文件名包含特殊字符、路径分隔符、多个后缀的文件。
    • 访问日志中对上传目录的高频访问和异常 User-Agent。
    • 应用日志中的文件解析异常、下载异常、权限校验失败记录。
    • 部署目录是否出现非发布流程产生的新文件。

    排查时建议先冻结现场,复制上传目录和日志再分析,避免直接删除导致线索丢失。确认风险后,再进行隔离、修复、密钥轮换和补充监控。

    总结

    文件上传漏洞的根因通常不是某一行代码,而是上传链路缺少整体安全设计。比较可靠的方案是:入口限制大小,服务端做白名单和魔数校验,文件名随机化,存储目录与 Web 解析环境隔离,访问通过鉴权接口完成。

    实际项目里,上传功能往往分散在多个模块,建议把校验和存储封装成统一组件,禁止业务代码各自实现一套。这样既能降低漏洞重复出现的概率,也方便后续审计和应急排查。

    代码审计与漏洞定位示意图
    代码审计、调用链与关键函数定位示意

    0篇意见

    推荐意见

    没有意见。

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

    SiteMap HACKHAT © 2026 All rights reserved.