背景文件上传一直是 Web 安全里比较“老”的问题,但在真实环境中仍然很常见。原因不只是后端判断不严,还包括对象存储直传、反向代理转发、异步处理任务、缩略图生成、临时目录清理等多个环节。只盯着一个上传接口,往往看不到完整风险链路。前段时间复盘过一次内部演练中的 WebShell 告警,最终没有形成实际危害,但暴露出几个典型问题:扩展名校验分散、上传目录可执行、图片处理逻辑信任用户输入、日志字段不足。这里整理成一篇偏长期参考的文章,重点放在代码审计思路和防护落地,不讨论攻击利用细节。场景说明业务中有一个头像上传功能,前端限制只能上传 jpg、png,后端也做了扩展名判断,并将文件保存到本地静态目录。表面看风险不高,但实际链路如下:用户上传文件到后端接口;后端根据原始文件名截取扩展名;保存到 Web 可访问目录;异步任务读取文件生成缩略图;Nginx 将该目录作为静态资源直接对外提供。告警来自主机侧文件监控,发现上传目录下出现了非预期内容。虽然没有执行成功,但问题点已经比较明显:上传文件进入了 Web 根目录,并且校验逻辑不足以保证文件类型可信。问题代码示例下面是一段简化后的伪代码,很多项目里都能看到类似写法:public String upload(MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf('.') + 1);
if (!Arrays.asList(\"jpg\", \"jpeg\", \"png\").contains(suffix.toLowerCase())) {
throw new RuntimeException(\"invalid file type\");
}
String fileName = UUID.randomUUID() + \".\" + suffix;
Path target = Paths.get(\"/data/www/static/upload/\", fileName);
file.transferTo(target.toFile());
return \"/upload/\" + fileName;
}这段代码的问题不在于“完全没有校验”,而是校验点太单薄:只看文件名扩展,不验证真实内容;直接保存到静态目录,扩大了误上传文件的影响面;保留了用户提供的扩展名,后续解析策略不确定;没有限制文件大小、图片尺寸、MIME 类型一致性;缺少统一审计日志,事后难以定位来源用户和请求上下文。代码审计关注点审计文件上传功能时,我一般不会只搜 upload 关键字,而是按数据流梳理。建议重点看以下几个位置。1. 文件名处理常见风险包括路径穿越、特殊字符、双扩展名、大小写混用、空字节历史兼容问题等。现代框架多数已经处理了部分边界,但业务层仍然不要信任原始文件名。比较稳妥的做法是:原始文件名只用于展示或审计,不参与实际存储路径;存储文件名由服务端生成;扩展名由服务端根据识别结果决定。String safeId = UUID.randomUUID().toString().replace(\"-\", \"\");
String storedName = safeId + \".jpg\";2. 类型识别只看 Content-Type 或扩展名都不够,建议至少做三层判断:扩展名白名单;Content-Type 白名单;文件头与实际解析校验。对于图片上传,更推荐使用可靠图片库重新解码并重编码。这样即使原文件中包含额外异常内容,也能在重编码过程中被丢弃。BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new RuntimeException(\"invalid image\");
}
if (image.getWidth() > 4096 || image.getHeight() > 4096) {
throw new RuntimeException(\"image too large\");
}
ImageIO.write(image, \"jpg\", target.toFile());注意,ImageIO 在某些格式支持上有限,生产环境可以根据语言栈选择更成熟的库,例如 Java 的 TwelveMonkeys、Go 的 image 标准库配合格式限制、Python 的 Pillow 等。关键点是“解析后重写”,而不是原样落盘。3. 存储位置上传文件不应直接进入可执行目录,也不建议和应用代码放在同一目录。更合理的做法是:上传文件保存到独立数据目录;目录挂载 noexec、nodev、nosuid;通过下载接口或对象存储访问;静态服务只暴露经过处理后的安全文件。Linux 挂载参数可以参考:/dev/vdb1 /data/upload ext4 defaults,noexec,nodev,nosuid 0 2Nginx 侧如果必须提供静态访问,也应避免将脚本类型交给后端解释器处理。示例配置如下:location /upload/ {
alias /data/upload/public/;
autoindex off;
add_header X-Content-Type-Options nosniff;
location ~* \\.(php|jsp|jspx|asp|aspx|cgi|pl|sh)$ {
return 403;
}
}4. 大小与资源限制上传功能经常被忽略的另一个风险是资源消耗。即使文件类型合法,超大文件、超高分辨率图片、压缩炸弹都可能造成 CPU、内存或磁盘压力。建议在多个层面限制:Nginx:client_max_body_size;应用框架:multipart max file size;业务逻辑:图片尺寸、帧数、压缩比;异步任务:超时、内存限制、失败重试次数。client_max_body_size 5m;spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=6MB5. 日志与告警很多上传事件复盘困难,并不是没有日志,而是日志没有记录关键字段。建议至少记录:用户 ID 或业务主体;请求 IP、User-Agent、TraceId;原始文件名、存储文件名;文件大小、识别类型、最终保存类型;校验失败原因;处理耗时与异步任务结果。日志中不要记录文件内容,也不要把敏感 token 写进去。对于校验失败次数异常的账号或 IP,可以接入限速和安全告警。加固后的处理流程一个更稳的上传流程可以这样设计:接收上传请求,先做认证和权限判断;限制请求大小和文件数量;生成服务端文件 ID,不使用原始文件名作为路径;保存到临时隔离目录;执行类型识别、尺寸校验、内容解析;对图片进行重编码,生成标准格式;将安全结果移动到正式目录或对象存储;记录审计日志;定期清理临时文件。示例伪代码如下:public UploadResult uploadAvatar(MultipartFile file, User user) throws IOException {
checkPermission(user);
if (file.isEmpty() || file.getSize() > 5 * 1024 * 1024) {
throw new RuntimeException(\"invalid size\");
}
String fileId = UUID.randomUUID().toString().replace(\"-\", \"\");
Path tmp = Paths.get(\"/data/upload/tmp/\", fileId + \".bin\");
Files.copy(file.getInputStream(), tmp, StandardCopyOption.REPLACE_EXISTING);
try {
BufferedImage image = ImageIO.read(tmp.toFile());
if (image == null) {
throw new RuntimeException(\"not an image\");
}
if (image.getWidth() > 2048 || image.getHeight() > 2048) {
throw new RuntimeException(\"invalid dimension\");
}
Path finalPath = Paths.get(\"/data/upload/public/\", fileId + \".jpg\");
ImageIO.write(image, \"jpg\", finalPath.toFile());
auditUpload(user, file.getOriginalFilename(), fileId, file.getSize(), \"jpg\");
return new UploadResult(fileId, \"/upload/\" + fileId + \".jpg\");
} finally {
Files.deleteIfExists(tmp);
}
}排查与复盘建议如果已经出现可疑上传告警,建议按以下顺序处理,避免一上来就删文件导致证据丢失。先隔离上传目录的执行能力,例如临时调整 Nginx 或应用解析规则;保留可疑文件副本,记录哈希、路径、时间戳;关联访问日志,确认上传来源、访问次数、返回状态码;检查同时间段是否有异常进程、计划任务、外联连接;梳理上传接口代码和部署配置是否存在同类问题;完成修复后再清理文件,并补充监控规则。sha256sum suspicious_file
stat suspicious_file
grep \"suspicious_file\" /var/log/nginx/access.log
find /data/upload -type f -mtime -3 -ls如果环境中有 EDR、HIDS 或文件完整性监控,可以把上传目录中新出现的脚本扩展、异常 MIME、短时间大量失败上传等作为低成本规则。规则不需要一开始就很复杂,关键是覆盖核心资产。容易忽略的细节对象存储直传也要做回调校验,不能只依赖前端限制;CDN 可能缓存错误响应或异常文件,修复后记得刷新;缩略图服务、OCR 服务、文档预览服务都属于上传链路的一部分;不要把上传目录加入脚本解释器的可处理范围;多租户业务要校验文件归属,避免越权读取他人文件;文件下载接口要设置 Content-Disposition,降低浏览器直接解析风险。总结文件上传的核心原则其实很朴素:不信任文件名,不信任 Content-Type,不原样进入可执行路径,不让上传结果脱离审计。很多事故不是因为某个点完全没做,而是每个点都“差一点”。从代码审计角度看,上传功能要按完整链路看:入口校验、临时存储、内容解析、重编码、正式存储、访问控制、日志告警、清理策略。只要这些环节能形成闭环,即使某个边界条件处理不完美,也不容易演变成高风险事件。
推荐意见