java字节码中内联自定义功能

320 阅读5分钟

背景

借助java instrumentation技术可以实现对字节码的热修改,来实现一些动态的能力增强,例如阿里巴巴的开源工具arthaswatch功能,就是对特定类的字节码进行了修改,在函数进入和退出的时候去记录了出入参、耗时等信息,并将其打印展示了出来。这种增强相对来说是比较简单的,在网上可以搜到很多种实现方式,例如使用javassist bytebuddy等库,都能够很容易的实现类似的功能,下面就是实现该功能的bytebuddy样例代码。之所以说这个功能实现起来比较简单,是因为他只需要在函数进入和函数返回的时候注入,这两个情况下的状态是比较简单的,并且有我们需要的各种信息:

  • 函数进入的时候,状态为操作数栈是空,局部变量依次为this arg1 arg2...
  • 函数退出的时候,状态为操作数栈上为返回值。
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;

import java.lang.instrument.Instrumentation;

public class MyAgent {

    public static void premain(String agentArgs, Instrumentation inst) {
        new AgentBuilder.Default()
                .type(ElementMatchers.named("com.example.MyClass"))
                .transform((builder, typeDescription, classLoader, module, domain) -> builder
                        .visit(Advice.to(TimingAdvice.class)
                        .on(ElementMatchers.named("myMethod")))
                ).installOn(inst);
    }

    public static class TimingAdvice {
        @Advice.OnMethodEnter
        public static long onEnter(@Advice.AllArguments Object[] args) {
            // 打印方法参数
            System.out.println("Arguments: ");
            for (Object arg : args) {
                System.out.println(arg);
            }
            // 捕获开始时间
            return System.currentTimeMillis();
        }

        @Advice.OnMethodExit(onThrowable = Throwable.class)
        public static void onExit(@Advice.Enter long startTime, @Advice.Return Object returnValue) {
            // 计算并打印方法执行时间
            long elapsedTime = System.currentTimeMillis() - startTime;
            System.out.println("Method execution time: " + elapsedTime + " ms");
            // 打印返回值
            System.out.println("Return value: " + returnValue);
        }
    }
}

但是如果把内连嵌入的位置和时机稍微变换,变成对某个子方法调用内连成自定义的代码,问题就会变得复杂,这里我们先说明白要实现的功能。现想对main方法下的,String#toUpperCase功能进行替换,使其能将0-9,转换成零到玖。

package com.example;

public class MyClass {
    public static void main(String[] args) {
        String str = "abc123";
        System.out.println(str.toUpperCase()); // ABC123
    }
}

// 期望能在运行时修改字节码,使得这里`toUpperCase`打印ABC壹貳叁

本质上就是要把,所有main中调用string#toUpperCase的地方给修改如下:

public static void main(String[] args) {
    String str = "abc123";

    // ----------------修改成如下 代码块------------------------
    // 先转换为大写
    String _str = str.toUpperCase();
    // 然后加工数字
    char[] cs = new char[] {'零', '壹', '貳', '叁', '肆', '伍', '陸', '柒', '捌', '玖'};
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < _str.length(); i++) {
        char c = _str.charAt(i);
        if (c >= '0' && c <= '9') {
            sb.append(cs[c - '0']);
        } else {
            sb.append(c);
        }
    }
    _str = sb.toString();
    // ----------------------------------------

    System.out.println(_str);
}

方式一:替换函数调用

我们分析上述问题,要做的事情,就是把上面的增强封装到一个新的方法中,然后替换掉字节码中原来的方法的调用即可。

image.png

这里我们借助ASM库来实现这些操作,创建一个maven项目,引入ASM依赖

<dependency>
	<groupId>org.ow2.asm</groupId>
	<artifactId>asm-commons</artifactId>
	<version>9.7</version>
</dependency>

代码如下,核心代码其实就是一行,把原来调用toUpperCase函数的指令,替换成静态方法myUpperCase

package com.example;

