☰
Java代码审计:深入剖析文件写入漏洞成因与防范
2026/10/21 17:47:57 网站建设 项目流程

第九篇:文件写入漏洞成因及防范

做Java代码审计做得久了,你会发现一个特别有意思的现象:不少开发同学对SQL注入、XSS这类漏洞已经很敏感了,但一碰到文件写入相关的问题,普遍觉得“不就是往服务器写个文件嘛,能有多大事”。恰恰是这种想法,让文件写入漏洞成了高危漏洞排行榜上的常客。这篇文章是“Java代码审计”系列的第九篇,咱们把文件写入漏洞从头到尾捋一遍:它到底是怎么产生的、危害有多夸张、代码审计时该从哪里入手找,以及修复时有哪些要命的细节。适合刚入门代码审计的安全新人,也适合想提升代码防御能力的Java开发。

先说一个我自己的判断:文件写入漏洞从来不是某个函数的问题,而是“不可信数据”和“危险操作”之间缺少隔离的问题。你只要看透这一点,审计思路一下就通了。

1. 文件写入漏洞是怎么一回事

1.1 先搞懂它在攻击链里的位置

文件写入漏洞,简单说就是攻击者能控制程序往服务器文件系统里写入内容,而且写入的路径、文件名、甚至文件内容都可能被攻击者操纵。

很多朋友会把它和文件上传漏洞搞混。我做个区分:文件上传是“用户主动传一个文件,服务器负责保存”;而文件写入往往是“服务器因为某个业务逻辑,自己主动去写文件,但写文件的参数被攻击者污染了”。两者有交叉,但审计思路不太一样。文件上传漏洞的焦点是后缀校验和内容检测,文件写入漏洞的焦点则是路径拼接和内容可控性。

危害有多大?我按严重程度排个序:

  • 直接写入WebShell,拿到服务器权限(最极端的利用)
  • 覆盖系统关键配置文件,破坏业务甚至导致服务瘫痪
  • 往应用日志里塞脏数据,污染审计链条
  • 写入恶意脚本或HTML,结合存储型XSS攻击其他用户
  • 通过符号链接或路径穿越,覆盖任意目录的文件

你在代码审计时看到文件写入点,先别急着定性为“高危”。你要做的是顺着数据流往下走,看攻击者能控制什么、写入到哪里、内容是什么。这三个问题想清楚了,危害等级自然就出来了。

1.2 我认为的核心成因:三类问题交叉叠加

文件写入漏洞的根源,我总结为三个环节的问题叠加。任何一个环节做扎实了,漏洞都很难成立。

第一,数据源不可信。文件名、路径、甚至文件内容,可能来自用户请求、上传文件名、数据库字段、外部系统回调。这些通通要当作“攻击者可控”来处理。

第二,校验环节缺失或可绕过。开发同学确实写了一些校验,但用的是“黑名单”思路,比如过滤掉../、过滤掉.jsp。黑名单天生有缺陷,编码绕过、系统特性差异、大小写变形,都能轻松绕过。

第三,危险函数被直接调用。FileOutputStream、Files.write、FileWriter、RandomAccessFile这类API本身没毛病,但它们把“写入”这个动作暴露给了上层逻辑。如果上层没有做安全决策,等于把一个可以拿刀的人领进了银行金库,还告诉他“随便用”。

给大家画个简单的因果链:

不可信输入 --> 拼接/透传 --> 危险写入函数 --> 文件系统操作 ^ ^ | | 校验缺失 权限过大

我见过的大部分文件写入漏洞,都能在这个链路上找到突破口。审计时你就沿着这条链一段一段看,哪里断了哪里就是问题。

2. 审计实战:从危险函数到数据流追踪

2.1 需要重点盯守的危险写入函数清单

开始审计之前,先把脑子里要时刻绷着的那根弦立起来——哪些函数可能出现文件写入?

我按使用频率和危险程度列了一张表,你自己审计时可以照着扫:

函数/API常见出现场景危险点
FileOutputStream上传文件保存、临时文件生成路径和内容均可控
FileWriter日志写入、配置文件生成内容可控时可注入恶意数据
Files.write()现代代码风格的项目同样存在路径与内容风险
RandomAccessFile断点续传、文件随机读写可通过offset控制写入位置
PrintWriter导出报表、生成HTML内容可控时可能写入脚本
ZipOutputStream/解压代码导入导出ZIP包解压路径穿越(Zip Slip)
File.renameTo()/Files.move()临时文件转正式文件目标路径可控时覆盖文件
Base64.decode()后写入图片上传、附件处理解码内容可控,配合路径可控

注意:不要只盯着“写入”那一刻。File.createTempFile()、mkdirs()这类文件操作常常出现在危险写入点前面,如果目录路径可控,同样能影响最终写入位置。

2.2 数据流追踪法:从入口到写点的完整链路

代码审计和渗透测试不一样,你不是拿着一堆payload去试,而是通过读代码理解整个数据流转。我最常用的方法是“入口→处理→出口”三点定位法。

具体操作分四步:

  1. 找到接收用户输入的地方。常见入口有Controller的@RequestParam、@PathVariable、MultipartFile的上传参数、消息队列的消费逻辑等。
  2. 顺着调用关系往下走,看参数有没有经过任何处理。这个“处理”很关键:是原样透传,还是做了编码、拼接、截断?
  3. 找到最终的“出口”——也就是真正调用文件写入函数的那一行代码。
  4. 反向验证:从出口往回看,写入的路径、文件名、内容,这三个要素里有哪些是攻击者能控制的。

我拿一个典型的例子来说明,某后台管理系统的“导出报表”功能:

@PostMapping("/export") public String export(HttpServletRequest request) { String type = request.getParameter("type"); String fileName = request.getParameter("fileName"); String content = buildReportContent(type); // 直接拼接路径,没有任何校验 String path = "/opt/app/reports/" + fileName; Files.write(Paths.get(path), content.getBytes(StandardCharsets.UTF_8)); return "success"; }

这段代码如果出现在你眼前的项目里,你会怎么审?

我按刚才的方法走一遍:

  • 入口:type和fileName都来自HTTP请求参数
  • 处理:fileName直接拼进路径,content虽然是内部生成,但type可能影响内容
  • 出口:Files.write(Paths.get(path), ...),路径可控、写入机制直接

结论很清楚:攻击者传一个fileName=../../../../tmp/evil.jsp,如果Web目录的路径也猜得准,配合内容可控,WebShell基本就落地了。即便一时写不进Web目录,覆盖个配置文件也够受的。

审计时你要有耐心,一条链一条链去追。很多开发者会把拼接动作封装在Service层,不追到实现类根本看不见。

3. 常见漏洞形态逐个拆解

3.1 路径穿越型任意文件写入

这是文件写入漏洞里最经典的一种,本质上就是路径拼接时没做规范化处理,导致../可以跳出预期目录。

咱们还是从代码层面拆。下面这种写法,在真实项目里不要太常见:

public void saveAttachment(MultipartFile file, String bizId) throws IOException { String userProvidedName = file.getOriginalFilename(); String fullPath = "/data/upload/" + bizId + "/" + userProvidedName; File target = new File(fullPath); target.getParentFile().mkdirs(); file.transferTo(target); }

bizId如果是个用户可控的业务编号,攻击者传个../../../../var/www/html,配合文件名里再来一层穿越,那路径就完全失控了。我见过一个极端案例,某系统的头像上传功能,攻击者通过路径穿越把JSP文件写到了Web根目录下,直接拿到服务器权限。事后复盘,代码里其实有校验——但只校验了文件名后缀,压根没校验路径中是否包含上级目录。

3.2 看似无害的“文件名可控”场景

还有一种情况特别隐蔽:文件名看着是系统自动生成的,但生成规则里某个部分可以被用户影响。

举个例子,短信验证码、订单号、用户ID用来拼文件名,路径本身是固定的。看起来人畜无害?但如果文件名里能带入/或者..,一样能穿透。比如某个导出功能生成的文件名是report_${userId}.csv,userId需要是数字对吧?可是有些开发者忘了做数字校验,用户传一个../../../../tmp/evil.txt,照样穿。

我一直跟团队说:路径上的每一个拼接片段,只要来自外部输入,就必须单独做合法性校验。别觉得“这里应该是数字”就放松警惕,代码审计看的就是“应该”和“实际”之间的缝隙。

3.3 压缩包解压:Zip Slip漏洞

这个必须单独列一节,因为它太有迷惑性了。

很多系统都有“导入ZIP包”的功能,比如批量导入图片、导入模板文件。常见的实现逻辑是:先把ZIP包里的每个entry用ZipInputStream读出来,然后拼到目标目录里写文件。

问题在哪?ZIP包里的entry名称是可以伪造的。攻击者可以构造一个entry,名字叫../../../../tmp/evil.sh,如果代码里直接拿entry.getName()拼路径,解压的时候就把文件写到外面去了。

看代码:

ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile)); ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { String entryName = entry.getName(); // 根本没有检查 entryName 是否包含 ../ Path outputPath = Paths.get(uploadDir, entryName); Files.copy(zis, outputPath, StandardCopyOption.REPLACE_EXISTING); }

这段代码是不是看着挺正常的?审计时如果不特意去想“ZIP包里的entryName是外部可控数据”,很容易漏掉。我在一次项目中就是这样,代码翻了好几遍,最后是抱着“压缩包这种东西应该也会出问题”的心态,才在这个点上找到突破口。从那以后,凡是碰到解压缩逻辑,我都会条件反射地去看entryName的处理。

修复方案就是加一层解析:

Path resolvedPath = outputPath.normalize(); if (!resolvedPath.startsWith(uploadDir)) { throw new IOException("非法解压路径: " + entryName); }

3.4 日志与配置文件写入的间接利用

先别笑,日志写入导致代码执行,听起来像天方夜谭,但在Java生态里真发生过。

有些项目用log4j、logback这类日志框架,配置文件里开了“根据日志内容动态写文件”的功能。如果日志内容里混入了用户可控的数据,攻击者就可能通过日志注入payload,间接控制写入的内容。

另外一类常见的场景是“生成配置文件”。很多系统提供“系统设置”功能,把配置项拼成properties文件或JSON文件写到磁盘。如果配置项的内容可控,比如应用名称、回调地址,攻击者把内容写成SQL或命令拼接语句,这块配置再被其他逻辑读取使用时,就埋下了一颗雷。

我的建议是:审计时凡是碰到“引号”“分隔符”“路径分隔符”这类特殊字符能进入文件内容的场景,都要多问一句——“这段内容之后会被谁加载?”

4. 修复方案与代码加固实践

4.1 路径规范化校验,抵御穿越的标配手段

所有涉及文件路径组合的逻辑,无论来自用户还是数据库,第一道防线都是“规范化后再校验”。

Java里最简单的做法是使用Paths.get(baseDir, userInput).normalize(),然后再校验规范化后的路径是否还在允许的目录范围内。

参考代码:

public static Path safeResolve(Path baseDir, String userInput) { // 1. 先解析,再规范化,把 ../ 和 . 都展开 Path resolved = baseDir.resolve(userInput).normalize(); // 2. 校验是否还在 baseDir 内 if (!resolved.startsWith(baseDir)) { throw new IllegalArgumentException("非法路径: " + userInput); } return resolved; }

这个方案有个细节要注意:startsWith(baseDir)比较的是路径前缀,所以baseDir本身必须是规范化的绝对路径。如果baseDir是相对路径,解析出来的结构可能和你预期的不一致。建议在代码里统一用Paths.get("/some/abs/path"),别在相对路径上赌运行时的工作目录。

然后,路径里禁止出现的字符做一次黑名单兜底:

if (userInput.contains("../") || userInput.contains("..\\")) { throw new IllegalArgumentException("非法路径"); }

黑名单是弱势校验,不能作为唯一防线,但它可以和规范化校验叠加,增加攻击者的绕过成本。

4.2 文件名与内容的白名单强校验

白名单,白名单,白名单。重要的事情说三遍。搞代码审计的人对黑名单是又爱又恨——它省事,但永远堵不干净。真正可靠的做法是从业务层面定义“什么是合法的”。

文件名合法性的白名单规则,我通常建议这样定:

  • 文件名长度限制,比如不超过128个字符
  • 字符集限制,限制为[a-zA-Z0-9-_]
  • 后缀名白名单,并且把“允许写入的后缀”表格化,而不是从黑名单排除

实际代码可以这样写:

private static final Pattern SAFE_FILENAME = Pattern.compile("^[a-zA-Z0-9_-]{1,128}$"); public static void validateFileName(String fileName) { if (!SAFE_FILENAME.matcher(fileName).matches()) { throw new IllegalArgumentException("非法文件名: " + fileName); } }

然后是对内容做校验。如果是图片,用ImageIO解析一下,确认它真的是图片;如果是文本,限制字符集和特殊字符;如果是压缩包,逐个entry做校验。还有一点,很多团队会忘记响应式校验:文件写入后,服务器上的安全扫描工具可以二次检测恶意内容。程序层面的校验永远是第一道门,部署层的扫描可以作为兜底。

4.3 目录隔离与最小权限原则

修复文件写入漏洞,不能只盯着代码里的那几行。我见过太多“代码是修了,但服务器上的部署方式本身就有问题”的案例。

目录隔离的核心思想是:让Web服务能写的地方,和能执行的地方彻底分开。

具体到操作层面,至少做到这几点:

  1. 上传目录放在Web根目录之外,静态资源通过独立的代理路径来访问,而不是直接让Servlet容器映射整个上传目录
  2. 如果Web根目录必须开放写权限,那上传目录内禁止解析JSP、PHP等动态脚本。在Tomcat里可以通过<Context>配置,或者在Web应用里修改默认Servlet映射
  3. 运行应用的操作系统账号使用低权限账号,只给必要的目录写权限,不要用root跑Java进程
  4. 对Linux服务器,可以用mount的noexec选项挂载上传目录,从根本上阻断目录内的脚本执行

如果项目本身用的是容器服务,还可以考虑把文件写入操作放到独立的内部服务里,由该服务严格控制允许写入的目录和文件类型。把“写文件”当一个敏感操作来对待,而不是万能工具。

4.4 代码加固实例:修改前后对比

我把最经典的漏洞代码和修复后代码写在一起,照着抄基本不会翻车。

修复前:

@RequestMapping("/save") public String save(@RequestParam("filename") String filename, @RequestParam("content") String content) throws IOException { String dir = "/data/files/"; FileWriter writer = new FileWriter(dir + filename); writer.write(content); writer.close(); return "ok"; }

修复后:

private static final Path UPLOAD_DIR = Paths.get("/data/files").toAbsolutePath().normalize(); private static final Pattern SAFE_FILENAME = Pattern.compile("^[a-zA-Z0-9_-]{1,64}$"); @RequestMapping("/save") public String save(@RequestParam("filename") String filename, @RequestParam("content") String content) throws IOException { // 1. 文件名白名单校验 if (!SAFE_FILENAME.matcher(filename).matches()) { throw new IllegalArgumentException("非法文件名"); } // 2. 路径规范化 + 目录约束 Path targetPath = UPLOAD_DIR.resolve(filename).normalize(); if (!targetPath.startsWith(UPLOAD_DIR)) { throw new IllegalArgumentException("非法路径"); } // 3. 写入权限检查(可选:依赖系统ACL) if (!Files.isWritable(UPLOAD_DIR)) { throw new IOException("目录不可写"); } // 4. 写入内容做基础过滤(对于文本类内容) String safeContent = content.replaceAll("[\\r\\n]", " "); Files.write(targetPath, safeContent.getBytes(StandardCharsets.UTF_8)); return "ok"; }

修复后的代码看着繁琐,但每一步都有它的意义:白名单从根上限制了文件名,路径规范化堵住了穿越,内容过滤降低了日志注入和数据污染的风险。审计时看到这种写法,基本可以安心往下走了。

5. 常见问题与排查技巧实录

5.1 常见问题速查表

我整理了团队在审计和修复过程中遇到过的问题,做成一个速查表,按“症状-原因-解法”来列:

症状常见原因排查/修复思路
修复后../仍然可以绕校验放在了拼接之前,但拼接时又用了可变的绝对路径确保校验目标与最终写入路径一致,全部转换为绝对路径再判断
Windows上修复了,Linux上仍然能穿开发环境是Windows,生产是Linux,分隔符处理不一致统一使用Paths.get(),禁止手动拼接/或\
路径校验通过了,但仍然写到了别的目录baseDir本身包含符号链接,startsWith判断被绕过先toRealPath()解析符号链接后再校验
保存的文件下载后不执行/访问404修复时把文件写到了Web目录外,但访问路径没改通过独立的静态资源代理映射目录,而不是直接改写入位置
解压ZIP时校验了entry,但依旧被穿校验用了entry.getName()的字符串包含,没有做规范化用Paths.get(baseDir, entryName).normalize().startsWith(baseDir)严格判断
安全扫描扫出恶意文件,但应用没有明显风险日志或临时文件目录也能被外部访问关闭目录列表,限制代理映射范围,禁止直接访问临时文件路径

5.2 我自己踩过的坑

讲几个真实遇到的案例,都是那种“复盘时觉得很简单,当时就是没想到”的坑。

第一个坑:校验和写入用的不是同一个路径。当时审计一个老项目,开发同学在Controller里做了文件名后缀的判断,但传下去的时候走的是另一个拼接方法,那里的baseDir和判断时用的完全不是同一个。攻击者的路径虽然过了第一道校验,但最终拼出来早就跑出去了。那次之后我就记住了:审计时校验代码只是参考,真正要看的是实际拼路径的那几行。

第二个坑:只测了Linux,没测Windows。有个项目修复后测试全绿,结果上线后Windows服务器上仍然出现路径穿越。原因是代码里有人用了File.separator拼接,Windows上是\,但黑名单只过滤了/。从那以后我养成了习惯:所有路径修复代码,必须同时验证/和\两种情况,最好直接禁用文件名字段中所有路径分隔符类字符。

第三个坑:解压校验太“宽松”。当时用entry.getName().contains("..")做校验,没想到..可以encode成%2e%2e,在特定场景下会被二次解码。正确的做法是把entry的name先还原成真实路径,再做normlize和startsWith判断,别在原始字符串上玩包含关系。

5.3 审计清单,建议存一份

最后给出一份完整的检查清单,每次做文件写入相关审计前,对着这个清单过一遍:

  1. 全项目搜索Files.write、FileOutputStream、FileWriter、RandomAccessFile、PrintWriter、transferTo、Files.copy等写入点
  2. 找到每个写入点后,向上追踪路径参数的来源
  3. 确认路径中的每个片段,哪些是硬编码、哪些来自外部输入
  4. 对来自外部输入的片段,检查是否有白名单、长度限制、路径规范化
  5. 对写入内容,检查是否有格式校验、字符过滤
  6. 检查服务器部署方式:上传目录是否在Web根内、是否禁止脚本执行、是否使用低权限账号
  7. 检查压缩包处理逻辑,逐个确认entryName的校验方式
  8. 检查日志框架配置,是否有动态文件追加或敏感数据入日志

把这八条跑完,文件写入方向的漏洞基本不会漏。


做文件写入漏洞审计这些年,我最大的体会就一句话:别给它贴上“我能写文件”的标签就完了,真正重要的是想清楚“谁能控制文件名”“谁能控制路径”“谁能控制内容”这三个问题。代码审计不只是找一个bug,更是在理解这个功能的设计意图和边界。修复的时候,也别只往代码里塞几行校验,得从架构上想想“这个写入动作真的需要暴露给用户吗”“是否能放到更安全的隔离环境里执行”。把这些问题想通,你写的每一行代码都会比之前更稳一点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询