深入理解虚拟机实战:修改class文件实现System标准输出重定向

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_35603331/article/details/79773843

一.背景

在深入理解Java虚拟机的过程中,理解java程序在虚拟机层次如何执行十分重要。了解了深层次的东西,才可以实现一般情况下做不到的特殊功能,而这种特殊功能面向的对象往往是程序员本身。下面我们通过一个实例进行学习。

二.需求

已有一个编译好的class文件,这个文件中只有一个类,并且有一个main方法。这个方法中调用了System.out.println()输出了一些信息。现在我们想运行这个程序,确切的说是调用这个文件的main方法,将输出的信息打印到一个文件中,但是与此同时,为了不影响其他程序的正常输出,不能改变System的标准输出对象。此外,我们还希望在向文件中输出信息时,可以在每条信息前加上序号。另外我们没有这个class文件的源代码。

三.思路

要运行这个类的main方法,可以使用反射的方式。难点在于标准输出重定向,如果直接使用System.setOut()方法,会改变System的标准输出对象,因此不能采用。
这里通过一个偷梁换柱的方式,直接修改class文件,将对System类的调用指向我们自己定义的HackSystem类,并重新封装一个PrintStream类作为HackSystem的out对象,从而实现添加行号的功能。

四.实现

字节工具类


package main;

import java.io.UnsupportedEncodingException;

import javax.xml.stream.events.StartDocument;

public class BytesUtil
{   
    /**
     * 
     * @param b 字节数组高位在前,第0个字节是最高位字节
     * @param start
     * @param len
     * @return
     */
    public static int bytes2Int(byte[] b, int start, int len)
    {
        int sum = 0;
        int end = start + len;
        for (int i = start; i < end; i++)
        {
            // 字节转无符号整数
            int n = ((int) b[i]) & 0xff;
            // 考虑到一个字节八位,将高位字节的值左移 右侧字节个数*8位
            n <<= (--len) * 8;
            sum += n;
        }
        return sum;
    }

    /**
     * 将value用len长度的字节数组表示,要求value为无符号整数,字节数组高位在前
     * @param value
     * @param len
     * @return
     */
    public static byte[] int2Bytes(int value,int len) {
            byte[] b = new byte[len];
            for (int i = 0; i < len; i++)
            {
                //从低位到高位填充字节数组
                //只考虑无符号情况,不考虑value为负
                b[len-1-i] = (byte) (value >>> (8*i));
            }
            return b;
    }
    /**
     * 返回字节数组UTF-8解码后的字符串
     * @param b
     * @param start
     * @param len
     * @return
     */
    public static String bytes2String(byte[] b,int start,int len)
    {
        try
        {
            return new String(b,start,len,"UTF-8");
        } catch (UnsupportedEncodingException e)
        {
            e.printStackTrace();
            return null;
        }
    }
    /**
     * 
     * @param string
     * @return
     */
    public static byte[] string2Bytes(String string)
    {
        try
        {
            return string.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e)
        {
            e.printStackTrace();
            return null;
        }
    }
    /**
     * 用给定的字节数组替换指定字节数组中的部分字节
     * @param src
     * @param offset
     * @param length
     * @param replaceBytes
     * @return
     */
    public static byte[] replaceBytes(byte[] src,int offset,int length,byte[] replaceBytes)
    {   
        //计算替换后长度,建立新数组
        byte[] newBytes = new byte[src.length-length+replaceBytes.length];
        //前
        System.arraycopy(src, 0, newBytes, 0, offset);
        //中
        System.arraycopy(replaceBytes,0,newBytes,offset, replaceBytes.length);
        //后
        System.arraycopy(src, offset+length, newBytes, offset+replaceBytes.length,src.length-offset-length);
        return newBytes;
    }
}

修改class文件工具类


package main;

