文件上传是 Web 系统里很常见的功能:头像、附件、导入模板、工单截图、富文本图片都会用到。它也是代码审计中高频出问题的位置。很多漏洞并不是因为开发完全没有校验,而是校验点放错了、校验维度不完整,或者存储和访问链路没有隔离。
这篇文章整理的是我在审计文件上传功能时比较常用的一套检查思路,重点放在防护设计和代码审计,不涉及未授权攻击或真实目标利用。示例建议只在本地测试环境或授权环境中验证。
一、常见场景与风险边界
文件上传风险通常不是单点问题,而是“上传入口、解析处理、存储位置、访问方式、后续消费”组合后的结果。单纯禁止某几个扩展名并不能覆盖完整风险。
| 场景 | 常见问题 | 主要影响 |
| 头像/图片上传 | 只校验 Content-Type 或前端校验 | 上传非预期文件、触发解析风险 |
| 附件上传 | 原文件名直接拼接路径 | 路径穿越、覆盖文件 |
| 压缩包导入 | 解压时未校验条目路径 | Zip Slip、写入任意目录 |
| Office/PDF 预览 | 调用解析器未隔离 | 解析器漏洞、资源消耗 |
| 对象存储直传 | Key 可控、权限策略过宽 | 越权覆盖、公开敏感文件 |
审计时不要只看上传接口本身,还要跟踪文件被保存到哪里、是否会被 Web 容器直接解析、是否会被其他组件二次处理。
二、审计入口:先定位所有上传路径
很多系统不止一个上传接口。除了显式的 upload 接口,还要关注导入、富文本编辑器、IM 附件、临时文件、批量任务等入口。
常用的代码搜索关键字如下:
MultipartFile
Part
CommonsMultipartFile
@RequestParam("file")
getOriginalFilename
transferTo
FileOutputStream
Files.copy
ZipInputStream
ZipFile
putObject
uploadFile如果是 PHP 项目,可关注:
$_FILES
move_uploaded_file
is_uploaded_file
pathinfo
finfo_file
ZipArchive
copy
file_put_contents如果是 Node.js 项目,可关注 multer、busboy、formidable、koa-body 等中间件配置,重点看 storage、filename、limits、fileFilter 等参数。
三、文件名处理:不要信任原始文件名
原始文件名是用户可控输入。直接使用原文件名保存,容易带来路径穿越、特殊字符兼容性问题、同名覆盖、日志污染等风险。
不建议出现类似逻辑:
String name = file.getOriginalFilename();
File dest = new File(uploadDir + "/" + name);
file.transferTo(dest);更稳妥的做法是:原始文件名仅用于展示,存储名由服务端生成;路径使用安全 API 归一化后校验;扩展名从安全解析结果中获取,而不是简单字符串拼接。
String originalName = file.getOriginalFilename();
String safeExt = resolveAllowedExtension(originalName, file);
String storedName = UUID.randomUUID().toString().replace("-", "") + "." + safeExt;
Path base = Paths.get(uploadDir).toAbsolutePath().normalize();
Path target = base.resolve(storedName).normalize();
if (!target.startsWith(base)) {
throw new IllegalArgumentException("invalid upload path");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);这里的关键点不是 UUID 本身,而是“用户输入不参与最终路径决策”。展示原文件名时也要做 HTML 转义,避免后续页面渲染引入 XSS。
四、类型校验:扩展名、MIME、魔数要组合判断
单独依赖 Content-Type 不可靠,因为它来自客户端请求头。单独依赖扩展名也不够,因为扩展名可以伪造。实际项目里建议采用白名单策略,并结合多维度校验。
- 扩展名:只允许业务确实需要的类型,例如 jpg、png、pdf。
- MIME:服务端识别,不直接信任请求头。
- 魔数:读取文件头部字节判断基本格式。
- 内容解析:图片可尝试 decode,PDF 可使用安全库读取基本结构。
- 大小限制:入口层和应用层都要限制。
例如图片上传,不要只判断后缀为 jpg。可以先限制最大大小,再读取头部,再用图片库解码确认尺寸。
BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new IllegalArgumentException("not a valid image");
}
int width = image.getWidth();
int height = image.getHeight();
if (width <= 0 || height <= 0 || width > 8000 || height > 8000) {
throw new IllegalArgumentException("invalid image size");
}需要注意,图片解码本身也是消耗资源的操作。大图、畸形图可能造成 CPU 或内存压力,因此上传大小和尺寸限制要放在解码之前或尽早处理。
五、存储隔离:避免上传目录被脚本引擎解析
上传文件最好不要放在 Web 根目录下,更不要放在会被服务端脚本引擎解析的位置。对于 Java、Go、Node 这类应用,虽然不像 PHP 那样常见脚本解析问题,但仍然要避免静态目录直接暴露敏感附件。
推荐的访问模型是:
- 文件存储在应用工作目录之外,或专门的对象存储桶中。
- 数据库只保存文件 ID、存储 Key、大小、Hash、上传人、权限信息。
- 下载走受控接口,先做鉴权,再返回文件流。
- 公开图片可使用独立静态域名或对象存储,并配置最小权限。
Nginx 层也可以增加兜底限制,例如禁止上传目录中的脚本类型被执行或错误解析:
location /uploads/ {
autoindex off;
types { }
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff;
}
location ~* ^/uploads/.*\.(php|jsp|jspx|asp|aspx|cgi|pl)$ {
return 403;
}这类配置是兜底,不应替代应用层校验。真正可靠的设计仍然是上传文件不进入可执行路径。
六、压缩包处理:重点看 Zip Slip 与资源消耗
导入功能经常允许上传 zip、jar、war、docx、xlsx 等压缩格式。审计时要关注解压逻辑是否校验每个条目的目标路径。
有风险的写法通常是把 entry name 直接拼接到输出目录:
ZipEntry entry = zipInputStream.getNextEntry();
File out = new File(destDir, entry.getName());更安全的做法是对每个条目做 normalize,并确认最终路径仍在目标目录下:
Path dest = Paths.get(destDir).toAbsolutePath().normalize();
Path out = dest.resolve(entry.getName()).normalize();
if (!out.startsWith(dest)) {
throw new IllegalArgumentException("invalid zip entry path");
}除此之外,还要限制:
- 压缩包总大小。
- 解压后总大小。
- 文件数量。
- 目录层级深度。
- 单文件最大大小。
否则即使没有路径穿越,也可能因为压缩炸弹导致磁盘或内存被打满。
七、对象存储直传:不要把服务端校验完全交给前端
现在不少系统使用对象存储直传。直传本身没问题,但容易出现两个误区:一是前端限制被当成安全边界;二是服务端签名策略过宽。
签名策略建议至少约束:
- Key 前缀,例如 user-upload/{userId}/yyyyMMdd/。
- 文件大小范围。
- Content-Type 白名单。
- 签名有效期尽量短。
- 禁止客户端覆盖任意 Key。
上传完成后,服务端仍应通过回调或主动查询确认对象元数据,并把文件记录绑定到当前用户和业务对象。不要让客户端随意提交一个 URL 就认为上传完成。
八、本地验证建议
审计发现问题后,建议在本地最小化复现,不要直接对线上环境做破坏性测试。可以准备一个临时目录和测试接口,验证校验逻辑是否符合预期。
# 仅用于本地测试环境
mkdir -p /tmp/upload-lab
# 构造普通文本文件,观察服务端是否错误当作图片接收
printf 'hello' > /tmp/upload-lab/test.jpg
# 上传到本地接口验证校验逻辑
curl -F 'file=@/tmp/upload-lab/test.jpg'
void shouldRejectZipEntryOutsideBaseDir() {
Path base = Paths.get("/tmp/app/upload").toAbsolutePath().normalize();
Path out = base.resolve("../evil.txt").normalize();
assertFalse(out.startsWith(base));
}九、日志与监控:上传失败也值得记录
文件上传的异常行为通常会留下比较明显的信号,例如大量失败请求、异常扩展名、超大文件、畸形压缩包、同一用户频繁触发校验失败等。建议记录以下字段:
- 用户 ID 或匿名会话 ID。
- 上传接口和业务类型。
- 原文件名经过安全处理后的展示值。
- 服务端识别出的 MIME、扩展名、大小、Hash。
- 失败原因分类,例如 ext_denied、size_exceeded、magic_mismatch。
- 请求来源 IP 和 User-Agent。
注意不要把完整文件内容、敏感业务数据写进日志。日志的价值在于定位异常模式,而不是保存用户文件。
十、审计清单
最后给一个可复用的检查清单,适合代码审计或上线前评审时逐项确认。
- 是否定位了所有上传入口,包括导入、富文本、临时文件和对象存储直传。
- 是否存在前端校验替代服务端校验的问题。
- 是否使用白名单限制扩展名和文件类型。
- 是否校验服务端识别的 MIME、魔数或解析结果。
- 是否限制文件大小、图片尺寸、压缩包解压后大小和文件数量。
- 是否使用服务端生成的存储名,避免原文件名参与路径拼接。
- 是否对路径做 normalize,并校验目标路径位于预期目录内。
- 上传目录是否位于 Web 可执行路径之外。
- 下载或访问文件时是否有鉴权。
- 对象存储签名策略是否限制 Key、大小、类型和有效期。
- 是否有失败日志和异常上传监控。
- 是否有单元测试覆盖异常扩展名、伪造 MIME、路径穿越和压缩包异常条目。
总结
文件上传安全的核心不是某一个 if 判断,而是完整链路的收敛:入口白名单、内容识别、路径安全、存储隔离、访问鉴权、资源限制和日志监控都要到位。
实际审计时,我通常先找入口,再追踪文件保存和访问路径,最后看是否存在二次解析或异步处理。只要把这条链路梳理清楚,大部分上传类问题都能比较稳定地发现和修掉。
推荐意见