首页 > 编程语言 >字节码增强javassist 使用javassist运行时动态修改字节码对象

字节码增强javassist 使用javassist运行时动态修改字节码对象

时间:2023-05-19 16:45:05浏览次数:55  
标签:kdyzm java 字节 修改 defineClass class javassist

java程序什么时候需要在运行的时候动态修改字节码对象?

如何在运行的时候动态修改字节码对象?

修改字节码对象的时候会发生哪些错误,又该如何解决这些问题?

一、java程序什么时候需要在运行的时候动态修改字节码对象

我认为有两种场景,一种是无法修改源代码的时候;另外一种是功能增强的时候。

1、无法修改源代码

举个例子,java程序依赖的第三方的jar包中发现了bug,但是官方还没有修复,本地通过debug已经发现了解决方法,该如何修复该问题呢?

在spring程序中,如果目标对象在spring容器中,可以通过Spring AOP创建切面解决。但是如果目标对象并没有在spring容器中,或者干脆程序根本不是spring技术栈中的,问题就比较麻烦了,因为无法创建切面拦截目标方法执行。

这时候很容易想到,如果能在不修改第三方源代码的基础上做到修复第三方的bug就好了,这时候使用字节码修改工具动态的修改字节码对象是比较常见的方法。

2、功能增强

在fastjson框架中就是用了asm工具直接操作字节码替代反射技术以加快执行速度。详情可以参考文章:ASM在FastJson中的应用

二、如何在运行的时候修改字节码对象

常见的字节码修改工具有asm和javassist两种,asm工具是直接操作字节码对象底层的,使用它需要对字节码数据结构有很深入的理解;javassist相对于asm工具来说就很亲民了,它提供了两种级别的API:源级别和字节码级别,如果用户使用源代码级API,他们可以不需要了解Java字节码的规范的前提下编辑类文件,这得使操作Java字节码变得简单。

由于技术水平有限,这里使用javassist工具进行字节码修改的操作。

以下程序使用javassist工具演示如何在运行中动态的整体替换掉一个方法中的所有内容。

首先创建一个类Test1

package com.kdyzm;

import lombok.extern.slf4j.Slf4j;

/**
 * @author kdyzm
 * @date 2022/1/29
 */
@Slf4j
public class Test1 {

    public void sayHi() {
        log.info("Hello,world");
    }
}

然后创建主类Main

package com.kdyzm;

import javassist.*;
import lombok.extern.slf4j.Slf4j;

/**
 * @author kdyzm
 * @date 2022/1/29
 */
@Slf4j
public class Main {

    public static void main(String[] args) throws NotFoundException, CannotCompileException {
        ClassPool classPool = ClassPool.getDefault();
        classPool.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));
        String clsName = "com.kdyzm.Test1";
        CtClass ctClass = classPool.get(clsName);
        CtMethod ctMethod = ctClass.getDeclaredMethod("sayHi");
        ctMethod.setBody("log.info(\"Hello,kdyzm\");");
        ctClass.toClass();
        // 释放对象
        ctClass.detach();
        new Test1().sayHi();
    }
}

在以上代码中,Test1对象本应当打印输出

Hello,world

但是在运行中被我将sayHi方法体替换成了

log.info("Hello,kdyzm");

所以,最终方法的执行结果是

Hello,kdyzm

当然,这是一个最简单的代码示例。更多的高级用法可以参考CtMethod使用文档:

http://repository.transtep.com/repository/thirdparty/javassist-3.1/tutorial/tutorial2.html

三、使用Javassist的弊端

一个显而易见的弊端就是替换的方法内容不能过于复杂,否则代码的可读性会变的非常差,调试和修改会变的非常困难,比如下面一段代码

image-20220301155749929

这段代码不算很复杂,但是调试和修改已经非常困难(因为没法断点,编写代码逻辑的时候没有代码提示),而且由于代码作为字符串显示在源代码中,没有代码高亮,再加上换行符,如果没有代码格式化,整个就像一坨*一样,所以,不到万不得已,最好不要使用这种方式。