import org.objectweb.asm.*;
import org.objectweb.asm.tree.*;

import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;


public class Inject {
    public static void main(String[] args) throws Exception {
        ClassReader myClassCr = new ClassReader("com.example.MyClass");
        ClassNode myClassNode = new ClassNode();
        myClassCr.accept(myClassNode, ClassReader.EXPAND_FRAMES);

        MethodNode mainMethod = myClassNode.methods.stream().filter(it -> it.name.equals("main")).findFirst().get();

        InsnList newInsnList = new InsnList();
        for (AbstractInsnNode instruction : mainMethod.instructions) {
            if (instruction instanceof MethodInsnNode &&
                    ((MethodInsnNode) instruction).name.equals("toUpperCase")) {
                // 替换成新方法的调用
                newInsnList.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "com/example/Inject", "myUpperCase", "(Ljava/lang/String;)Ljava/lang/String;", false));
            } else {
                newInsnList.add(instruction);
            }
        }

        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
        myClassNode.accept(cw);
        byte[] byteCode = cw.toByteArray();
        Files.write(Paths.get("MyClass.class"), byteCode, StandardOpenOption.WRITE, StandardOpenOption.CREATE);

        ClassLoader cl = Inject.class.getClassLoader();
        Method define = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
        define.setAccessible(true);
        Class<?> c = (Class<?>)define.invoke(cl, byteCode, 0, byteCode.length);

        c.getDeclaredMethod("main", String[].class).invoke(null, (Object) null); // 打印ABC壹貳叁
    }

    public static String myUpperCase(String str) {
        // 先转换为大写
        String _str = str.toUpperCase();
        // 然后加工数字
        char[] cs = new char[] {'零', '壹', '貳', '叁', '肆', '伍', '陸', '柒', '捌', '玖'};
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < _str.length(); i++) {
            char c = _str.charAt(i);
            if (c >= '0' && c <= '9') {
                sb.append(cs[c - '0']);
            } else {
                sb.append(c);
            }
        }
        return sb.toString();
    }
}

这是生成的MyClass.class反编译后的结果:

package com.example;

public class MyClass {
    public MyClass() {
    }

    public static void main(String[] args) {
        String str = "abc123";
        System.out.println(Inject.myUpperCase(str));
    }
}

这里的静态方法myUpperCase格式是有要求的,他与原函数toUpperCase调用前后的操作数栈帧情况(操作数栈+局部变量)需要完全匹配。所以这里先介绍下基础知识,一个函数调用的时候,操作数栈的情况是这样的:

  • 函数调用指令前,对于非静态函数,栈顶部是最后一个函数入参,再往下是倒数第二个入参,依次往前到第一个入参,接下来是this对象地址,而静态函数则是没有this,其他一致。
  • 函数调用指令后,对于无返回值函数,栈上不产生任何数据,对于有返回值的,栈帧顶部为函数返回值。

而对于局部变量,则是这样:

  • 非静态函数中,下标0变量是this,下标1是第一个参数,其中long/double会占用2个位置,例如double add(double a, double b)函数var0=this, var1=a, var3=b,a是double占2个位置。
  • 静态函数中,没有thisvar0=第一个入参,以此类推。
  • 而对于内部定义的变量,下标从最后一个参数之后排序,还是以上面double add(double a, double b) { int c = 100, d=100; ...}为例,var3=b之后,局部变量c的序号就从5开始了,var5=c, var6=d.

回到这个例子toUpperCase是非静态函数,并且空参数,所以进入之前,栈顶有个this,也就是对应的字符串地址,而调用之后返回值也是个字符串,所以调用之后栈顶还是一个字符串地址。而我们的静态方法myUpperCase,有String入参和返回值,虽然入参比原来多了个String,但是静态方法没有this,因而调用前后栈顶也是一个字符串地址,与原来完全对应。

