RMI 学习

OneZ3r0 Lv4

RMI复盘 [TL;DR]

服务端

首先是创建RMI服务端,远程对象,远程接口

  • 接口 必须继承自Remote,换言之,它必须是个远程接口
  • 所有远程接口的方法必须声明 throws RemoteException,原因:让客户端能够处理远程调用可能出现的各种网络和服务器问题

因为在创建stub代理的时候会检查远程接口的方法isAssignableFrom(RemoteException.class)

image-20251205164632101
image-20251205164632101

1
2
3
4
5
6
7
8
9
10
11
12
//  这里是sun包里面的源码,自己添加sdk的源路径
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/Util.java

private static void checkMethod(Method m) {
Class<?>[] ex = m.getExceptionTypes();
for (int i = 0; i < ex.length; i++) {
if (ex[i].isAssignableFrom(RemoteException.class))
return;
}
throw new IllegalArgumentException(
"illegal remote method encountered: " + m);
}

所以接口的方法需要throws RemoteException

1
2
3
public interface IRemoteObj extends Remote {
public String sayHello(String name) throws RemoteException;
}
  • 远程对象(实现了远程接口)

因为在UnicastRemoteObject里面构造函数会使用(Remote) this强制转换,所以前面接口需要extends Remote

1
2
3
4
5
6
7
//usr/lib/jvm/jdk1.8.0_65/src.zip!/java/rmi/server/UnicastRemoteObject.java

protected UnicastRemoteObject(int port) throws RemoteException
{
this.port = port;
exportObject((Remote) this, port);
}

如果不继承UnicastRemoteObject就需要UnicastRemoteObject.exportObject(this, 0);手动导出为远程对象

1
2
3
4
5
6
7
8
9
10
public class RemoteObjImpl extends UnicastRemoteObject implements IRemoteObj {
public RemoteObjImpl() throws RemoteException {
UnicastRemoteObject.exportObject(this, 0);
}

@Override
public String sayHello(String name) throws RemoteException {
return name.toUpperCase();
}
}
  • 服务端,通常和注册中心放在一起
1
placeholder

远程对象创建流程

对象的父类的字段会在对象的构造方法之前执行

现在有对象了,重点是对象发布到网络上去的过程,核心理解:export就是导出为远程的东西

前面的自定义对象,不管是继承UnicastRemoteObject还是手动导出为远程对象,都会走到这个静态方法

1
2
3
4
5
//usr/lib/jvm/jdk1.8.0_65/src.zip!/java/rmi/server/UnicastRemoteObject.java

public static Remote exportObject(Remote obj, int port) throws RemoteException {
return exportObject(obj, new UnicastServerRef(port));
}

前面的obj是我们创建的远程对象,有功能实现的逻辑,那么后面的UnicastServerRef(port)自然就是处理网络请求的部分

创建UnicastServerRef

UnicastServerRef创建了一个LiveRef,也就是UnicastRemoteObject.sref=UnicastServerRef,且这个sref.ref=LiveRef

这个LiveRef是核心

经过层层构造链,会把传入的obj的ref字段,设置为刚刚生成配置好的UnicastRemoteObject.sref,然后继续用这个sref导出obj,目前这一步还是在UnicastRemoteObject进行导出,从上面的公开方法调用自己的私有方法

1
2
3
4
5
6
7
8
9
10
11
//usr/lib/jvm/jdk1.8.0_65/src.zip!/java/rmi/server/UnicastRemoteObject.java

private static Remote exportObject(Remote obj, UnicastServerRef sref)
throws RemoteException
{
// if obj extends UnicastRemoteObject, set its ref.
if (obj instanceof UnicastRemoteObject) {
((UnicastRemoteObject) obj).ref = sref;
}
return sref.exportObject(obj, null, false); // 这里传入的第三个参数为permanent,第一个参数是自定义的对象,是临时的
}

创建Stub

下一步走到UnicastServerRef进行导出,创建了给client用的stub代理对象

其实一路上都是在调用这个exportObject,只不过是在不同的类里面调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java

public Remote exportObject(Remote impl, Object data,
boolean permanent)
throws RemoteException
{
Class<?> implClass = impl.getClass();
Remote stub;

try {
stub = Util.createProxy(implClass, getClientRef(), forceStubUse);
} catch (IllegalArgumentException e) {
throw new ExportException(
"remote object implements illegal remote interface", e);
}
...
}

stub不是客户端用的吗,为什么在服务端创建了?

因为实际上是服务端创建好放到注册中心,客户端去注册中心拿,然后再使用

也就是服务端在创建远程对象的时候,同时生成了stub和skeleton这两个代理,之后stub会放到registry里面,skeleton留在服务端,client通过注册中心获取stub,然后通过stub操作skeleton,进而操作服务端的远程对象

[!Note]
stub和skeleton的通信,其实就是client和service的通信

image-20251205160055620
image-20251205160055620

stub = Util.createProxy(implClass, getClientRef(), forceStubUse);

  • forceStubUse = true强制使用stub:无论什么情况,都通过代理/存根进行远程调用
  • forceStubUse = false默认为false,根据实际情况优化调用路径,可能是本地实现,也可能是远程代理

而这里的clientRef实际上就是用UnicastRef封装的LiveRef,还是同一个东西,stub里面的handler也是这个

服务端UnicastServerRef和客户端的UnicastRef用的是同一个LiveRef,可以理解为服务端和客户端它们之间要通信,所以用同一个

image-20251205171754395
image-20251205171754395