四、最佳实践

使用javassist工具修改字节码对象,由于替换内容的复杂性,使得维护和debug非常困难,我在实践的过程中发现,将要修改的点封装成单独的类,将核心修改点委托给该类执行是个挺不错的方法。

image-20220301160857666

五、报错和问题分析

1、出现的问题

将在二、如何在运行的时候修改字节码对象中的Main类的main方法中新增加一行代码:new Test1().sayHi();

package com.kdyzm;

import javassist.*;
import lombok.extern.slf4j.Slf4j;

/**
 * @author kdyzm
 * @date 2022/1/29
 */
@Slf4j
public class Main {

    public static void main(String[] args) throws NotFoundException, CannotCompileException {
        new Test1().sayHi();//此处新增加一行代码
        ClassPool classPool = ClassPool.getDefault();
        classPool.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));
        String clsName = "com.kdyzm.Test1";
        CtClass ctClass = classPool.get(clsName);
        CtMethod ctMethod = ctClass.getDeclaredMethod("sayHi");
        ctMethod.setBody("log.info(\"Hello,kdyzm\");");
        ctClass.toClass();
        // 释放对象
        ctClass.detach();
        new Test1().sayHi();
    }
}

看似人畜无害的一行代码加完之后执行就会报错:

16:10:45.519 [main] INFO com.kdyzm.Test1 - Hello,world
Exception in thread "main" javassist.CannotCompileException: by java.lang.ClassFormatError: loader (instance of  sun/misc/Launcher$AppClassLoader): attempted  duplicate class definition for name: "com/kdyzm/Test1"
	at javassist.util.proxy.DefineClassHelper.toClass(DefineClassHelper.java:271)
	at javassist.ClassPool.toClass(ClassPool.java:1240)
	at javassist.ClassPool.toClass(ClassPool.java:1098)
	at javassist.ClassPool.toClass(ClassPool.java:1056)
	at javassist.CtClass.toClass(CtClass.java:1298)
	at com.kdyzm.Main.main(Main.java:21)
Caused by: java.lang.ClassFormatError: loader (instance of  sun/misc/Launcher$AppClassLoader): attempted  duplicate class definition for name: "com/kdyzm/Test1"
	at javassist.util.proxy.DefineClassHelper$Java7.defineClass(DefineClassHelper.java:182)
	at javassist.util.proxy.DefineClassHelper.toClass(DefineClassHelper.java:260)
	... 5 more

问题代码就出在:ctClass.toClass();这行代码上,从问题描述上来看,是重复加载了同一个类导致的。

2、异常分析

通过一步一步debug,最终看到了报错执行的方法是:javassist.util.proxy.DefineClassHelper.Java7#defineClass

image-20220304140313960

在截图中可以清楚的看到,实际上捕获到的异常类型是LinkeageError,但是捕获到之后被转换成了ClassFormatError抛出,ClassformatError类的定义如下:

image-20220304140540554

可以看出,ClassFormatError类是LinkageError类的子类,所以这里可能只是想要做到更加符合ClassFormatError的语义要求。

3、使用反射技术实现类加载

image-20220304141258211

截图中的代码

defineClass.invokeWithArguments(
            loader, name, b, off, len, protectionDomain)

实际上是使用反射调用了ClassLoader类的defineClass方法,看下defineClass的定义就知道了

private static class Java7 extends Helper {
        private final SecurityActions stack = SecurityActions.stack;
        private final MethodHandle defineClass = getDefineClassMethodHandle();
        private final MethodHandle getDefineClassMethodHandle() {
            if (privileged != null && stack.getCallerClass() != this.getClass())
                throw new IllegalAccessError("Access denied for caller.");
            try {
                return SecurityActions.getMethodHandle(ClassLoader.class, "defineClass",
                        new Class[] {
                            String.class, byte[].class, int.class, int.class,
                            ProtectionDomain.class
                        });
                } catch (NoSuchMethodException e) {
                    throw new RuntimeException("cannot initialize", e);
                }
        }

