字节码基础

栈帧维护

插入语句

注意对字节码进行Patch的时候要维护栈帧,如果单纯的添加语句的话,保证自定义指令结束前恢复栈帧;举个例子,如果插入语句:

new XXX().get("abc");

转换为字节码:

NEW XXX
DUP
INVOKESPECIAL XXX.<init>()V
LDC "abc"
INVOKEVIRTUAL XXX.get(Ljava/lang/String;)Ljava/lang/String;

但是这样就破坏了栈帧,上面字节码中,从栈帧分析来看,假设当前栈为空:

  1. NEW XXX [XXX]
  2. DUP [XXX, XXX]
  3. INVOKESPECIAL XXX.<init>()V [XXX]
  4. LDC "abc" [XXX, “abc”]
  5. INVOKEVIRTUAL XXX.get(Ljava/lang/String;)Ljava/lang/String; [String]

可以发现语句结束后栈帧不为空,这会破坏函数栈,是不行的;所以应该在最后加入一个POP将栈顶元素弹出,这样进入插入的指令前后栈状态一致。

移除语句

与插入语句不同的是,移除语句需要对原始字节码进行完整的分析,确保在移除前后栈帧平衡。

以下列字节码为例子(假设本地变量表中,索引1为HashMap对象引用):

...
ALOAD 1
LDC "Key"
LDC "Value"
INVOKEINTERFACE java/util/Map.put(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;
...

上面四条语句实际不能直接删除,还是一样看栈帧:

  1. [HashMap]
  2. [HashMap, “Key”]
  3. [HashMap, “Key”, “Value”]
  4. [Object]

可以看到,由于HashMap实际返回了一个Object,导致四条语句结束后栈顶还有一个元素,因此移除后会导致栈平衡被破坏。

从语句移除的角度来看,我们应尽可能地移除连续指令前后栈状态一致的字节码,这样不需要手动进行栈帧维护;但是事实总是不尽如意的,有些时候我们必须要移除一些前后栈状态不一致的连续指令。

再举个例子(假设本地变量表中,索引1为字符串引用,索引2为加解密器引用,索引3为请求器引用):

ALOAD 2
ALOAD 1
INVOKEVIRTUAL Cryptor.encrypt(Ljava/lang/String;)Ljava/lang/String;
ASTORE 1
ALOAD 3
ALOAD 1
INVOKEVIRTUAL Request.send(Ljava/lang/String;)Ljava/lang/String;
... 

现在一定要删除上述语句,但是不能破坏栈帧平衡;分析过程也很简单,到第三行只剩一个String、最后一行还是剩一个String;也就是说连续指令前后的栈状态由 [] -> [String]

因此在移除上面的语句后,还应该加入一条LDC指令。

从这里其实也可以看出,由于我们总是假定指令分析是的栈初始态为空,所以移除的连续指令前后栈状态总是栈元素增多;而元素增多还需要考虑类型相关问题,因此如果移除连续指令前后的栈元素是不变,字节码修改就更为容易了。

如果是栈元素减少的话,那么问题就会略微再复杂一点,还是以上面为例子:

INVOKEVIRTUAL Cryptor.encrypt(Ljava/lang/String;)Ljava/lang/String;
ASTORE 1
ALOAD 3
ALOAD 1
INVOKEVIRTUAL Request.send(Ljava/lang/String;)Ljava/lang/String;

但是这里注意,为了分析,我们需要假定栈帧不为空了,假定初始态为 [Cryptor, String, String],第一行结束时栈帧中只剩一个String,第五行结束后的栈帧应该还是一个String,所以连续指令前面的栈状态由 [Crypot, String, String] -> [String]

注意此时为了维护栈状态,我们必须连续POP三次,这是为了使得栈底的Cryptor出栈,然后还要再压一个String引用入栈。

替换语句

注意替换语句时,尽量使用同操作指令,这样就无需考虑栈帧的影响了。

同操作是指弹出同等数额的元素,压入同等数额的元素

如果是使用不同的操作指令;则需要考虑在替换的指令前后压入或弹出对应数量的元素。

本地变量表维护

除了栈帧需要维护外,编辑字节码也需要时刻注意本地变量表的维护;在Java编译时会确定一个方法的本地变量表的大小。

插入语句

插入语句时应尽量不影响本地变量表,如果确实需要存储本地变量,则应先将原始值通过栈进行存储,例如:

ALOAD 1
LDC "temp"
ASTORE 1
// 使用索引1的代码,注意栈底的元素不能被POP
// 完成后栈里应只有原先ALOAD 1的元素
ASTORE 1 // 恢复原始本地变量

移除语句

移除语句时需要注意本地变量表是否存在更新,应尽量绕过更新本地变量表的的字节码进行移除。

语句替换

由于只涉及很少的字节码,所以一般来说不会影响到本地变量表。

方法编辑

清空方法体

注意不能直接通过methodNode.instructions.clear()清理;一定要注意方法中是否存在try-catch语句,如果存在,只清空了指令集会报错,需同时清理异常表:methodNode.exceptions.clear()

当然也可以直接删除方法再创建一个空方法体的同名、同签名、同描述的方法来实现(兼容性更好,无需考虑各种情况)。