背景
借助java instrumentation技术可以实现对字节码的热修改,来实现一些动态的能力增强,例如阿里巴巴的开源工具arthas的watch功能,就是对特定类的字节码进行了修改,在函数进入和退出的时候去记录了出入参、耗时等信息,并将其打印展示了出来。这种增强相对来说是比较简单的,在网上可以搜到很多种实现方式,例如使用javassist bytebuddy等库,都能够很容易的实现类似的功能,下面就是实现该功能的bytebuddy样例代码。之所以说这个功能实现起来比较简单,是因为他只需要在函数进入和函数返回的时候注入,这两个情况下的状态是比较简单的,并且有我们需要的各种信息:
- 函数进入的时候,状态为操作数栈是空,局部变量依次为
thisarg1arg2... - 函数退出的时候,状态为操作数栈上为返回值。
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);
}
方式一:替换函数调用
我们分析上述问题,要做的事情,就是把上面的增强封装到一个新的方法中,然后替换掉字节码中原来的方法的调用即可。
这里我们借助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个位置。 - 静态函数中,没有
this,var0=第一个入参,以此类推。 - 而对于内部定义的变量,下标从最后一个参数之后排序,还是以上面
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方法,这就是内联。
我们把上面的代码进行修改,把替换函数调用的,如下。
....
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壹貳叁。
能做什么
看完前面的内容,会有个疑惑,内联代码能干什么,为什么不自己就按照最终的样子写代码呢?
举一个很真实的例子,测试环境调试一个接口的时候,发现下游依赖的服务挂了,导致没法运行到我们要测试的代码,如下
那就可以用上面的内联修改的方式,让下游的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
我的工具中还集成了其他的很多功能,这只是其中一项,感兴趣的可以去
github了解一下。