        @Override
        Class<?> defineClass(String name, byte[] b, int off, int len, Class<?> neighbor,
                ClassLoader loader, ProtectionDomain protectionDomain)
            throws ClassFormatError
        {
            if (stack.getCallerClass() != DefineClassHelper.class)
                throw new IllegalAccessError("Access denied for caller.");
            try {
                return (Class<?>) defineClass.invokeWithArguments(
                            loader, name, b, off, len, protectionDomain);
            } catch (Throwable e) {
                if (e instanceof RuntimeException) throw (RuntimeException) e;
                if (e instanceof ClassFormatError) throw (ClassFormatError) e;
                throw new ClassFormatError(e.getMessage());
            }
        }
    }

和常见的反射技术不同的是,这里使用的MethodHandle类实现反射,最终调用的方法是:java.lang.ClassLoader#defineClass(java.lang.String, byte[], int, int, java.security.ProtectionDomain)

image-20220304141754544

该方法从一个字节数组中获取字节码数据并最终调用defineClass1方法解析成为类对象,该方法会抛出ClassFormatError、NoClassDefFoundError等异常,但是实际上不仅仅这些异常,还有本例中的LinkageError,这里并没有包含所有的异常种类。

这个方法有个特点,如果加载了重复的类对象,会抛出LinkageError异常,这是在defineClass1方法中发生的逻辑

image-20220304142408725

可以看到,defineClass1方法是一个本地方法,底层是C++实现的,没法直接看到

4、defineClass1源码解析

以jdk1.8为例,defineClass1的源码地址:https://github.com/openjdk/jdk/blob/jdk8-b81/jdk/src/share/native/java/lang/ClassLoader.c#L90

由于这玩意是C实现的,我看的也是云里来雾里去,大体上的调用链是:

Java_java_lang_ClassLoader_defineClass1->JVM_DefineClassWithSource->resolve_from_stream->SystemDictionary::find_or_define_instance_class或者SystemDictionary::define_instance_class

在find_or_define_instance_class方法上,有一段注释如下:

// Support parallel classloading
// All parallel class loaders, including bootstrap classloader
// lock a placeholder entry for this class/class_loader pair
// to allow parallel defines of different classes for this class loader
// With AllowParallelDefine flag==true, in case they do not synchronize around
// FindLoadedClass/DefineClass, calls, we check for parallel
// loading for them, wait if a defineClass is in progress
// and return the initial requestor's results
// This flag does not apply to the bootstrap classloader.
// With AllowParallelDefine flag==false, call through to define_instance_class
// which will throw LinkageError: duplicate class definition.
// False is the requested default.
// For better performance, the class loaders should synchronize
// findClass(), i.e. FindLoadedClass/DefineClassIfAbsent or they
// potentially waste time reading and parsing the bytestream.
// Note: VM callers should ensure consistency of k/class_name,class_loader

代码可能看不大懂,但是这段注释还是能看个几分明白,特别是这段

With AllowParallelDefine flag==false, call through to define_instance_class which will throw LinkageError: duplicate class definition.

define_instance_class方法会抛出LinkageError:duplicate class definition.这和java代码中看到的错误异常一模一样,而且,注释的最后,还贴心的给了一个提示:VM callers should ensure consistency of k/class_name,class_loader,这告诉我们,要确保目标类和加载的ClassLoader的一致性,否则会抛出异常:LinkageError。

下面的代码就看不懂了,但是基本上我也找到了答案:调用java.lang.ClassLoader#defineClass(java.lang.String, byte[], int, int, java.security.ProtectionDomain)方法要确保一个类只会被同一个ClassLoader加载一次,否则就会报错:loader (instance of sun/misc/Launcher$AppClassLoader): attempted duplicate class definition for name xxx

5、问题复现

上面使用了javassist修改完字节码问题件之后出现了attempted duplicate class definition for name xxx的错误,现在不使用javassist,使用最简单的代码来重现这个问题

