0%

Tomcat 条件竞争导致远程代码执行(CVE-2024-50379/CVE-2024-56337)

漏洞描述

该漏洞是由于Tomcat在验证文件路径时存在缺陷,如果readonly参数被设置为false(这是一个非标准配置),并且服务器允许通过PUT方法上传文件,
那么攻击者就可以上传含有恶意JSP代码的文件。通过不断地发送请求,攻击者可以利用条件竞争,使得Tomcat解析并执行这些恶意文件,从而实现远程代码执行。

影响版本

11.0.0-M1 <= Apache Tomcat < 11.0.2
10.1.0-M1 <= Apache Tomcat < 10.1.34
9.0.0.M1 <= Apache Tomcat < 9.0.98

环境搭建

该漏洞只在大小写不敏感的操作系统下发生,故所有测试环境均基于Windows系统。
下载 Tomcat 9.0.97 版本,解压后下载地址
下载对应版本的源代码下载地址
配置ROOT APP 修改web.xml文件,设置DefaultServletreadonly属性为false,即对静态资源启动PUT方法以为ROOT APP添加静态文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<servlet>
<servlet-name>default</servlet-name>
<servlet-class>org.apache.catalina.servlets.DefaultServlet</servlet-class>
<init-param>
<param-name>debug</param-name>
<param-value>0</param-value>
</init-param>
<init-param>
<param-name>listings</param-name>
<param-value>false</param-value>
</init-param>
<init-param>
<param-name>readonly</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>

此时我们可以使用类似下面这样的请求在 ROOT APP中新增一个静态文件。

request
1
2
3
4
5
6
7
8
9
10
11
PUT /test.html HTTP/1.1
Host: localhost:8000
Content-Type: text/plain
Content-Length: 0
Connection: close

<html>
<body>
test
<body>
</html>

这样请求体中的内容将被写入到 ROOT APP 的根目录下。

选择较低版本的JDK对tomcat进行启动,我这里使用的是8u202,因为较高版本的JDK规范化路径缓存默认是禁用的,而该漏洞需要这个选项开启。
进入Tomcat根目录的/bin目录下执行以下命令启动tomcat并启动调试

1
catalina.bat jpda start

启动后程序默认监听在8000端口
使用idea打开Tomcat源代码,并配置调试选项,设置调试端口为8000
img.png

漏洞成因

这里我直接先介绍漏洞成因,以方便我对该漏洞进行说明。
当Tomcat的某一个APP的启动了静态文件的写入功能时(即readonly参数被设置为false),此时服务器允许通过PUT方法上传文件。此时我们可以通过PUT方法
想服务器上传一个名为test.JSP的文件,然后在该文件没有正式落地的时候,通过GET请求访问test.jsp,在特殊的时机下静态资源文件test.JSP将被作为动态
jsp文件解析,从而导致远程代码执行。
值得关注的点时test.JSP是被作为静态资源文件由DefaultServlet进行解析处理的,而test.jsp则是作为动态文件由JspServlet进行解析处理的,
或者说JspServlet只处理.jsp以及.jspx作为后缀的文件(JspServlet是大小写敏感的),而test.JSP或者test.Jsp这类文件都被认为是静态资源文件
DefaultServlet处理。
因为我们的payload访问的是test.jsp,该请求是由JspServlet处理的,所以我们从JspServletserviceJspFile方法开始。
jspUri即我们传入的test.jsp,这里跟进到getResource方法。

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
private void serviceJspFile(HttpServletRequest request,
HttpServletResponse response, String jspUri,
boolean precompile)
throws ServletException, IOException {

JspServletWrapper wrapper = rctxt.getWrapper(jspUri);
if (wrapper == null) {
synchronized(this) {
wrapper = rctxt.getWrapper(jspUri);
if (wrapper == null) {
// Check if the requested JSP page exists, to avoid
// creating unnecessary directories and files.
if (null == context.getResource(jspUri)) {
handleMissingResource(request, response, jspUri);
return;
}
wrapper = new JspServletWrapper(config, options, jspUri,
rctxt);
rctxt.addWrapper(jspUri,wrapper);
}
}
}

try {
wrapper.service(request, response, precompile);
} catch (FileNotFoundException fnfe) {
handleMissingResource(request, response, jspUri);
}

}

