偷天换日,用JavaAgent欺骗你的JVM

云计算 虚拟化
熟悉Spring的小伙伴们应该都对aop比较了解,面向切面编程允许我们在目标方法的前后织入想要执行的逻辑,而今天要给大家介绍的Java Agent技术,在思想上与aop比较类似,翻译过来可以被称为Java代理、Java探针技术。

[[435136]]

熟悉Spring的小伙伴们应该都对aop比较了解,面向切面编程允许我们在目标方法的前后织入想要执行的逻辑,而今天要给大家介绍的Java Agent技术,在思想上与aop比较类似,翻译过来可以被称为Java代理、Java探针技术。

Java Agent出现在JDK1.5版本以后,它允许程序员利用agent技术构建一个独立于应用程序的代理程序,用途也非常广泛,可以协助监测、运行、甚至替换其他JVM上的程序,先从下面这张图直观的看一下它都被应用在哪些场景:

看到这里你是不是也很好奇,究竟是什么神仙技术,能够应用在这么多场景下,那今天我们就来挖掘一下,看看神奇的Java Agent是如何工作在底层,默默支撑了这么多优秀的应用。

回到文章开头的类比,我们还是用和aop比较的方式,来先对Java Agent有一个大致的了解:

  • 作用级别:aop运行于应用程序内的方法级别,而agent能够作用于虚拟机级别
  • 组成部分:aop的实现需要目标方法和逻辑增强部分的方法,而Java Agent要生效需要两个工程,一个是agent代理,另一个是需要被代理的主程序
  • 执行场合:aop可以运行在切面的前后或环绕等场合,而Java Agent的执行只有两种方式,jdk1.5提供的preMain模式在主程序运行前执行,jdk1.6提供的agentMain在主程序运行后执行

下面我们就分别看一下在两种模式下,如何动手实现一个agent代理程序。

Premain模式

Premain模式允许在主程序执行前执行一个agent代理,实现起来非常简单,下面我们分别实现两个组成部分。

agent

