Java 代码审计实战速查(五):文件类漏洞

文件类漏洞是企业项目里出现频率最高的一类,也是最容易出成果的一类。

原因很简单:这类漏洞不需要读懂业务。 只要看到一个可控的字符串被拼进了文件路径,基本就能定性。而权限绕过、逻辑漏洞那些,你得先理解这个系统在干什么。

所有文件类漏洞最终都归结为两个问题:

1. 路径 / 文件名 / 文件内容,从哪来,可控到什么程度
2. 有没有校验,校验的是"字符串里有没有坏东西",还是"最终结果有没有越界"

第二个问题的区别,就是这一篇的核心。


0x01 上传

黑名单的绕法清单

先看一段典型的黑名单写法:

String suffix = filename.substring(filename.lastIndexOf(".") + 1);
if ("exe".equals(suffix) || "jsp".equals(suffix)) {
    throw new Exception("不允许的文件类型");
}

这段代码的问题不止一个。jsp 没有做大小写归一化,.JSP 直接过。而且只挡了两个后缀。

后缀黑名单能想到的绕法,大致这些:

大小写          .JSP  .Jsp  .jSP
等价后缀        .jspx  .jspf  .jspa  .jsw  .jsv
双写后缀        .jspjsp     (过滤只删一次时,删完拼成 .jsp)
尾随字符        .jsp.       (Windows 会忽略结尾的点)
                .jsp        (Windows 会忽略结尾的空格)
                .jsp::$DATA (NTFS 数据流,Windows 上等价于 .jsp)
截断            .jsp%00.jpg (老 JDK / 老 Servlet 容器)
配置文件        .htaccess  web.config  .user.ini

最后一项经常被忽略。上传 .htaccess 可以让 Apache 把任意后缀当 PHP/JSP 解析,上传 web.config 在 IIS 上同理。后缀黑名单如果只想着脚本后缀,这类配置文件就全漏了。

所以上传点唯一正确的做法是白名单:

public static final String[] DEFAULT_ALLOWED_EXTENSION = {
    // 图片
    "bmp", "gif", "jpg", "jpeg", "png",
    // word excel powerpoint
    "doc", "docx", "xls", "xlsx", "ppt", "pptx",
    // 压缩文件
    "rar", "zip", "gz", "bz2",
    // 视频格式
    "mp4", "avi", "rmvb"
};

这是若依的默认白名单。用 ArrayUtils.contains 判断后缀在不在里面,不在就拒绝。

但这个白名单里有两个东西值得单独盯一眼:zip 和 rar。 压缩包本身不是漏洞,问题是如果系统里还有解压功能——0x05 会讲这条链。

上传目录能不能通过 HTTP 访问

这一条决定了你要不要在上传点上花时间,很多人漏掉。

若依把上传目录直接映射成了静态资源:

// ruoyi-framework/src/main/java/com/ruoyi/framework/config/ResourcesConfig.java
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    /** 本地文件上传路径 */
    registry.addResourceHandler("/profile/**")
            .addResourceLocations("file:" + RuoYiConfig.getProfile() + "/");
}

也就是说,上传到 RuoYiConfig.getProfile() 下的文件,通过 /profile/xxx 就能直接访问。

判断矩阵:

上传目录可 HTTP 访问 + 后缀白名单有洞      → 直接 getshell,最高优先级
上传目录可 HTTP 访问 + 后缀白名单严格      → 看能不能传 html/svg 做存储型 XSS
                                            看目标中间件有没有解析漏洞
上传目录不可 HTTP 访问                     → 降级,需要配合路径穿越或文件包含

先确认上传目录在哪里、有没有被映射出去,再决定投入。 一个后缀白名单严格、目录又不可访问的上传点,不值得花两小时。

审计动作:

grep -rn "addResourceHandler\|addResourceLocations" --include=*.java .
grep -rn "static-locations\|spring.resources" --include=*.yml --include=*.properties .

上传点在哪里找