getResource方法位于org.apache.catalina.core.ApplicationContext.getResource
validateResourcePath方法首先检查path是否以/开头,如果以/开头则返回path,否则返回/加上path
然后调用StandRootgetResource方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public URL getResource(String path) throws MalformedURLException {

String validatedPath = validateResourcePath(path, !GET_RESOURCE_REQUIRE_SLASH);

if (validatedPath == null) {
throw new MalformedURLException(sm.getString("applicationContext.requestDispatcher.iae", path));
}

WebResourceRoot resources = context.getResources();
if (resources != null) {
return resources.getResource(validatedPath).getURL();
}

return null;
}

如果缓存允许缓存则从缓存中查找文件子资源。

1
2
3
4
5
6
7
8
9
10
11
protected WebResource getResource(String path, boolean validate, boolean useClassLoaderResources) {
if (validate) {
path = validate(path);
}

if (isCachingAllowed()) {
return cache.getResource(path, useClassLoaderResources);
} else {
return getResourceInternal(path, useClassLoaderResources);
}
}

noCache方法中指定了一些特殊的文件是不能缓存的,如:.class .jar 或者 classes目录下的文件等。
然后从资源缓存列表resourceCache中根据请求的资源路径查找缓存资源,第一次访问时会返回null,即cacheEntry此时为null
然后创建一个 CachedResource对象赋值给cacheEntry并调用其validateResource方法,进入该方法。

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
protected WebResource getResource(String path, boolean useClassLoaderResources) {

if (noCache(path)) {
return root.getResourceInternal(path, useClassLoaderResources);
}

CacheStrategy strategy = getCacheStrategy(); // 默认为空
if (strategy != null) {
if (strategy.noCache(path)) {
return root.getResourceInternal(path, useClassLoaderResources);
}
}

lookupCount.increment();

CachedResource cacheEntry = resourceCache.get(path); // 从资源缓存列表中根据请求的资源路径查找缓存资源,

if (cacheEntry != null && !cacheEntry.validateResource(useClassLoaderResources)) {
removeCacheEntry(path);
cacheEntry = null;
}
if (cacheEntry == null) {
int objectMaxSizeBytes = getObjectMaxSizeBytes();
CachedResource newCacheEntry =
new CachedResource(this, root, path, getTtl(), objectMaxSizeBytes, useClassLoaderResources);

cacheEntry = resourceCache.putIfAbsent(path, newCacheEntry);

if (cacheEntry == null) {
cacheEntry = newCacheEntry;
cacheEntry.validateResource(useClassLoaderResources);
long delta = cacheEntry.getSize();
long result = size.addAndGet(delta);
if (log.isDebugEnabled()) {
log.debug(sm.getString("cache.sizeTracking.add", Long.toString(delta), cacheEntry, path,
Long.toString(result)));
}

if (size.get() > maxSize) {
// Process resources unordered for speed. Trades cache
// efficiency (younger entries may be evicted before older
// ones) for speed since this is on the critical path for
// request processing
long targetSize = maxSize * (100 - TARGET_FREE_PERCENT_GET) / 100;
long newSize = evict(targetSize, resourceCache.values().iterator());
if (newSize > maxSize) {
// Unable to create sufficient space for this resource
// Remove it from the cache
removeCacheEntry(path);
log.warn(sm.getString("cache.addFail", path, root.getContext().getName()));
}
}
} else {
// Another thread added the entry to the cache
if (cacheEntry.usesClassLoaderResources() != useClassLoaderResources) {
cacheEntry = newCacheEntry;
}
// Make sure it is validated
cacheEntry.validateResource(useClassLoaderResources);
}
} else {
hitCount.increment();
}

return cacheEntry;
}

该方法首次调用时webResourcenull
然后会进入到StrandardRootgetResourceInternal方法中。

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
protected boolean validateResource(boolean useClassLoaderResources) {
// It is possible that some resources will only be visible for a given
// value of useClassLoaderResources. Therefore, if the lookup is made
// with a different value of useClassLoaderResources than was used when
// creating the cache entry, invalidate the entry. This should have
// minimal performance impact as it would be unusual for a resource to
// be looked up both as a static resource and as a class loader
// resource.
if (usesClassLoaderResources != useClassLoaderResources) {
return false;
}

long now = System.currentTimeMillis();

if (webResource == null) {
synchronized (this) {
if (webResource == null) {
webResource = root.getResourceInternal(webAppPath, useClassLoaderResources);
getLastModified();
getContentLength();
nextCheck = ttl + now;
// exists() is a relatively expensive check for a file so
// use the fact that we know if it exists at this point
if (webResource instanceof EmptyResource) {
cachedExists = Boolean.FALSE;
} else {
cachedExists = Boolean.TRUE;
}
return true;
}
}
}

...
}