先写一个简单的功能,在主程序执行前打印一句话,并打印传递给代理的参数:

  1. public class MyPreMainAgent { 
  2.     public static void premain(String agentArgs, Instrumentation inst) { 
  3.         System.out.println("premain start"); 
  4.         System.out.println("args:"+agentArgs); 
  5.     } 

在写完了agent的逻辑后,需要把它打包成jar文件,这里我们直接使用maven插件打包的方式,在打包前进行一些配置。

  1. <build> 
  2.     <plugins> 
  3.         <plugin> 
  4.             <groupId>org.apache.maven.plugins</groupId> 
  5.             <artifactId>maven-jar-plugin</artifactId> 
  6.             <version>3.1.0</version> 
  7.             <configuration> 
  8.                 <archive> 
  9.                     <manifest> 
  10.                         <addClasspath>true</addClasspath> 
  11.                     </manifest> 
  12.                     <manifestEntries> 
  13.                         <Premain-Class>com.cn.agent.MyPreMainAgent</Premain-Class>                             
  14.                         <Can-Redefine-Classes>true</Can-Redefine-Classes> 
  15.                         <Can-Retransform-Classes>true</Can-Retransform-Classes> 
  16.                         <Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix> 
  17.                     </manifestEntries> 
  18.                 </archive> 
  19.             </configuration> 
  20.         </plugin> 
  21.     </plugins> 
  22. </build> 

配置的打包参数中,通过manifestEntries的方式添加属性到MANIFEST.MF文件中,解释一下里面的几个参数:

  • Premain-Class:包含premain方法的类,需要配置为类的全路径
  • Can-Redefine-Classes:为true时表示能够重新定义class
  • Can-Retransform-Classes:为true时表示能够重新转换class,实现字节码替换
  • Can-Set-Native-Method-Prefix:为true时表示能够设置native方法的前缀

其中Premain-Class为必须配置,其余几项是非必须选项,默认情况下都为false,通常也建议加入,这几个功能我们会在后面具体介绍。在配置完成后,使用mvn命令打包:

  1. mvn clean package 

打包完成后生成myAgent-1.0.jar文件,我们可以解压jar文件,看一下生成的MANIFEST.MF文件:

可以看到,添加的属性已经被加入到了文件中。到这里,agent代理部分就完成了,因为代理不能够直接运行,需要附着于其他程序,所以下面新建一个工程来实现主程序。

主程序

在主程序的工程中,只需要一个能够执行的main方法的入口就可以了。

  1. public class AgentTest { 
  2.     public static void main(String[] args) { 
  3.         System.out.println("main project start"); 
  4.     } 

在主程序完成后,要考虑的就是应该如何将主程序与agent工程连接起来。这里可以通过-javaagent参数来指定运行的代理,命令格式如下:

  1. java -javaagent:myAgent.jar -jar AgentTest.jar 

并且,可以指定的代理的数量是没有限制的,会根据指定的顺序先后依次执行各个代理,如果要同时运行两个代理,就可以按照下面的命令执行:

  1. java -javaagent:myAgent1.jar -javaagent:myAgent2.jar -jar AgentTest.jar 

以我们在idea中执行程序为例,在VM options中加入添加启动参数:

  1. -javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Hydra 
  2.  
  3. -javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Trunks 

执行main方法,查看输出结果:

根据执行结果的打印语句可以看出,在执行主程序前,依次执行了两次我们的agent代理。可以通过下面的图来表示执行代理与主程序的执行顺序。

缺陷

在提供便利的同时,premain模式也有一些缺陷,例如如果agent在运行过程中出现异常,那么也会导致主程序的启动失败。我们对上面例子中agent的代码进行一下改造,手动抛出一个异常。

  1. public static void premain(String agentArgs, Instrumentation inst) { 
  2.     System.out.println("premain start"); 
  3.     System.out.println("args:"+agentArgs); 
  4.     throw new RuntimeException("error"); 

再次运行主程序:

可以看到,在agent抛出异常后主程序也没有启动。针对premain模式的一些缺陷,在jdk1.6之后引入了agentmain模式。

Agentmain模式

agentmain模式可以说是premain的升级版本,它允许代理的目标主程序的jvm先行启动,再通过attach机制连接两个jvm,下面我们分3个部分实现。

agent

agent部分和上面一样,实现简单的打印功能:

  1. public class MyAgentMain { 
  2.     public static void agentmain(String agentArgs, Instrumentation instrumentation) { 
  3.         System.out.println("agent main start"); 
  4.         System.out.println("args:"+agentArgs); 
  5.     } 

修改maven插件配置,指定Agent-Class:

  1. <plugin> 
  2.     <groupId>org.apache.maven.plugins</groupId> 
  3.     <artifactId>maven-jar-plugin</artifactId> 
  4.     <version>3.1.0</version> 
  5.     <configuration> 
  6.         <archive> 
  7.             <manifest> 
  8.                 <addClasspath>true</addClasspath> 
  9.             </manifest> 
  10.             <manifestEntries> 
  11.                 <Agent-Class>com.cn.agent.MyAgentMain</Agent-Class> 
  12.                 <Can-Redefine-Classes>true</Can-Redefine-Classes> 
  13.                 <Can-Retransform-Classes>true</Can-Retransform-Classes> 
  14.             </manifestEntries> 
  15.         </archive> 
  16.     </configuration> 
  17. </plugin> 

主程序

这里我们直接启动主程序等待代理被载入,在主程序中使用了System.in进行阻塞,防止主进程提前结束。

  1. public class AgentmainTest { 
  2.  
  3. public static void main(String[] args) throws IOException { 
  4.  
  5. System.in.read(); 
  6.  
  7.  

attach机制

和premain模式不同,我们不能再通过添加启动参数的方式来连接agent和主程序了,这里需要借助com.sun.tools.attach包下的VirtualMachine工具类,需要注意该类不是jvm标准规范,是由Sun公司自己实现的,使用前需要引入依赖:

  1. <dependency> 
  2.     <groupId>com.sun</groupId> 
  3.     <artifactId>tools</artifactId> 
  4.     <version>1.8</version> 
  5.     <scope>system</scope> 
  6.     <systemPath>${JAVA_HOME}\lib\tools.jar</systemPath> 
  7. </dependency> 

 VirtualMachine代表了一个要被附着的java虚拟机,也就是程序中需要监控的目标虚拟机,外部进程可以使用VirtualMachine的实例将agent加载到目标虚拟机中。先看一下它的静态方法attach:

  1. public static VirtualMachine attach(String var0); 

通过attach方法可以获取一个jvm的对象实例,这里传入的参数是目标虚拟机运行时的进程号pid。也就是说,我们在使用attach前,需要先获取刚才启动的主程序的pid,使用jps命令查看线程pid:

  1. 11140 
  2. 16372 RemoteMavenServer36 
  3. 16392 AgentmainTest 
  4. 20204 Jps 
  5. 2460 Launcher 

获取到主程序AgentmainTest运行时pid是16392,将它应用于虚拟机的连接。

  1. public class AttachTest { 
  2.     public static void main(String[] args) { 
  3.         try { 
  4.             VirtualMachine  vm= VirtualMachine.attach("16392"); 
  5.             vm.loadAgent("F:\\Workspace\\MyAgent\\target\\myAgent-1.0.jar","param"); 
  6.         } catch (Exception e) { 
  7.             e.printStackTrace(); 
  8.         } 
  9.     } 

在获取到VirtualMachine实例后,就可以通过loadAgent方法可以实现注入agent代理类的操作,方法的第一个参数是代理的本地路径,第二个参数是传给代理的参数。执行AttachTest,再回到主程序AgentmainTest的控制台,可以看到执行了了agent中的代码:

这样,一个简单的agentMain模式代理就实现完成了,可以通过下面这张图再梳理一下三个模块之间的关系。

应用

到这里,我们就已经简单地了解了两种模式的实现方法,但是作为高质量程序员,我们肯定不能满足于只用代理单纯地打印语句,下面我们再来看看能怎么利用Java Agent搞点实用的东西。

在上面的两种模式中,agent部分的逻辑分别是在premain方法和agentmain方法中实现的,并且,这两个方法在签名上对参数有严格的要求,premain方法允许以下面两种方式定义:

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

agentmain方法允许以下面两种方式定义:

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

如果在agent中同时存在两种签名的方法,带有Instrumentation参数的方法优先级更高,会被jvm优先加载,它的实例inst会由jvm自动注入,下面我们就看看能通过Instrumentation实现什么功能。

Instrumentation

先大体介绍一下Instrumentation接口,其中的方法允许在运行时操作java程序,提供了诸如改变字节码,新增jar包,替换class等功能,而通过这些功能使Java具有了更强的动态控制和解释能力。在我们编写agent代理的过程中,Instrumentation中下面3个方法比较重要和常用,我们来着重看一下。

addTransformer

addTransformer方法允许我们在类加载之前,重新定义Class,先看一下方法的定义:

  1. void addTransformer(ClassFileTransformer transformer); 

ClassFileTransformer是一个接口,只有一个transform方法,它在主程序的main方法执行前,装载的每个类都要经过transform执行一次,可以将它称为转换器。我们可以实现这个方法来重新定义Class,下面就通过一个例子看看具体如何使用。

首先,在主程序工程创建一个Fruit类:

  1. public class Fruit { 
  2.  
  3. public void getFruit(){ 
  4.  
  5. System.out.println("banana"); 
  6.  
  7.  

编译完成后复制一份class文件,并将其重命名为Fruit2.class,再修改Fruit中的方法为:

  1. public void getFruit(){ 
  2.  
  3. System.out.println("apple"); 
  4.  

创建主程序,在主程序中创建了一个Fruit对象并调用了其getFruit方法:

  1. public class TransformMain { 
  2.  
  3. public static void main(String[] args) { 
  4.  
  5. new Fruit().getFruit(); 
  6.  
  7.  

这时执行结果会打印apple,接下来开始实现premain代理部分。

在代理的premain方法中,使用Instrumentation的addTransformer方法拦截类的加载:

  1. public class TransformAgent { 
  2.  
  3. public static void premain(String agentArgs, Instrumentation inst) { 
  4.  
  5. inst.addTransformer(new FruitTransformer()); 
  6.  
  7.  

FruitTransformer类实现了ClassFileTransformer接口,转换class部分的逻辑都在transform方法中:

  1. public class FruitTransformer implements ClassFileTransformer { 
  2.     @Override 
  3.     public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, 
  4.                             ProtectionDomain protectionDomain, byte[] classfileBuffer){ 
  5.         if (!className.equals("com/cn/hydra/test/Fruit")) 
  6.             return classfileBuffer; 
  7.  
  8.         String fileName="F:\\Workspace\\agent-test\\target\\classes\\com\\cn\\hydra\\test\\Fruit2.class"
  9.         return getClassBytes(fileName); 
  10.     } 
  11.  
  12.     public static byte[] getClassBytes(String fileName){ 
  13.         File file = new File(fileName); 
  14.         try(InputStream is = new FileInputStream(file); 
  15.             ByteArrayOutputStream bs = new ByteArrayOutputStream()){ 
  16.             long length = file.length(); 
  17.             byte[] bytes = new byte[(int) length]; 
  18.  
  19.             int n; 
  20.             while ((n = is.read(bytes)) != -1) { 
  21.                 bs.write(bytes, 0, n); 
  22.             } 
  23.             return bytes; 
  24.         }catch (Exception e) { 
  25.             e.printStackTrace(); 
  26.             return null
  27.         } 
  28.     } 

在transform方法中,主要做了两件事:

  • 因为addTransformer方法不能指明需要转换的类,所以需要通过className判断当前加载的class是否我们要拦截的目标class,对于非目标class直接返回原字节数组,注意className的格式,需要将类全限定名中的.替换为/
  • 读取我们之前复制出来的class文件,读入二进制字符流,替换原有classfileBuffer字节数组并返回,完成class定义的替换

将agent部分打包完成后,在主程序添加启动参数:

  1. -javaagent:F:\Workspace\MyAgent\target\transformAgent-1.0.jar 

再次执行主程序,结果打印:

  1. banana 

这样,就实现了在main方法执行前class的替换。

redefineClasses

我们可以直观地从方法的名字上来理解它的作用,重定义class,通俗点来讲的话就是实现指定类的替换。方法定义如下:

  1. void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException; 

它的参数是可变长的ClassDefinition数组,再看一下ClassDefinition的构造方法:

  1. public ClassDefinition(Class<?> theClass,byte[] theClassFile) {...} 

ClassDefinition中指定了的Class对象和修改后的字节码数组,简单来说,就是使用提供的类文件字节,替换了原有的类。并且,在redefineClasses方法重定义的过程中,传入的是ClassDefinition的数组,它会按照这个数组顺序进行加载,以便满足在类之间相互依赖的情况下进行更改。

下面通过一个例子来看一下它的生效过程,premain代理部分:

  1. public class RedefineAgent { 
  2.     public static void premain(String agentArgs, Instrumentation inst)  
  3.             throws UnmodifiableClassException, ClassNotFoundException { 
  4.         String fileName="F:\\Workspace\\agent-test\\target\\classes\\com\\cn\\hydra\\test\\Fruit2.class"
  5.         ClassDefinition def=new ClassDefinition(Fruit.class, 
  6.                 FruitTransformer.getClassBytes(fileName)); 
  7.         inst.redefineClasses(new ClassDefinition[]{def}); 
  8.     } 

主程序可以直接复用上面的,执行后打印:

  1. banana 

可以看到,用我们指定的class文件的字节替换了原有类,即实现了指定类的替换。

retransformClasses

retransformClasses应用于agentmain模式,可以在类加载之后重新定义Class,即触发类的重新加载。首先看一下该方法的定义:

  1. void retransformClasses(Class... classes) throws UnmodifiableClassException; 

它的参数classes是需要转换的类数组,可变长参数也说明了它和redefineClasses方法一样,也可以批量转换类的定义。

下面,我们通过例子来看看如何使用retransformClasses方法,agent代理部分代码如下:

  1. public class RetransformAgent { 
  2.     public static void agentmain(String agentArgs, Instrumentation inst) 
  3.             throws UnmodifiableClassException { 
  4.         inst.addTransformer(new FruitTransformer(),true); 
  5.         inst.retransformClasses(Fruit.class); 
  6.         System.out.println("retransform success"); 
  7.     } 

看一下这里调用的addTransformer方法的定义,与上面略有不同:

  1. void addTransformer(ClassFileTransformer transformer, boolean canRetransform); 

ClassFileTransformer转换器依旧复用了上面的FruitTransformer,重点看一下新加的第二个参数,当canRetransform为true时,表示允许重新定义class。这时,相当于调用了转换器ClassFileTransformer中的transform方法,会将转换后class的字节作为新类定义进行加载。

主程序部分代码,我们在死循环中不断的执行打印语句,来监控类是否发生了改变:

  1. public class RetransformMain { 
  2.     public static void main(String[] args) throws InterruptedException { 
  3.         while(true){ 
  4.             new Fruit().getFruit(); 
  5.             TimeUnit.SECONDS.sleep(5); 
  6.         } 
  7.     } 

最后,使用attach api注入agent代理到主程序中:

  1. public class AttachRetransform { 
  2.     public static void main(String[] args) throws Exception { 
  3.         VirtualMachine vm = VirtualMachine.attach("6380"); 
  4.         vm.loadAgent("F:\\Workspace\\MyAgent\\target\\retransformAgent-1.0.jar"); 
  5.     } 

回到主程序控制台,查看运行结果:

可以看到在注入代理后,打印语句发生变化,说明类的定义已经被改变并进行了重新加载。

其他

除了这几个主要的方法外,Instrumentation中还有一些其他方法,这里仅简单列举一下常用方法的功能:

  • removeTransformer:删除一个ClassFileTransformer类转换器
  • getAllLoadedClasses:获取当前已经被加载的Class
  • getInitiatedClasses:获取由指定的ClassLoader加载的Class
  • getObjectSize:获取一个对象占用空间的大小
  • appendToBootstrapClassLoaderSearch:添加jar包到启动类加载器
  • appendToSystemClassLoaderSearch:添加jar包到系统类加载器
  • isNativeMethodPrefixSupported:判断是否能给native方法添加前缀,即是否能够拦截native方法
  • setNativeMethodPrefix:设置native方法的前缀

Javassist

在上面的几个例子中,我们都是直接读取的class文件中的字节来进行class的重定义或转换,但是在实际的工作环境中,可能更多的是去动态的修改class文件的字节码,这时候就可以借助javassist来更简单的修改字节码文件。

简单来说,javassist是一个分析、编辑和创建java字节码的类库,在使用时我们可以直接调用它提供的api,以编码的形式动态改变或生成class的结构。相对于ASM等其他要求了解底层虚拟机指令的字节码框架,javassist真的是非常简单和快捷。

下面,我们就通过一个简单的例子,看看如何将Java agent和Javassist结合在一起使用。首前先引入javassist的依赖:

  1. <dependency> 
  2.     <groupId>org.javassist</groupId> 
  3.     <artifactId>javassist</artifactId> 
  4.     <version>3.20.0-GA</version> 
  5. </dependency> 

我们要实现的功能是通过代理,来计算方法执行的时间。premain代理部分和之前基本一致,先添加一个转换器:

  1. public class Agent { 
  2.     public static void premain(String agentArgs, Instrumentation inst) { 
  3.         inst.addTransformer(new LogTransformer()); 
  4.     } 
  5.  
  6.     static class LogTransformer implements ClassFileTransformer { 
  7.         @Override 
  8.         public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,  
  9.                                 ProtectionDomain protectionDomain, byte[] classfileBuffer)  
  10.             throws IllegalClassFormatException { 
  11.             if (!className.equals("com/cn/hydra/test/Fruit")) 
  12.                 return null
  13.  
  14.             try { 
  15.                 return calculate(); 
  16.             } catch (Exception e) { 
  17.                 e.printStackTrace(); 
  18.                 return null
  19.             } 
  20.         } 
  21.     } 

在calculate方法中,使用javassist动态的改变了方法的定义:

  1. static byte[] calculate() throws Exception { 
  2.     ClassPool pool = ClassPool.getDefault(); 
  3.     CtClass ctClass = pool.get("com.cn.hydra.test.Fruit"); 
  4.     CtMethod ctMethod = ctClass.getDeclaredMethod("getFruit"); 
  5.     CtMethod copyMethod = CtNewMethod.copy(ctMethod, ctClass, new ClassMap()); 
  6.     ctMethod.setName("getFruit$agent"); 
  7.  
  8.     StringBuffer body = new StringBuffer("{\n"
  9.             .append("long begin = System.nanoTime();\n"
  10.             .append("getFruit$agent($$);\n"
  11.             .append("System.out.println(\"use \"+(System.nanoTime() - begin) +\" ns\");\n"
  12.             .append("}"); 
  13.     copyMethod.setBody(body.toString()); 
  14.     ctClass.addMethod(copyMethod); 
  15.     return ctClass.toBytecode(); 

在上面的代码中,主要实现了这些功能:

  • 利用全限定名获取类CtClass
  • 根据方法名获取方法CtMethod,并通过CtNewMethod.copy方法复制一个新的方法
  • 修改旧方法的方法名为getFruit$agent
  • 通过setBody方法修改复制出来方法的内容,在新方法中进行了逻辑增强并调用了旧方法,最后将新方法添加到类中

主程序仍然复用之前的代码,执行查看结果,完成了代理中的执行时间统计功能:

这时候我们可以再通过反射看一下:

  1. for (Method method : Fruit.class.getDeclaredMethods()) { 
  2.  
  3. System.out.println(method.getName()); 
  4.  
  5. method.invoke(new Fruit()); 
  6.  
  7. System.out.println("-------"); 
  8.  

查看结果,可以看到类中确实已经新增了一个方法:

除此之外,javassist还有很多其他的功能,例如新建Class、设置父类、读取和写入字节码等等,大家可以在具体的场景中学习它的用法。

总结

虽然我们在平常的工作中,直接用到Java Agent的场景可能并不是很多,但是在热部署、监控、性能分析等工具中,它们可能隐藏在业务系统的角落里,一直在默默发挥着巨大的作用。

本文从Java Agent的两种模式入手,手动实现并简要分析了它们的工作流程,虽然在这里只利用它们完成了一些简单的功能,但是不得不说,正是Java Agent的出现,让程序的运行不再循规蹈矩,也为我们的代码提供了无限的可能性。

 

责任编辑:武晓燕 来源: 码农参上
相关推荐

2018-10-26 22:29:35

勒索软件网络攻击欺骗手段

2009-06-30 18:39:10

2012-12-13 10:34:35

ARP欺骗

2016-01-06 11:15:03

VR

2009-07-07 17:06:46

Silverlight

2010-08-10 13:25:14

2009-10-21 16:53:46

MAC OS XWindows

2009-10-19 20:39:16

Windows7特性MAC OS X特性

2021-06-01 09:29:43

ArthasJVM内存

2019-04-16 10:05:52

996公司互联网

2010-07-06 16:22:01

2014-04-03 11:04:50

2010-09-07 10:44:14

2011-06-24 09:43:45

打印机行情

2010-08-06 10:38:59

2009-12-31 15:36:52

2021-08-30 15:23:03

prometheus局限性cortex

2020-11-11 10:13:08

PPID欺骗DLL注攻击

2010-04-23 15:40:26

72ub手机费

2018-09-13 15:14:47

点赞
收藏

51CTO技术栈公众号