背景
文件上传是 Web 系统里很常见的功能,但也是安全问题高发点。很多漏洞并不是因为框架本身有问题,而是业务代码在文件类型判断、文件名处理、存储路径控制、访问权限隔离等环节做得不够严谨。
这篇文章整理一次偏实战的代码审计思路,以 Spring Boot 项目中的文件上传接口为例,讨论如何定位风险点、如何复现验证、以及如何做相对稳妥的修复。内容只面向防御、审计和加固,不涉及未授权攻击或恶意利用。
常见场景
审计中经常遇到这样的上传接口:前端限制只能上传图片,后端接收 MultipartFile,然后根据原始文件名拼接路径并保存到服务器某个目录。代码大致类似:
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
String filename = file.getOriginalFilename();
File dest = new File("/data/upload/" + filename);
file.transferTo(dest);
return "/upload/" + filename;
}这段代码看起来简单,但问题比较集中:文件名完全信任用户输入,未限制后缀,未校验真实内容,保存目录可能可被 Web 直接访问,也没有大小限制和权限隔离。
风险点分析
文件上传相关风险通常不止一个点,需要结合部署方式和业务逻辑综合判断。
- 路径穿越:如果直接使用原始文件名,攻击者可能提交包含 ../ 或特殊路径分隔符的文件名,导致文件写入非预期目录。
- 危险文件类型:仅依赖前端校验或 Content-Type 判断不可靠。Content-Type 可以由客户端自行指定,文件扩展名也可以伪造。
- 双后缀与大小写绕过:例如 .jpg.jsp、.pHp、带空格或特殊字符的后缀,在不同服务器和系统上可能存在解析差异。
- 可执行目录问题:如果上传目录被 Web 容器当作可执行目录处理,风险会明显升高。静态资源目录也要确认是否存在脚本解析能力。
- 文件覆盖:使用原始文件名保存,容易覆盖已有文件,造成业务数据异常或安全配置被覆盖。
- 拒绝服务:未限制文件大小、数量和解析复杂度时,可能被大文件或压缩包炸弹拖垮存储与 CPU。
审计时的定位方法
代码审计可以从入口、处理、存储、访问四个环节入手。
1. 查找上传入口
在 Java 项目中,可以先搜索以下关键字:
MultipartFile
@RequestParam("file")
CommonsMultipartFile
transferTo(
getOriginalFilename()
getInputStream()如果项目使用对象封装,也可以搜索 Controller 层中与 upload、file、attachment、avatar、import 等相关的接口。
2. 观察文件名处理
重点看是否直接使用 file.getOriginalFilename(),以及是否做了 basename 提取、特殊字符过滤、随机重命名。
风险代码通常具有这类特征:
String name = file.getOriginalFilename();
String path = uploadDir + "/" + name;
file.transferTo(new File(path));较好的做法是不信任原始文件名,只保留用于展示的名称,实际落盘使用服务端生成的随机文件名。
3. 检查类型校验方式
仅判断 Content-Type 通常不够:
if (file.getContentType().startsWith("image/")) {
// 保存文件
}更合理的方式是多层校验:后缀白名单、MIME 类型辅助判断、文件魔数校验,必要时使用图片解码库重新读取并重编码。对于头像、封面这类图片上传,重编码可以有效降低混入异常内容的风险。
4. 确认存储目录和访问方式
上传文件最好放在 Web 根目录之外,通过受控接口读取。如果必须对外静态访问,也要保证该目录不会执行脚本,不允许目录列表,并配置合适的响应头。
# Nginx 示例:上传目录仅作为静态文件返回,不做脚本解析
location /uploads/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
}如果后端还有下载接口,需要检查是否存在任意文件读取问题,尤其是根据文件名或路径参数直接读取本地文件的逻辑。
安全实现示例
下面是一个相对稳妥的上传处理思路,重点是:白名单、随机文件名、路径归一化、大小限制、非 Web 根目录存储。
private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "gif", "webp");
private static final long MAX_SIZE = 2 * 1024 * 1024;
private static final Path UPLOAD_ROOT = Paths.get("/data/app/uploads").toAbsolutePath().normalize();
@PostMapping("/upload")
public Map<String, String> upload(@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) {
throw new IllegalArgumentException("empty file");
}
if (file.getSize() > MAX_SIZE) {
throw new IllegalArgumentException("file too large");
}
String original = Optional.ofNullable(file.getOriginalFilename()).orElse("");
String cleanName = Paths.get(original).getFileName().toString();
String ext = getExt(cleanName).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
throw new IllegalArgumentException("unsupported file type");
}
if (!looksLikeImage(file.getInputStream())) {
throw new IllegalArgumentException("invalid image content");
}
String storedName = UUID.randomUUID() + "." + ext;
Path target = UPLOAD_ROOT.resolve(storedName).normalize();
if (!target.startsWith(UPLOAD_ROOT)) {
throw new IllegalArgumentException("invalid path");
}
Files.createDirectories(UPLOAD_ROOT);
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
return Map.of("fileId", storedName);
}
private String getExt(String filename) {
int idx = filename.lastIndexOf('.');
if (idx < 0 || idx == filename.length() - 1) {
return "";
}
return filename.substring(idx + 1);
}其中 looksLikeImage 不建议自己简单判断几个字节就结束。可以结合业务要求选择更严格的方式,比如使用 ImageIO 读取并确认宽高,或者对图片进行重新编码。
private boolean looksLikeImage(InputStream input) throws IOException {
BufferedImage image = ImageIO.read(input);
if (image == null) {
return false;
}
return image.getWidth() > 0 && image.getHeight() > 0;
}配置层面的限制
除了业务代码,框架和网关层也应设置上传大小限制。只靠代码判断,往往已经让请求体进入应用层,资源消耗会更高。
# Spring Boot
spring.servlet.multipart.max-file-size=2MB
spring.servlet.multipart.max-request-size=4MB# Nginx
client_max_body_size 4m;如果上传功能面向高频场景,还应考虑限流、鉴权、日志审计和对象存储隔离。对象存储的 Bucket 权限也要单独检查,避免上传后被公开列目录或越权访问。
复现与验证建议
验证上传风险时,建议在本地测试环境或授权环境中进行,重点验证以下项目:
- 上传超大文件是否被网关和应用正确拒绝。
- 上传无后缀、异常后缀、大小写后缀是否被拒绝。
- 提交包含路径分隔符的文件名,确认不会写入上传目录之外。
- 上传伪造 Content-Type 的非图片文件,确认会被内容校验拦截。
- 重复上传同名文件,确认不会覆盖已有文件。
- 直接访问上传目录,确认不会列目录,不会执行脚本。
- 下载接口传入 ../ 等路径片段,确认不会读取非授权文件。
这里给一个简单的本地验证命令,用于确认服务端不是只看 Content-Type:
curl -F "[email protected];type=image/png" Content-Type 判断,这类请求可能会被误判为图片。正确实现下,应继续检查文件内容并拒绝。日志与监控
上传接口的安全日志很容易被忽略。建议记录以下字段,但不要记录完整敏感内容:
- 用户标识或调用方标识。
- 源 IP、User-Agent、请求路径。
- 原始文件名、最终文件 ID、文件大小、校验结果。
- 失败原因,例如后缀不允许、大小超限、内容非法。
对于频繁失败、上传大量异常后缀、短时间内大量请求的行为,可以接入告警或风控规则。很多事件早期都能在上传失败日志里看到迹象。
容易踩坑的细节
- 不要把黑名单当核心策略:危险后缀太多,且不同中间件解析规则不同。上传类型应使用白名单。
- 不要相信前端校验:前端校验只能改善体验,不能作为安全边界。
- 不要直接返回本地路径:接口返回 /data/app/uploads/xxx 这类路径会暴露部署信息,也可能给后续攻击提供线索。
- 注意 SVG:SVG 本质是 XML,可能包含脚本或外部引用。若无必要,不建议允许上传 SVG。
- 注意压缩包:如果业务需要上传并解压压缩包,必须额外处理 Zip Slip、解压后总大小、文件数量和路径归一化。
- 注意对象存储回调:直传对象存储时,不要只相信客户端回传的文件地址,应由服务端校验上传结果和对象元数据。
加固清单
检查项 建议做法 文件名 服务端生成随机名,原始文件名仅展示使用 后缀 白名单控制,统一转小写后判断 内容 检查魔数或使用解析库读取,图片建议重编码 路径 使用 normalize 后确认仍在上传根目录内 目录 存放在 Web 根目录之外,静态目录禁止脚本执行 大小 网关、框架、业务三层限制 权限 上传、访问、删除分开鉴权 日志 记录失败原因和异常行为,便于追踪
总结
文件上传安全的核心不是某一个判断条件,而是一组边界控制:入口限流和大小限制、类型白名单、内容校验、随机落盘、路径归一化、存储隔离、访问鉴权和日志监控。
从代码审计角度看,看到 getOriginalFilename、transferTo、静态目录映射、下载接口路径参数时,都值得顺手多看几眼。很多真实风险并不复杂,往往就是几个基础细节叠在一起导致的。

代码审计、调用链与关键函数定位示意
推荐意见