跳转到帖子

背景

文件上传是 Web 安全里很常见的一类问题,但真实项目中它往往不是单点漏洞,而是由扩展名校验、MIME 判断、存储路径、访问权限、解析规则、鉴权边界等多个环节共同决定风险。很多团队在修复时只补了一处黑名单,短期看似有效,后续换个网关、改个对象存储策略或新增预览功能,又会把问题带回来。

这篇文章整理一套偏实战的复现和修复验证思路,适合用于代码审计、安全测试和研发自查。内容只讨论在授权环境中的验证方法,不涉及未授权入侵、持久化控制或恶意利用。

典型场景

一个业务系统提供头像、附件、工单截图等上传能力。接口大致具备以下特征:

  • 前端限制文件类型,例如只允许 jpg、png、pdf。
  • 后端接收 multipart/form-data 请求。
  • 文件保存到本地目录或对象存储。
  • 上传成功后返回一个可访问 URL。
  • 部分业务还提供在线预览、缩略图生成或文件转码。

风险通常不只来自“能否上传某个后缀”,还包括上传后的文件是否会被解释执行、是否能被跨权限读取、是否可覆盖已有文件、是否能触发服务端解析组件漏洞等。

问题拆解

做文件上传审计时,我通常按以下几个维度拆:

检查点关注内容常见问题
文件类型判断扩展名、MIME、文件头、内容解析只信任前端或 Content-Type
文件命名随机名、路径拼接、目录隔离原文件名直存、路径穿越、覆盖文件
存储位置Web 根目录、对象存储、私有桶上传目录可执行、公开读权限过宽
访问控制下载鉴权、租户隔离、临时链接越权读取其他用户附件
后处理流程图片压缩、PDF 预览、解压缩解析器漏洞、压缩炸弹、资源耗尽
日志与告警异常后缀、超大文件、失败次数无审计记录,难以追踪

安全复现思路

在授权测试中,不建议直接上高危 payload。先用无害样本验证边界,比如文本内容的伪图片、异常扩展名、双扩展名、大小写变体、特殊文件名等。目标是确认服务端真实校验逻辑,而不是证明可以造成破坏。

可以准备几类样本:

  • 正常图片:用于基线测试。
  • 扩展名不一致样本:例如文件内容是文本,但扩展名为 .jpg。
  • MIME 不一致样本:请求头声明 image/png,但内容不是 PNG。
  • 双扩展名样本:例如 report.pdf.txt,用于观察后端取后缀方式。
  • 特殊名称样本:包含空格、中文、超长文件名、多个点号。
  • 大文件样本:用于验证大小限制和超时控制。

使用 curl 可以构造基础请求,注意这里的文件内容应为无害测试文件:

curl -i -X POST ' \
  -H 'Authorization: Bearer <test-token>' \
  -F '[email protected];type=image/png' \
  -F 'bizType=avatar'

如果接口返回了文件地址,需要继续验证几个点:是否必须登录才能访问、是否只能本人访问、URL 是否可枚举、文件是否带正确的 Content-Type 与 Content-Disposition。

