MENU

【代码札记】使用Java ASM替换字符串常量

August 10, 2021 • 瞎折腾

最近在用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_0this压入了栈),随后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.2ClassVisitor提供了许多可以覆盖的方法,比如VisitFieldvisitMethod之类的方法,他们分别在访问成员和方法时被调用,由于我们的目标是<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 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code