Javassist学习
参考:Javassist使用教程【译】说明 由于后续的 agent 相关文章要用到 Javassist,为了不和agent掺和 - 掘金
首先导入javassist包
|
|
我这里用的是jdk8,如果是jdk9+要用javassist4以上的版本
读写字节码
javassist是处理字节码的库,java字节码存在class文件中
javassist.CtClass是class文件的一个抽象代表。一个CtClass(编译期类)对象处理一个class文件。例如:
|
|
这个程序首先定义了个ClassPool对象,它控制着字节码的修改。ClassPool对象是CtClass对象的一个容器,它代表一个Class文件。它会读取Class(test.Rectangle)文件,然后构造并返回一个CtClass对象。为了修改一个类,用户必须用ClassPool对象的get() 方法来获取CtClass对象。上面展示的例子中,CtClass的实例cc代表类test.Rectanle。ClassPool实例通过 getDefault() 方法实例化,它采用默认的搜索路径方式。
CtClass对象可以被修改(下边会详细介绍)。上面的例子中,它将test.Point作为自己的父类。在调用writeFile() 后,该修改就会更新到源class文件中。
writeFile() 将CtClass对象转化为一个Class文件,并把它写到本地磁盘上。Javassist也提供了一个方法,用于直接获取被修改的字节码。可以调用toBytecode() 方法获取:
|
|
你也可以直接加载CtClass:
|
|
toClass() 请求当前线程的上下文类加载器来加载CtClass代表的class文件,它返回java.lang.Class对象。
定义一个类
要定义一个新的类,必须使用ClassPool对象,调用其makeClass() 方法:
|
|
这段代码定义了一个类名为Point的类,它没有任何成员,Point的成员方法可以通过CtNewMethod声明的工厂方法make创建,然后通过CtClass的addMethod方法添加到Point类中。
makeClass() 不能创建一个新的接口,想创建一个接口需要用makeInterface() 。接口的成员方法是使用CtNewMethod的abstractMethod() 来创建的 。注意接口的方法是抽象方法。
冻结类
如果一个CtClass对象已经转化成了class文件,比如通过writeFile() 、toClass() 、 toBytecode() , Javassist会冻结CtClass对象。之后对于CtClass对象的修改都是不允许的。
冻结的类可以如果想要修改,可以进行解冻,这样就允许修改了,如下:
|
|
在defrost() 被调用之后,该CtClass对象可以再次被修改。
如果ClassPool.doPruning被设置为true,当CtClass被冻结时,Javassist会修剪它的数据结构。为了减少内存消耗,会删除那个对象中不需要的属性(attribute_info structures)。例如,Code_attribute结构(方法体)会被删除。因此,在CtClass对象被修剪之后,方法的字节码是不可访问的,除了方法名称,签名和注解。被修剪的CtClass对象不能再次被解冻(defrost)。ClassPool.doPruning 的默认是false.
类路径搜索
ClassPool.getDefault 默认会搜索JVM下面相同路径的类,并返回ClassPool。但是,如果一个程序运行在Web应用服务器上,像JBoss和Tomcat那种,ClassPool对象可能就找不到用户指定的类了,因为web应用服务使用了多个系统类加载器。这种情况下,需要给ClassPool注册一个额外的Class路径。如下:
|
|
这句代码注册了一个类的类路径,这个类是this指向的那个类。你可以使用任意Class代替this.getClass().
你也可以注册一个文件夹作为类路径。例如,下面这段代码增添可以了文件夹 /usr/local/javalib到搜索路径中:
|
|
搜索路径不仅可以是目录,甚至可以是URL:
|
|
该代码增添了**www.javassist.org:80/java/** 到类文件搜索路径下。该URL仅仅搜索org.javassist. 包下的class文件。例如,要加载org.javassist.test.Main 这个类,javassist会从这个地址下获取该类文件:
|
|
此外,你也可以直接给ClassPool对象一个byte数组,然后用这个数组构建CtClass对象。要这样做,用ByteArrayClassPath, 例如:
|
|
获得的CtClass对象表示一个由b指定的类文件定义的类。如果调用get() ,ClassPool会从ByteArrayClassPath中读取一个Class文件,指定的Class的名字就是上面的name变量。
如果你不知道这个类的全限定名,你可以使用ClassPool中的makeClass() :
|
|
makeClass() 返回一个通过输入流构建出来的CtClass。你可以使用makeClass() 给ClassPool 对象提供一个Class文件。如果搜索路径包含了一个很大的jar包,这可以提高性能。因为ClassPool对象会一个一个找,它可能会重复搜索整个jar包中的每一个class文件。makeClass() 可以优化这个搜索。makeClass() 构造出来的类会保存在ClassPool对象中,你下次再用的时候,不会再次读Class文件。
ClassPool
简介
ClassPool对象是多个CtClass对象的容器。一旦CtClass对象被创建,它就会永远被记录在ClassPool对象中。这是因为编译器之后在编译源码的时候可能需要访问CtClass对象。
例如,假定有一个新方法getter() 被增添到了Point类的CtClass对象。稍后,程序会试图编译代码,它包含了对Point方法的getter() 调用,并会使用编译后代码作为一个方法的方法体。如果表示Point类的CtClass对象丢了的话,编译器就不能编译调用getter() 的方法了(注意:原始类定义中不包含getter() )。因此,为了正确编译这样一个方法调用,ClassPool在程序过程中必须始终包含所有的CtClass对象。
避免内存溢出
为了避免内存溢出,你可以从ClassPool中移除不需要的CtClass对象。只需要调用CtClass的detach() 方法就行了:
|
|
在调用detach() 之后,这个CtClass对象就不能再调用任何方法了。但是你可以依然可以调用classPool.get() 方法来创建(没有则创建)一个相同的类。如果你调用get() ,ClassPool会再次读取class文件,然后创建一个新的CtClass对象并返回。
另一种方式是new一个新的ClassPool,旧的就不要了。这样旧的ClassPool就会被垃圾回收,它的CtClass也会被跟着垃圾回收。可以使用以下代码完成:
|
|
上面这个new ClassPool和ClassPool.getDefault() 的效果是一样。注意,ClassPool.getDefault() 是一个单例的工厂方法,它只是为了方便用户创建提供的方法。这两种创建方式是一样的,源码也基本是一样的,只不过ClassPool.getDefault() 是单例的。
注意,new ClassPool(true) 是一个很方便的构造函数,它构造了一个ClassPool对象,然后给他增添了系统搜索路径。它构造方法的调用就等同于下面的这段代码:
|
|
更改类名的方式定义新类
一个“新类”可以从一个已经存在的类copy出来。可以使用以下代码:
|
|
这段代码首先获取了Point的CtClass对象。然后调用setName() 方法给对象一个新的名字Pair。在这个调用之后,CtClass表示的类中的所有Point都会替换为Pair。类定义的其他部分不会变。
既然setName() 改变了ClassPool对象中的记录。从实现的角度看,ClassPool是一个hash表,setName() 改变了关联这个CtClass对象的key值。这个key值从原名称Point变为了新名称Pair。
因此,如果之后调用get(“Point”) ,就不会再返回上面的cc引用的对象了。ClassPool对象会再次读取class文件,然后构造一个新的CtClass对象。这是因为Point这个CtClass在ClassPool中已经不存在了。请看下面代码:
|
|
cc1和cc2引用的是相同的CtClass实例,与cc一样,cc3是另外一个实例对象。注意:cc.setName(“Pair”) 执行后,CtClass对象,cc和cc1引用的都都变成Pair类。
ClassPool对象用于维护CtClass对象和类之间的一一映射关系。Javassist不允许两个不同的CtClass对象代表相同的类,除非你用两个ClassPool。这个是程序转换一致性的重要特性。
重命名冻结类的方式定义新类
一旦CtClass对象转化为Class文件后,比如writeFile() 或是 toBytecode() 之后,Javassist会拒绝CtClass对象进一步的修改。因此,在CtClass对象转为Class文件之后,你将不能再通过setNme() 的方式将该类拷贝成一个新的类了。
为了解除这个限制,你应该调用ClassPool 的 getAndRename() 方法。
|
|
如果调用了getAndRename,ClassPool首先为了创建代表Pair的CtClass而去读取Point.class。然而,它在记录CtClass到hash表之前,会把CtClass由Point重命名为Pair。因此getAndRename() 可以在writeFile() 或 toBytecode() 之后执行。
ClassLoader
如果一个类是否要被修改是在加载时确定的,用户就必须让Javassist和类加载器协作。Javassist可以和类加载器一块儿使用,以便于可以在加载时修改字节码。用户可以自定义类加载器,也可以使用Javassist提供好的。
CtClass的 toClass() 方法
CtClass提供了一个方便的方法toClass() , 它会请求当前线程的上下文类加载器,让其加载CtClass对象所代表的那个类。要调用这个方法,必须要拥有权限。此外,该方法还会抛出SecurityException异常。
|
|
Test.main() 在Hello的say() 方法的方法体中插入了println() 的调用。然后构建了被修改后的Hello的实例,然后调用了该实例的say() 方法。
面这段程序有一个前提,就是Hello类在调用toClass() 之前没有被加载过。否则,在toClass() 请求加载被修改后的Hello类之前,JVM就会加载原始的Hello类。因此,加载被修改后的Hello类就会失败(抛出LinkageError)
如果像下面这样写
|
|
main函数的第一行加载了Hello类,cc.toClass() 这行就会抛出异常。原因是类加载器不能同时加载两个不同版本的Hello类。
使用javassist.Loader
Javassist提供了一个类加载器javasist.Loader,该加载器使用一个javassist.ClassPool对象来读取类文件。
例如,javassist.Loader可以加载一个被Javassist修改过的特定类:
|
|
这段 程序修改了test.Rectangle,将它的父类设置为了test.Point。然后程序加载了修改后的类,并且创建了test.Rectangle的一个新实例。
如果用户想根据需要在类被加载的时候修改类,那么用户可以增添一个事件监听器给javassist.Loader。该事件监听器会在类加载器加载类时被通知。事件监听器必须实现下面这个接口:
|
|
自省和定制
最重要的一集,这里介绍如何在方法中插入代码和插入内容的介绍
CtClass 提供了自省的方法。Javassist的自省能力是能够兼容Java的反射API的。CtClass提供了getName() ,getSuperclass() ,getMethods() 等等方法。它也提供了修改类定义的方法。它允许增添一个新的属性,构造函数以及方法。甚至可以改变方法体。
CtMethod* 对象代表一个方法。CtMethod提供了一些修改方法定义的方法。注意,如果一个方法是从父类继承过来的,那么相同的 CtMethod对象也会代表父类中声明的方法。CtMethod对象会对应到每一个方法定义中。
例如,如果Point类声明了move() 方法,并且它的子类ColorPoint 没有重写move() 方法,那么Point和ColorPoint的move() 方法会具有相同的CtMethod对象。如果用CtMethod修改了方法定义,那么该修改就会在两个类中都生效。如果你只想修改ColorPoint中的move() 方法,你必须要增添一个Point.move() 方法的副本到ColorPoint中去。可以使用CtNewMethod.copy() 来获取CtMethod对象的副本。
Javassist不允许移除方法或者属性,但是允许你重命名它们。所以如果你不再需要方法或属性的时候,你应该将它们重命名或者把它们改成私有的,可以调用CtMethod的setName() 和 setModifiers() 来实现。
Javassist不允许给一个已经存在的方法增添额外的参数。如果你非要这么做,你可以增添一个同名的新方法,然后把这个参数增添到新方法中。
只要标识符是$开头的,那么在修改class文件的时候就需要javassist.runtime包用于运行时支持。那些特定的标识符会在下面进行说明。要是没有标识符,可以不需要javassist.runtime和其他的运行时支持包。
在方法的开头和结尾插入代码
CtMethod和CtConstructor提供了insertBefore() ,insertAfter() ,addCatch() 方法。它们被用于在已经存在的方法上面插入代码片段。用户可以把它们的代码以文本的形式写进去。Javassist包含了一个简单的编译器,可以处理这些源码文本。它能够把这些代码编译成字节码,然后将它们内嵌到方法体中。
往指定行插入代码也是有可能的(前提是class文件中包含行号表)。CtMethod和CtConstructor中的insertAt() 就可以将代码插入指定行。它会编译代码文本,然后将其编译好的代码插入指定行。
insertBefore() ,insertAfter() ,addCatch() ,insertAt() 这些方法接受一个字符串来表示一个语句(statements)或代码块(block)。一句代码可以是一个控制结构,比如if、while,也可以是一个以分号(;)结尾的表达式。代码块是一组用 {} 括起来的语句。
所以下面这些都合法
|
|
语句和代码块都可以引用属性或方法。如果方法使用 -g 选项(class文件中包含局部变量)进行编译,它们也可以引用自己插入方法的参数。否则,它们只能通过特殊的变量 $0,$1,$2,… 来访问方法参数,下面有说明。虽然在代码块中声明一个新的局部变量是允许的,但是在方法中访问它们确是不允许的。然而,如果使用 -g 选项进行编译, 就允许访问。
下面列出所有的标识符
标识符 | 含义 |
---|---|
$0, $1, $2, … | this 和实参, $0 代表this , $1 代表方法的第一个参数,$2代表第二个参数,以此类推 |
$args | 参数数组。 $args 的类型是 Object[] |
$$ | 所有实参,例如m($$)等同于m($1,$2,…) |
$cflow(…) | cflow变量 |
$r | 返回值类型。用于强制类型转换表达式 |
$w | 包装类型。用于强制类型转换表达式 |
$_ | 结果值 |
$sig | java.lang.Class对象的数组,表示参数类型 |
$type | java.lang.Class对象的数组,表示结果类型 |
然后一个个解释
$0, $1, $2, …
传递给目标方法的参数可以通过 $1,$2,… 访问,而 不是通过原先的参数名称! 。 $1 代表第一个参数, $2代表第二个参数,以此类推。那些变量的类型和参数的类型是一样的。 $0代表this,如果是静态方法的话, $0不能用。
先写一个Point类
|
|
要想在调用move() 时打印dx和dy的值,我们需要添加打印的语句,添加前面说过用insertBefore
这种方法
|
|
然后Point类的代码就被修改成
|
|
$args
变量 $arg 表示所有参数的一个数组。数组中的类型都是 Object 。如果参数是基本数据类型比如int,那么该参数就会被转换成包装类型比如java.lang.Integer,然后存储到 $args中。因此, $args[0] 就等于 $1 ,除非它是个基本类型(int不等于Integer)。注意, $args[0] 不等于 $0 。 $0 是this。
如果一个 Object 数组赋值给了 $args , 那么参数的每一个元素都会一一赋值。
$$
$$ 是一个以逗号分隔参数的缩写。例如,如果move() 方法的参数是3个。那么:
|
|
与下面这个等价
|
|
$r
$r 代表方法的返回值类型。他必须用于强制转换表达式中的转换类型。
如果返回值类型是一个基本数据类型,那么 ($r) 就会遵循特殊的语义。首先,如果被转换对象的类型就是基本类型,那么 ($r) 就会省去基本类型到基本类型的转换。但是,如果被转换对象的类型是包装类型,那么 $r就会从包装类型转为基本数据类型。例如,如果返回值类型为int,那 ($r) 就会将其从java.lang.Integer转为int(也即拆箱)。
如果返回值类型为void,那么 ($r) 不会进行类型转换。 它什么都不做。然而,如果调用的方法返回值为void的话,那么 ($r) 的结果就是null。
类型转换符 ($r) 在return语句中也是很有用的。即使返回值类型为void,下面的return语句也是合法的:
|
|
$_
CtMethod和CtConstructor中的insertAfter() 在方法最后插入代码时,不只是 $1,$2.. 这些可以用,你也可用 $_ 。
$_ 表示方法的返回值。而该变量的类型取决于该方法的返回值类型。如果方法的返回值类型为void,那么 $_ 的值是null,类型为Object。
只有方法不报错,运行正常的情况下,insertAfter() 中的代码才会运行。如果你想让方法在抛出异常的时候也能执行insertAfter() 中的代码,那你就把该方法的第二个参数asFinally设置为true.
如果方法中抛出了异常,那么insertAfter() 中的代码也会在finally语句中执行。这时 $_ 的值是0或null。插入的代码执行完毕后,抛出的异常还会抛给原来的调用者。注意, $_ 的值不会抛给原来的调用者,它相当于没用了(抛异常的时候没有返回值)。
$class
$class 值是 java.lang.Class 对象,代表修改的方法所对应的那个类。 $class 是 $0 的类($0是this)。
addCatch()
addCatch() 往方法体插入了的代码片段会在方法抛出异常的时候执行。在源码中,你可以用 $e 来表示抛出异常时候的异常变量。
|
|
注意,插入的代码以throw或return语句结尾。
修改方法体
setBody
最常用的方法
CtMethod和CtConstructor提供了setBody() 方法,该方法用于取代整个方法体。它们会把你提供的源码编译成字节码,然后完全替代之前方法的方法体。如果你传递的源码参数为null,那么被替换的方法体只会包含一条return语句。
在setBody() 方法传递的源码中,以$开头的标识符会有一些特殊含义,跟上面提供的表格一样
注意,这里不能用 $_ 。
增加新方法或新属性
增加新方法
Javassist允许用户从零开始创建一个新的方法和构造函数。CtNewMethod和CtNewConstructor提供了几个工厂方法,它们都是静态方法 ,用于创建CtMethod或CtConstructor对象。尤其是make() 方法,它可以直接传递源代码,用于创建CtMethod和CtConstructor对象。
|
|
该代码给Point类增添了一个public方法xmove() 。其中x是Point类原本就有的一个int属性。
传递给make() 的代码也可以包含以 $ 开头的标识符,就跟setBody() 方法是一样的,除了 $_ 之外。如果你还把make() 传递了目标对象和目标方法,你也可以使用 $proceed。
|
|
这里实际上ymove() 为
|
|
增添属性
Javassist也允许用户创建一个新的属性:
|
|
这个程序给Point类增添一个名为z的属性。
如果增添的属性需要进行值初始化,则上面的程序就要改成这样:
|
|
addField() 方法接受了第二个参数,它代表了计算初始值表达式的源码文本。该源码文本可以是任何Java表达式,前提是表达式的结果类型和属性类型匹配。注意,表达式不以分号(;)结尾,意思就是0后面不用跟分号。
注解
CtClass, CtMethod, CtField, CtConstructor 提供了一个很方便的方法getAnnotations() 来读取注解。它返回一个注解类型对象。
假设我们注解是这样:
|
|
注解被这样使用:
|
|
那么,这个注解的值可以通过getAnnotations() 方法获取。它返回一个包含了注解类型对象的数组
|
|
因为Point只包含了 @Author 一个注解,所以 all 数组的长度是1,all[0] 是Author对象。该注解的属性值可以使用Author对象的name() 和 year() 方法获取。
Import
源码中所有的类名都必须是完全限定的(它们必须导入包名)。然而,java.lang包时一个特例;例如,Javassist编译器可以解析Object也可以解析java.lang.Object.
为了告知编译器当解析类时搜索其他的包,可以调用ClassPool的importPackage() 方法。
|
|
第二行告诉编译器要导入java.awt包。因此,第三行不会抛出异常。编译器会把Point看作java.awt.Point.
利用javassist创建恶意字节码
先定义恶意代码,然后先创建classpool对象,在池里面创建Test
类,makeClassInitializer
是创建一个静态代码块(static{}),也就是初始化这个类,然后在静态代码块开头插入恶意代码
为了实现后面TemplatesImpl
加载字节码,继承AbstractTranslet
为父类,然后toBytecode
方法获取字节码
|
|
一个demo
|
|
或者换成新建构造函数注入
|
|