public class ClassModifier
{
    private static final int CONSTANT_POOL_COUNT_INDEX = 8;
    private static final int CONSTANT_UTF8_INFO = 1;
    //相应tag的常量池中的数据结构对应的字节数,-1表示扫描常量池时不使用该tag的长度
    private static final int[] CONSTANT_ITEM_LENGTH = {-1,-1,-1,5,5,9,9,3,3,5,5,5,5};
    private static final int u1 = 1;
    private static final int u2 = 2;
    private byte[] classBytes;
    public ClassModifier(byte[] classBytes){
        this.classBytes = classBytes;
    }
    public byte[] modifyUTF8Constant(String oldStr,String newString){
        byte[] newBytes = classBytes;
        //读取常量池长度
        int len = BytesUtil.bytes2Int(classBytes, CONSTANT_POOL_COUNT_INDEX, u2);
        int index = CONSTANT_POOL_COUNT_INDEX+u2;
        for (int i = 0; i < len; i++)
        {
            int tag = BytesUtil.bytes2Int(classBytes, index, u1);

            if(tag == CONSTANT_UTF8_INFO) //是UTF-8类型变量
            {   
                int oldStringLength = BytesUtil.bytes2Int(classBytes, index+u1, u2);
                String content = BytesUtil.bytes2String(classBytes, index+3, oldStringLength);
                System.out.println("utf8常量:"+content);
                if(content.equalsIgnoreCase(oldStr))
                {   
                    //发现目标
                    //新字符串字节
                    byte[] newStringBytes = BytesUtil.string2Bytes(newString);
                    //新字符串长度字节
                    byte[] newLengthBytes = BytesUtil.int2Bytes(newStringBytes.length, u2);
                    //替换长度
                    newBytes = BytesUtil.replaceBytes(classBytes, index+1,2, newLengthBytes);
                    //替换字符串
                    newBytes = BytesUtil.replaceBytes(newBytes, index+3,oldStringLength, newStringBytes);
                    break;
                }
                index += (3+oldStringLength);
            }else {
                //其他类型常量,直接跳过
                index+=CONSTANT_ITEM_LENGTH[tag];
            }
        }
        //如果没找到目标字符串,直接返回原始字节数组
        return newBytes;
    }
}

用来替换System类的HackSystem


package main;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.PrintStream;

public class HackSystem
{   
    public static PrintStream out ;
    static{
        try
        {
            out = new MyPrintStream(new File("C:/Users/Administrator/Desktop/out.txt"));
        } catch (FileNotFoundException e)
        {
            e.printStackTrace();
        }
    }
}

自定义的类加载器,主要目的是开放出defineClass方法,将.class文件转化为一个Class对象


package main;

public class MyClassLoader extends ClassLoader
{
    public MyClassLoader()
    {
        super(MyClassLoader.class.getClassLoader());
    }
    public Class loadByte(byte[] classBytes)
    {   
        return defineClass(null,classBytes, 0,classBytes.length);
    }
}

自定义PrintStream


package main;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.PrintStream;

public class MyPrintStream extends PrintStream
{
    int i = 0;
    public MyPrintStream(File file) throws FileNotFoundException
    {
        super(file);
    }
    @Override
    public void println(String string)
    {   
        super.println((++i)+"."+string);
    }
}

主类