该方法循环遍历allResources的所有元素并调用其getResource方法,直到扎找到资源为止。
第一个被调用的WebResourceSetDirResourceSet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protected final WebResource getResourceInternal(String path, boolean useClassLoaderResources) {
WebResource result = null;
WebResource virtual = null;
WebResource mainEmpty = null;
for (List<WebResourceSet> list : allResources) {
for (WebResourceSet webResourceSet : list) {
if (!useClassLoaderResources && !webResourceSet.getClassLoaderOnly() ||
useClassLoaderResources && !webResourceSet.getStaticOnly()) {
result = webResourceSet.getResource(path);
if (result.exists()) {
return result;
}
if (virtual == null) {
if (result.isVirtual()) {
virtual = result;
} else if (main.equals(webResourceSet)) {
mainEmpty = result;
}
}
}
}
}
...
}

DirResourceSet#getResource 方法中会调用file方法查找文件。

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
public WebResource getResource(String path) {
checkPath(path);
String webAppMount = getWebAppMount();
WebResourceRoot root = getRoot();
if (path.startsWith(webAppMount)) {
ResourceLock lock = lockForRead(path);
try {
File f = file(path.substring(webAppMount.length()), false);// 查找文件资源
if (f == null) {
return new EmptyResource(root, path);
}
if (!f.exists()) { // 检查文件是否存在,也就是说在这条代码执行前我们通过PUT方法上传的文件必须要落地
return new EmptyResource(root, path, f);
}
if (f.isDirectory() && path.charAt(path.length() - 1) != '/') {
path = path + '/';
}
return new FileResource(root, path, f, isReadOnly(), getManifest(), this, lock.key);
} finally {
unlockForRead(lock);
}
} else {
return new EmptyResource(root, path);
}
}

通过fileBasename一起构造文件对象,这里name的值即我们传入的/test.jsp
然后会调用getCanonicalPath来获得文件的规范化路径。
最后会比较getCanonicalPath方法获取的规范化路径canPath是否与文件的绝对路径absPath相等,如果两者不想的则返回null,此时我们需要这两个值是相等的,
所以我们需要保证canPathabsPath相等。跟进getCanonicalPath方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected final File file(String name, boolean mustExist) {
...
File file = new File(fileBase, name);
...
try {
canPath = file.getCanonicalPath();
} catch (IOException e) {
}
...
absPath = absPath.substring(absoluteBase.length());
canPath = canPath.substring(canonicalBase.length());
...
if (!canPath.equals(absPath)) {
if (!canPath.equalsIgnoreCase(absPath)) {
logIgnoredSymlink(getRoot().getContext().getName(), absPath, canPath);
}
return null;
}

return file;
}

java.io.File.getCanonicalPath
继续跟进 canonicalize方法。

1
2
3
4
5
6
public String getCanonicalPath() throws IOException {
if (isInvalid()) {
throw new IOException("Invalid file path");
}
return fs.canonicalize(fs.resolve(this));
}

