深入理解虛擬機實戰:修改class文件實現System標準輸出重定向

一.背景

在深入理解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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章