package main;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class Main
{   
    public static void main(String[] args)
    {
        System.out.println("第一行");
        System.out.println("第二行");
        System.out.println("第三行");
    }
    public static void exec() throws IOException
    {   
        InputStream inputStream = new FileInputStream("C:/Users/Administrator/Desktop/Main.class");
        byte[] classBytes = new byte[inputStream.available()];
        inputStream.read(classBytes);
        inputStream.close();
        //偷梁换柱
        ClassModifier classModifier = new ClassModifier(classBytes);
        classBytes = classModifier.modifyUTF8Constant("java/lang/System","main/HackSystem");
        //输出查看
        OutputStream outputStream = new FileOutputStream("C:/Users/Administrator/Desktop/Main2.class");
        outputStream.write(classBytes);
        outputStream.flush();
        outputStream.close();
        //
        MyClassLoader loader = new MyClassLoader();
        Class clazz = loader.loadByte(classBytes);
        try
        {
            Method method = clazz.getMethod("main",new Class[]{String[].class});
            method.invoke(null,new String[]{null});
        } catch (NoSuchMethodException e)
        {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (SecurityException e)
        {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IllegalAccessException e)
        {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IllegalArgumentException e)
        {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (InvocationTargetException e)
        {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }


}

五.操作流程

首先,我们将main方法这么写

    public static void main(String[] args)
    {
        System.out.println("第一行");
        System.out.println("第二行");
        System.out.println("第三行");
    }

然后编译,生成Main.class文件,将这个文件复制到桌面,路径为C:/Users/Administrator/Desktop/Main.class。
之后将main方法改为如下

public static void main(String[] args)
    {
//      System.out.println("第一行");
//      System.out.println("第二行");
//      System.out.println("第三行");
        try
        {
            exec();
        } catch (IOException e)
        {
            e.printStackTrace();
        }
    }

直接运行,获得输出结果
控制台内容如下

utf8常量:main/Main
utf8常量:java/lang/Object
utf8常量:《init》 //实际上是左右尖括号,不知为何markdown转义不了
utf8常量:()V
utf8常量:Code
utf8常量:LineNumberTable
utf8常量:LocalVariableTable
utf8常量:this
utf8常量:Lmain/Main;
utf8常量:main
utf8常量:([Ljava/lang/String;)V
utf8常量:java/lang/System

out.txt内容

1.第一行
2.第二行
3.第三行

很显然,目标达成

六.原理分析


1.常量池全限定类名替换

首先,我们需要知道,.class文件中存在一个常量池,这个常量池中存在11种类型的数据,包括一个UTF-8编码的字符串,五种字面量,五种符号引用。它们的结构如下图:常量池1
常量池
此外,常量池总是在class文件的第八个字节开始,并首先使用两个字节表述常量池中数据项的数量,之后则是各个数据项。
了解常量池的结构以后,我们就可以找到字符串为java/lang/System的UTF-8类型常量,并将其替换为main/HackSystem
完成这一操作的类是ClassModifier,需要注意的是,在《深入理解Java虚拟机第三版》一书中,作者给出的代码在ClassModifier类中有这样一段:

private static final int[] CONSTANT_ITEM_LENGTH = {-1,-1,5,-1,5,9,9,3,3,5,5,5,5};

我在参考常量池数据结构后,改为

    private static final int[] CONSTANT_ITEM_LENGTH = {-1,-1,-1,5,5,9,9,3,3,5,5,5,5};

大家可以思索一下有无道理,如有疑问欢迎指正。
在ClassModifier的方法modifyUTF8Constant(String oldStr,String newString)中,我们首先读取了常量池的数据个数

//读取常量池长度
        int len = BytesUtil.bytes2Int(classBytes, CONSTANT_POOL_COUNT_INDEX, u2);

之后扫描所有常量池数据,根据tag判断是否是UTF-8类型,不是的话移动相应长度索引

//其他类型常量,直接跳过
index+=CONSTANT_ITEM_LENGTH[tag];

如果是,打印该字符串(仅仅是查看一下而已,实际没啥用),并判断该字符串是不是我们要找的全限定类名,如果是,则进行替换

if(tag == CONSTANT_UTF8_INFO) //是UTF-8类型变量
{   
    int oldStringLength = BytesUtil.bytes2Int(classBytes, index+u1, u2);
    String content = BytesUtil.bytes2String(classBytes, index+3, oldStringLength);
    System.out.println("utf8常量:"+content);
    if(content.equalsIgnoreCase(oldStr))
    {   
        //发现目标
        //新字符串字节
        byte[] newStringBytes = BytesUtil.string2Bytes(newString);
        //新字符串长度字节
        byte[] newLengthBytes = BytesUtil.int2Bytes(newStringBytes.length, u2);
        //替换长度
        newBytes = BytesUtil.replaceBytes(classBytes, index+1,2, newLengthBytes);
        //替换字符串
        newBytes = BytesUtil.replaceBytes(newBytes, index+3,oldStringLength, newStringBytes);
        break;
    }
    index += (3+oldStringLength);
}

最后返回修改后的字节数组。

2.替换后的实际效果分析

在class文件中,每个方法的字节码都存储在相应方法的Code属性属性中,有些字节码在使用时需要传递参数,这些参数很多都是常量池中常量的索引,因此,我们替换了常量池中System类的全限定类名,就等于把Main类中所有对System类的调用转化成对HackSystem类的调用。
我们首先使用javap来看一下Main.class修改前的内容。
这是main方法的内容

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #22 // String 第一行
5: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream;
11: ldc #30 // String 第二行
13: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
16: getstatic #16 // Field java/lang/System.out:Ljava/io/PrintStream;
19: ldc #32 // String 第三行
21: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
24: return

以`

System.out.println("第一行");

这句代码为例,首先访问了System类的静态成员out,之后使用invokevirtual调用了虚方法println(),并传入了字符串作为参数 。关键的地方是,在getstatic字节码中,传入了一个指向常量池中field类型数据的索引,在field数据中,会有一个字段指向这个field所属的class类型数据,在class类型数据中,又会有一个字段指向一个UTF-8类型常量,这个字段就是这个类的全限定类名。getstatic字节码执行的过程实际就是对out字段的解析过程。解析就是把class文件中的符号引用在内存中的直接引用。在字段解析过程中,首先需要完成对类的符号解析。如果该类不是一个数组,虚拟机会把全限定类名交给类加载器去加载相应的类, 之后检查类的访问权限,如果访问权限不满足,则会报错。类解析完毕后,会在解析好的类中查找简单名称描述符和待解析字段相同的字段,如果找到,则返回该字段的直接引用,解析成功,否则会在父类和接口中查找。
这里有一个重要的知识点,字段的描述符是什么?其实就是由该字段的类型来决定,如果是方法的描述符的话,则由方法的参数列表和返回值决定。
那么我们自定义的HackSystem中的out字段,字段类型也必须是PrintStream才行,如果改成MyPrintStream,则会出错。
下面看看替换后的结果,是不是改变了getstatic字节码最终使用的全限定类名。

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #16 // Field main/HackSystem.out:Ljava/io/PrintStream;
3: ldc #22 // String 第一行
5: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: getstatic #16 // Field main/HackSystem.out:Ljava/io/PrintStream;
11: ldc #30 // String 第二行
13: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
16: getstatic #16 // Field main/HackSystem.out:Ljava/io/PrintStream;
19: ldc #32 // String 第三行
21: invokevirtual #24 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
24: return

可以看到,最终要解析的类的全限定类名已经改变。

三.方法重写的本质

我们的out字段实际上已经指向我们自定义的MyPrintStream对象了,在执行的时候,为什么执行的是我们重写的方法,而不是原来的println呢?
首先引入静态类型和实际类型的概念,静态类型就是字段被声明的类型,编译器可知,而实际类型则是字段所指向的对象的类型。
另外介绍非虚方法和虚方法,非虚方法包括静态方法,private方法,init方法,父类方法和final修饰的方法,这些方法在解析阶段就可以确定其直接引用,而不会被覆盖,其他的如public实例方法则属于虚方法,可能被覆盖,只有运行期才能知道其直接引用,说白了就是到底调用哪个println。
注意到字节码invokevirtual,这个指令是调用虚方法,会找到操作数栈顶的第一个元素所指向的实际类型,实际上就是找到我们自定义的那个MyPrintStream,如果在实际类型中找到方法名称和描述符均符合的方法,且通过访问权限校验,则返回该方法的直接引用,否则去父类中查找,这就是方法重写的本质,也是我们重写的println被成功调用的原因。

四.进一步扩展

那么我们可以更进一步,假如我们不让MyPrintStream类继承PrintStream类,直接把out声明为MyPrintStream,有没有办法能够成功实现功能呢?当然有,实现的关键在于成功通过字段解析,字段解析会对描述符进行校验,那么我们只要把原来的描述符Ljava/io/PrintStream替换为Lmain/MyPrintStream即可,更确切地说,是把值为Ljava/io/PrintStream的UTF-8常量替换为Lmain/MyPrintStream

猜你喜欢

转载自blog.csdn.net/qq_35603331/article/details/79773843
今日推荐