import lombok.extern.slf4j.Slf4j;

import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.Method;
import java.security.AccessController;
import java.security.PrivilegedActionException;
import java.security.PrivilegedExceptionAction;
import java.security.ProtectionDomain;

/**
 * @author kdyzm
 * @date 2022/3/2
 */
@Slf4j
public class Main2 {

    public static void main(String[] args) throws Throwable {
        defineClass();
        defineClass();
    }

    private static void defineClass() throws Throwable {
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        MethodHandle methodHandle = null;
        try {
            methodHandle = getMethodHandle(ClassLoader.class, "defineClass", new Class[]{
                    String.class,
                    byte[].class,
                    int.class,
                    int.class,
                    ProtectionDomain.class});
        } catch (Throwable e) {
            log.error("", e);
            return;
        }
        byte[] bytes = getClassBytes();
        try {
            Class<Test1> clazz = (Class<Test1>) methodHandle.invokeWithArguments(
                    contextClassLoader,
                    "com.kdyzm.Test1",
                    bytes,
                    0,
                    bytes.length,
                    null
            );
            log.info(clazz.toString());
        } catch (Throwable throwable) {
            log.error("",throwable);
        }
    }


    static MethodHandle getMethodHandle(final Class<?> clazz,
                                        final String name,
                                        final Class<?>[] params) throws NoSuchMethodException {
        try {
            return AccessController.doPrivileged(
                    (PrivilegedExceptionAction<MethodHandle>) () -> {
                        Method rmet = clazz.getDeclaredMethod(name, params);
                        rmet.setAccessible(true);
                        MethodHandle meth = MethodHandles.lookup().unreflect(rmet);
                        rmet.setAccessible(false);
                        return meth;
                    });
        } catch (PrivilegedActionException e) {
            if (e.getCause() instanceof NoSuchMethodException) {
                throw (NoSuchMethodException) e.getCause();
            }
            throw new RuntimeException(e.getCause());
        }
    }

    private static byte[] getClassBytes() throws IOException {
        FileInputStream fis = new FileInputStream("D:\\projects-my\\Main\\target\\classes\\com\\kdyzm\\Test1.class");
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        byte[] buff = new byte[1024];
        int length = -1;
        while ((length = fis.read(buff)) != -1) {
            byteArrayOutputStream.write(buff, 0, length);
        }
        return byteArrayOutputStream.toByteArray();
    }
}

结果报错如下:

15:12:21.799 [main] INFO com.kdyzm.Main2 - class com.kdyzm.Test1
15:12:21.803 [main] ERROR com.kdyzm.Main2 - 
java.lang.LinkageError: loader (instance of  sun/misc/Launcher$AppClassLoader): attempted  duplicate class definition for name: "com/kdyzm/Test1"
	at java.lang.ClassLoader.defineClass1(Native Method)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:756)
	at java.lang.invoke.MethodHandle.invokeWithArguments(MethodHandle.java:627)
	at com.kdyzm.Main2.defineClass(Main2.java:44)
	at com.kdyzm.Main2.main(Main2.java:25)

可以看到第一次加载成功后再次调用defineClass方法加载Test1类就会直接报错LinkageError,符合预期结果。

六、其它疑问的思考

上面只是说了javassist调用了ClassLoader的defineClass方法实现的类加载,但是类加载的方法有好几种,为什么要调用defineClass方法而不调用Class.forName方法或者ClassLoader.loadClass方法加载类?毕竟,调用defineClass方法必须通过反射调用,而且重复加载类还会报错异常。。。

我的理解是:使用javassist并没有修改字节码文件,而只是修改了字节码对象,举个例子,我们通过jar包运行的程序,根本不可能在运行中修改jar包中打包的class文件。提前调用defineClass方法加载好被修改该过的类,这样运行中正常调用Class.forName或者ClassLoader.loadClass方法的时候,发现该类已经被加载过了就不再重新加载了,这样就实现了运行中修改字节码对象实现偷梁换柱的目的。

