Java文件上传漏洞审计实战:从入口定位到修复闭环
1. 文件上传漏洞为什么值得花时间细审搞 Java 代码审计绕不开文件上传这个点几乎所有后台管理系统、CMS、API接口都涉及附件、头像、导入、富文本图片等上传场景。凡是能上传文件的地方一旦校验不到位轻则被人丢个恶意脚本进服务器重则直接拿下主机权限。我把文件上传列为 Java 审计里优先级最高的几个漏洞类型之一因为它利用门槛低、危害路径短而且很多开发者直到上线都没意识到自己的校验方案在这几个方向上是纸糊的。文件上传审计的核心目标只有一个判断攻击者能否通过上传接口在服务器上写入一个能被当作代码执行的Web脚本比如 JSP或者能被触发的恶意文件比如包含脚本的HTML、SVG、XML等。围绕这个目标审计时真正要追的是三件事一是文件从进入接口到最后落盘之间经过了哪些处理二是“文件名、后缀、类型、内容”这些要素分别在哪一层被校验、校验是否可绕过三是文件落盘后是不是直接暴露在Web可访问且可被容器解析的目录里。适合阅读这篇内容的人包括正在做代码审计的初级安全工程师、甲方安全侧负责自研系统自查的同行、以及后端开发人员——尤其是那种“上传功能是老代码一直没人敢动”的项目的维护者。下面这些内容基本是我在真实项目里踩过的坑和总结的判断逻辑偏实操不堆理论。2. 从危险函数起步文件上传审计的源码清单2.1 入口定位Controller/Servlet 层的关键标记审计文件上传第一步永远是在代码里找到上传入口。Java 生态里处理上传的常见姿势就那么几类我把它们叫“审计指纹”你在 IDE 里全局搜索这些关键点就能快速圈定范围Spring MVC / Spring Boot 体系方法参数中出现MultipartFile、MultipartHttpServletRequest、CommonsMultipartFile、StandardMultipartHttpServletRequest、Part等类型或者注解出现RequestParam(file)、RequestPart(file)。Servlet 原生体系request.getPart()、request.getInputStream()、request.getParameter(file)以及配合 Apache Commons FileUpload 的FileItem、ServletFileUpload类。第三方组件封装的工具类比如 hutool 的FileUploadUtil、easyexcel/poi 的导入解析接口、富文本编辑器如 UEditor、wangEditor 后端的图片接收接口等。老框架 Struts2 体系里的FileUploadInterceptor配合 Action 的File属性接收。定位入口之后不要急着看校验逻辑先把整个处理链条摸清楚。我习惯的做法是从上传方法进入按“接收 → 校验 → 重命名 → 存储 → 回显路径”这条线画出数据流。任何一个环节出现“直接信任前端参数”“只做前端限制”“黑名单过滤”“文件名拼接路径”等迹象都值得继续往下深挖。2.2 文件存储与命名的核心逻辑上传漏洞能不能利用成功后半场看的是存储和命名。审计时要重点找这几类代码文件名直接从请求里取String filename file.getOriginalFilename()之后没有做任何处理就直接用于路径拼接或落盘这类代码基本一抓一个准。用了 UUID/时间戳拼接但保留了原始后缀String savePath baseDir System.currentTimeMillis() _ filename如果原始文件名里带了路径分隔符或..就会引出目录穿越和路径注入问题。存储目录是静态字符串拼接出来的String path /upload/ request.getParameter(dir)这里如果 dir 参数可控存储目录就是攻击者说了算。上传文件之后回显完整 Web 访问路径直接返回http://xxx/upload/xxx.jsp这种绝对路径等于在告诉攻击者“文件放在哪、该怎么访问”。存储逻辑这块还需要注意容器层面的解析特性。比如上传到webapps/ROOT/下部署的是 Tomcat新增 JSP 文件后容器通常能实时解析。如果项目用了 Nginx 做静态资源映射到上传目录而上传目录里允许jsp、jspx之类后缀那同样具备被解析的条件。审计时要结合中间件类型、部署方式和目录权限综合判断不能只看 Java 代码本身。提示判断一个文件上传漏洞“能不能被打”的关键永远是“落盘位置是否具备脚本执行能力”而不是“用了哪家框架”。有些系统把上传文件放在独立存储服务或对象存储里这时候利用链路就长了很多但代码审计的判定逻辑还是一样的。3. 从黑名单到内容校验五类典型缺陷模式3.1 后缀黑名单过滤不完整大量老系统喜欢用黑名单方式过滤上传文件比如只屏蔽jsp、jspx或者干脆只屏蔽exe、sh这类高危后缀。黑名单方案本身就没有“安全闭环”可言因为绕过手段太多了。Java Web 里能作为“可执行后缀”的远不止.jsp常用的就有一长串.jspx、.jspf、.jhtml、.jspw、.jsw、.jsv、.jspa、.jspx再次强调这个容易被漏、还有.war可以部署成应用。除此之外和容器、框架解析相关的后缀也会因环境而异比如 Tomcat 配置了Jasper相关映射时某个特定后缀也可能被解析为 JSP。有些系统部署在特定中间件上.do、.action映射到 Struts/ActionServlet如果上传路径恰好落在这些扩展映射范围里同样能触发解析。更隐蔽的是黑名单经常漏掉大小写混合和 URL 编码变体.JSp、.JsP、.j%73p如果中间层存在解码差异以及双重扩展名.jsp.jpg。审计时只要发现黑名单过滤就立即把上述变体挨个过一遍。我一般直接看过滤逻辑里是否包含toLowerCase()或大小写归一化处理——没有的话一个.JSp就能突破有的话继续看它过滤的是“最后一个点”的后缀还是“完整文件名”——只过滤完整文件名包含的固定后缀还能用分号、空格、换行等 Trick 绕过比如shell.jsp%00.jpg、shell.jsp;.jpg、shell.jsp .jpg这类具体取决于操作系统和中间件对特殊字符的容忍度。3.2 过度信任前端传入的文件名与类型前端上传组件返回的文件名在很多系统里居然直接被后端当成了真相。代码里常见的写法是这样的String fileName file.getOriginalFilename(); String suffix fileName.substring(fileName.lastIndexOf(.) 1); String saveFilePath uploadDir File.separator fileName; file.transferTo(new File(saveFilePath));这段代码的问题不只是没校验后缀而是连后缀都没限制。攻击者直接把一个 JSP 后门文件原名叫shell.jsp传上去就完事了。更常见的变体是把后缀判断建立在前端传的Content-Type上if (image/jpeg.equals(file.getContentType())) { // 存下来 }Content-Type这个字段完全由客户端掌控。Burp Suite 抓个包把Content-Type: application/octet-stream改成image/jpeg哪怕文件内容是一个完整的 JSP 脚本也能通过这种“MIME 类型白名单”的校验。还有一种半吊子方案是既检查后缀、又校验Content-Type但后缀和类型是用拼接方式比较的比如从文件名取后缀后和固定集合比对只要集合里同时有jpg和jsp虽然不太可能或者忘了把jsp拿掉那这个白名单也成了摆设。注意任何“基于客户端可伪造字段”的校验都不能作为安全边界。文件名、Content-Type、文件大小这些都是 HTTP 报文里能随意改的东西服务端必须自行通过流的方式获取真实数据来做判断。3.3 存储路径拼接导致的目录穿越文件上传接口里如果路径拼接不合理就能通过精心构造的文件名实现目录穿越把文件写到预期目录之外。最经典的利用形态是把上传目录直接指到 Web 应用的根目录或可执行目录下。看这段问题代码String dir request.getParameter(dir); // 前端传入比如 avatar String path UPLOAD_ROOT File.separator dir File.separator fileName; File saveFile new File(path); file.transferTo(saveFile);如果fileName是../../../../webapps/ROOT/shell.jspFile类在做new File()和transferTo()的时候不会帮你校验“最终路径是否还在允许目录之内”而是直接按给定路径写文件。只要进程权限够、目录存在这个穿越就能把 JSP 传进 Web 根目录。两端分别可控时同样危险。比如dir参数可控攻击者传入../../webapps/ROOT把任意文件写到可解析目录也是同一类问题。甚至有些系统拼接时用的是Paths.get(base, fileName).normalize()以为 normalize 就能防穿越但如果在某些逻辑分支里依然用原始路径做写入或者 base 目录本身层级较深../超出根目录后路径解析行为也会出现各种预期之外的结果。3.4 文件内容校验形同虚设有些系统做了内容头校验却不是真校验。最典型的是只读取文件前几个字节判断魔数Magic Numberbyte[] header new byte[4]; inputStream.read(header); if (header[0] (byte)0xFF header[1] (byte)0xD8) { // 认为是 JPG }这个校验本身是有效的错在系统通常只校验前几个字节却仍然保留文件原始后缀。攻击者完全可以做一个“前4字节是 JPEG 文件头、后面是完整 JSP 代码”的文件后缀改成.jsp或者把整个文件命名为xxx.jsp只要存储时后缀可控魔数校验就变成了摆设。更夸张的是某些系统真的会“读文件头、改后缀、但校验后存的是原始文件名”等于自己打自己的脸。审计时需要特别留意“校验用内容、存储用文件名”这种信息不对称情况。真正的文件内容校验应当做到校验通过后用服务端重新生成的扩展名从白名单映射表里取去保存而不是继续信任原文件名。3.5 上传目录可执行导致直通后门上传文件本身没问题但存储位置选得差就是给漏洞利用搭好了桥。最常见的两种错误位置上传目录位于 Web 应用部署目录内部比如webapps/ROOT/uploads/、/static/uploads/等且该目录允许执行 JSP。这是最经典的“文件上传直通 WebShell”路径。上传目录位于应用源码目录下IDE 项目里经常是src/main/resources/static/uploads/生产环境如果打成 war 包展开后这些文件会被拷贝到可执行位置或者位于容器配置的应用路径下。审计时要结合部署形态判断。如果项目是前后端分离前端静态资源走 Nginx后端上传接口把文件写到 Nginx 托管目录里虽然 JSP 无法在这种纯静态服务上执行但如果上传的是.html、.svg就存在存储型 XSS 风险也可能被用来挂钓鱼页面、恶意脚本加载等。4. 一次模拟文件上传审计的完整推演4.1 入口断点与调用链梳理我用一个脱敏后的模拟项目 X 来演示完整审计过程。这个系统是一个后台管理系统有一个头像上传功能。从代码里搜MultipartFile关键字很容易定位到接口PostMapping(/api/user/avatar) public Result uploadAvatar(RequestParam(file) MultipartFile file, HttpServletRequest request) { String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.) 1); // 校验环节 if (!ext.toLowerCase().matches(jpg|png|gif|jpeg)) { return Result.error(不支持的文件类型); } // 存储环节 String savePath /data/upload/avatar/; String newFileName UUID.randomUUID().toString().replace(-, ) . ext; File targetFile new File(savePath, newFileName); file.transferTo(targetFile); return Result.success(/upload/avatar/ newFileName); }乍一看这个接口做了后缀白名单校验文件名也换成了 UUID存储目录是固定的/data/upload/avatar/看起来似乎问题不大。但审计不能只看这段代码还需要继续往上传访问路径和容器配置往下追。4.2 逐个分支的校验穿透首先后缀校验这里用的是正则表达式matches(jpg|png|gif|jpeg)。注意如果外层的ext是从originalFilename.lastIndexOf(.)取出来的那么对aaa.jsp来说ext jsp确实进不了白名单。但String.matches()是全匹配而这里传入的是空字符串或特殊后缀时会直接返回 false常规绕过无效。不过再往下看如果这个校验不是用正则而是用了集合判断呢换成SetString allowSet Set.of(jpg, png, gif, jpeg)然后是if (!allowSet.contains(ext.toLowerCase()))从逻辑上也没问题。真正容易出问题的是校验与存储分离——也就是校验函数里判断的是 A 后缀存储时用的却是原始文件名。我把审计范围扩大到整个上传流程发现系统里还有另一个通用文件上传接口代码是这样的PostMapping(/api/common/upload) public String commonUpload(RequestParam(file) MultipartFile file) { String originalFilename file.getOriginalFilename(); String ext FilenameUtils.getExtension(originalFilename); if (!allowExt.contains(ext.toLowerCase())) { return 不允许上传; } File targetFile new File(UPLOAD_PATH, originalFilename); file.transferTo(targetFile); return UPLOAD_PATH originalFilename; }问题就在这里。校验时确实用FilenameUtils.getExtension()取了后缀做白名单但保存时用的是完整原始文件名originalFilename。攻击者把文件名改成aaa.jsp;.jpgFilenameUtils.getExtension()会返回jpg因为它的规则是取最后一个点后面的部分通过了白名单而new File(UPLOAD_PATH, aaa.jsp;.jpg)落盘时Linux 系统里分号是文件名合法字符但这个文件后缀是.jpg不会被 JSP 解析。等等文件名里还有.jsp这个字段怎么办如果上传的是 Tomcat 环境文件名aaa.jsp;.jpg直接访问时Tomcat 的 DefaultServlet 会按jpg内容类型返回但不会当作 JSP 执行。那有没有可能让 Tomcat 解析它需要配合容器对分号路径参数的处理差异。在某些情况下访问/upload/aaa.jsp;.jpgServlet 容器可能会将路径参数后的内容剥离开处理。这个具体行为取决于版本和配置审计时不能默认能打但也绝不能默认打不了。最稳妥的做法是直接在审计报告里标记为“存在文件名与实际存储不一致的绕过风险High”同时继续追容器配置确认可执行性。真正的近距离观察是这个系统把上传目录UPLOAD_PATH配置成了 Web 应用根目录下的相对路径通过浏览器直接访问http://target/upload/aaa.jsp;.jpg时中间层走的是容器默认映射不做额外处理反而打不了但如果有人把文件名构造为aaa.jsp%20.jpg、aaa.jsp..jpg之类配合不同 OS 的截断特性和容器解析差异情况就会完全不同。所以审计结论不应该是“没有 WebShell”而是“校验与存储之间信息不一致存在多重绕过的可能性”。4.3 构造可落地的验证载荷要验证这类上传点能不能实际打通我会在测试环境按以下顺序构造载荷第一步直接传test.jsp文件内容随便写一句% out.println(poc); %看是否被拦截第二步传test.jsp;.jpg或test.jpg.jsp分别测试白名单取后缀与完整文件名保存的不同行为第三步传test.jpg但在文件内容末尾附加 JSP 脚本观察是否存在包含解析比如.shtml、SSI、JSP include 等机制第四步测试目录穿越文件名传../test.jsp、..%2Ftest.jsp、%2e%2e%2f%2e%2e%2ftest.jsp等变体同时观察响应里的回显路径第五步如果返回路径是完整的 URL 拼接直接访问该 URL确认 JSP 是否被容器解析执行。对模拟项目 X 来说我构造了shell.jsp;.jpg这个载荷白名单校验用FilenameUtils.getExtension()取到jpg放行落盘文件名是shell.jsp;.jpg。之后我在服务器上手动测试了容器对该文件名的解析行为。结果显示当前容器版本并不会把它当 JSP 执行于是我把结论定为“可绕过校验但当前中间件不可直接利用需结合容器版本进一步确认”风险评级中高。这类审计结论必须写清楚两个层面代码层面漏洞成立利用层面受中间件影响这样修复方才能明白问题的本质。5. 修复不是简单加校验落地修复方案5.1 白名单与随机重命名修复文件上传第一条铁律是文件在服务端保存时应该使用服务端生成的随机字符串作为文件名而不是任何与用户输入相关的名称。推荐的做法是String ext FilenameUtils.getExtension(originalFilename).toLowerCase(); if (!ALLOW_EXTENSIONS.contains(ext)) { throw new UploadException(文件类型不允许); } String newFileName UUID.randomUUID().toString().replace(-, ) . ext; String savePath uploadDir File.separator newFileName; file.transferTo(new File(savePath));这里有几个关键点后缀必须用服务端取到的、且经过小写归一化的值存储时用的文件名必须和校验时是同一个来源保存路径必须是完全服务端控制的绝对路径不能拼接任何来自请求的参数。白名单集合建议综合考虑业务需要来收敛。如果只需要图片就只放行jpg、jpeg、png、gif、webp如果必须支持文件上传那就把高危后缀列进黑名单的同时仍然采用白名单策略两者都做、白名单为主。常见的文档上传白名单可以控制在pdf、doc、docx、xls、xlsx、ppt、pptx、txt、zip等然后根据业务按需放行。对于需要保留原始业务场景的情况至少要保证文件名随机化原始文件名只作为展示字段存数据库不参与磁盘路径构建。完全不参与这是底线。5.2 存储与执行权限分离从架构层面看上传文件存储位置应当和 Web 应用的代码执行目录分离。也就是说上传目录不能放在webapps、app、static这些容器的应用根目录里而应该是应用之外独立的磁盘路径比如/data/files/然后由 Web 服务器Nginx、Caddy 等单独配置一个 URL 前缀映射到该目录。这样设计之后上传的 JSP 文件即使落到磁盘上容器解析不到它它就只是一堆普通文本文件不具备任何执行能力。这比在代码里层层校验后缀要可靠得多。如果因为历史原因目录没法迁移那就必须做到以下两点Nginx 层配置location对上传目录关闭脚本解析能力。比如 Nginx 下可以设置location /upload/ { alias /data/upload/; location ~* \.(jsp|jspx|do|action)$ { deny all; } }应用层在文件和脚本类型之间建立“不可解析”的边界。具体实现方式包括让上传目录下的文件强制以附件方式下载设置Content-Disposition: attachment或者增加一层 Servlet Filter 拦截对上传目录下脚本文件的请求这个由中间件配置操作即可。对于无法用 Nginx 的场景可以考虑把上传目录放在应用上下文之外用自定义的 Servlet 输出文件流并配合下载头响应。总之目标就一个执行权与上传权不得落在同一个目录边界内。5.3 响应头与内容协商防护很多人修文件上传只盯“存的时候干不干净”不看“取的时候有没有问题”。实际上即使上传的是图片响应阶段如果给了错误的内容类型也会把用户拖入 XSS 陷阱。保存文件后应用回显文件访问地址时要在响应阶段加这样几个响应头X-Content-Type-Options: nosniff Content-Security-Policy: default-src none; style-src unsafe-inline Content-Disposition: attachment; filenamexxx如果业务场景明确要求文件在浏览器中预览比如图片、PDF则不能简单强制 attachment 下载。这时候建议对图片类文件做“服务端重新压缩/转码”而不是直接输出用户上传的原始文件。业界常见的图片处理做法是上传后使用ImageIO或专业图像库把图片重新解码、改变尺寸或格式再存储这样即使文件中嵌入了脚本也已经在转码过程中被剥掉了。这个处理同时解决了文件内容夹带和文件头伪造两个问题。注意如果你在审计报告里给出修复建议务必把“响应头”和“转码”这一点也写上。因为很多开发看到“白名单随机名”就以为修完了结果一年后审计复查发现攻击者换了攻击方式通过上传带恶意 SVG/HTML 的图片让管理员一预览就中招。文件上传的修复是链路上的完整闭环入口校验、存储隔离、出口防护缺一不可。6. 审计实战中的常见误区与经验汇总6.1 常见问题与排查速查表排查点典型误判正确判断方式看到白名单就放心白名单校验存在但存储用原始文件名必须追到transferTo/ 输出流的实际写入路径看到 UUID 重命名就放心文件名用了 UUID但目录拼接没有固定前缀检查拼接路径的 Base 是否来自用户参数能否目录穿越看到 MIME 类型校验就放心Content-Type是客户端可控字段抓包篡改后重放看校验是否放行看到文件头校验就放心只读前几字节魔数但保留了原始后缀确认存储文件的扩展名是否可被攻击者指定只关注后缀不关注路径后缀校验过了就能打路径不重要路径穿越同样能把文件写入任意目录只审代码不确认中间件代码里无法执行就认为安全必须结合部署目录、容器解析扩展名、代理转发规则判断实际可利用性实际审计中我经常遇到“代码上看不出来部署上全是洞”的情况。比如某个项目代码里已经把上传文件名随机化了但部署时把上传目录直接映射成了 Web 应用根目录而且 Nginx 还配了FastCGI转发到 Tomcat 的 JSP 解析模块结果随机化文件名后缀是.jsp的文件照样能被解析执行。审计结论里必须写清楚利用条件让运维团队去排查部署问题不能只看代码。6.2 几条值得长期生效的实操心得我做了大量文件上传审计之后沉淀下来几条对效率和准确率提升最明显的经验第一把上传功能当成“整个数据流”来审计而不是“单一函数”来扫。从接收接口开始一直追到文件回显的 URL任何一环都可能是攻击面。只盯着transferTo、FileOutputStream这些写入点容易漏掉存储前通过过滤器/拦截器异步改名、经过第三方云存储 SDK 时参数拼接等衍生的攻击面。第二优先找“校验与使用分离”的节点。我见过的最多漏洞形态不是完全没有校验而是校验代码规规矩矩、看着无懈可击但真正被使用的那份文件名或后缀来自另一个变量。审计代码时养成一个习惯凡是出现了两次以上“取后缀”“取文件名”的地方都要逐一比对最终落盘用的是哪一次的结果。这种信息不对称漏洞在真实项目里的比例高得惊人。第三不要假设容器安全。如果不知道目标容器是什么版本就不要轻易得出“这个后缀解析不了”的结论。Tomcat 一直在修 CVE修复前后对特殊文件名的解析行为可能完全不同。审计时宁可把中间件版本因素作为“不确定项”写进风险描述也不要因为“本地复现不了”就降低漏洞级别。第四黑名单绕过方向要烈度不要穷尽。网上能搜到各种上传绕过 Payload 大全但审计时纠结于“我有没有覆盖所有变体”是错误的。正确的做法是确认校验策略的类型黑名单、白名单、后缀拼接、MIME 校验一旦确认是脆弱类型就可以直接下结论——即使当前环境打不穿这个问题也是客观存在的修复方向也是明确的。真正要花时间的是判断存储目录的可执行性这决定了实际危害上限。第五审计报告里始终区分“代码缺陷”和“可利用条件”。这两者经常被混为一谈导致开发团队拿着“本地没复现”的说法反驳审计结论。我一般在报告中把漏洞定义在代码层面然后单独列出“在 XX 版本 TomcatNginx XX 配置下可实际利用”把“修不修”的判断权留在风险层面而不是让一次不成功的验证成为推翻漏洞的理由。文件上传这个点代码审计的复杂度不高难的是审计思维的系统性。只要把“入口 → 校验 → 存储 → 回显”这条链路完整走一遍再对中间件环境保持敬畏大部分问题都能提前暴露。这套方法我在内部代审和自研系统自查里反复用准确率和落地效率都还可以分享出来供同行参考。