背景展开目录
具体来说,这个区块链是 Neo N3 区块链,其中 Java 的语言支持由 Neow3j 这个库提供。这个库允许开发者使用任意 JVM 语言开发合约,并根据最终编译的 Java 字节码生成 Neo 的可执行合约。因此上文提到的合约就是使用 Java 开发的,没有用 Kotlin 的原因就是怕翻车。
典型的合约管理员地址是将其作为一个静态字段实现的:
• public class TestContract { • static Hash160 CONTRACT_OWNER = addressToScriptHash("NR9pwfToFHHYti4RY1dLP3wGr4waSGn7NH"); • }
其中 NR9pwfToFHHYti4RY1dLP3wGr4waSGn7NH 是用于测试的地址,其私钥随着代码一并公开到 GitHub 仓库,显然这种公开的钱包地址并不适宜作为公链的管理地址使用,但对于自动化测试来说,让程序获取到地址的私钥并进行自动调用,这可比手工测试轻松多了。其中的 addressToScriptHash 函数是 Neow3j 编译器提供的一个本地方法,它没有具体实现,而是在编译为合约的时候,由编译器将里面的字符串解读为 NeoVM 能够理解的钱包地址。
这种方法最直观,官方的教程也是这样写的。看起来没什么问题,但涉及到实际使用和开发,就如同我所说的,自动化测试要求密钥开放,而实际部署要求密钥必须保密,如果在开发和部署两个分支上频繁修改这个地址,不美观且麻烦,最重要的是一旦忘记,会导致很大的安全隐患。
另一个解决方法是在合约中获取当前网络的 MagicNumber 来判断网络类型,由合约自动决定使用哪一个地址。由于每个网络的魔数都是固定的,并且公共测试网和主网的数字都是固定不变的,这种方法相对可行,但额外的代码会给执行阶段带来额外的手续费(NeoN3 的智能合约是按指令收费的),并且同时写两个地址,并不美观,也不优雅。
解决展开目录
为了美观优雅,我决定探索 ObjectWeb ASM 库,这个库用于直接操作底层的 Java 字节码。谈及 Java,我的印象总是诸如 “抽象”、“不关心底层硬件”、“面向 Java 虚拟机” 之类的,所以我们在编写 Java 的时候只需要关注逻辑上的实现,而不必关心这套代码是否会在不同架构的 CPU 上有不同的行为。而能够支持这种功能得,正是 Java 虚拟机和 Java 字节码的配合,所有运行在 Java 虚拟机上的语言,包括 Java、Kotlin,最终都要编译成 Class 文件。而这个 Class 文件中就包含了即将执行的 Java 字节码,可以理解为面向 Java 虚拟机的汇编语言。
字节码初探展开目录
以如下类为例:
• package info.skyblond.jvm.asm; • • public class ASMTargetClass { • static Pair<String, String> target = genPair("Something", "Nothing"); • String local = "local"; • static String staticDirect = "staticDirect"; • • private static Pair<String, String> genPair(String s1, String s2) { • return new Pair<>(s1, s2); • } • }
其中的 Pair<> 是这个类:
• package info.skyblond.jvm.asm; • • public class Pair <T, U>{ • private final T first; • private final U second; • • public Pair(T first, U second) { • this.first = first; • this.second = second; • } • • public T getFirst() { • return first; • } • • public U getSecond() { • return second; • } • }
编译 ASMTargetClass 会得到文件 ASMTargetClass.class,和其他可执行文件一样,我们可以直接用 16 进制编辑器打开它看内容,这种方法可以但是并不推荐。在 Linux 下我们会使用 readelf 之类的工具来查看可执行文件的信息。对应的,我们可以使用 javap 命令查看编译好的 class 文件:
javap -c path/to/class/file
其中 -c 表示我们要反汇编,这样我们就能在控制台中看到结果了,另外如果使用 IDEA 的话,可以安装 ASM Bytecode Viewer 这个插件,直接在 IDEA 里面查看。除了字节码之外,插件还会进行额外的处理,比如查询字节码版本,字段的访问控制,将常量拼接到字节码中。这里我们只关心字节码:
• public class info.skyblond.jvm.asm.ASMTargetClass { • static info.skyblond.jvm.asm.Pair<java.lang.String, java.lang.String> target; • • java.lang.String local; • • static java.lang.String staticDirect; • • public info.skyblond.jvm.asm.ASMTargetClass(); • Code: • 0: aload_0 • 1: invokespecial #1 // Method java/lang/O • bject."<init>":()V • 4: aload_0 • 5: ldc #2 // String local • 7: putfield #3 // Field local:Ljava/lang/String; • 10: return • • static {}; • Code: • 0: ldc #6 // String Something • 2: ldc #7 // String Nothing • 4: invokestatic #8 // Method genPair:(Ljava/lang/String;Ljava/lang/String;)Linfo/skyblond/jvm/asm/Pair; • 7: putstatic #9 // Field target:Linfo/skyblond/jvm/asm/Pair; • 10: ldc #10 // String staticDirect • 12: putstatic #11 // Field staticDirect:Ljava/lang/String; • 15: return • }
可以看到,反编译给出了三个变量和两个函数。三个变量正是我们在代码中定义的,而两个函数,好像都不太像我们定义的那个 genPair(),仔细观瞧得话,可以发现 Code 那一节正是对象的 <init> 方法,也就是在 JVM 创建对象后真正用来初始化对象的代码。首先它调用了 Object 的 <init> 方法,毕竟 Object 是万物之父嘛。然后通过 ldc 将常量池中的 #2 压入栈(在此之前还通过指令 aload_0 将 this 压入了栈),随后 putfield 就将常量池中 #3 代表的局部变量赋值为刚刚压入栈的 #2。
而后面的 static {},就是初始化类的代码,这个代码只有在类被加载的时候才会执行。比如先从常量池加载了 #6 和 #7,然后调用了一个 static 方法,之后又把这个栈顶(也就是刚刚调用的那个方法的返回值)赋值给 #9,再从常量池中加载 #10,付给静态变量 #11,最后返回。结合后面的注释来看,这个注释是 javap 自动生成的,在 ldc 指令后面它标出了加载的值,比如最后一个 ldc #10,加载的正是我们写在代码里的字符串 staticDirect。
但是我们写的 genPair 方法还是没有找到。如果我们在 javap 命令中添加 -private 标志,就可以看到这个方法了:
• public class info.skyblond.jvm.asm.ASMTargetClass { • // ... • private static info.skyblond.jvm.asm.Pair<java.lang.String, java.lang.String> genPair(java.lang.String, java.lang.String); • Code: • 0: new #4 // class info/skyblond/jvm/asm/Pair • 3: dup • 4: aload_0 • 5: aload_1 • 6: invokespecial #5 // Method info/skyblond/jvm/asm/Pair."<init>":(Ljava/lang/Object;Ljava/lang/Object;)V • 9: areturn • // ... • }
实际上,如果我们使用刚刚提到的插件,会得到更友好的输出:
• // class version 55.0 (55) • // access flags 0x21 • public class info/skyblond/jvm/asm/ASMTargetClass { • • // compiled from: ASMTargetClass.java • • // access flags 0x8 • // signature Linfo/skyblond/jvm/asm/Pair<Ljava/lang/String;Ljava/lang/String;>; • // declaration: target extends info.skyblond.jvm.asm.Pair<java.lang.String, java.lang.String> • static Linfo/skyblond/jvm/asm/Pair; target • • // access flags 0x0 • Ljava/lang/String; local • • // access flags 0x8 • static Ljava/lang/String; staticDirect • • // access flags 0x1 • public <init>()V • L0 • LINENUMBER 3 L0 • ALOAD 0 • INVOKESPECIAL java/lang/Object.<init> ()V • L1 • LINENUMBER 5 L1 • ALOAD 0 • LDC "local" • PUTFIELD info/skyblond/jvm/asm/ASMTargetClass.local : Ljava/lang/String; • RETURN • L2 • LOCALVARIABLE this Linfo/skyblond/jvm/asm/ASMTargetClass; L0 L2 0 • MAXSTACK = 2 • MAXLOCALS = 1 • • // access flags 0xA • // signature (Ljava/lang/String;Ljava/lang/String;)Linfo/skyblond/jvm/asm/Pair<Ljava/lang/String;Ljava/lang/String;>; • // declaration: info.skyblond.jvm.asm.Pair<java.lang.String, java.lang.String> genPair(java.lang.String, java.lang.String) • private static genPair(Ljava/lang/String;Ljava/lang/String;)Linfo/skyblond/jvm/asm/Pair; • L0 • LINENUMBER 8 L0 • NEW info/skyblond/jvm/asm/Pair • DUP • ALOAD 0 • ALOAD 1 • INVOKESPECIAL info/skyblond/jvm/asm/Pair.<init> (Ljava/lang/Object;Ljava/lang/Object;)V • ARETURN • L1 • LOCALVARIABLE s1 Ljava/lang/String; L0 L1 0 • LOCALVARIABLE s2 Ljava/lang/String; L0 L1 1 • MAXSTACK = 4 • MAXLOCALS = 2 • • // access flags 0x8 • static <clinit>()V • L0 • LINENUMBER 4 L0 • LDC "Something" • LDC "Nothing" • INVOKESTATIC info/skyblond/jvm/asm/ASMTargetClass.genPair (Ljava/lang/String;Ljava/lang/String;)Linfo/skyblond/jvm/asm/Pair; • PUTSTATIC info/skyblond/jvm/asm/ASMTargetClass.target : Linfo/skyblond/jvm/asm/Pair; • L1 • LINENUMBER 6 L1 • LDC "staticDirect" • PUTSTATIC info/skyblond/jvm/asm/ASMTargetClass.staticDirect : Ljava/lang/String; • RETURN • MAXSTACK = 2 • MAXLOCALS = 0 • }
除了字节码之外,插件还会帮我们添加上对应的行号、栈最大大小和局部变量最大个数等信息,同时还会将常量池中#x 的引用替换成实际的内容,比如 LDC #10 变成了 LDC "staticDirect"。
ASM 入门展开目录
这样一来我们就能够对应上了,如果要修改某一个常量的值,我们只需要修改对象初始化,或者类初始化时的代码,让 ldc 加载一个不同的常量就可以了。改动的方法有两种:一种是修改常量池,指令不变;另一种是新增一个常量,然后让指令加载新的常量。
对于 ASM 库来说,它提供了两种面向对象的访问方式,一种叫做 Visitor API,另一种叫做 Tree API。两种 API 各有优劣,而对于我们的需求来说,两种 API 都可以胜任。
Visitor API展开目录
Visitor API 以遍历的思想实现:例如我们要将一个 class 中的字符串常量替换,产生一个新的 class,那么我们需要定义一个 Visitor,决定在遇到各种事件时执行什么动作,而新的 class 就在遍历时产生了。例如遇到普通的方法或函数,我们原封不动的复述这个方法或函数,而一旦遇到我们要修改的函数,我们就替换成自己的 Visitor;当遇到其他指令的时候我们复述这个指令,而一旦遇到 LDC 指令,我们就检查原来加载的值是不是我们要替换的,是就替换成新的值,不是就复述这个指令,写到新的 class 中去。
代码如下:
• package info.skyblond.jvm.asm; • • import org.objectweb.asm.*; • • import java.io.IOException; • import java.lang.reflect.Field; • import java.lang.reflect.InvocationTargetException; • • import static org.objectweb.asm.Opcodes.ASM9; • • public class VisitorDemo extends ClassVisitor { • public VisitorDemo(ClassVisitor cv) { • super(ASM9, cv); • this.cv = cv; • } • • @Override • public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { • MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions); • if (name.equals("<clinit>")) { • return new MethodVisitor(ASM9, mv) { • @Override • public void visitLdcInsn(Object value) { • if ("Something".equals(value)) • value = "Replace!"; • super.visitLdcInsn(value); • } • }; • } • return mv; • } • • public static void main(String[] args) throws IOException, InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { • • String className = ASMTargetClass.class.getCanonicalName(); • • ClassReader reader = new ClassReader(className); • ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS); • VisitorDemo visitor = new VisitorDemo(writer); • reader.accept(visitor, ClassReader.EXPAND_FRAMES); • byte[] classData = writer.toByteArray(); • • Class clazz = new MyClassLoader().defineClass(className, classData); • Object personObj = clazz.getDeclaredConstructor().newInstance(); • Field targetField = clazz.getDeclaredFields()[0]; • targetField.setAccessible(true); • Pair<String, String> p = (Pair<String, String>) targetField.get(personObj); • • System.out.println(p.getFirst()); • System.out.println(p.getSecond()); • System.out.println(ASMTargetClass.target.getFirst()); • System.out.println(ASMTargetClass.target.getSecond()); • } • }
首先我们通过继承 ClassVisitor 来实现我们自己的 Visitor。这个 Visitor 使用 ASM9 标准,这里我用的库是 org.ow2.asm:asm:9.2。ClassVisitor 提供了许多可以覆盖的方法,比如 VisitField、visitMethod 之类的方法,他们分别在访问成员和方法时被调用,由于我们的目标是 <clinit> 方法,也就是类初始化时的代码,因此这里只覆盖了 visitMethod。
• @Override • public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { • System.out.println("Visit method: " + name); • MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions); • if (name.equals("<clinit>")) { • return new MethodVisitor(ASM9, mv) { • @Override • public void visitLdcInsn(Object value) { • if ("Something".equals(value)) • value = "Replace!"; • super.visitLdcInsn(value); • } • }; • } • return mv; • }
在这个方法中,首先我们先用外部的 ClassVisitor 进行访问,访问结果存在 mv 变量里,随后判断,如果方法的名字是 <clinit>,那我们就返回一个自己创建的 MethodVisitor,这个 Visitor 的默认行为由刚刚的 mv 提供,我们只覆盖处理 Ldc 指令的逻辑,即覆盖了 visitLdcInsn 方法,在这个方法中,我们判断 value 和字符串 “Something” 是否一致,如果一致就把它的值就改为 “Replaced!”,然后调用父类的默认实现(这里的默认实现是 ClassWriter,稍后讲解 main 的时候会说,它的默认行为就是在访问时把访问的指令写到新的 class 里,即复述)。
随后就是主函数。
• String className = ASMTargetClass.class.getCanonicalName(); • • ClassReader reader = new ClassReader(className); • ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS); • VisitorDemo visitor = new VisitorDemo(writer); • reader.accept(visitor, ClassReader.EXPAND_FRAMES); • byte[] classData = writer.toByteArray(); • • Class clazz = new MyClassLoader().defineClass(className, classData); • Object personObj = clazz.getDeclaredConstructor().newInstance(); • Field targetField = clazz.getDeclaredFields()[0]; • targetField.setAccessible(true); • Pair<String, String> p = (Pair<String, String>) targetField.get(personObj); • • System.out.println(p.getFirst()); • System.out.println(p.getSecond()); • System.out.println(ASMTargetClass.target.getFirst()); • System.out.println(ASMTargetClass.target.getSecond());
这里我们获取到了要替换的类的全名,然后用它创造了一个 ClassReader。之后我们还创建了一个 ClassWriter,它类似于我们平时用的 Writer,能够向其中写入数据。最后我们实例化我们自定义的 Visitor,因为他要以 writer 作为默认实现,就是说我们没有动的地方,就按照 ClassWriter 的实现原封不动的写入到新的 class 中。对于我们覆盖了的方法,按照我们写的代码执行,这样我们就能够复述原来的 Class,并在我们想要的地方做出修改。
之后使用了一个自定义的 ClassLoader 将写好的新 class 加载进来,因为我们这个 Class 和原来的 Class 是一个名字,并且没有对应的文件,所以不能直接让默认的加载器干活。其实现如下:
• package info.skyblond.jvm.asm; • • public class MyClassLoader extends ClassLoader { • public final Class<?> defineClass(String name, byte[] b) throws ClassFormatError { • return defineClass(name, b, 0, b.length); • } • }
其实就是把字节数组转换成一个 Class 对象,之后利用反射即可拿到我们声明的第一个变量(也就是静态的 target 变量)的值,将他转换为 Pair<String, String> 类型后就可以正常操作了。最后四行代码打印了修改前后的不同:
• Replace! • Nothing • Something • Nothing
可以看到通过反射获取到的修改的类,它的 Something 已经被替换成了 Replace!,而后面的 Nothing 和原来的一致。
对于合约来说,我们不需要自定义类加载器,因为合约的编译器允许我们给他一个 Class 文件的 InputStream,它会通过这个 InputStream 读取 class 并编译成合约,因此我们只需要将新的 Class 用 ByteArrayInputStream 包装一下即可。
Tree API展开目录
Tree API 则是将 Class 文件转换成一颗树,根节点 ClassNode 指向了整个树的根节点,而 MethodNode 则表示某一个方法的根节点,最后还有 InsnNode,每一个 node 表示一条指令。我们可以在程序中找到不同的节点进行不同的操作以实现目的。
使用 Tree API 写的程序比起 Visitor API 的更为简洁,只需要一个主函数即可:
• package info.skyblond.jvm.asm; • • import org.objectweb.asm.ClassReader; • import org.objectweb.asm.ClassWriter; • import org.objectweb.asm.tree.AbstractInsnNode; • import org.objectweb.asm.tree.ClassNode; • import org.objectweb.asm.tree.LdcInsnNode; • import org.objectweb.asm.tree.MethodNode; • • import java.lang.reflect.Field; • • public class TreeDemo { • public static void main(String[] args) throws Exception { • String className = ASMTargetClass.class.getCanonicalName(); • • ClassReader reader = new ClassReader(className); • ClassNode classNode = new ClassNode(); • reader.accept(classNode, ClassReader.EXPAND_FRAMES); • • for (MethodNode methodNode : classNode.methods) { • if (methodNode.name.equals("<clinit>")) { • for (AbstractInsnNode insnNode : methodNode.instructions) { • if (insnNode.getType() == AbstractInsnNode.LDC_INSN) { • LdcInsnNode node = (LdcInsnNode) insnNode; • if (node.cst.equals("Something")) { • node.cst = "Replace!"; • } • } • } • } • } • • ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); • classNode.accept(writer); • byte[] classData = writer.toByteArray(); • • Class clazz = new MyClassLoader().defineClass(className, classData); • Object personObj = clazz.getDeclaredConstructor().newInstance(); • Field targetField = clazz.getDeclaredFields()[0]; • targetField.setAccessible(true); • Pair<String, String> p = (Pair<String, String>) targetField.get(personObj); • • System.out.println(p.getFirst()); • System.out.println(p.getSecond()); • System.out.println(ASMTargetClass.target.getFirst()); • System.out.println(ASMTargetClass.target.getSecond()); • } • }
可以看到主函数的后半部分几乎 Visitor API 的一模一样,不同指出在于前半部分:
• ClassReader reader = new ClassReader(className); • ClassNode classNode = new ClassNode(); • reader.accept(classNode, ClassReader.EXPAND_FRAMES); • • for (MethodNode methodNode : classNode.methods) { • if (methodNode.name.equals("<clinit>")) { • for (AbstractInsnNode insnNode : methodNode.instructions) { • if (insnNode.getType() == AbstractInsnNode.LDC_INSN) { • LdcInsnNode node = (LdcInsnNode) insnNode; • if (node.cst.equals("Something")) { • node.cst = "Replace!"; • } • } • } • } • }
首先通过 ClassReader 读取要修改的类,然后创建一个 ClassNode,让 reader 将类读取的结果转换成 ClassNode,然后我们遍历 classNode 的方法节点,如果找到了名为 <clinit> 的节点,那么我们就开始遍历这个方法的指令节点。通过判别每一个 insnNode 的类型(注意是 Type 而不是 OpCode),如果是 LDC_INSN,即 LDC 指令,我们需要将他的类型转换为 LdcInsnNode,而这种 node 的 cst 字段即是该字段载入的常量的值,它可以是 String,可以是 Integer,也可以是其他类型。总之我们找到我们想要修改的值之后,可以直接修改成目标值,然后利用如下代码将修改后的 classNode 写成 class 即可:
• ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); • classNode.accept(writer); • byte[] classData = writer.toByteArray();
后面的验证代码和 Visitor API 的一样,而效果也是一样的。