后面就是一个标准的创建动态代理的流程了

而后半部分创建的target,则相当于封装了目前所有有用的东西

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java
public Remote exportObject(Remote impl, Object data,
O boolean permanent)
throws RemoteException
{
...
if (stub instanceof RemoteStub) {
setSkeleton(impl);
}

Target target =
new Target(impl, this, stub, ref.getObjID(), permanent);
ref.exportObject(target);
hashToMethod_Map = hashToMethod_Maps.get(implClass);
return stub;
}
1
2
3
4
5
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/LiveRef.java

public void exportObject(Target target) throws RemoteException {
ep.exportObject(target);
}

ep开始进入tcp transport之类的了,这里的ep好像要动态才能知道,静态分析不知道,这里只跟进到了接口

1
2
3
4
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/Endpoint.java

void exportObject(Target target)
throws RemoteException;

在动态调试的时候,才能知道LiveRef的ep是个tcp Endpoint

1
2
3
4
5
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/tcp/TCPEndpoint.java

public void exportObject(Target target) throws RemoteException {
transport.exportObject(target);
}
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
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/tcp/TCPTransport.java

public void exportObject(Target target) throws RemoteException {
/*
* Ensure that a server socket is listening, and count this
* export while synchronized to prevent the server socket from
* being closed due to concurrent unexports.
*/
synchronized (this) {
listen();
exportCount++;
}

/*
* Try to add the Target to the exported object table; keep
* counting this export (to keep server socket open) only if
* that succeeds.
*/
boolean ok = false;
try {
super.exportObject(target);
ok = true;
} finally {
if (!ok) {
synchronized (this) {
decrementExportCount();
}
}
}
}

TCPEndpoint.exportObject=>TCPTransport.exportObject,调用listen()方法

在一个新的线程创建socketserver = ep.newServerSocket();并等待客户端连接,接下来自然就是如果收到客户端请求该执行什么的逻辑了

走AcceptLoop的run,java多线程的标准写法

网络请求的线程和代码逻辑的线程是独立的

值得一提的是在创建这个ServerSocket的过程中,一开始默认port=0,最后会走到JNI调用系统原生的方法随机获取空闲端口

image-20251205191247353
image-20251205191247353

存表

target导出完,exportObject之后,服务端调用了ObjectTable的静态方法,进行了记录ObjectTable.putTarget(target);
要知道自己的对象发布到哪去了,所以要记录

1
2
3
4
5
6
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/Transport.java

public void exportObject(Target target) throws RemoteException {
target.setExportedTransport(this);
ObjectTable.putTarget(target);
}

保存在了两个静态的表里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/ObjectTable.java

private static final Map<ObjectEndpoint,Target> objTable =
new HashMap<>();
private static final Map<WeakRef,Target> implTable =
new HashMap<>();
...
static void putTarget(Target target) throws ExportException
{
...
objTable.put(oe, target);
implTable.put(weakImpl, target);
...
}

之后就没什么特别的了,至此完成了service创建并发布的过程

关于网络请求

LiveRef默认传入ep=TCPEndpoint.getLocalEndpoint(0)isLocal=true

1
2
3
4
5
6
7
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/LiveRef.java

public LiveRef(ObjID objID, Endpoint endpoint, boolean isLocal) {
ep = endpoint;
id = objID;
this.isLocal = isLocal;
}

UnicastServerRef继承自UnicastRef,UnicastRef实现了RemoteRef接口,同时自己有一个ref字段,存储这个LiveRef

[!Note]
这里的UnicastServerRef相当于服务端UnicastRef相当于客户端
从始至终只创建一个LiveRef (对应远程服务的ip:port),剩下的都是在赋值

1
2
3
public UnicastRef(LiveRef liveRef) {
ref = liveRef;
}

而这里的TCPTransport是底层真正处理网络连接的类

image-20251205103750971
image-20251205103750971

有点绕绕的啊,这个TCPTransport里面有个epList是LinkedList,里面的唯一一个元素又是我们刚才的TCPEndpoint@786

image-20251205104235525
image-20251205104235525

注册中心

使用静态方法创建注册中心,LocateRegistry.createRegistry(1099);

1
2
3
4
5
//usr/lib/jvm/jdk1.8.0_65/src.zip!/java/rmi/registry/LocateRegistry.java

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

创建RegistryImpl对象,之后export这个RegistryImpl对象

和上一节服务端创建远程对象如出一辙,都是Impl,各种UnicastServerRef,UnicastRef,LiveRef

在构造方法中,执行了setup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/registry/RegistryImpl.java

public RegistryImpl(int port)
throws RemoteException
{
...
LiveRef lref = new LiveRef(id, port);
setup(new UnicastServerRef(lref));
}

private void setup(UnicastServerRef uref)
throws RemoteException
{
/* Server ref must be created and assigned before remote
* object 'this' can be exported.
*/
ref = uref;
uref.exportObject(this, null, true); // 第三个参数为permanent,如果只有两个参数,就会走另一个方法,默认forceStubUse为true, permanent为false。第一个参数为自己,也就是RegistryImpl自己,因为这个是写在jdk里面的
}

这个是和其实new UnicastServerRef(lref) 也执行了一遍,

[!Warning]
这里要自己调一下,forceStubUse 和 permanent 的区别

这里uref.exportObject(this, null, true);的permanent为true,说明这个RegistryImpl是“永久的”,使用jdk自带的RegistryImpl_Stub

