java的asm若干实践

一、前置知识、工具、代码库等

  • jdk里的javap,用来反汇编class文件查看生成的字节码
  • org.objectweb.asm 用来解析、修改、保存字节码的代码库
  • fernflower.jar 用来批量将class文件反编译成java代码(当然idea也可以手动单个反编译)
  • java的Instrumentation,可以编写代码使用javaagent代理执行其它java文件,在代码里使用上面的asm库动态修改代码。也可以直接将所有字节保存,并加载其它class文件。

二、Instrumentation

在代理jar包中MANIFEST.MF使用Premain-Class指定代理类,代理类需要有premain方法,此方法会在被代理的jar包的main执行前执行:

1
2
3
public static void premain(String var0, Instrumentation inst) {
inst.addTransformer(new MyTransformer());
}

JVM在加载每一个类时,都会调用MyTransformer的transform方法对字节码进行处理:

1
2
3
4
5
6
7
8
9
public final byte[] transform(ClassLoader l, String className, Class<?> c,ProtectionDomain pd, byte[] b) throws IllegalClassFormatException {
// 在这里可以根据className筛选到自己想要更改的类
if (className.equal("example")){
b = getAnotherCodeArray(b);
b = modifyCodeArray(b);
}

return b;
}
  • 在transform函数也可保存修改前或修改后的字节为class文件,只需要直接将b保存成class文件即可。
  • 如果transform里抛出异常,程序也能正常执行,只不过类没有发生任何变化(因为没有返回修改过的字节码)。
  • 如果修改过的字节码中,有错误,比如在生成方法的局部变量时,生成了如下格式
1
2
3
4
5
LocalVariableTable:
Start Length Slot Name Signature
45 65 0 this Lnet/minecraft/client/network/NetHandlerLoginClient;
45 65 1 packetIn Lnet/minecraft/network/login/server/SPacketLoginSuccess;
0 0 2 address desc

其中address对应的类型为desc,显然是非法的,则启动程序时会报异常:

1
2
java.lang.NoClassDefFoundError: net/minecraft/client/network/NetHandlerLoginClient
Caused by: java.lang.ClassFormatError: Field "address" in class net/minecraft/client/network/NetHandlerLoginClient has illegal signature "desc"

三、asm实践

使用asm修改字节码的示例有很多,这里就不再详细列举,这里仅说几个我填过的坑。在破解forge版本minecraft游戏的过程中,有些asm代码在老版本执行正常,但是在v1.12版本就导致游戏崩溃。

在transform里将修改过的代码保存成class文件并且通过idea反汇编成java文件,对比1.11和1.12两个版本生成的java代码是一样的,在各种调整jvm参数、java版本、asm库版本之后,最终找到原因和解决办法。

错误1:Caused by: java.lang.VerifyError: Expecting a stackmap frame at branch target xxx

此bug是因为原有代码在插入了一些if(xxx)等分支流程,假设代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
MethodNode method = xxx;// 从原字节码里使用ClassReader得到方法对象。

// 插入自己的新的指令
InsnList rawMethodInstructions = method.instructions;
InsnList newCall = new InsnList();
...

// 在某处逻辑添加了跳转指令
LabelNode labelNode = new LabelNode();
newCall.add(new JumpInsnNode(Opcodes.IFEQ, labelNode));
...
newCall.add(labelNode);

...

rawMethodInstructions.insertBefore(rawMethodInstructions.getFirst(), newCall);
method.maxStack += 4;

以上代码在很多mc版本上都正常运行,只有v1.12版本才会报标题中的错误。是因为v1.12的代码在执行时对生成字节码的StackMapTable区域做了检测。在未修改之前的原方法中可能StackMapTable是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
StackMapTable: number_of_entries = 6
frame_type = 10 /* same */
frame_type = 33 /* same */
frame_type = 64 /* same_locals_1_stack_item */
stack = [ int ]
frame_type = 252 /* append */
offset_delta = 20
locals = [ int ]
frame_type = 252 /* append */
offset_delta = 34
locals = [ class net/minecraft/entity/Entity ]
frame_type = 250 /* chop */
offset_delta = 42

以上方法修改后,生成的字节码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
StackMapTable: number_of_entries = 6
frame_type = 10 /* same */
frame_type = 33 /* same */
frame_type = 64 /* same_locals_1_stack_item */
stack = [ int ]
frame_type = 252 /* append */
offset_delta = 20
locals = [ int ]
frame_type = 252 /* append */
offset_delta = 34
locals = [ class net/minecraft/entity/Entity ]
frame_type = 250 /* chop */
offset_delta = 42
frame_type = 6 /* same */

其实这个 frame_type 的顺序应该和代码块的顺序一致(至于StackMapTable对应代码结构的关系可以见(http://hllvm.group.iteye.com/group/topic/26545中R大的解释及其它资料)。

知道这个特性后,尝试了给java添加了某博客提到的 -noverify-XX:-UseSplitVerifier 这两个jvm参数来禁用字节码验证,然而不知道为什么并没有卵用。

最后使用asm在代码合适的位置补上了一个frame解决问题。

错误2:java.lang.VerifyError: Bad local variable type

hook代码增加了局部变量,需要手动设置LocalVariableTable,通过 methodnode.visitLocalVariable来增加变量,注意第三个参数signature如果不是generic types,则不需要设置。例如:

1
2
3
4
5
/**
* 必须要调该方法,手动设置LocalVariableTable,否则会报 Caused by: java.lang.VerifyError: Bad local variable type
*/
methodnode.visitLocalVariable("addressObj", "Ljava/net/SocketAddress;", null, newLabelBegin.getLabel(), newLabel2.getLabel(), 3);
methodnode.visitLocalVariable("addressStr", "Ljava/lang/String;", null, newLabelBegin.getLabel(), newLabel2.getLabel(), 4);