标签:kdyzm,java,字节,修改,defineClass,class,javassist
From: https://www.cnblogs.com/ZhangZiXue/p/17415683.html

相关文章

  • 微信扫码登录(new WxLogin)二维码样式修改
    一、自定义二维码样式例如:.impowerBox.qrcode{width:180px;}.impowerBox.title{display:none;}.impowerBox.info{width:180px;}.status_icon{display:none}.impowerBox.status{text-align:center;}二、自定义二维码样式进行base64加密(在线加密解密网站)......
  • 使用Python脚本修改Linux用户的密码
    直接上代码使用python,通过系统默认的passwd命令,修改用户Tom的密码为NewPasswordimportsubprocess#Gettheusernameandnewpasswordfromtheuserusername="Tom"new_password="NewPassword"#Usethe'passwd'commandtoupdatethepassword#Th......
  • Oracle从入门到精通-合并查询、添加修改删除数据
    6Oracle表的管理6.5oracle表的管理-表查询(重点)6.5.5Oracle表复杂查询--合并查询·合并查询有时在实际应用中,为了合并多个select语句的结果,可以使用集合操作符号union,unionall,intersect,minus1)union该操作符用于取得两个结果集的并集,当使用该操作符时,=selectename,sal,j......
  • Kali学习—修改Kali镜像源
    KaliLinux是一款基于Debian的Linux操作系统,由于其强大的安全测试及渗透测试工具而广受欢迎。国内用户可以使用以下KaliLinux镜像源来加速软件包更新和安装:清华大学镜像站:https://mirrors.tuna.tsinghua.edu.cn/kali/中科大镜像站:http://mirrors.ustc.edu.cn/kali/阿里......
  • EF Core 主从表修改主键类型步骤
    1.背景有两张表Blog与PostclassDiagramclassBlog{+GuidId+StringName+DateTimeCreateTime+intOrder+List<Post>Posts}classPost{+StringId+StringContent+GuidBlogId+BlogBlog}......
  • Spartacus base-url 访问 - CSR 端需要修改的配置
    假设我想把Spartacus的url后面增加customurl访问,比如以前通过https://spartacus-demo.eastus.cloudapp.azure.com/electronics-spa/访问,现在通过https://spartacus-demo.eastus.cloudapp.azure.com/electronics-spa/jerry访问。在CSR即客户端渲染模式下,在app.modul......
  • C# 内存流转换为字节数组(内存流转比特数组)
    MemoryStreamms=newMemoryStream();//方法一byte[]bytes=ms.ToArray();//方法二byte[]bytes1=newbyte[ms.Length];ms.Read(bytes1,0,bytes1.Length);//设置当前流的......
  • asp.net web应用程序,如果配置数据设置在 Web. config,每次修改配置项的值,都需要重启应
    问题:asp.netweb应用程序,如果配置数据设置在Web.config,每次修改配置项的值,都需要重启应用才能生效,怎么优化? 对于ASP.netweb应用程序,如果配置数据设置在Web.config,每次修改配置项的值,都需要重启应用才能生效。有没有更好的方法来配置应用程序所需的数据?解决每次修改配置......
  • kube-proxy修改日志级别并观察endpoint变化
    k8sv1.15.0修改日志级别keditdskube-proxy-nkube-system增加kube-system命名空间下corednsPodkgetendpointskube-dns-nkube-system-oyaml持续输出kube-proxy日志dockerlogs-f`dockerps|grepkube-proxy|grep-vpause|awk'{print$1}'`pkg/prox......
  • Golang基础-字节跳动青训营
    Golang安装访问https://go.dev/,点击Download,下载对应平台安装包,安装即可如果无法访问上述网址,可以改为访问https://studygolang.com/dl下载安装如果访问github速度比较慢,建议配置gomodproxy,参考https://goproxy.cn/里面的描述配置,下载第三方依赖包的速度可以大......