背景
文件上传是 Web 安全里非常常见的一类风险点,但很多项目里的问题并不只是“没校验后缀”这么简单。实际审计中更常见的是:业务层做了一部分限制,框架层又做了一部分处理,最后在存储、访问、解析链路上出现偏差,导致风险被放大。
这篇文章整理一个偏通用的分析思路:如何从代码审计角度定位上传功能风险,如何在本地环境做安全复现,以及如何给出可落地的修复建议。内容偏防御和验证,不涉及对第三方系统的攻击操作。
典型场景
假设一个业务系统提供头像、附件、富文本图片等上传能力。后端大致流程如下:
- 前端限制文件类型,例如只允许 jpg、png、pdf。
- 后端接收 multipart/form-data 请求。
- 后端根据文件后缀或 Content-Type 做校验。
- 文件保存到本地目录或对象存储。
- 返回文件访问 URL,供前端展示或下载。
看起来流程完整,但真实风险往往出现在以下几个点:
- 只依赖前端校验,后端未做强校验。
- 只检查文件名后缀,未检查文件真实类型。
- 上传目录位于 Web 可执行路径下。
- 文件名由用户可控,存在路径穿越或覆盖风险。
- 下载接口缺少鉴权,导致越权访问附件。
- 图片处理库、解压缩逻辑、文档解析组件引入二次风险。
代码审计时的关注点
审计上传功能时,不建议只搜索 upload 关键字。实际项目里接口命名可能是 attachment、file、media、resource、import、avatar、material 等。可以从路由、Controller、Service、存储工具类三个层面去看。
1. 接口入口
先确认哪些接口接收文件对象,例如 Java Spring 项目中常见的 MultipartFile:
@PostMapping("/upload")
public Result upload(@RequestParam("file") MultipartFile file) {
return fileService.save(file);
}这里要继续跟进 fileService.save 的实现,而不是停留在 Controller。重点看以下问题:
- 是否判断空文件和文件大小。
- 是否限制业务允许的类型。
- 校验逻辑是白名单还是黑名单。
- 是否直接使用原始文件名。
- 是否将文件写入 Web 根目录。
2. 文件名处理
很多漏洞都和原始文件名有关。常见危险写法如下:
String filename = file.getOriginalFilename();
File dest = new File(uploadDir + "/" + filename);
file.transferTo(dest);这类代码至少有几个问题:路径拼接不安全、文件名未规范化、可能覆盖已有文件、可能被特殊字符影响日志或后续处理。更稳妥的方式是服务端生成文件名,并保留原始文件名仅用于展示。
String ext = getSafeExt(file);
String storedName = UUID.randomUUID().toString().replace("-", "") + ext;
Path target = Paths.get(uploadDir).resolve(storedName).normalize();
if (!target.startsWith(Paths.get(uploadDir).normalize())) {
throw new IllegalArgumentException("invalid path");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);3. 类型校验
上传类型校验尽量采用“多层校验”,不要只信任 Content-Type 或文件后缀。一个相对稳妥的策略是:
- 按业务定义允许的扩展名白名单。
- 检查 MIME 类型,但不单独依赖。
- 检查文件头魔数。
- 对图片类文件进行解码重写,去除异常内容。
- 对压缩包、Office、PDF 等复杂格式做更严格限制。
例如图片上传场景,可以使用图片库实际读取并重新编码,而不是原样保存:
BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new IllegalArgumentException("not an image");
}
ImageIO.write(image, "png", targetFile);注意:图片重编码不是万能方案,但它能有效降低“伪装图片”或异常元数据带来的风险。对于 SVG 这类文本型图片,建议默认禁用,确有需求时必须做标签和事件属性清洗。
4. 存储位置与访问方式
上传文件不应放在应用可执行目录下,也不应让脚本文件具备执行机会。推荐策略:
- 上传目录与应用部署目录分离。
- 静态资源服务只做下载,不做脚本解析。
- 对象存储使用私有桶,通过后端签名或鉴权代理访问。
- 敏感附件访问必须绑定业务权限。
Nginx 场景下,如果必须提供本地文件访问,可以单独配置静态目录,并禁止可疑类型被执行:
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff;
}如果历史包袱较重,上传目录曾经混放过各种类型文件,建议先做一次存量扫描和迁移,再调整访问策略,避免修复后影响业务。
安全复现的基本方法
复现建议只在本地靶场、测试环境或授权环境进行。复现目标不是“拿权限”,而是确认校验链路是否存在缺陷,并为修复提供证据。
1. 准备本地环境
可以搭一个最小上传服务,观察不同校验方式的行为差异。例如使用 curl 上传测试文件:
curl -i -X POST \
-F "[email protected];type=image/png"然后分别观察后端记录:
- 服务端获取到的原始文件名。
- Content-Type 是否来自客户端。
- 最终保存路径。
- 返回 URL 是否可直接访问。
- 访问该文件时响应头是否符合预期。
2. 构造边界用例
建议准备一些合法但容易触发边界问题的测试样本,例如:
- 超大文件,验证大小限制和内存占用。
- 无扩展名文件,验证默认处理逻辑。
- 双扩展名文件,验证后缀解析方式。
- 包含空格、中文、特殊字符的文件名,验证路径和日志处理。
- 损坏图片,验证图片解码异常处理。
- 压缩包内多层目录,验证解压路径控制。
对于压缩包导入功能,要特别注意 Zip Slip 类问题。解压时不能直接信任条目名称:
Path destDir = Paths.get("/data/import").normalize();
for (ZipEntry entry : entries) {
Path out = destDir.resolve(entry.getName()).normalize();
if (!out.startsWith(destDir)) {
throw new SecurityException("invalid zip entry");
}
// 继续写入文件
}3. 验证访问控制
文件上传后的访问控制经常被忽略。很多系统上传时需要登录,但下载时只要知道 URL 就能访问。审计时要确认:
- 附件 URL 是否包含可枚举 ID。
- 下载接口是否校验当前用户是否有权限访问该业务对象。
- 临时文件是否会长期保留。
- 删除业务数据后附件是否仍可访问。
一个常见的安全设计是:文件表只保存存储 key,不直接暴露真实路径;下载时通过业务 ID 查询权限,再由后端读取文件流返回。
@GetMapping("/file/{id}")
public void download(@PathVariable Long id, HttpServletResponse response) {
FileMeta meta = fileService.findById(id);
permissionService.checkReadable(meta.getBizId(), currentUser());
fileStorage.writeToResponse(meta.getStorageKey(), response);
}常见误区
- 只做黑名单:例如禁止 jsp、php、exe,但遗漏大小写、变体后缀或服务端解析差异。
- 信任 Content-Type:该字段可由客户端控制,只能作为辅助信息。
- 认为对象存储一定安全:如果桶权限、回源策略、签名 URL 过期时间配置不当,仍然会造成泄露。
- 忽略下载鉴权:上传接口安全不代表文件访问安全。
- 忽略二次处理:图片压缩、文档预览、压缩包解压、Excel 导入都可能引入新攻击面。
修复建议清单
| 风险点 | 建议 |
|---|---|
| 文件类型校验不足 | 使用扩展名白名单、MIME 辅助校验、魔数校验,必要时重编码 |
| 文件名可控 | 服务端生成随机文件名,原始文件名仅展示使用 |
| 路径拼接不安全 | 使用 Path resolve 和 normalize,并校验目标路径前缀 |
| 上传目录可执行 | 上传目录与应用目录隔离,静态服务禁止脚本解析 |
| 附件越权访问 | 下载接口绑定业务权限,不直接暴露真实路径 |
| 大文件导致资源消耗 | 限制单文件大小、总请求大小、上传频率和存储配额 |
| 复杂格式处理风险 | 限制解析组件能力,及时升级依赖,隔离预览/转换服务 |
日志与监控
上传功能还需要配合日志和监控。建议记录但不要泄露敏感内容:
- 上传用户、业务对象、文件大小、校验结果。
- 存储 key、文件哈希、来源 IP。
- 失败原因分类,如类型不允许、大小超限、解析失败。
- 异常频率,如短时间大量失败上传。
文件哈希在排查重复上传、异常样本和事件响应时比较有用。示例:
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = file.getInputStream()) {
byte[] buf = new byte[8192];
int len;
while ((len = in.read(buf)) != -1) {
digest.update(buf, 0, len);
}
}
String sha256 = HexFormat.of().formatHex(digest.digest());总结
文件上传漏洞的核心不只是“能不能上传某个后缀”,而是整个链路是否可信:入口校验、文件名处理、存储隔离、访问控制、二次解析、日志监控都要一起看。
实际审计时,我通常按三个问题推进:第一,用户能控制什么;第二,文件会被放到哪里、以什么方式访问;第三,后续有没有解析、预览、解压、转码等处理。只要把这三条链路理清,大多数上传相关风险都能定位到具体代码和配置上。
建议在项目基线里把上传安全做成统一组件,不要让各业务模块各写一套。上传功能越分散,越容易出现某个接口绕过统一策略的问题。
推荐意见