这和上一节服务端导出远程对象的sref.exportObject(obj, null, false);不同,我们自己创建的对象不是“永久的”

创建Skeleton

1
2
3
4
5
6
7
8
9
10
11
12
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java

try {
stub = Util.createProxy(implClass, getClientRef(), forceStubUse); // 这里会走到分支createStub然后直接返回,不走动态代理那一套
} catch (IllegalArgumentException e) {
throw new ExportException(
"remote object implements illegal remote interface", e);
}
// 走这里进去了
if (stub instanceof RemoteStub) {
setSkeleton(impl);
}

之后同样走到创建stub,不同的是这里的stub,由于是jdk自带的stubClassExists(remoteClass)为true,在createProxy的时候会走进这个if分支,通过createStub返回一个RemoteStub对象

1
2
3
4
5
6
7
8
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/Util.java

if (forceStubUse ||
!(ignoreStubClasses || !stubClassExists(remoteClass)))
// 如果forceStubUse为真 或者 (ignoreStubClasses为假的同时stubClassExists为真)
{
return createStub(remoteClass, clientRef); // 在这里就返回了,不走之前创建自己的远程对象的doPrivilege里面的Proxy.newProxyInstance了
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/Util.java

private static RemoteStub createStub(Class<?> remoteClass, RemoteRef ref)
throws StubNotFoundException
{
String stubname = remoteClass.getName() + "_Stub";

/* Make sure to use the local stub loader for the stub classes.
* When loaded by the local loader the load path can be
* propagated to remote clients, by the MarshalOutputStream/InStream
* pickle methods
*/
try {
Class<?> stubcl =
Class.forName(stubname, false, remoteClass.getClassLoader());
Constructor<?> cons = stubcl.getConstructor(stubConsParamTypes);
return (RemoteStub) cons.newInstance(new Object[] { ref });

进而创建的stub代理是RemoteStub,会进行setSkeleton(impl);
这里本质是创建服务端的代理skeleton,不是动态代理,是直接forName反射创建的,是jdk里面自带的静态预生成的类,和客户端的stub相对

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/Util.java

static Skeleton createSkeleton(Remote object)
throws SkeletonNotFoundException
{
Class<?> cl;
try {
cl = getRemoteClass(object.getClass());
} catch (ClassNotFoundException ex ) {
throw new SkeletonNotFoundException(
"object does not implement a remote interface: " +
object.getClass().getName());
}

// now try to load the skeleton based ont he name of the class
String skelname = cl.getName() + "_Skel";
try {
Class<?> skelcl = Class.forName(skelname, false, cl.getClassLoader());

return (Skeleton)skelcl.newInstance();

skel 是一个内部对象

在UnicastServerRef中设置skel为RegistryImpl_Skel,然后把这个Ref赋值给RegistryImpl对象的ref字段(extends java.rmi.server.RemoteServer extends RemoteObject,来自RemoteObject)

image-20251205201906204
image-20251205201906204

之后同样把这些impl,serverRef,stub放到一个target里面,经过层层export,最终放到一个静态表里面

这里和刚才创建服务端的区别是impl里面有个skel,还放了stub

image-20251205204052279
image-20251205204052279

也就是说,这个时候,静态表里面的stub,ref不仅有我们自己的远程对象的,还有注册中心对象的

但事实上,有三个!

image-20251205204210012
image-20251205204210012

  1. 自己的obj
  2. RegistryImpl:endpoint是自己定的端口,如1099
  3. DGCImpl:和obj的endpoint是同一个,但是封装的ref不同

绑定远程对象

1
2
3
4
5
6
7
8
9
10
11
12
13
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/registry/RegistryImpl.java

public void bind(String name, Remote obj)
throws RemoteException, AlreadyBoundException, AccessException
{
checkAccess("Registry.bind");
synchronized (bindings) {
Remote curr = bindings.get(name);
if (curr != null)
throw new AlreadyBoundException(name);
bindings.put(name, obj);
}
}
1
2
3
4
5
6
7
8
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/registry/RegistryImpl.java

public void rebind(String name, Remote obj)
throws RemoteException, AccessException
{
checkAccess("Registry.rebind");
bindings.put(name, obj);
}

checkAccess("Registry.rebind");一个安全检查,大概意思就是检查是不是在本地绑定的

一般来说RMI的服务端和注册中心是在一台机器上的,也就是的确是本地绑定的

在低版本的话有一些实现上的问题,是允许远程绑定的,但是默认的话还是本地绑定,所以服务端和注册中心是在一台机器上的

客户端(接受结果)

与注册中心

1
2
Registry r = LocateRegistry.getRegistry("127.0.0.1", 1099);
IRemoteObj remoteObj = (IRemoteObj) r.lookup("remoteObj");

LocateRegistry.getRegistry("127.0.0.1", 1099);这里相当于客户端根据参数,本地又创建了一个一模一样的RegistryImpl_Stub

1
2
3
4
5
6
7
//usr/lib/jvm/jdk1.8.0_65/src.zip!/java/rmi/registry/LocateRegistry.java

public static Registry getRegistry(String host, int port)
throws RemoteException
{
return getRegistry(host, port, null);
}
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
//usr/lib/jvm/jdk1.8.0_65/src.zip!/java/rmi/registry/LocateRegistry.java

public static Registry getRegistry(String host, int port,
RMIClientSocketFactory csf)
throws RemoteException
{
Registry registry = null;

if (port <= 0)
port = Registry.REGISTRY_PORT;

if (host == null || host.length() == 0) {
// If host is blank (as returned by "file:" URL in 1.0.2 used in
// java.rmi.Naming), try to convert to real local host name so
// that the RegistryImpl's checkAccess will not fail.
try {
host = java.net.InetAddress.getLocalHost().getHostAddress();
} catch (Exception e) {
// If that failed, at least try "" (localhost) anyway...
host = "";
}
}

/*
* Create a proxy for the registry with the given host, port, and
* client socket factory. If the supplied client socket factory is
* null, then the ref type is a UnicastRef, otherwise the ref type
* is a UnicastRef2. If the property
* java.rmi.server.ignoreStubClasses is true, then the proxy
* returned is an instance of a dynamic proxy class that implements
* the Registry interface; otherwise the proxy returned is an
* instance of the pregenerated stub class for RegistryImpl.
**/
LiveRef liveRef =
new LiveRef(new ObjID(ObjID.REGISTRY_ID),
new TCPEndpoint(host, port, csf, null),
false);
RemoteRef ref =
(csf == null) ? new UnicastRef(liveRef) : new UnicastRef2(liveRef);

return (Registry) Util.createProxy(RegistryImpl.class, ref, false); // 前面服务端创建也是一模一样的操作
}

[!Note]
上面的源码注释写的挺好的!!!正好借此机会再回顾一下前文提到的

总结一句话就是:获取 Registry 的 stub 时,可以根据属性选择使用静态预生成 stub 还是动态代理

对于return (Registry) Util.createProxy(RegistryImpl.class, ref, false);这个,可以发现,客户端竟然是也创建了一个一模一样的RegistryImpl_Stub,区别于 RemoteObjectImpl_Stub (自定义的远程对象)

[!Tip]
就是按我们正常理解:本来在服务端创建了,按理来说不应该通过序列化/反序列化给到客户端吗?
但实际上这里没有这样的操作,反而是让客户端自己重新创建一个

接着r.lookup("remoteObj");根据这个服务的naming,通过上面的stub去注册中心获取存储在注册的remoteObj的stub

这里调试本来应该走到RegistryImpl_Stub的lookup方法,但是由于这个类没有源码,而且bytecode是Java1.1,反编译出来行号对不上,所以没法调试,只能静态地去看它做了什么,但是该执行的流程还是会走的

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
//usr/lib/jvm/jdk1.8.0_65/jre/lib/rt.jar!/sun/rmi/registry/RegistryImpl_Stub.class

public Remote lookup(String var1) throws AccessException, NotBoundException, RemoteException {
try {
RemoteCall var2 = super.ref.newCall(this, operations, 2, 4905912898345647071L);

try {
ObjectOutput var3 = var2.getOutputStream();

// 这里把传入的 var1 进行序列化操作,也就是我们lookup的传参
var3.writeObject(var1);
} catch (IOException var18) {
throw new MarshalException("error marshalling arguments", var18);
}

super.ref.invoke(var2);

Remote var23; // 读取回来的远程对象的stub
try {
ObjectInput var6 = var2.getInputStream();
var23 = (Remote)var6.readObject();
} catch (IOException var15) {
throw new UnmarshalException("error unmarshalling return", var15);
} catch (ClassNotFoundException var16) {
throw new UnmarshalException("error unmarshalling return", var16);
} finally {
super.ref.done(var2);
}

return var23;
} catch (RuntimeException var19) {
throw var19;
} catch (RemoteException var20) {
throw var20;
} catch (NotBoundException var21) {
throw var21;
} catch (Exception var22) {
throw new UnexpectedException("undeclared checked exception", var22);
}
}

可以看到这里把lookup传入的字符串进行了序列化操作,那么注册中心肯定就会有反序列化读的过程

接着super.ref.invoke(var2);这里的var2是个RemoteCall对象

这里如果直接去定义的话会走到接口,可以在左边找实现方法

1
2
3
4
5
6
7
8
9
10
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastRef.java

public class UnicastRef implements RemoteRef {
...
public void invoke(RemoteCall call) throws Exception {
try {
clientRefLog.log(Log.VERBOSE, "execute call");
call.executeCall();
}
}

然后call.executeCall(),而这个就是客户端真正处理网络请求的方法

再完成网络通信后,又进行了反序列化的操作,把远程对象的动态代理stub读回来

[!Warning]
换言之,这个从注册中心获取Remote Stub的过程 (var23) ,是通过反序列化来实现的,自然也就有了漏洞的风险

更为关键是还是在这个super.ref.invoke(var2);=>call.executeCall();

  • 这是所有客户端stub里面处理网络请求的都会调用的方法,这更加隐蔽
  • 不只是lookup,RegistryImpl_Stub的list,bind,rebind等等,都调用了这个方法
  • 也就是说:所有的RMI客户端,基本上都是可以被攻击的,因为有这个invoke

[!Note]
值得注意的是,这里还有个异常处理,如果触发TransportConstants.ExceptionalReturn,会通过反序列化来获取这个ex对象(本意可能是想获取这个ex对象,来查看更详细的Exception异常信息吧)

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
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/StreamRemoteCall.java

public void executeCall() throws Exception {
byte returnType;

// read result header
DGCAckHandler ackHandler = null;
try {
if (out != null) {
ackHandler = out.getDGCAckHandler();
}
releaseOutputStream();
DataInputStream rd = new DataInputStream(conn.getInputStream());
byte op = rd.readByte();
if (op != TransportConstants.Return) {
if (Transport.transportLog.isLoggable(Log.BRIEF)) {
Transport.transportLog.log(Log.BRIEF,
"transport return code invalid: " + op);
}
throw new UnmarshalException("Transport return code invalid");
}
getInputStream();
returnType = in.readByte();
in.readID(); // id for DGC acknowledgement
} catch (UnmarshalException e) {
throw e;
} catch (IOException e) {
throw new UnmarshalException("Error unmarshaling return header",
e);
} finally {
if (ackHandler != null) {
ackHandler.release();
}
}

// read return value
switch (returnType) {
case TransportConstants.NormalReturn:
break;

case TransportConstants.ExceptionalReturn:
Object ex;
try {
ex = in.readObject();
} catch (Exception e) {
throw new UnmarshalException("Error unmarshaling return", e);
}
...
}
}

与服务端

客户端进行远程调用,调用获取的stub代理的对象方法,走到RemoteObjectInvocationHandler.invoke

接着一步步走到UnicastRef的另一个重载的invoke方法,和前面的传参是 RemoteCall 然后直接调用call.executeCall();的不一样

1
2
3
4
5
6
7
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastRef.java

public Object invoke(Remote obj,
Method method,
Object[] params,
long opnum)
throws Exception

然后在marshalValue中对传入的远程对象方法的参数进行序列化

image-20251205223455531
image-20251205223455531

接着,又调了…只要客户端想要进行远程网络通信,就会调用这个方法

1
2
3
4
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastRef.java

// unmarshal return
call.executeCall();

然后,在服务端的对象执行方法后,返回的结果,仍然是通过反序列化获取的

1
2
3
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastRef.java

Object returnValue = unmarshalValue(rtype, in);

然后走到类型判断,我这里远程对象方法调用后的返回类型是String所以getType之后是class java.lang.String,不是基本类型,所以会反序列化读取

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
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastRef.java

protected static Object unmarshalValue(Class<?> type, ObjectInput in)
throws IOException, ClassNotFoundException
{
if (type.isPrimitive()) {
if (type == int.class) {
return Integer.valueOf(in.readInt());
} else if (type == boolean.class) {
return Boolean.valueOf(in.readBoolean());
} else if (type == byte.class) {
return Byte.valueOf(in.readByte());
} else if (type == char.class) {
return Character.valueOf(in.readChar());
} else if (type == short.class) {
return Short.valueOf(in.readShort());
} else if (type == long.class) {
return Long.valueOf(in.readLong());
} else if (type == float.class) {
return Float.valueOf(in.readFloat());
} else if (type == double.class) {
return Double.valueOf(in.readDouble());
} else {
throw new Error("Unrecognized primitive type: " + type);
}
} else {
return in.readObject();
}
}

综上,客户端的RMI有两个反序列化利用点

  1. 远程调用返回值反序列化,所以可以被恶意的服务端利用,服务端返回一个恶意的序列化对象,然后客户端触发反序列化链
  2. 协议的反序列点,也就是call.executeCall();

而这个协议,正是JRMP协议

客户端(发起请求)

与注册中心 (在注册中心端进行调试)

前面讲stub的时候说到:会把target导出=>TCPEndpoint.exportObject=>TCPTransport.exportObject,调用listen()方法,在一个新的线程创建socketserver = ep.newServerSocket();,后面会有接收到请求对应处理的逻辑

1
2
3
4
5
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java

Target target =
new Target(impl, this, stub, ref.getObjID(), permanent);
ref.exportObject(target);

…省略了endpoint那一条导出的链

1
2
3
4
5
6
7
8
9
10
11
12
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/tcp/TCPTransport.java

public void exportObject(Target target) throws RemoteException {
/*
* Ensure that a server socket is listening, and count this
* export while synchronized to prevent the server socket from
* being closed due to concurrent unexports.
*/
synchronized (this) {
listen();
exportCount++;
}

这里RegistryImpl_Stub也是如此,得走到这个线程里面看看,是怎么处理来自客户端的请求的,于是定位到

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
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/tcp/TCPTransport.java

public class TCPTransport extends Transport {
private void listen() throws RemoteException {
...
try {
server = ep.newServerSocket();
/*
* Don't retry ServerSocket if creation fails since
* "port in use" will cause export to hang if an
* RMIFailureHandler is not installed.
*/
Thread t = AccessController.doPrivileged(
new NewThreadAction(new AcceptLoop(server),
"TCP Accept-" + port, true));
t.start();
}
...
}

// 实现了Runnable接口
private class AcceptLoop implements Runnable {
public void run() {
try {
executeAcceptLoop();
}
}
}
private void executeAcceptLoop() {...}
}

这里的AcceptLoopTCPTransport的内部类,会调用run方法,走到executeAcceptLoop()里面,又进入到一个新的线程

[!Note]
在 Java 中,创建一个线程要么实现Runnable接口,要么继承Thread
前者更现代化,因为 Java 是单继承限制,如果已经继承了其他类,就无法再继承 Thread 了

1
2
3
4
5
6
7
8
9
10
11
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/tcp/TCPTransport.java

/*
* Execute connection handler in the thread pool,
* which uses non-system threads.
*/
try {
connectionThreadPool.execute(
new ConnectionHandler(socket, clientHost));
} catch (RejectedExecutionException e) {

这里的ConnectionHandler还是TCPTransport内部类,还是看run方法=>run0方法

run0方法中,解析协议字段,根据不同协议类型(TransportConstants.SingleOpProtocol/StreamProtocol),调用外部类TCPTransport的handleMessages(conn, false/true);

[!Warning]
这里的 handleMessages 是重点

这里的bool是persistent参数,表示通信是否是持久连接

handleMessage进去之后,根据协议有不同的switch case,这里当然是研究RMI的ServiceCall了
之后会进行RMI call的handle,走到Transport抽象类的serviceCall方法

image-20251206001931213
image-20251206001931213

在这里面从target表中获取含ref(LiveRef)skeleton(RegistryImpl_Skel)disp(UnicastServerRef)

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
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/Transport.java

public boolean serviceCall(final RemoteCall call) {
try {
/* read object id */
...
/* get the remote object */

// !!!从target中静态的表获取,服务端先启动之后,再把断点下在这里,客户端发起连接的时候就能调试 服务端/注册中心了
Transport transport = id.equals(dgcID) ? null : this;
Target target =
ObjectTable.getTarget(new ObjectEndpoint(id, transport));
...
final Dispatcher disp = target.getDispatcher();
target.incrementCallCount();
try {
/* call the dispatcher */
transportLog.log(Log.VERBOSE, "call dispatcher");

final AccessControlContext acc =
target.getAccessControlContext();
ClassLoader ccl = target.getContextClassLoader();

ClassLoader savedCcl = Thread.currentThread().getContextClassLoader();

try {
setContextClassLoader(ccl);
currentTransport.set(this);
try {
java.security.AccessController.doPrivileged(
new java.security.PrivilegedExceptionAction<Void>() {
public Void run() throws IOException {
checkAcceptPermission(acc);
disp.dispatch(impl, call);
return null;
}
}, acc);
}
...
}
}
}
return true;
}

[!Question]
这里的Dispatcher又是什么呢?

其实,本质上还是属于skeleton的范畴

早期 Java RMI (JDK 1.1及以前),每一个对象都会创建一个skeleton,专门用于通信(接受解析客户端网络请求,然后调用实际远程对象上的对应方法,再返回字节流给客户端的Stub)

缺点:每个远程类都要单独生成 skeleton,而且一旦方法改变,skeleton 必须重新生成,缺乏灵活性

JDK 1.2 开始,Java 引入了反射 API,可以在运行时动态获取类的方法信息并调用远程对象的方法,从而废弃了预生成的skeleton

为了实现这种动态分派,RMI 引入了 Dispatcher 接口,在sun.rmi.transport包中,属于非公开 API

Dispatcher 的作用

  • 统一入口
  • 方法分发,根据RemoteCall中读取的Method Hash,找到对应的java.lang.reflect.Method对象
  • 上下文切换,设置ContextClassLoader,远程调用
  • 参数反序列化,在method.invoke之前,根据方法签名,从连接流中反序列化客户端传来的参数
  • 结果回写,执行完后,dispatcher负责捕捉返回值/异常,并将其序列化通过RemoteCall发回给客户端

但是你会发现 RegistryImpl 还是使用了 skeleton,这是因为

[!note]
RegistryImpl_Skel 是 JDK 源码中自带的,不需要在运行时通过反射去解析 lookup, bind 等方法。由于 Registry 的接口是固定的(java.rmi.registry.Registry),使用硬编码的 Skeleton 效率更高且更稳定

而且为了向后兼容性,以及不同版本的 JVM 通信的统一,所以 Registry 采用的还是 Skel 的方式

对于自定义的远程对象RemoteObjectImpl,都采取更现代的Dispatcher进行管理,让client能反射调用目标对象

最终走到disp.dispatch(impl, call);在UnicastServerRef中进行处理,这里的impl是RegistryImpl

[!tip]
其实你仔细读读源码就知道我上面讲的是什么一回事了

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java

public void dispatch(Remote obj, RemoteCall call) throws IOException {
// positive operation number in 1.1 stubs;
// negative version number in 1.2 stubs and beyond...
int num;
long op;

try {
// read remote call header
ObjectInput in;
try {
in = call.getInputStream();
num = in.readInt();
if (num >= 0) {
if (skel != null) {
oldDispatch(obj, call, num);
return;
} else {
throw new UnmarshalException(
"skeleton class not found but required " +
"for client version");
}
}
op = in.readLong();
} catch (Exception readEx) {
throw new UnmarshalException("error unmarshalling call header",
readEx);
}

/*
* Since only system classes (with null class loaders) will be on
* the execution stack during parameter unmarshalling for the 1.2
* stub protocol, tell the MarshalInputStream not to bother trying
* to resolve classes using its superclasses's default method of
* consulting the first non-null class loader on the stack.
*/
MarshalInputStream marshalStream = (MarshalInputStream) in;
marshalStream.skipDefaultResolveClass();

Method method = hashToMethod_Map.get(op);
if (method == null) {
throw new UnmarshalException("unrecognized method hash: " +
"method not supported by remote object");
}

// if calls are being logged, write out object id and operation
logCall(obj, method);

// unmarshal parameters
Class<?>[] types = method.getParameterTypes();
Object[] params = new Object[types.length];

try {
unmarshalCustomCallData(in);
for (int i = 0; i < types.length; i++) {
params[i] = unmarshalValue(types[i], in);
}
} catch (java.io.IOException e) {
throw new UnmarshalException(
"error unmarshalling arguments", e);
} catch (ClassNotFoundException e) {
throw new UnmarshalException(
"error unmarshalling arguments", e);
} finally {
call.releaseInputStream();
}

// make upcall on remote object
Object result;
try {
result = method.invoke(obj, params);
} catch (InvocationTargetException e) {
throw e.getTargetException();
}

// marshal return value
try {
ObjectOutput out = call.getResultStream(true);
Class<?> rtype = method.getReturnType();
if (rtype != void.class) {
marshalValue(rtype, result, out);
}
} catch (IOException ex) {
throw new MarshalException("error marshalling return", ex);
/*
* This throw is problematic because when it is caught below,
* we attempt to marshal it back to the client, but at this
* point, a "normal return" has already been indicated,
* so marshalling an exception will corrupt the stream.
* This was the case with skeletons as well; there is no
* immediately obvious solution without a protocol change.
*/
}
} catch (Throwable e) {
logCallException(e);

ObjectOutput out = call.getResultStream(false);
if (e instanceof Error) {
e = new ServerError(
"Error occurred in server thread", (Error) e);
} else if (e instanceof RemoteException) {
e = new ServerException(
"RemoteException occurred in server thread",
(Exception) e);
}
if (suppressStackTraces) {
clearStackTraces(e);
}
out.writeObject(e);
} finally {
call.releaseInputStream(); // in case skeleton doesn't
call.releaseOutputStream();
}
}

这里显然UnicastServerRef的skelRegistryImpl_Skel,不为null

走到oldDispatch(obj, call, num);,调用skel的dispatch,也就是服务端调用对象的代理

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
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java

public void oldDispatch(Remote obj, RemoteCall call, int op)
throws IOException
{
long hash; // hash for matching stub with skeleton

try {
// read remote call header
ObjectInput in;
try {
in = call.getInputStream();
try {
Class<?> clazz = Class.forName("sun.rmi.transport.DGCImpl_Skel");
if (clazz.isAssignableFrom(skel.getClass())) {
((MarshalInputStream)in).useCodebaseOnly();
}
} catch (ClassNotFoundException ignore) { }
hash = in.readLong();
} catch (Exception readEx) {
throw new UnmarshalException("error unmarshalling call header",
readEx);
}

// if calls are being logged, write out object id and operation
logCall(obj, skel.getOperations()[op]);
unmarshalCustomCallData(in);
// dispatch to skeleton for remote object
skel.dispatch(obj, call, op, hash);

} catch (Throwable e) {
logCallException(e);

ObjectOutput out = call.getResultStream(false);
if (e instanceof Error) {
e = new ServerError(
"Error occurred in server thread", (Error) e);
} else if (e instanceof RemoteException) {
e = new ServerException(
"RemoteException occurred in server thread",
(Exception) e);
}
if (suppressStackTraces) {
clearStackTraces(e);
}
out.writeObject(e);
} finally {
call.releaseInputStream(); // in case skeleton doesn't
call.releaseOutputStream();
}
}
1
2
3
4
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/server/UnicastServerRef.java

// dispatch to skeleton for remote object
skel.dispatch(obj, call, op, hash);

这里的RegistryImpl_Skel同样因为Java版本问题,只能静态分析了,也有各种case,对应bind,rebind,lookup等等不同请求时如何处理

可以看到在case2(lookup)中,存在反序列化操作,别的case中也同样存在这个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//usr/lib/jvm/jdk1.8.0_65/jre/lib/rt.jar!/sun/rmi/registry/RegistryImpl_Skel.class

case 2:
String var98;
try {
ObjectInput var104 = var2.getInputStream();
var98 = (String)var104.readObject();
} catch (IOException var89) {
throw new UnmarshalException("error unmarshalling arguments", var89);
} catch (ClassNotFoundException var90) {
throw new UnmarshalException("error unmarshalling arguments", var90);
} finally {
var2.releaseInputStream();
}

Remote var101 = var6.lookup(var98);
...

根据前面分析我们知道,在client端进行lookup时,r.lookup("remoteObj");,这个参数——远程对象的naming,是通过RegistryImpl_Stub序列化之后传进去的

与服务端

流程和注册中心大差不差

只是从target里面取出来的impl是远程对象自己的RemoteObjImpl,disp以及里面的ref对应也是远程对象service的,而不是注册中心的,另外也没有skel字段了

就走到远程方法调用执行的步骤了

由于前面客户端会把远程方法调用的参数序列化传进来,所以这里就会在服务端进行反序列化操作

也是unmarshalValue,之后就有参数可以调用执行了

最后再把调用的结果序列化后返回给客户端,于是和前面客户端反序列化调用结果相呼应

这是个对称的过程

DGC-分布式垃圾回收

DGC,我的理解就是一个使用 租约(references)来跟踪哪些客户端持有服务器对象的活引用(leases)

服务端service创建,在putTarget时会把,我们的远程对象的stub代理加进去

但是在这之前,会放入一个DGCImpl_Stub的代理

1
2
3
4
5
6
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/Transport.java

public void exportObject(Target target) throws RemoteException {
target.setExportedTransport(this);
ObjectTable.putTarget(target);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
//usr/lib/jvm/jdk1.8.0_65/src/sun/rmi/transport/ObjectTable.java

public final class ObjectTable {
static void putTarget(Target target) throws ExportException {
ObjectEndpoint oe = target.getObjectEndpoint();
WeakRef weakImpl = target.getWeakImpl();

if (DGCImpl.dgcLog.isLoggable(Log.VERBOSE)) {
DGCImpl.dgcLog.log(Log.VERBOSE, "add object " + oe);
}
...
}
}

这里的DGCImpl.dgcLog是个静态变量,根据类加载的内容,调一个类的静态变量的时候,会完成这个类的初始化

初始化->静态代码块,接着走到DGCImpl的静态代码块里面

image-20251206011313123
image-20251206011313123

由于jdk自带有DGCImpl_Stub,所以会得到一个RemoteStub对象DGCImpl_Stub,而不是stub动态代理Proxy

这个dgc的stub也对应一个ref,一个端口,用于远程回收服务,之后调用流程和注册中心RegistryImpl类似

接着看看DGCImpl_Stub有dirty和clean两个方法,一个是referenced,一个是unreferenced

这两个方法都调用了super.ref.invoke(),也就是JRMP攻击

除了这两个方法,还有会从客户端反序列化一个对象回来

同理DGCImpl_Skel也会受到服务端的反序列化攻击

/usr/lib/jvm/jdk1.8.0_65/jre/lib/rt.jar!/sun/rmi/transport/DGCImpl_Skel.class

  • 只要有远程对象,就会有回收机制,有DGC服务
  • 而攻击远程对象需要知道参数类型,而攻击DGC则不需要

RMI总结

感觉或许可以从stub, skel中的readObject往前分析
像是DGCImpl_Stub, DGCImpl_Skel, RegistryImpl_Stub, RegistryImpl_Skel

  • Skel 主要处理 dispatch,进行解包 Unmarshalling,序列化/反序列化,调用远程对象,结果返回等等 (但是JRMP 1.2+之后除了RegistryImpl都变成了动态反射)
  • Stub (如RegistryImpl_Stub,既给服务端使用,也给客户端使用)处理 lookup, bind, rebind, invoke 这些的逻辑,后两种有 checkAccess,确保只在服务端使用,而lookup则不限制是否远程

然后总结反序列化就是,服务端客户端,两边都可以被打
因为 传参/ 调用后执行结果 的 网络传输,就是以序列化/反序列化实现的
所以

  • 服务端/注册中心
  • 客户端
  • DGC
    这三个都能被利用

RMI jdk8 高版本绕过

1. 对比 8u65 做了哪些过滤?

[!Note]
JDK 8u121 之后 引入了 JEP 290 机制

  • Registry 白名单化:为 RegistryImpl 增加了 registryFilter。现在它只允许反序列化一些基础类(如 String, Number, Remote, Proxy 等)。像 CommonsCollections 里的恶意类会被直接拦截。

  • DGC 限制:为分布式垃圾回收(DGC)增加了 checkInput 过滤,限制了 DGC 通信中允许反序列化的类型。

  • 可配置的全局过滤器:允许开发者通过 jdk.serialFilter 属性定义全局的反序列化黑白名单。

2. 为什么 8u121 还能被攻击?(Bypass 原理)

虽然有了白名单,但攻击者发现:白名单里虽然没有恶意类,却有能发起“二次连接”的合法类。 这也就是著名的 JRMP 反弹攻击

  • 白名单里的“内鬼”UnicastRefRemoteObject 的子类是在白名单里的。
  • 逻辑漏洞:虽然你不能直接塞一个“炸弹(恶意类)”给服务端,但你可以塞一个“诱饵(UnicastRef)”。
  • 攻击链条
    1. 攻击者向服务端发送一个合法的 RemoteObject 对象,其内部封装了一个 UnicastRef
    2. UnicastRef 包含攻击者控制的 恶意 JRMP 服务端地址
    3. 服务端反序列化这个 RemoteObject 时,并不会触发危险操作,因此通过了 JEP 290 过滤。
    4. 关键点:反序列化完成后,RMI 系统的分布式垃圾回收机制(DGC)为了维持引用计数,会自动向这个 UnicastRef 指向的地址发起一个 JRMP 客户端请求(去拉取数据或同步状态),相当于 把服务端作为一个客户端 然后访问攻击者的服务端,然后返回一个Exception Response 走 JRMP 的恶意payload。

为什么这样能行呢?因为客户端发起是没有设置防护的,主要还是防护服务端(服务是公有的嘛,客户端自己连接恶意服务端就自己负责了)

RMI客户端之所以能通过传递参数攻击服务端并绕过JEP290,核心原因在于JEP290默认只保护了RMI注册中心(Registry)和分布式垃圾收集器(DGC)的入口反序列化点,并未对注册中心作为客户端去连接恶意服务器时产生的出口反序列化进行过滤。

就是 把服务端 当成一个 JRMP 客户端,去连接 恶意的 JRMP 服务端(控制返回报错信息)

和DGC 层的反序列化是不一样的,虽然都会走 DGC 的一部分

DGC 与 lookup 对比

维度 lookup 请求 DGC (Dirty/Clean) 请求
触发者 开发者手动编写代码触发 RMI 运行时自动触发 (引用管理)
目的 获取远程对象的 Stub 告诉远程端:“我还引用着你,别回收”
数据方向 客户端 -> 注册中心 服务端 -> 攻击者服务器
JEP 290 覆盖度 8u121 重点防御了输入流 8u121 早期对 DGC 返回值的过滤较弱

需要简单梳理一下RMI

看完这篇醍醐灌顶啊 https://xz.aliyun.com/news/7527

  • 标题: RMI 学习
  • 作者: OneZ3r0
  • 创建于 : 2026-03-13 14:14:11
  • 更新于 : 2026-05-05 18:31:44
  • 链接: https://blog.onez3r0.top/2026/03/13/java-rmi-learning/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。