背景文件上传一直是 Web 安全里很常见、也很容易被低估的一类风险。很多业务系统都会提供头像、附件、导入模板、工单图片等上传入口,看起来只是普通功能,但如果后端校验不严,可能导致任意文件写入、脚本解析、存储型 XSS,甚至进一步扩大为服务器侧风险。这篇文章整理的是一个相对典型的审计过程:从代码层发现问题,到在本地环境中验证风险,再到给出相对可落地的修复方案。内容只讨论授权环境下的安全测试和加固,不涉及未授权攻击。问题场景某业务系统提供附件上传接口,前端限制只能上传图片,后端代码也做了简单判断。接口大致逻辑如下:POST /api/upload/avatar
Content-Type: multipart/form-data
file: 用户上传文件从功能表现看,上传图片后会返回一个可访问的 URL,例如:{
\"url\": \"/uploads/2026/10/08/169xxxx.png\"
}初看没有明显问题,但代码审计时发现后端校验主要依赖文件后缀和客户端传入的 Content-Type,这两点都不适合作为唯一安全边界。技术分析一个常见的有问题实现大概是这样:String originalName = file.getOriginalFilename();
String contentType = file.getContentType();
if (!contentType.startsWith(\"image/\")) {
throw new RuntimeException(\"invalid file type\");
}
String suffix = originalName.substring(originalName.lastIndexOf(\".\"));
String fileName = System.currentTimeMillis() + suffix;
Path target = Paths.get(uploadDir, fileName);
file.transferTo(target.toFile());
return \"/uploads/\" + fileName;这段代码的问题主要有几个:信任 Content-Type:Content-Type 是客户端可控字段,不能证明文件真实类型。仅检查后缀:后缀可以伪造,且不同中间件对复合后缀、大小写、特殊字符的处理可能不一致。上传目录可直接访问:如果上传目录处于 Web 根目录下,一旦服务端配置不当,风险会明显增加。文件名处理不充分:原始文件名如果参与路径拼接,可能引入路径穿越、覆盖文件等问题。在真实审计里,不建议只盯着“能不能传脚本文件”。文件上传的风险面更宽,例如 SVG 造成的脚本执行、压缩包解压导致路径穿越、超大文件引起资源消耗、图片解析库历史漏洞触发等。本地验证思路安全验证应放在本地或授权测试环境中进行。对于文件上传漏洞,建议先确认三个问题:后端到底依据什么判断文件类型。文件最终存储在哪里,是否可被 Web 直接访问。上传后的文件是否会被二次处理,例如压缩、裁剪、解析、解压。可以用 curl 观察服务端行为,例如测试 Content-Type 是否被盲目信任:curl -i -X POST \\
-F \"[email protected];type=image/png\"如果普通文本文件在伪造 Content-Type 后被接受,说明服务端存在校验缺陷。这里验证的是校验逻辑,不需要上传任何可执行脚本或危险内容。再看后缀判断是否严格,例如上传一个实际内容不是图片但扩展名为 png 的文件:printf 'not a real image' > fake.png
curl -i -X POST \\
-F \"[email protected];type=image/png\"如果仍然通过,说明后端没有进行文件头、解析验证或安全重编码。关键审计点1. 文件类型校验文件类型判断建议采用多层策略,不要只依赖单个字段:限制业务允许的扩展名白名单,例如只允许 jpg、jpeg、png、webp。检查文件魔数,初步判断真实格式。使用成熟图片库进行解析,无法解析则拒绝。必要时对图片进行重新编码,丢弃原始文件结构中的异常内容。Java 场景下可以使用 ImageIO 做基础解析校验,但要注意 ImageIO 对部分格式支持有限,生产环境可以结合业务选择更可靠的库:BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new RuntimeException(\"invalid image\");
}
int width = image.getWidth();
int height = image.getHeight();
if (width <= 0 || height <= 0 || width > 8000 || height > 8000) {
throw new RuntimeException(\"invalid image size\");
}2. 文件名与路径处理不要直接使用用户上传的原始文件名作为最终存储名。更稳妥的做法是服务端生成随机文件名,并固定扩展名映射:String ext = detectSafeExtension(file);
String safeName = UUID.randomUUID().toString().replace(\"-\", \"\") + \".\" + ext;
Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
Path target = baseDir.resolve(safeName).normalize();
if (!target.startsWith(baseDir)) {
throw new RuntimeException(\"invalid path\");
}这里的 normalize 和 startsWith 判断主要用于避免路径穿越。即便当前代码没有使用原始文件名拼接路径,也建议保留这类防御逻辑,避免后续改动引入问题。3. 上传目录隔离上传目录最好不要放在应用可执行路径或 Web 根目录下。推荐做法是:上传文件存储在独立目录、对象存储或专用文件服务。访问时通过受控下载接口返回,而不是直接暴露真实路径。上传目录禁用脚本执行权限。对外访问域名与主站隔离,降低同源风险。Nginx 场景下,如果确实需要静态访问上传目录,应至少确保该目录不解析脚本。示例配置如下:location /uploads/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
location ~* \\.(php|jsp|jspx|asp|aspx|cgi|pl)$ {
return 403;
}
}如果上传内容可能包含 SVG、HTML、XML 等文本格式,建议谨慎开放直接访问。很多业务里用户只需要图片展示,并不需要允许这类格式。4. 文件大小与资源限制文件上传不仅是类型问题,也可能带来资源消耗风险。建议同时限制:单文件大小。单用户单位时间上传次数。图片分辨率和像素总量。压缩包文件数量、解压后总大小和路径合法性。以 Spring Boot 为例,可以设置上传大小限制:spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=10MB但只配置框架限制还不够,业务层仍然要做校验,因为不同入口、不同网关、不同服务之间可能存在配置不一致。5. 返回头与内容嗅探浏览器可能会根据内容进行 MIME 嗅探。对于上传文件访问接口,建议明确返回 Content-Type,并增加安全响应头:X-Content-Type-Options: nosniff
Content-Disposition: attachment; filename=\"download.png\"如果是下载类附件,尽量使用 attachment,避免浏览器直接渲染潜在危险内容。如果是头像展示接口,也应只返回经过校验和重编码的安全图片。修复示例一个相对稳妥的处理流程可以是:1. 接收文件
2. 判断文件是否为空、大小是否超限
3. 根据文件头和解析结果判断真实类型
4. 限制图片宽高、像素总量
5. 重新编码为安全格式
6. 服务端生成随机文件名
7. 保存到非 Web 根目录
8. 通过受控接口访问文件
9. 记录上传日志,便于审计和追踪示例伪代码如下:public UploadResult uploadImage(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new RuntimeException(\"empty file\");
}
if (file.getSize() > 5 * 1024 * 1024) {
throw new RuntimeException(\"file too large\");
}
BufferedImage image;
try (InputStream in = file.getInputStream()) {
image = ImageIO.read(in);
} catch (IOException e) {
throw new RuntimeException(\"read image failed\");
}
if (image == null) {
throw new RuntimeException(\"invalid image\");
}
if (image.getWidth() > 4000 || image.getHeight() > 4000) {
throw new RuntimeException(\"image dimension too large\");
}
String fileName = UUID.randomUUID().toString().replace(\"-\", \"\") + \".png\";
Path target = safeResolve(uploadDir, fileName);
ImageIO.write(image, \"png\", target.toFile());
return new UploadResult(fileName);
}这里有个细节:重新编码会改变原始文件内容,能减少一些嵌入异常内容带来的风险。当然,重新编码不是万能方案,仍然需要关注图片库自身的安全更新。测试用例建议修复后建议补充自动化测试,不要只靠人工点页面。常见测试点如下:测试项预期结果空文件上传拒绝超大文件上传拒绝伪造 Content-Type拒绝文本文件改 png 后缀拒绝正常 png 图片允许超大分辨率图片拒绝包含路径字符的文件名不影响最终路径如果系统支持压缩包上传,还要额外测试 Zip Slip、压缩炸弹、嵌套压缩包等情况。压缩包处理逻辑建议单独审计,不要和普通图片上传混在一起看。注意点前端限制不能替代后端校验:前端 accept 属性只能改善用户体验,不能作为安全控制。黑名单不可靠:危险扩展名很多,且不同环境解析规则不同,优先使用白名单。对象存储也要配置权限:使用对象存储不等于天然安全,桶权限、访问策略、Content-Type 元数据都要检查。日志要可追溯:记录上传用户、文件大小、检测结果、存储 key、来源 IP,便于后续排查。关注依赖安全:图片处理库、解压库、中间件都可能出现历史漏洞,版本治理不能忽略。总结文件上传漏洞的核心问题通常不是“某个后缀没拦住”这么简单,而是上传入口缺少完整的安全边界。比较稳的思路是:业务白名单、真实类型识别、内容解析或重编码、目录隔离、权限控制、访问接口收口、日志审计。做代码审计时,我一般会把上传功能当成一个小型数据入口来看:用户提交的不是普通字符串,而是一段复杂的二进制内容。只要后端会保存、解析、展示或转发它,就应该明确每一步的信任边界。把这些边界补齐,风险会下降很多。
推荐意见