欢迎访问 生活随笔!

生活随笔

当前位置: 首页 >

深入字节码操作:使用ASM和Javassist创建审核日志

发布时间:2025/3/21 51 豆豆
生活随笔 收集整理的这篇文章主要介绍了 深入字节码操作:使用ASM和Javassist创建审核日志 小编觉得挺不错的,现在分享给大家,帮大家做个参考.

深入字节码操作:使用ASM和Javassist创建审核日志

原文链接:https://blog.newrelic.com/2014/09/29/diving-bytecode-manipulation-creating-audit-log-asm-javassist/

在堆栈中使用spring和hibernate,您的应用程序的字节码可能会在运行时被增强或处理。 字节码是Java虚拟机(JVM)的指令集,所有在JVM上运行的语言都必须最终编译为字节码。 操作字节码原因如下:

  • 程序分析: 
    • 查找应用bug
    • 检查代码复杂性
    • 查找特定注解的类
  • 类生成: 
    • 使用代理从数据库中懒惰加载数据
  • 安全性 
    • 特定API限制访问权限
    • 代码混淆
  • 无Java源码类转换 
    • 代码分析
    • 代码优化
  • 最后,添加日志

有几种可用于操作字节码的工具,从非常低级的工具(如需要字节码级别工作的ASM)到诸如AspectJ等高级框架(允许编写纯Java)。

本博文,我将演示分别使用Javassist和ASM实现一种审计日志的方法。

审计日志例子

假定我没有如下代码:

public class BankTransactions {public static void main(String[] args) {BankTransactions bank = new BankTransactions();for (int i = 0; i < 100; i++) {String accountId = "account" + i;bank.login("password", accountId, "Ashley");bank.unimportantProcessing(accountId);bank.withdraw(accountId, Double.valueOf(i));}System.out.println("Transactions completed");} }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

我们要记录重要的操作以及关键信息以确定操作。 以上,我将确定登录退出的重要动作。 对于登录,重要信息将是帐户ID和用户。 对于退出,重要信息将是帐户ID和撤回的金额。 记录重要操作的一种方法是将日志记录语句添加到每个重要的方法,但这将是乏味的。 相反,我们可以为重要的方法添加注释,然后使用工具来注入日志记录。 在这种情况下,该工具将是一个字节码操作框架。