总结下来,如果你要替换的函数是非静态函数a func(b, c, d)这种形式,那么你的静态函数需要定义为static a func(cls, b, c, d)cls是原函数所属的类);而如果你要替换的是静态函数static a func(b, c, d),那么你的静态函数需要定义为相同的格式static a func(b, c, d)

方式二:内联

函数调用的方式有一些缺点:

  • 1 需要保留静态函数和其所在的类,对于一些动态的场景,需要创建很多这样的Inject类和myUpperCase方法。
  • 2 函数调用有一定性能开销。

更好的方式是把myUpperCase的代码直接嵌入到目标函数中,而不是调用Inject.myUpperCase方法,这就是内联。

image.png

我们把上面的代码进行修改,把替换函数调用的,如下。

....
        InsnList newInsnList = new InsnList();
        for (AbstractInsnNode instruction : mainMethod.instructions) {
            if (instruction instanceof MethodInsnNode &&
                    ((MethodInsnNode) instruction).name.equals("toUpperCase")) {
                // 替换成新方法的调用
                ClassReader cr = new ClassReader("com.example.Inject");
                ClassNode node = new ClassNode();
                cr.accept(node, ClassReader.EXPAND_FRAMES);
                MethodNode myUpperMethod = node.methods.stream().filter(it -> it.name.equals("myUpperCase")).findFirst().get();
                newInsnList.add(inline(mainMethod, myUpperMethod, (MethodInsnNode) instruction));
            } else {
                newInsnList.add(instruction);
            }
        }
....

然后我们来看如何写inline方法,如下主要就是分3个主要的动作:

第一步,把栈上的内容挪到局部变量,对齐函数进入时候的格式,这样才能直接把函数的指令搬过来,这里要注意局部变量就不能从0开始了,需要从之前的max开始,产生偏移。

第二步,则是进行指令复制,这里我们用了更通用的,clone深拷贝的方式,更加安全,指令复制过程中,需要特殊处理变量操作的节点(修改变量下标偏移);return节点;行号节点等。

第三步,把try-catch控制流挪过来。

下面的代码是比较通用的写法,并不只针对当前这个函数,也适用于,静态函数,多入参,含判断循环try-catch等各种情况的函数替换。