useCanonCaches 表示是否启用规范路径缓存,也就是我们文章开头提到的高版本JDK这个值被设为false导致漏洞无法利用的原因。
我们要利用漏洞useCanonCaches的值必须为true,即我们希望进行规范化路径缓存。
该方法最终会调用到 canonicalize0方法,这是一个native方法,具体的细节我就不再分析了,感兴趣可以查看参考链接
canonicalize0方法有个特性,其规范的路径时不区分大小写的,即如果我要规范一个路径bastPath/test.jsp
该方法会去basePath路径下查找是否存在test.jsp文件,如果此时basePath下不存在test.jsp文件,将basePath/test.jsp这个路径原样返回,
如果basePath目录下存在test.JSP文件,则返回basePath/test.JSP,即规范路径时不区分大小写。
根据这个特性我们就可以将一个.JSP结尾的静态资源文件当作动态文件解析了。

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
@Override
public String canonicalize(String path) throws IOException {
if (!useCanonCaches) {
return canonicalize0(path);
} else {
String res = cache.get(path);// 先查找缓存,首次访问时返回null
if (res == null) {
String dir = null;
String resDir = null;
if (useCanonPrefixCache) {// 是否启用了前缀缓存,即缓存目录,我们直接访问的文件没有多层目录结构所以不涉及这一点。
dir = parentOrNull(path);
if (dir != null) {
resDir = prefixCache.get(dir);
if (resDir != null) {
String filename = path.substring(1 + dir.length());
res = canonicalizeWithPrefix(resDir, filename);
cache.put(dir + File.separatorChar + filename, res);
}
}
}
if (res == null) {
res = canonicalize0(path);// native方法
cache.put(path, res); // 将规范化的结果放入到缓存中
if (useCanonPrefixCache && dir != null) { // 如果启动了前缀缓存,还需要往前缀缓存添加缓存项。
resDir = parentOrNull(res);
if (resDir != null) {
File f = new File(res);
if (f.exists() && !f.isDirectory()) {
prefixCache.put(dir, resDir);
}
}
}
}
}
return res;
}
}

上面的方法执行完后会往cache中写入这样的键值对test.jsp -> test.JSP
当向上回退到file方法的时候出现问题了。
再file方法中计算的basePath/test.jsp计算的canPathbasePath/test.JSP,计算的absPathbasePath/test.jsp
两者不相等导致file方法将会返回null,这不是我们想要的。
还记得前面提到canonicalize0方法的时候说过其有一个特性是当basePath/test.jsp或者basePath/test.JSP等文件在文件系统中不存在的时候会原样返回basePath/test.jsp
这样在file方法中得到的canPath就与absPath一致了,也就能正确返回File对象了。
继续向上回溯到DirResourceSet#getResource方法,在成功调用file方法查找到文件资源后,调用f.exists()方法,检查文件是否存在,这是时候需要保证文件系统中存在文件basePath/test.jsp
因为在Windows的文件系统中文件名是大小写不敏感的,所以basePath/test.jsp或者basePath/test.JSP其实表示同一个文件。
我们无法凭空创建一个test.jsp文件,但是可以创建test.JSP文件,因为该文件在Tomcat中是一个被当作一个静态资源文件处理的,我们可以通过PUT方法进行上传。

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
public WebResource getResource(String path) {
checkPath(path);
String webAppMount = getWebAppMount();
WebResourceRoot root = getRoot();
if (path.startsWith(webAppMount)) {
ResourceLock lock = lockForRead(path);
try {
File f = file(path.substring(webAppMount.length()), false);// 成功找到了file资源 path为 `basePath/test.jsp`
if (f == null) {
return new EmptyResource(root, path);
}
if (!f.exists()) { // 检查文件是否存在,也就是说在这条代码执行前我们通过PUT方法上传的文件必须要落地
return new EmptyResource(root, path, f);
}
if (f.isDirectory() && path.charAt(path.length() - 1) != '/') {
path = path + '/';
}
return new FileResource(root, path, f, isReadOnly(), getManifest(), this, lock.key);
} finally {
unlockForRead(lock);
}
} else {
return new EmptyResource(root, path);
}
}

test.JSP文件的上传时机是非常重要的,我们需要canonicalize0方法调用完之前这个文件是不存在于文件系统的,否则缓存中保存的就是a.jsp -> a.JSP的映射关系,计算的canPath也变成了a.JSP
这样不能通过absPathcanPath的比较。 且要保证在f.exists调用之前test.JSP文件成功落盘,因为我们要保证文件存在否则将响应一个空的资源对象。

在调试环境下要做到这一点是非常简单的,我们只需要在f.exists这一行下断点,访问test.jsp运行到这一行时再到文件系统中创建test.JSP文件,然后让代码继续执行即可观察到test.JSP中的命令被执行了。

<%=Runtime.getRuntime().exec("calc")%>

img_1.png

实战环境下PoC的写法在了解了原理后相比并不困难(人比较懒),这里不再赘述。

参考链接

Buy me a coffee.

欢迎关注我的其它发布渠道