curl -i ' 是否由可信解析结果决定,而不是用户传入值。
  • Content-Disposition 是否对非展示类文件使用 attachment。
  • X-Content-Type-Options 是否设置为 nosniff。
  • Cache-Control 是否符合业务敏感度。
  • 代码审计关注点

    以常见 Java/Spring 上传逻辑为例,容易出现下面几类问题。

    第一类是只使用原始文件名判断类型:

    String filename = file.getOriginalFilename();
    String suffix = filename.substring(filename.lastIndexOf('.') + 1);
    if (!allowList.contains(suffix)) {
        throw new RuntimeException("invalid file type");
    }

    这段逻辑的问题不在于“判断后缀”本身,而是它通常还伴随几个缺陷:未统一大小写、未处理无后缀文件、未处理路径分隔符、未结合内容识别、未限制文件名长度。如果后面又把 filename 直接拼到路径里,风险会进一步放大。

    相对稳妥的做法是:原文件名只用于展示,存储名由服务端生成;文件类型采用白名单;扩展名、MIME、文件魔数和业务用途要相互匹配。

    String originalName = StringUtils.cleanPath(file.getOriginalFilename());
    String ext = getSafeExtension(originalName).toLowerCase(Locale.ROOT);
    
    if (!Set.of("jpg", "jpeg", "png", "pdf").contains(ext)) {
        throw new IllegalArgumentException("unsupported file extension");
    }
    
    String detectedType = tika.detect(file.getInputStream());
    if (!isAllowedContentType(ext, detectedType)) {
        throw new IllegalArgumentException("file content type mismatch");
    }
    
    String storageName = UUID.randomUUID() + "." + ext;
    Path target = uploadRoot.resolve(storageName).normalize();
    if (!target.startsWith(uploadRoot)) {
        throw new SecurityException("invalid path");
    }
    Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);

    第二类是上传目录部署在可执行路径。比如把文件放在应用静态目录、Web 根目录,且 Web 服务器对某些扩展存在解释能力。更稳妥的方式是上传文件与应用代码分离存储,下载统一走受控接口或私有对象存储临时 URL。

    第三类是下载接口只根据 fileId 返回文件,没有校验用户与文件归属关系。很多“上传漏洞”最后实际落点是越权读取附件,尤其在工单、合同、报销、聊天附件场景中很常见。

    服务端配置要点

    如果使用 Nginx 承载静态附件目录,建议显式关闭脚本解析,并对响应头做约束。下面是一个示例,仅用于说明思路,具体路径和类型应按业务调整:

    location /uploads/ {
        alias /data/app/uploads/;
        autoindex off;
        add_header X-Content-Type-Options nosniff always;
        add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'" always;
    
        types {
            image/jpeg jpg jpeg;
            image/png png;
            application/pdf pdf;
            text/plain txt;
        }
        default_type application/octet-stream;
    }
    
    location ~* ^/uploads/.*\.(php|jsp|jspx|asp|aspx|sh|cgi)$ {
        return 403;
    }

    如果使用对象存储,关键是避免把整个 bucket 设为公共读。更推荐使用私有 bucket,加业务鉴权后生成短时有效的预签名 URL。对于头像这类确实需要公开访问的资源,也应独立 bucket 或独立前缀隔离,避免和敏感附件混放。

    后处理链路的风险

    很多团队把注意力放在上传接口,却忽略了后处理组件。比如图片压缩、Exif 读取、PDF 转图片、Office 文档预览、压缩包解压等。这些组件通常依赖第三方库或系统命令,历史上出现过不少解析漏洞和命令注入问题。

    比较实用的防护措施包括:

    • 后处理任务放到隔离环境执行,降低对主业务进程的影响。
    • 限制输入文件大小、页数、图片像素、压缩包层级和解压后总大小。
    • 调用外部命令时使用参数数组,不拼接 shell 字符串。
    • 处理超时要可控,失败任务要有重试上限。
    • 第三方解析库纳入依赖漏洞扫描和版本管理。

    例如调用系统工具时,应避免类似下面这种字符串拼接:

    // 不推荐:参数和命令混在一个字符串里
    Runtime.getRuntime().exec("convert " + inputPath + " " + outputPath);

    更安全的方式是使用参数列表,并确保路径来自服务端生成的受控值:

    ProcessBuilder pb = new ProcessBuilder(
        "convert",
        inputPath.toString(),
        outputPath.toString()
    );
    pb.redirectErrorStream(true);
    Process p = pb.start();
    boolean finished = p.waitFor(10, TimeUnit.SECONDS);
    if (!finished) {
        p.destroyForcibly();
        throw new RuntimeException("convert timeout");
    }

    修复验证清单

    漏洞修复后,建议不要只验证“某个样本传不上去了”。可以按清单逐项确认:

    • 服务端是否强制白名单,而不是依赖前端限制。
    • 扩展名、MIME、文件内容识别是否一致校验。
    • 原始文件名是否不会参与真实存储路径。
    • 上传目录是否不可执行,且与应用代码目录隔离。
    • 下载或预览接口是否校验用户权限和资源归属。
    • 对象存储权限是否最小化,敏感附件是否默认私有。
    • 响应头是否设置 nosniff,非展示文件是否 attachment 下载。
    • 是否限制大小、数量、频率、像素、页数和处理超时。
    • 异常上传是否有日志记录,是否能关联用户、IP、文件摘要。
    • 修复是否覆盖所有上传入口,包括后台、移动端、富文本编辑器、导入接口。

    日志与排查建议

    文件上传相关日志不建议记录完整文件内容,但至少应保留以下字段,便于事后排查:

    • 用户 ID、租户 ID、请求 IP、User-Agent。
    • 业务类型、接口路径、文件大小、原始文件名。
    • 服务端生成的文件 ID、存储路径或对象 key。
    • 检测出的 MIME、扩展名、文件哈希。
    • 校验失败原因和后处理失败原因。

    文件哈希比较有价值,既可以排查重复上传,也可以在事件响应中快速定位同一文件是否被多个账号上传。注意日志中不要泄露预签名 URL、访问令牌等敏感信息。

    总结

    文件上传安全的核心不是某一个过滤规则,而是一条完整链路:上传前校验、服务端重命名、隔离存储、受控访问、安全响应头、后处理隔离、日志审计。复现时要尽量使用无害样本验证边界,修复时要覆盖所有入口和后续处理流程。

    从长期维护角度看,建议把上传能力做成统一组件,而不是每个业务各写一套。统一组件里固化白名单、命名、鉴权、存储、日志和限流策略,后续新增业务只配置用途和允许类型,能明显减少重复踩坑。

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

    0篇意见

    推荐意见

    没有意见。

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

    SiteMap HACKHAT © 2026 All rights reserved.