[Android][ASM]代码注入入门(二)—— 开发环境搭建与Hello World
前言
在上一篇中,我们见识了AOSP中使用asm实现的一个功能——lockedregioncodeinjection
:其可以在编译时为services.core.unboosted.jar
中指定类中的同步代码块添加try-catch
代码块,并在代码块的首位追加boostPriorityForLockedSection()
与resetPriorityAfterLockedSection()
方法调用,并将最后修改产物保存为services.core.priorityboosted.jar
,供services模块后续继续编译使用;
透过浅析lockedregioncodeinjection
工具本身实现的代码,我们领略了asm的强大之处。那么我们是否可以利用其特性,为自己实现一些自动化追加、修改代码的功能呢?
答案是肯定的,但是在那之前,我们还是从搭建开发环境、编写Hello World demo入手,循序渐进。
开发环境搭建
开发环境介绍
操作系统
为了调试方便,我这边使用Ubuntu(WSL2) 做展示,理论上平台是不限的;
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 20.04.4 LTS
Release: 20.04
Codename: focal
$ uname -a
Linux xxxx 5.10.60.1-microsoft-standard-WSL2 #1 SMP Wed Aug 25 23:20:18 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
JAVA环境
为了快速调试,我不打算依附于AOSP的编译流程来搭建开发环境,而是基于Ubuntu(Linux)来搭建一个纯java的开发环境;
本次使用OpenJDK 8来搭建:
$ dpkg -l | grep jdk
ii openjdk-8-jdk:amd64 8u312-b07-0ubuntu1~20.04 amd64 OpenJDK Development Kit (JDK)
ii openjdk-8-jdk-headless:amd64 8u312-b07-0ubuntu1~20.04 amd64 OpenJDK Development Kit (JDK) (headless)
ii openjdk-8-jre:amd64 8u312-b07-0ubuntu1~20.04 amd64 OpenJDK Java runtime, using Hotspot JIT
ii openjdk-8-jre-headless:amd64 8u312-b07-0ubuntu1~20.04 amd64 OpenJDK Java runtime, using Hotspot JIT (headless)
asm框架下载
lockedregioncodeinjection
使用的是asm-6.0
,为了保持行为一致,我们也下载6.0
版本的来搭建环境;
官网Maven 提供多个版本、多个模块的下载链接,这里我们下载如下几个同版本号的文件:
- asm-6.0.jar
- asm-analysis-6.0.jar
- asm-commons-6.0.jar
- asm-tree-6.0.jar
注:本篇的demo只需要使用asm-6.0.jar
与asm-tree-6.0.jar
,但后续更多功能会依赖另外两个,因此就一并下载了;
下载完成后放置的目录结构如下:
~/workspace $ tree ./
./
└── asm
├── asm-6.0.jar
├── asm-analysis-6.0.jar
├── asm-commons-6.0.jar
└── asm-tree-6.0.jar
1 directory, 4 files
环境配置
Ubuntu若使用apt-get/apt
安装OpenJDK
,无需额外操作即可正常使用javac/java
的等命令;
ryan ~/workspace $ java -version
openjdk version "1.8.0_312"
OpenJDK Runtime Environment (build 1.8.0_312-8u312-b07-0ubuntu1~20.04-b07)
OpenJDK 64-Bit Server VM (build 25.312-b07, mixed mode)
ryan ~/workspace $ javac -version
javac 1.8.0_312
ryan ~/workspace $ jar -version
此外,由于我们的demo包含两部分:
- 待修改的字节码文件;
- 调用
asm
来修改创建字节码的工具;
因此在workspace
目录下,src
与tools
目录来存放,与上面的asm
目录并列;
同时创建一个名为out
的目录,用于存放asm修改后的产物:
ryan ~/workspace $ tree
.
├── asm
│ ├── asm-6.0.jar
│ ├── asm-analysis-6.0.jar
│ ├── asm-commons-6.0.jar
│ └── asm-tree-6.0.jar
├── out
├── src
└── tools
4 directories, 4 files
Hello World
如上所说,这个demo需要两个部分的开发:
- 待修改的字节码文件,以下称为源码端;
- 调用
asm
来修改创建字节码的工具,以下称为工具端;
源码端
源码端我们先写个简单的:
//src/Test.java
public class Test {
public static void main(String[] args) {
String a = "Hi Test";
System.out.println(a);
}
}
编译、运行:
ryan ~/workspace/src $ javac Test.java
ryan ~/workspace/src $ java Test
Hi Test
我们的目标就定为:将输出结果从"Hi Test"
改为"Hello World"
;
工具端
与lockedregioncodeinjection
不同,我们写demo,不需要那么多参数传入,直接先硬编码实现即可;
//tools/StringModifier.java
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.InsnList;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.MethodNode;
public class StringModifier {
public static void main(String[] args) throws IOException {
//输入src/Test.class
FileInputStream fis = new FileInputStream("../src/Test.class");
//输出out/Test.class
FileOutputStream fos = new FileOutputStream("../out/Test.class");
//输入输出流对接,对内容进行解析与修改
convert(fis, fos);
fos.flush();
fis.close();
fos.close();
}
private static void convert(InputStream in, OutputStream out)
throws IOException {
//ClassReader封装了类的解析
ClassReader reader = new ClassReader(in);
//ClassWriter则负责修改后的类的生成
ClassWriter writer = new ClassWriter(0);
//对解析过程的事件回调,是通过ClassVisitor及其子类来实现的,并将ClassWriter传入,以追踪所有改动
StringModificationClassVisitor visitor = new StringModificationClassVisitor(writer);
//ClassReader.accept()这是一个耗时操作,会解析整个类,并在特定阶段调用ClassVisitor的对应方法回调,用户可以通过创建子类集成ClassVisitor,并重写对应方法来实现截获、修改
reader.accept(visitor, 0);
byte[] data = writer.toByteArray();
//将ClassWriter保存的信息,以二进制形式写到文件中;
out.write(data);
}
private static class StringModificationClassVisitor extends ClassVisitor {
private String currentClassName = null;
private StringModificationClassVisitor(ClassWriter writer) {
super(Opcodes.ASM6, writer);
}
@Override
public void visit(int version, int access, String name, String signature, String superName,
String[] interfaces) {
//当解析到类声明头时调用
currentClassName = name;
super.visit(version, access, name, signature, superName, interfaces);
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature,
String[] exceptions) {
//当解析到方法时调用,包括构造方法
MethodVisitor chain = super.visitMethod(access, name, desc, signature, exceptions);
//由于我们需要关注Test类的main方法,因此在此硬编码条件过滤
if ("Test".equals(currentClassName) && "main".equals(name)) {
//返回自定义的一个MethodVisitor
return new StringModificationMethodVisitor(currentClassName, chain);
} else {
//父类实现返回(不修改)
return chain;
}
}
}
private static class StringModificationMethodVisitor extends MethodVisitor {
private MethodVisitor chain;
private String owner;
private StringModificationMethodVisitor(String owner, MethodVisitor chain) {
//构造一个MethodNode用于代理、保存MethodVisitor的解析结果
super(Opcodes.ASM6, new MethodNode());
this.owner = owner;
this.chain = chain;
}
public void visitEnd() {
//方法解析到末尾时调用
MethodNode mn = (MethodNode) mv;
//成员变量mv实际就是构造方法传入的那个MethodNode
//mv.instructions即为当前方法的所有指令集合
InsnList instructions = mn.instructions;
//遍历所有指令
for (int i = 0; i < instructions.size(); i++) {
AbstractInsnNode node = instructions.get(i);
//字符串加载为LDC指令,因此在此用类型判断过滤
if (node instanceof LdcInsnNode) {
LdcInsnNode ldcNode = (LdcInsnNode)node;
//创建一个新的LDC指令,并将其插入到之前"Hi Test"字符串加载指令之后
LdcInsnNode newLdcNode = new LdcInsnNode(new String("Hello World"));
instructions.insert(ldcNode, newLdcNode);
//然后删除原先那条指令
instructions.remove(ldcNode);
break;
}
}
super.visitEnd();
//让MethodVisitor采纳当前修改后MethodNode信息;
mn.accept(chain);
}
}
}
整个流程大致总结如下:
- 创建一个输入流读取
../src/Test.class
字节码文件,创建一个输出流写入修改后的../out/Test.class
字节码文件; - 解析、封装类信息使用
ClassReader
与ClassWriter
; - 解析类时若需在解析到特定步骤进行修改,创建一个
ClassVisitor
的子类,并重写对应方法来完成; - 如果要在特定方法内进行修改,在3的基础上,还要创建一个
MethodVisitor
的子类,并重写对应方法,并在上方创建的ClassVisitor
子类的visitMethod
方法中作为结果返回; MethodVisitor
的方法中,最常用的是重写visitEnd()
方法,因为此时整个方法的信息都解析完毕,可以进行各种修改;MethodVisitor
的构造方法中,第二个参数传入一个MethodNode
,可以保存MethodVisitor
解析到的信息,这样有利于我们进行修改;InsnList
为方法内的所有指令集合,具体指令种类与规范此处暂不展开;- 字符串加载为LDC指令,因此过滤
Test
类中main
方法内的LDC指令,并将其替换为我们想要的即可;
编译
ryan ~/workspace/tools $ javac StringModifier.java -cp ../asm/asm-6.0.jar:../asm/asm-tree-6.0.jar
运行
ryan ~/workspace/tools $ java -classpath ../asm/asm-6.0.jar:../asm/asm-tree-6.0.jar:./ StringModifier
验证
ryan ~/workspace/tools $ cd ../out/
ryan ~/workspace/out $ java Test
Hello World
后记
基于这个Demo,已经算是敲开了ASM的大门,至于怎么使用,各位根据需求修改即可;
关于指令类型、写法之类的,这里不展开,也没法展开,感兴趣的可以去翻
官方规范接口;
个人建议,除了本职工作需要深入了解JVM设计规范,其余人员可根据自己的需求,按需了解即可;
后面会再记录一个案例,这也是我探索出这两篇文章的最初动机;
更新时间待定……