grep -rn "MultipartFile" --include=*.java .
grep -rn "transferTo\|FileOutputStream\|FileUtils.write" --include=*.java .

transferTo() 是最常见的落盘方式,但它本身不做任何校验——校验要写在它前面。


0x02 文件写入

和上传是两回事。上传是文件可控,写入是路径可控加内容可控。

MCMS 的后台模板管理里有一个写文件的功能:

// 写入模板文件内容
public void writeFileContent(String path, String content) {
    ...
}

对应的接口是 writeFileContent.do。对这个点的审计过程是这样的:

第一遍看代码,发现它对后缀做了限制:

if (fileName.endsWith(".exe") || fileName.endsWith(".jsp")) {
    throw new Exception("不允许的文件类型");
}

只挡了 exe 和 jsp。jspx 看起来能过,实际传上去被拦了——不是这个函数拦的,是全局配置里还有一层。翻了配置文件,确实对 jspx 也做了过滤。

到这里看起来堵住了。

但这是 Windows + Tomcat war 部署的场景。前面那份绕法清单里有一条在这里生效:

payload.jsp::$DATA

::$DATA 是 NTFS 的备用数据流语法,Windows 在解析文件名时会把它后面的部分当成数据流标识丢掉,最终落盘的文件名仍然是 payload.jsp。而过滤函数看到的字符串是 payload.jsp::$DATA,endsWith(".jsp") 判断为假,直接放行。

这类"利用操作系统特性绕过字符串判断"的思路要单独记一条。 它不属于注入,也不属于逻辑问题,就是过滤函数看的字符串和文件系统最终理解的名字不是一回事。


0x03 下载与读取

危险形态

new FileInputStream(new File(path));
Files.readAllBytes(Paths.get(path));
new FileReader(path);
resource.getInputStream();
new File(path);

