最近在用Java写智能合约,其中有一个静态字段是合约的管理员地址,这个地址用于鉴权,确保只有该地址能够对合约进行管理操作(升级、销毁等)。但是为了做到自动化测试,这个管理员地址在develop分支上是私链的,而实际部署的时候得换成公链上的另一个地址。前几天升级合约的时候忘记替换地址,直接把私链地址部署到了公开的测试网了。好在是测试网,问题不大,这要是主网就别玩了,重开吧。为了杜绝这样的后顾之忧,本文决定探索一种方法,能够一份代码按照使用情况填入不同的地址来生成合约,避免将地址写死在代码里产生问题。
背景
具体来说,这个区块链是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的一样,而效果也是一样的。
-全文完-
【代码札记】使用Java ASM替换字符串常量 由 天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。