背景
文件上传是 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、访问令牌等敏感信息。
总结
文件上传安全的核心不是某一个过滤规则,而是一条完整链路:上传前校验、服务端重命名、隔离存储、受控访问、安全响应头、后处理隔离、日志审计。复现时要尽量使用无害样本验证边界,修复时要覆盖所有入口和后续处理流程。
从长期维护角度看,建议把上传能力做成统一组件,而不是每个业务各写一套。统一组件里固化白名单、命名、鉴权、存储、日志和限流策略,后续新增业务只配置用途和允许类型,能明显减少重复踩坑。

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