public static InsnList inline(MethodNode mainMethod, MethodNode myUpperMethod, MethodInsnNode originMethodInsn) {
    InsnList list = new InsnList();
    // ! 第一步,处理操作数栈和局部变量
    // 函数进入后栈是空的,需要把当前操作数栈上的数据set到局部变量,需要区分是静态还是非静态
    Type[] paramTypes = Type.getArgumentTypes(originMethodInsn.desc);
    int[] paramIndex = new int[paramTypes.length];
    boolean isStatic = originMethodInsn.getOpcode() == Opcodes.INVOKESTATIC;
    // 静态方法进入前,栈顶只有入参;非静态方法还多个this引用。
    for (int i = 0; i < paramTypes.length; i++) {
        paramIndex[i] = mainMethod.maxLocals + (isStatic ? 0 : 1) + (i > 0 ? paramTypes[i - 1].getSize() : 0);
    }

    for (int i = paramTypes.length - 1; i >= 0; i--) {
        Type t = paramTypes[i];
        if (t.getSort() >= Type.BOOLEAN && t.getSort() <= Type.INT) {
            list.add(new VarInsnNode(Opcodes.ISTORE, paramIndex[i]));
        } else if (t.getSort() == Type.FLOAT) {
            list.add(new VarInsnNode(Opcodes.FSTORE, paramIndex[i]));
        } else if (t.getSort() == Type.LONG) {
            list.add(new VarInsnNode(Opcodes.LSTORE, paramIndex[i]));
        } else if (t.getSort() == Type.DOUBLE) {
            list.add(new VarInsnNode(Opcodes.DSTORE, paramIndex[i]));
        } else if (t.getSort() == Type.OBJECT || t.getSort() == Type.ARRAY) {
            list.add(new VarInsnNode(Opcodes.ASTORE, paramIndex[i]));
        }
    }
    if (!isStatic) {
        list.add(new VarInsnNode(Opcodes.ASTORE, mainMethod.maxLocals));
    }

    // ! 第二步,复制指令
    Map<LabelNode, LabelNode> labels = new HashMap<>();
    for (AbstractInsnNode enInsn : myUpperMethod.instructions) {
        if (enInsn instanceof LabelNode) {
            LabelNode cloned = new LabelNode();
            labels.putIfAbsent((LabelNode) enInsn, cloned);
        }
    }
    LabelNode finishLabel = new LabelNode();
    for (AbstractInsnNode instruction : myUpperMethod.instructions) {
        // return不能拷贝过来,会导致目标函数直接结束。需改为跳转到一个label
        if (instruction.getOpcode() >= Opcodes.IRETURN && instruction.getOpcode() <= Opcodes.RETURN) {
            list.add(new JumpInsnNode(Opcodes.GOTO, finishLabel));
        }
        // 局部变量操作的指令,需要修改下标,添加偏移量
        else if (instruction instanceof VarInsnNode) {
            VarInsnNode _t = (VarInsnNode) instruction.clone(labels);
            _t.var += mainMethod.maxLocals;
            list.add(_t);
        } else if (instruction instanceof IincInsnNode) {
            IincInsnNode _t = (IincInsnNode) instruction.clone(labels);
            _t.var += mainMethod.maxLocals;
            list.add(_t);
        } else if (instruction instanceof LabelNode) {
            list.add(labels.get(instruction));
        } else if (instruction instanceof  LineNumberNode) {
            // 行号是插入函数的行号,没有意义,直接扔了
        } else {
            list.add(instruction.clone(labels));
        }
    }
    list.add(finishLabel);
    
    // ! 第三步,需要把try-catch拿过来
    mainMethod.tryCatchBlocks.addAll(myUpperMethod.tryCatchBlocks.stream().map(it->
        new TryCatchBlockNode(labels.get(it.start), labels.get(it.end), labels.get(it.handler), it.type)
    ).collect(Collectors.toList()));
    return list;
}

上面操作后,得到MyClass.class反编译结果如下

package com.example;

import java.io.PrintStream;

public class MyClass {
    public MyClass() {
    }

    public static void main(String[] args) {
        String str = "abc123";
        PrintStream var10000 = System.out;
        String var3 = str.toUpperCase();
        char[] var4 = new char[]{'零', '壹', '貳', '叁', '肆', '伍', '陸', '柒', '捌', '玖'};
        StringBuilder var5 = new StringBuilder();

        for(int var6 = 0; var6 < var3.length(); ++var6) {
            char var7 = var3.charAt(var6);
            if (var7 >= '0' && var7 <= '9') {
                var5.append(var4[var7 - 48]);
            } else {
                var5.append(var7);
            }
        }

        var10000.println(var5.toString());
    }
}

此时加载该类,运行main函数能够正确打印ABC壹貳叁

能做什么

看完前面的内容,会有个疑惑,内联代码能干什么,为什么不自己就按照最终的样子写代码呢?

举一个很真实的例子,测试环境调试一个接口的时候,发现下游依赖的服务挂了,导致没法运行到我们要测试的代码,如下

image.png

那就可以用上面的内联修改的方式,让下游的check函数在这里先返回true,而不需要修改代码,我对上述功能已经封装好了一个工具swapper,可以点击到github仓库下载最新release包试用。

启动选择我们的服务进程,此时已经在8000端口启动了webUI

$java -jar swapper/swapper.jar 
[0] 43099 
[1] 66013 
[2] 44770 com.example.jack.JackApplication
[3] Custom PID
>>>>>>>>>>>>Please enter the serial number
2
============The PID is 44770
============Attach finish

20240821203333_rec_.gif 我的工具中还集成了其他的很多功能,这只是其中一项,感兴趣的可以去github了解一下。