@ImportantLog(fields = { "1", "2" }) public void login(String password, String accountId, String userName) {// login logic } @ImportantLog(fields = { "0", "1" }) public void withdraw(String accountId, Double moneyToRemove) {// transaction logic }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

@ImportantLog注释表示我们要在每次调用该方法时记录一条消息,而@ImportantLog注释中的fields参数表示应记录的每个参数的索引位置。 例如,对于登录,我们要记录第1位和第2位的输入参数。它们是accountIduserName。 我们不会记录第0位的密码参数。

使用字节码和注释来执行日志记录有两个主要优点:

  • 日志记录与业务逻辑分离,这有助于保持代码清洁和简单。
  • 在不修改源代码的情况下,轻松删除审核日志记录。
  • 在哪里实际修改字节码?

    我们可以使用1.5中引入的核心Java功能来操纵字节码。 此功能称为Java代理。 
    要了解Java代理,让我们来看一下典型的Java处理流程。

    使用包含我们的main方法的类作为输入参数执行命令java。 这将启动Java运行时环境,使用classloader来加载输入类,并调用该类的main方法。 在我们具体的例子中,调用了BankTransactions的main方法,这将导致一些处理发生,并打印“完成交易”。

    现在来看一下使用Java代理的Java进程。

    命令java运行两个输入参数。第一个是JVM参数-javaagent,指向代理jar。第二个是包含我们主要方法的类。javaagent标志告诉JVM首先加载代理。 代理的主类必须在代理jar的清单中指定。 一旦类被加载,类的premain方法被调用。 这个premain方法充当代理的安装钩子。 它允许代理注册一个类变换器。 当类变换器在JVM中注册时,该变换器将在类加载到JVM前接收每个类的字节。 这为类变换器提供了根据需要修改类的字节的机会。 一旦类变换器修改了字节,它将修改的字节返回给JVM。 这些字节接着由JVM验证和加载。

    在我们具体的例子中,当BankTransaction加载时,字节将首先进入类变换器进行潜在的修改。修改后的字节将被返回并加载到JVM中。 加载完之后,调用类中的main方法,进行一些处理,并打印“事务完成”。

    让我们来看看代码。 下面我有代理的premain方法:

    public class JavassistAgent {public static void premain(String agentArgs, Instrumentation inst) {System.out.println("Starting the agent");inst.addTransformer(new ImportantLogClassTransformer());} }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    premain方法打印出一个消息,然后注册一个类变换器。 类变换器必须实现方法转换,加载到JVM中的每个类都会调用它。它以该类的字节数组作为方法的输入,然后返回修改后的字节数组。如果类变换器决定不修改特定类的字节,则可以返回null。

    public class ImportantLogClassTransformer implements ClassFileTransformer {public byte[] transform(ClassLoader loader, String className,Class classBeingRedefined, ProtectionDomain protectionDomain,byte[] classfileBuffer) throws IllegalClassFormatException {// manipulate the bytes herereturn modified bytes;} }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    现在我们知道在哪里修改一个类的字节,接着需要知道如何修改字节。

    如何使用Javassist修改字节码?

    Javassist是一个具有高级和低级API的字节码操作框架。我将重点关注高级的面向对象的API,首先从Javassist中的对象的解释开始。接下来,我将实现审核日志应用程序的实际代码。

    Javassist使用CtClass对象来表示一个类。 这些CtClass对象可以从ClassPool获得,用于修改Classes。ClassPool是一个基于HashMap实现的CtClass对象容器,其中键是类名称,值是表示该类的CtClass对象。默认的ClassPool使用与底层JVM相同的类路径。因此,在某些情况下,可能需要向ClassPool添加类路径或类字节。

    类似于包含字段,方法和构造函数的Java类,CtClass对象包含CtFieldsCtConstructorsCtMethods。所有这些对象都可以修改。我将重点关注方法操作,因为审核日志应用程序需要这种行为。

    以下是修改方法的几种方法:

    上图显示了Javassist的主要优点之一。实际上不必写字节码。而是编写Java代码。一个复杂的情况是Java代码必须在引号内。

    现在我们了解了Javassist的基本构建块,现在来看看应用程序的实际代码。 类变换器的变换方法需要执行以下步骤:

  • 将字节数组转换为CtClass对象
  • 检查CtClass中每个带注解@ImportantLog的方法
  • 如果方法中存在@ImportantLog注释,那么: 
    • 获取方法重要参数索引
    • 函数开始增加日志语句
  • 使用Javassist编写Java代码时,请注意以下问题:

    • JVM在包之间使用斜杠,而Javassist使用点。
    • 当插入多行Java代码时,代码需要在括号内。
    • 当使用12等引用方法参数值时,知道0this1。
    • 注释拥有可见和不可见的签。 不可见的注释在运行时无法获取。

    实际的Java代码如下:

    public class ImportantLogClassTransformer implements ClassFileTransformer {private static final String METHOD_ANNOTATION = "com.example.spring2gx.mains.ImportantLog";private static final String ANNOTATION_ARRAY = "fields";private ClassPool pool;public ImportantLogClassTransformer() {pool = ClassPool.getDefault();}public byte[] transform(ClassLoader loader, String className,Class classBeingRedefined, ProtectionDomain protectionDomain,byte[] classfileBuffer) throws IllegalClassFormatException {try {pool.insertClassPath(new ByteArrayClassPath(className,classfileBuffer));CtClass cclass = pool.get(className.replaceAll("/", "."));if (!cclass.isFrozen()) {for (CtMethod currentMethod : cclass.getDeclaredMethods()) {Annotation annotation = getAnnotation(currentMethod);if (annotation != null) {List parameterIndexes = getParamIndexes(annotation);currentMethod.insertBefore(createJavaString(currentMethod, className, parameterIndexes));}}return cclass.toBytecode();}} catch (Exception e) {e.printStackTrace();}return null;}private Annotation getAnnotation(CtMethod method) {MethodInfo mInfo = method.getMethodInfo();// the attribute we are looking for is a runtime invisible attribute// use Retention(RetentionPolicy.RUNTIME) on the annotation to make it// visible at runtimeAnnotationsAttribute attInfo = (AnnotationsAttribute) mInfo.getAttribute(AnnotationsAttribute.invisibleTag);if (attInfo != null) {// this is the type name meaning use dots instead of slashesreturn attInfo.getAnnotation(METHOD_ANNOTATION);}return null;}private List getParamIndexes(Annotation annotation) {ArrayMemberValue fields = (ArrayMemberValue) annotation.getMemberValue(ANNOTATION_ARRAY);if (fields != null) {MemberValue[] values = (MemberValue[]) fields.getValue();List parameterIndexes = new ArrayList();for (MemberValue val : values) {parameterIndexes.add(((StringMemberValue) val).getValue());}return parameterIndexes;}return Collections.emptyList();}private String createJavaString(CtMethod currentMethod, String className,List indexParameters) {StringBuilder sb = new StringBuilder();sb.append("{StringBuilder sb = new StringBuilder");sb.append("(\"A call was made to method '\");");sb.append("sb.append(\"");sb.append(currentMethod.getName());sb.append("\");sb.append(\"' on class '\");");sb.append("sb.append(\"");sb.append(className);sb.append("\");sb.append(\"'.\");");sb.append("sb.append(\"\\n Important params:\");");for (String index : indexParameters) {try {// add one because 0 is "this" for instance variable// if were a static method 0 would not be anythingint localVar = Integer.parseInt(index) + 1;sb.append("sb.append(\"\\n Index \");");sb.append("sb.append(\"");sb.append(index);sb.append("\");sb.append(\" value: \");");sb.append("sb.append($" + localVar + ");");} catch (NumberFormatException e) {e.printStackTrace();}}sb.append("System.out.println(sb.toString());}");return sb.toString();} }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92

    完成了!我们可以运行应用程序,并将日志记录输出到“System.out”。

    积极的一面是写入的代码量非常小,而且实际上不需要使用Javassist编写字节码。 最大的缺点是用引号编写Java代码可能会变得乏味。幸运的是,其他一些字节码操作框架更快。我们来看看其中一个更快的框架。

    如何使用ASM修改字节?

    ASM是一个字节码操作框架,使用较小的内存占用并且速度相对较快。我认为ASM是字节码操作的行业标准,即使是Javassist也在使用ASM。ASM提供基于对象和事件的库,但在这里我将重点介绍基于事件的模型。

    要理解ASM,我将从ASM自己的文档的一个Java类图(下图)开始。它表明Java类由几个部分组成,包括一个超类,接口,注释,字段和方法。在ASM基于事件的模型中,所有这些类组件都可以被认为是事件。

    可以在ClassVisitor上找到ASM的类事件。为了“看到”这些事件,必须创建一个classVisitor来覆盖您想要查看的所需组件。

    除了类访问者,我们需要一些东西来解析类并生成事件。为此,ASM提供了一个名为ClassReader的对象。reader解析课程并产生事件。类被解析后,需要ClassWriter来消耗事件,将它们转换成一个类字节数组。在下图中,我们BankTransactions类的字节传递给ClassReader,该字节将字节发送到ClassWriter,该ClassWriter会输出生成的BankTransaction。当没有ClassVisitor存在时,输入BankTransactions字节应基本上匹配其输出字节。

    public byte[] transform(ClassLoader loader, String className,Class<?> classBeingRedefined, ProtectionDomain protectionDomain,byte[] classfileBuffer) throws IllegalClassFormatException {ClassReader cr = new ClassReader(classfileBuffer);ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);cr.accept(cw, 0);return cw.toByteArray(); }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    ClassReader得到类的字节,ClassWriter从类读取器获取。ClassReader的accept调用解析该类。接下来,我们从ClassWriter访问生成的字节。

    现在我们想修改BankTransaction字节。首先,我们需要链接在ClassVisitor中。 此ClassVisitor将覆盖诸如visitField或visitMethod之类的方法来接收关于该特定类组件的通知。

    以下是上图的代码实现。 类访问者LogMethodClassVisitor已添加。请注意,可以添加多个类访问者。

    public byte[] transform(ClassLoader loader, String className,Class<?> classBeingRedefined, ProtectionDomain protectionDomain,byte[] classfileBuffer) throws IllegalClassFormatException {ClassReader cr = new ClassReader(classfileBuffer);ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);ClassVisitor cv = new LogMethodClassVisitor(cw, className);cr.accept(cv, 0);return cw.toByteArray(); }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    对于审核日志应用,我们需要检查类中的每个方法。这意味着ClassVisitor只需要覆盖’visitMethod’。

    public class LogMethodClassVisitor extends ClassVisitor {private String className;public LogMethodClassVisitor(ClassVisitor cv, String pClassName) {super(Opcodes.ASM5, cv);className = pClassName;}@Overridepublic MethodVisitor visitMethod(int access, String name, String desc,String signature, String[] exceptions) {//put logic in here} }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    请注意,visitMethod返回一个MethodVisitor。 就像一个类有多个组件,一个方法也有很多的组件,当解析该方法时,它可以被认为是事件。

    MethodVisitor在方法上提供事件。对于审核日志应用,我们要检查带注释的方法上。基于注释,我们可能需要修改方法中的实际代码。为了进行这些修改,我们需要在一个methodVisitor链接,如下所示。

    @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature,exceptions);return new PrintMessageMethodVisitor(mv, name, className); }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这个PrintMessageMethodVisitor将需要覆盖visitAnnotation和visitCode。请注意,visitAnnotation返回一个AnnotationVisitor。就像类和方法具有组件一样,还有一个注释的多个组件。AnnotationVisitor允许我们访问注释的所有部分。

    下面我简要介绍了visitAnnotation和visitCode的步骤。

    public class PrintMessageMethodVisitor extends MethodVisitor {@Overridepublic AnnotationVisitor visitAnnotation(String desc, boolean visible) {// 1. check method for annotation @ImportantLog// 2. if annotation present, then get important method param indexes}@Overridepublic void visitCode() {// 3. if annotation present, add logging to beginning of the method} }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    当使用ASM编写Java代码时,请注意以下问题:

    • 在事件模型中,类或方法的事件将始终以特定顺序发生。 例如,带注解的方法将始终在实际代码之前访问。
    • 当使用12等引用方法参数值时,知道0this1。

    实际Java代码如下:

    public AnnotationVisitor visitAnnotation(String desc, boolean visible) {if ("Lcom/example/spring2gx/mains/ImportantLog;".equals(desc)) {isAnnotationPresent = true;return new AnnotationVisitor(Opcodes.ASM5,super.visitAnnotation(desc, visible)) {public AnnotationVisitor visitArray(String name, Object value) {if ("fields".equals(name)) {return new AnnotationVisitor(Opscodes.ASM5,super.visitArray(name)) { public void visit(String name, Object value) {parameterIndexes.add((String) value);super.visit(name, value);}};} else {return super.visitArray(name);}}};}return super.visitAnnotation(desc, visible); } public void visitCode() {if (isAnnotationPresent) {// create string buildermv.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out","Ljava/io/PrintStream;");mv.visitTypeInsn(Opcodes.NEW, "java/lang/StringBuilder");mv.visitInsn(Opcodes.DUP);// add everything to the string buildermv.visitLdcInsn("A call was made to method \"");mv.visitMethodInsn(Opcodes.INVOKESPECIAL,"java/lang/StringBuilder", "","(Ljava/lang/String;)V", false);mv.visitLdcInsn(methodName);mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,"java/lang/StringBuilder", "append","(Ljava/lang/String;)Ljava/lang/StringBuilder;", false); . . .
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39

    以上可以看出Javassist和ASM之间的主要区别之一。使用ASM,必须在修改方法时在字节码级别编写代码,这意味着需要很好地了解JVM的工作原理。需要在给定的时刻确切知道堆栈和局部变量的内容。 在字节码级别的编写方面,在功能和优化方面提高了门槛,这意味着开发人员需要较长的时间熟悉ASM开发。

    家庭作业

    现在你已经看到如何使用ASM和Javassist的一个场景,我鼓励你尝试一个字节码操作框架。字节码操作不仅可以让您更好地了解JVM,而且还有无数的应用程序。一旦开始,你会发现天空的极限。

    from: http://blog.csdn.net/lihenair/article/details/69948918

    《新程序员》:云原生和全面数字化实践50位技术专家共同创作,文字、视频、音频交互阅读

    总结

    以上是生活随笔为你收集整理的深入字节码操作:使用ASM和Javassist创建审核日志的全部内容,希望文章能够帮你解决所遇到的问题。

    如果觉得生活随笔网站内容还不错,欢迎将生活随笔推荐给好友。