跳转到帖子

背景文件上传是 Web 系统里很常见的功能,头像、附件、导入模板、工单截图都会用到。问题在于,这类功能经常横跨前端校验、后端解析、对象存储、静态资源访问、权限控制等多个环节,只要其中一个环节设计不严谨,就可能变成高风险入口。这篇文章基于一次常见业务系统的代码审计经验整理,重点讨论如何识别文件上传风险、如何验证风险边界,以及如何给出可落地的修复方案。内容只关注防护与审计方法,不涉及非法攻击和控制行为。问题场景某业务系统提供了附件上传接口,用户可以在工单中上传图片、PDF、压缩包等文件。审计时发现开发侧已经做了一些限制,例如前端限制扩展名、后端判断 Content-Type、上传后重命名文件。但进一步检查后发现几个典型问题:后端扩展名判断依赖原始文件名,且处理逻辑不统一。Content-Type 直接信任请求头,缺少文件内容检测。上传目录位于 Web 可访问目录下,且与应用同域。部分历史接口绕过了新上传组件,未纳入统一校验。对象存储桶策略较宽,部分文件可被匿名访问。这些问题单独看不一定都能造成严重后果,但组合起来就会形成较大的攻击面。代码审计时不要只看某一个上传接口是否做了判断,而要看完整的上传、存储、访问、解析链路。技术分析文件上传风险一般不是“有没有判断扩展名”这么简单。实际审计时,我通常按下面几个层次拆解。一、入口层:上传接口是否统一很多系统会随着业务迭代出现多个上传入口。例如:新版通用上传接口:/api/file/upload旧版富文本上传接口:/editor/upload头像上传接口:/user/avatar后台导入接口:/admin/import移动端单独上传接口:/app/file/upload如果只审计当前主流程,很容易漏掉历史接口。建议先通过路由、控制器、网关日志、接口文档进行归类。grep -R \"MultipartFile\\|Request.Files\\|multer\\|formidable\\|upload\" ./src对于 Java 项目,可以重点检索:grep -R \"MultipartFile\\|CommonsMultipartResolver\\|transferTo\" ./src对于 Node.js 项目,可以重点检索:grep -R \"multer\\|formidable\\|busboy\\|express-fileupload\" ./src对于 PHP 项目,可以重点检索:grep -R \"\\$_FILES\\|move_uploaded_file\\|is_uploaded_file\" ./找到入口后,应检查它们是否复用了同一套安全校验逻辑。如果每个接口各自写一套判断,很容易出现不一致。二、校验层:不要信任单一属性常见错误是只校验文件名后缀或 Content-Type。请求头中的 Content-Type 可以被客户端设置,文件名也可以被构造,因此它们只能作为辅助信息。比较合理的策略是多维度校验:扩展名白名单:只允许业务确实需要的类型。文件头魔数检测:识别真实文件格式。文件大小限制:防止资源消耗和异常文件。文件内容解析:对图片、文档等进行安全解码或重编码。上传者权限检查:不同角色允许上传的类型应不同。存储位置隔离:上传文件不应进入可执行目录。例如图片上传场景,不能只判断 .jpg、.png,还应对文件进行解码验证,并在条件允许时重新生成图片。// Java 示例:使用 ImageIO 尝试解码并重新输出,避免只信任扩展名
BufferedImage image = ImageIO.read(uploadFile.getInputStream());
if (image == null) {
throw new IllegalArgumentException(\"invalid image file\");
}

// 重新编码输出到安全目录
File output = new File(safeDir, newFileName + \".png\");
ImageIO.write(image, \"png\", output);这类处理方式的价值在于:上传内容必须能被图像库正常解析,且最终存储的是重新编码后的文件,而不是用户原始文件。三、命名层:避免路径穿越和特殊字符问题文件名处理是审计中容易被忽略的点。常见风险包括:使用用户原始文件名拼接路径。未处理 ../、反斜杠、URL 编码等路径字符。文件名中包含控制字符、空字节、特殊 Unicode 字符。大小写扩展名、双扩展名处理不一致。Windows 与 Linux 路径规则差异导致判断失效。推荐做法是:文件存储名完全由服务端生成,用户原始文件名只作为展示字段,并进行转义处理。// 伪代码:服务端生成存储名
String originalName = file.getOriginalFilename();
String ext = getSafeExtension(originalName);

if (!allowList.contains(ext.toLowerCase())) {
throw new IllegalArgumentException(\"unsupported file type\");
}

String storedName = UUID.randomUUID().toString().replace(\"-\", \"\") + \".\" + ext;
Path target = safeBaseDir.resolve(storedName).normalize();

if (!target.startsWith(safeBaseDir)) {
throw new SecurityException(\"invalid path\");
}

Files.copy(file.getInputStream(), target);这里有两个关键点:一是不要用原始文件名作为落盘名;二是路径拼接后要做 normalize 并校验是否仍在安全目录内。四、存储层:上传目录不要具备执行能力从防护角度看,上传文件的存储位置比扩展名判断更重要。即便校验出现遗漏,只要上传目录没有脚本执行能力、没有被应用服务器解释执行,风险会明显降低。常见建议:上传目录与应用代码目录分离。上传目录禁止脚本执行权限。静态资源使用独立域名,避免同源风险。敏感附件不要直接公开访问,应通过鉴权接口下载。对象存储使用最小权限策略,不要默认公开整个桶。Nginx 场景下,可以对上传目录做明确限制。示例仅用于说明思路:location /uploads/ {
alias /data/app/uploads/;
autoindex off;

# 禁止常见脚本类型被直接访问
location ~* \\.(php|jsp|jspx|asp|aspx|cgi|pl|py|sh)$ {
return 403;
}

# 建议配合明确的 Content-Type 与下载策略
add_header X-Content-Type-Options nosniff;
}如果系统使用对象存储,也要检查桶策略。很多问题不是上传接口本身造成的,而是存储桶被配置成了匿名可列目录或公开读。五、访问层:公开文件与私有文件要分开文件上传后的访问控制经常被误判。开发者可能认为“文件名是随机的,所以别人猜不到”,但随机文件名不能替代权限控制。建议按文件敏感性分级:文件类型访问方式建议策略公开图片静态资源访问独立域名、禁执行、限制类型工单附件鉴权下载接口校验用户与工单关系合同、报表临时签名 URL短有效期、审计日志导入文件内部处理处理后删除或归档隔离如果附件属于业务数据,就应在下载接口中检查当前用户是否有权限访问对应业务对象,而不是只检查文件是否存在。// 伪代码:下载前校验业务权限
FileRecord file = fileService.getById(fileId);
if (file == null) {
return notFound();
}

boolean allowed = permissionService.canAccessFile(currentUser, file);
if (!allowed) {
return forbidden();
}

return download(file.getStoragePath());关键步骤:一次完整审计流程实际项目中,我一般按以下顺序推进。1. 梳理上传点检索代码中的上传 API、上传组件、文件处理库。查看网关访问日志,确认真实业务中仍在使用的历史接口。检查后台管理、富文本编辑器、移动端接口是否存在独立上传逻辑。2. 确认允许类型列出每个接口允许上传的文件类型。确认这些类型是否确实有业务必要。避免“为了省事”允许任意文件或压缩包。3. 检查校验逻辑扩展名是否白名单,而不是黑名单。是否存在大小写、双扩展名、空格、特殊字符处理差异。Content-Type 是否只作为辅助判断。图片、文档是否有内容解析或重编码。4. 检查存储路径是否使用原始文件名落盘。是否存在路径拼接和目录穿越风险。上传目录是否可被脚本引擎执行。对象存储桶策略是否过宽。5. 检查访问控制公开文件与私有文件是否分离。下载接口是否校验业务权限。是否存在通过文件 ID 或 URL 访问他人附件的问题。是否有访问日志和异常下载告警。6. 检查后处理链路很多上传风险发生在后处理环节。例如:上传压缩包后服务端自动解压。上传 Office 文档后服务端自动转换 PDF。上传图片后调用第三方库生成缩略图。导入 CSV、Excel 后进入数据解析流程。这些处理链路需要额外关注解析库漏洞、压缩炸弹、公式注入、路径穿越、资源消耗等问题。常见误区只做前端限制:前端校验只能改善体验,不能作为安全边界。依赖 Content-Type:请求头由客户端提供,必须结合内容检测。黑名单过滤扩展名:黑名单容易遗漏,建议使用严格白名单。随机文件名等于安全:随机性不能替代权限控制。上传到对象存储就安全:桶策略、访问签名、回源配置同样需要审计。忽略历史接口:新组件安全不代表旧接口已经下线。修复建议从工程落地角度,建议不要让各业务模块自行实现上传安全逻辑,而是建设统一上传服务或公共组件。统一入口:所有上传走同一套服务或 SDK。统一策略:按业务场景配置允许类型、大小、访问权限。统一存储:服务端生成文件名,按租户、业务、日期分目录存储。统一检测:扩展名、魔数、内容解析、病毒扫描按需组合。统一访问:私有文件必须通过鉴权接口或短期签名访问。统一日志:记录上传人、文件类型、大小、业务对象、访问行为。一个比较可维护的上传策略示例:upload:
default:
maxSize: 10MB
allowedExtensions: [jpg, jpeg, png, pdf]
publicAccess: false

avatar:
maxSize: 2MB
allowedExtensions: [jpg, jpeg, png]
reEncodeImage: true
publicAccess: true

ticketAttachment:
maxSize: 20MB
allowedExtensions: [jpg, jpeg, png, pdf, txt]
publicAccess: false
requireBusinessPermission: true

importFile:
maxSize: 5MB
allowedExtensions: [csv, xlsx]
publicAccess: false
deleteAfterProcessed: true这类配置的好处是安全策略可审计、可回滚,也方便后续新增业务时复用。注意点如果允许上传压缩包,必须限制解压后文件数量、总大小、目录层级,并防止路径穿越。如果允许上传 SVG,需要特别谨慎。SVG 本质上是 XML,可能包含脚本、外部引用等风险。多数业务不建议开放。如果使用 CDN 分发上传文件,应配置正确的缓存策略和响应头。如果附件和主站同域,需关注同源策略带来的影响,建议使用独立静态资源域名。如果文件用于数据导入,解析前应做格式校验,避免把上传安全只理解为“能不能执行”。修复后要补充回归用例,覆盖历史接口和边界文件名。总结文件上传安全的核心不是某一个判断条件,而是完整链路的安全设计。比较可靠的思路是:入口统一、类型白名单、内容校验、服务端命名、存储隔离、访问鉴权、日志可追踪。在代码审计中,建议把文件上传作为一条数据流来分析:用户提交了什么,后端如何判断,文件存在哪里,谁能访问,是否会被解析,后续是否进入其他处理链路。把这些问题逐项确认,通常能发现比单点漏洞更有价值的系统性风险。经验上,上传功能最怕“看起来已经做了限制”。真正要检查的是限制是否在服务端、是否覆盖所有入口、是否能在存储和访问层形成兜底。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…