搜 new File( 会有大量噪音,因为构造 File 对象本身不读文件。要搜到真正读的那一行(FileInputStream / Files.read* / FileReader),再看 path 变量是怎么来的。

两种校验形态

形态一:黑名单条目。

if (StringUtils.contains(resource, "..")) {
    return false;
}

形态二:边界校验。

File base = new File(RuoYiConfig.getProfile()).getCanonicalFile();
File target = new File(base, resource).getCanonicalFile();
if (!target.getPath().startsWith(base.getPath() + File.separator)) {
    throw new Exception("非法路径");
}

两者的区别是根本性的。

形态一校验的是字符串里有没有出现某个东西。它能不能成立,取决于"攻击者有没有别的方式达到同样的目的"——而这个问题的答案永远是否定的,因为逃出一个基目录从来不只有 .. 一种方式。

形态二校验的是最终结果有没有越界。getCanonicalFile() 会把 ..、软链接、大小写等各种写法归一化,然后判断归一化之后的路径前缀是不是仍然在基目录下。这才是把问题闭合的写法。

看到形态一,不要急着下"安全"的结论。

若依 /common/download:一次下早了的判断

这个案例我印象比较深,因为第一遍我判错了。

ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java:

@GetMapping("/download")
public void fileDownload(String fileName, Boolean delete,
                         HttpServletResponse response, HttpServletRequest request) {
    try {
        if (!FileUtils.checkAllowDownload(fileName)) {
            throw new Exception(StringUtils.format("资源文件({})非法,不允许下载。 ", fileName));
        }
        String realFileName = System.currentTimeMillis() + fileName.substring(fileName.indexOf("_") + 1);
        String filePath = RuoYiConfig.getDownloadPath() + fileName;

        response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
        FileUtils.setAttachmentResponseHeader(response, realFileName);
        FileUtils.writeBytes(filePath, response.getOutputStream());
        if (delete) {
            FileUtils.deleteFile(filePath);
        }
    } catch (Exception e) {
        log.error("下载文件失败", e);
    }
}

校验函数:

// ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUtils.java
public static boolean checkAllowDownload(String resource) {
    // 禁止目录上跳级别
    if (StringUtils.contains(resource, "..")) {
        return false;
    }
    // 检查允许下载的文件规则
    if (ArrayUtils.contains(MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION,
                            FileTypeUtils.getFileType(resource))) {
        return true;
    }
    return false;
}

第一遍看到这里,我的判断是:.. 被挡了,后缀走白名单,路径又是从 getDownloadPath()(也就是 profile 目录下的 download 子目录)拼出来的。三个条件看着都在,判定"路径穿越不可行",记了一笔放过了。

第二遍是做报告前的交叉验证,我把这个问题重新问了一遍:"最终拼出来的这个路径,能不能落在基目录外面?"

答案是:这段代码里没有任何东西能回答这个问题。

checkAllowDownload 校验的是字符串里有没有 .. 和扩展名在不在白名单。它没有 getCanonicalFile(),没有基目录前缀判断。 它的安全性完全建立在一个假设上——".. 是唯一能跳出基目录的方式"。

而 filePath = RuoYiConfig.getDownloadPath() + fileName 是纯字符串拼接,往 Windows 部署环境下想,能跳出基目录的写法不止一种:绝对路径、UNC 路径、以及各种只有归一化之后才显形的写法。

顺着这个思路去查,发现这个接口确实被公开报告过路径穿越问题,影响 RuoYi-Vue 3.9.2 及以下。报告的 PoC 用的是不含 .. 的路径,正好绕开那个 contains(resource, "..")。

我没在本地复现这个点,所以在报告里的写法是"校验方式不足以约束路径边界,建议按部署环境核实",而不是"确认存在任意文件读取"。

这里的教训比漏洞本身重要:

黑名单只能用来否定,不能用来肯定。 一个点被黑名单挡住了,你得到的状态是"未验证",不是"安全"。要把它变成"安全",只能证明"所有到达 Sink 的路径都在允许范围内"——而黑名单证明不了这件事,只有边界校验能。

同一个接口还有个附带的点:if (delete) 这个分支。

FileUtils.writeBytes(filePath, response.getOutputStream());
if (delete) {
    FileUtils.deleteFile(filePath);
}

一个参数同时控制读和删。这类"下载完顺手删掉"的功能设计,把两个漏洞面合并在一个入口上了。


0x04 删除

删除是文件类漏洞里最容易被评估成低危的一类,但它值得单独看。

public static boolean deleteFile(String filePath) {
    File file = new File(filePath);
    if (file.isFile() && file.exists()) {
        return file.delete();
    }
    return false;
}

判断逻辑和读取一样,两个问题:路径可控吗,校验是不是边界校验。但危害评估的路径不同。

危害一,拒绝服务。 删掉应用的配置文件、数据库数据文件、或者某个服务的 lock 文件,服务直接起不来。

危害二,为其他漏洞铺路。 删掉 .htaccess 或 web.config 之后,之前被限制的目录就可能允许脚本执行;删掉某个校验用的本地文件,可能让某个功能走进异常分支。

危害三,销毁痕迹。 用自己的账户删掉日志。

所以删除点在报告里至少要定中危,不能因为"只是删除"就降级到低危。

搜法:

grep -rn "\.delete()\|deleteFile\|Files\.delete\|FileUtils\.forceDelete" --include=*.java .

对应的入口通常是"删除附件""清理缓存""删除导出文件"这类功能。


0x05 解压穿越

这是这篇里最值得记的一个点,因为校验和利用发生在两个不同的环节。

MCMS 的后台模板管理支持上传 zip 包再解压。上传环节对路径做了检测:

if (path.contains("../")) {
    throw new Exception("非法路径");
}

../ 挡住了。上传的文件名也做了处理。看起来没问题。

解压的实现在另一个函数里:

public static void unzip(String zipFile, String outPath) throws IOException {
    ZipFile zip = new ZipFile(zipFile);
    Enumeration<? extends ZipEntry> entries = zip.entries();
    while (entries.hasMoreElements()) {
        ZipEntry entry = entries.nextElement();
        File file = new File(outPath + entry.getName());   // 拼接 entry 名
        ...
        try (FileOutputStream fos = new FileOutputStream(file)) {
            ...
        }
    }
}

问题在 entry.getName()。

这个名字不是上传时的文件名,是 zip 包内部记录的文件名,完全由构造 zip 的人决定。上传环节检测的是 HTTP 请求里的文件名和路径参数,根本没碰过 entry.getName()。

构造一个 zip,内部条目名写成 ../../xxx.jsp:

import zipfile

z = zipfile.ZipFile('archive.zip', 'w', zipfile.ZIP_DEFLATED)
with open('payload.jsp', 'rb') as f:
    z.writestr('../../payload.jsp', f.read())
z.close()

上传、解压,文件就落到了上层目录。哪里能访问,取决于部署结构。

审计动作:

grep -rn "ZipEntry\|ZipInputStream\|ZipFile\|unzip\|Unzip" --include=*.java .

找到解压函数之后,只问一个问题:entry.getName() 有没有被校验过。

大部分情况下答案是没有。因为开发在做安全加固的时候,想到的是"用户上传的文件名",不会想到"zip 内部的文件名"。这两个东西在他脑子里是一回事,在实现上是两回事。

顺带连上第一篇留的线头:上传白名单里允许 zip 和 rar。只要系统同时有"允许上传压缩包"和"解压功能",这两个点就必须连起来看。 单独看,一个只是白名单宽一点,一个只是解压功能;连起来才是一条完整的利用链。


0x06 一个重复出现的模式

写到这里,本系列里同一个模式已经出现了三次:

第四篇   若依定时任务的 invokeTarget 校验加在"新增/编辑任务"接口上,执行时不校验
第四篇   若依代码生成的 createTable 用 ${sql} 直接执行
本篇     若依 checkAllowDownload 校验字符串内容,不校验路径边界
本篇     MCMS 上传时检测 ../,解压时不检测 entry.getName()

校验和利用不在同一个位置,或者校验的不是同一个对象。

这个模式比任何单个漏洞都重要。它的检查方法是固定的:找到一个 Sink,然后往上问——在这条路径上,究竟是哪一行代码在阻止这个输入变成危害? 如果答不上来,那就是没有防护,而不是"有防护但我没找到"。


0x07 清单

上传
[ ] 搜 MultipartFile / transferTo,列出所有上传点
[ ] 后缀校验是黑名单还是白名单;黑名单按清单逐条试绕
[ ] 后缀判断有没有做大小写归一化
[ ] 上传目录有没有被 addResourceHandler / static-locations 映射出去
[ ] 是否允许上传 zip / rar,同系统是否有解压功能
[ ] 是否允许 html / svg,是否在同域

文件写入
[ ] 搜 FileOutputStream / writeBytes / Files.write,确认路径与内容是否同时可控
[ ] 后缀过滤之外的 Windows 特性(::$DATA、尾随点空格)

下载与读取
[ ] 搜 FileInputStream / Files.read / FileReader,而不是 new File(
[ ] 校验是黑名单条目还是 getCanonicalFile + 基目录前缀判断
[ ] 黑名单命中时,结论写"未验证"而不是"安全"
[ ] 有 delete 参数的地方单独看删除分支

删除
[ ] 搜 .delete() / deleteFile,确认路径可控性
[ ] 危害评估不要直接按低危

解压
[ ] 搜 ZipEntry / ZipInputStream,确认 entry.getName() 有没有校验
[ ] 上传环节的路径检测不能替代解压环节的检测

下一篇讲反序列化和组件漏洞。这一类是本系列里唯一需要看依赖版本的——第一篇的版本对照表在那里会真正用上。


免责声明:本文技术内容仅用于安全研究与防御建设,请在获得合法授权的环境下测试。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