Java Class 文件格式及其简单 Hack

最近由于项目要求,需要对 Java Class 文件进行更改。因此必须先了解 Java Class 文件的结构。下面是对 JVMS(Java Virtual Machine Specification) 和一些博客内容的总结。

每个 class 文件包括了一个类或者接口的定义。尽管并不是每个类或者接口都要在一个文件中有外部表示(例如通过类加载器生成的类),我们一般认为 class 文件格式是一个类或接口的有效表示。

一个 class 文件由 8位字节流构成。所有16位、32位以及64位的属性都通过读取2个、4个或者8个连续的8位字节构造出来,并以此类推。多字节字段用大端法存储,也就是说高位优先。在 Java SE 平台中,这种格式由
接口 java.io.DataInput 和 java.io.DataOutput 以及 java.io.DataInputStream 和 java.io.DataOutputStream 等类支持。

Java Class 文件结构

一个 Java Class 文件包括 10 个基本组成部分:

  1. 魔数: 0xCAFEBABE
  2. Class 文件格式版本号:class 文件的主次版本号(the minor and major versions)
  3. 常量池(Constant Pool):包含 class 中的所有常量
  4. 访问标记(Access Flags):例如该 class 是否为抽象类、静态类,等等。
  5. 该类(This Class):当前类的名称
  6. 父类(Super Class):父类的名称
  7. 接口(Interfaces):该类的所有接口
  8. 字段(Fields):该类的所有字段
  9. 方法(Methods):该类的所有方法
  10. 属性(Attributes):该类的所有属性(例如源文件名称,等等)

下面是一个示意图。

ClassFile {
    u4             magic;
    u2             minor_version;
    u2             major_version;
    u2             constant_pool_count;
    cp_info        constant_pool[constant_pool_count-1];
    u2             access_flags;
    u2             this_class;
    u2             super_class;
    u2             interfaces_count;
    u2             interfaces[interfaces_count];
    u2             fields_count;
    field_info     fields[fields_count];
    u2             methods_count;
    method_info    methods[methods_count];
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

下图是使用 Java Bytecode Editor 打开 HelloWorld.class 文件(该文件由后面的 HelloWorld.java 编译得到)后显示的该文件的一些信息:(后面详细介绍到每个部分的时候可以再看看这个图)
Java Class 文件信息

这里有一些可变长度部分,例如常量池、方法、以及属性,因此在加载之前无法知道 Java Class 文件的长度。在这些部分的前面都有长度信息。这样 JVM 在真正加载这些部分之前就可以知道可变长度部分的大小。

Class 文件中的数据都是按照单字节对齐并且紧密压缩。这使得 Class 文件能尽可能小。

Java Class 文件中不同部分的顺序是严格定义的,因此 JVM 知道 Class 文件中每个部分分别是什么、要按照什么顺序加载。

下面来详细看看一个 Class 文件中的每个部分。

魔数(Magic number)

魔数(Magic number)用来唯一确定格式并和其它格式区别开来。 Class 文件的头四个字节是0xCAFEBABE
Java Class 文件魔数

Class 文件版本号

Class 文件接下来的 4 个字节表示主次版本号。这个数字使得 JVM 可以识别和验证 class 文件。如果数字比 JVM 能够加载的还要大,就会拒接加载该 class 文件并抛出 java.lang.UnsupportedClassVersionError 异常。

你可以使用 javap 命令行工具查看任意 Java Class 文件的版本号。例如:

1javap -verbose MyClass

假设我们有如下一个 Java 类:

1public class HelloWorld {
2  private String msg;
3  public HelloWorld(String msg) {
4    this.msg = msg;
5  }
6  public HelloWorld() {
7    this.msg = "Default message";
8  }
9  public String getMsg() {
10    return msg;
11  }
12  public void setMsg(String msg) {
13    this.msg = msg;
14  }
15  public void printMsg() {
16    System.out.println(msg);
17  }
18  public static void main(String args[]) {
19    HelloWorld hw = new HelloWorld("Hello world from Java");
20    hw.printMsg();
21  }
22}

我们用命令 javac HelloWorld.java 编译创建 class 文件。然后执行 javap -verbose HelloWorld命令查看 class 文件的版本号:

Java Class 文件版本号

下面是一个主版本号(Major version)和 class 文件对应 JDK 版本号的列表。

Major VersionHexJDK version
510x33J2SE 7
500x32J2SE 6.0
490x31J2SE 5.0
480x30JDK 1.4
470x2FJDK 1.3
460x2EJDK 1.2
450x2DJDK 1.1

常量池(Constant Pool)

所有和类或者接口相关的常量都保存在常量池里。这些常量包括类名、变量名、接口名称、方法名称、签名和字符串常量等。

常量在常量池中以一个可变长数组的元素形式保存。常量数组前面有一个数组大小,因此 JVM 知道加载 class 文件的时候需要加载多少个常量。

对于每一个数组元素,第一个字节是一个标记(tag),表示该位置常量的类型。JVM 通过读取这个字节确定常量的类型。如果单字节标记表示是一个字符串字面值,就会读取后两个字节,表示字符串字面值的长度,根据长度再从后面读取对应长度的字符串的实际值。

你可以使用 javap 命令分析任何 class 文件的常量池。如果对上面的 HelloWorld.class 文件执行 javap 命令,我们可以获得下面的符号表。

Java Class 文件常量池

常量池总共有 42 个元素。注意:constant_pool_count 的值是常量池的数目再加上1,例如这里是 43。一个常量池索引只有大于0且小于 constant_pool_count 时才认为有效。

下面是单字节标记对应的值及其解释,对于每个类型对应的结构体,可以参考 JVMS The Constant Pool

常量类型
CONSTANT_Class7
CONSTANT_Fieldref9
CONSTANT_Methodref10
CONSTANT_InterfaceMethodref11
CONSTANT_String8
CONSTANT_Integer3
CONSTANT_Float4
CONSTANT_Long5
CONSTANT_Double6
CONSTANT_NameAndType12
CONSTANT_Utf81
CONSTANT_MethodHandle15
CONSTANT_MethodType16
CONSTANT_InvokeDynamic18

访问标记(Access flags)

常量池后面的就是访问标记。它由两个字节组成,表示该文件定义的是类还是接口、如果是个类,是 public、abstract还是 final 等。下面是访问标记列表及其对应的解释:

标记名称解释
ACC_PUBLIC0x0001表示public/strong>;包外的类也可以访问。
ACC_FINAL0x0010表示 final;不允许有任何子类。
ACC_SUPER0x0020通过 invokespecial 指令调用时调用父类的方法。
ACC_INTERFACE0x0200是一个接口而不是类
ACC_ABSTRACT0x0400表示 抽象类,不能被实例化。

this Class

This class 是一个两个字节的条目,它的值是一个常量池索引。例如对于 HelloWorld.class 文件,该处的值是0x0006。在常量池中这个索引指向的条目包括两个部分,第一个部分是单字节标记,表示这是一个类或是接口,第二部分又是一个两个字节的常量池索引,指向表示该类或接口的字符串字面值。例如在这个例子中,0x0006 索引所在的条目是一个Class_info,它指向索引值为 0x0021,也就是 33 的 Utf8_info,这个 utf8_info 的值为 HelloWorld,也就是实际的类名。可以查看上面 Java Class 文件常量池示意图对应 #6 和 #33部分。

super Class

接下来的 2 个字节是该类的父类(Super Class)。和 this class 类似,两个字节的值是常量池的一个索引,该索引处的常量值是该类的父类。

接口(Interfaces)

该类(或接口)定义的所有接口都在 class 文件的这个部分。起始的两个字节表示接口的数目,接下来是一个数组,每个数据包括两个字节,这两个字节的值又是一个常量池索引,指向具体的接口名称。

字段(Fields)

一个字段是类或者接口在实例或类层面的变量(属性)。字段(Fields)部分只包括 class 文件中类或接口定义的字段,而不包括从父类或父接口中继承而来的字段。

Fileds 部分的前两个字节也是一个计数,表示字段的数目。接下来是一个表示每个字段的一个数组。每个数组元素是一个可变长度的结构体。该字段的一些信息保存在这个结构体中,也有一些信息保存在常量池中。

方法(Methods)

Methods 部分包括了该类显式定义的方法,不包括从父类或父接口中继承来的方法。

头两个字节表示方法的数目。剩下的又是一个可变长度数组,其中保存了每个方法的信息。方法结构体保存了方法的多个信息,例如参数列表、返回值、保存局部变量和操作数需要的堆栈数目、异常表、字节码系列等。

属性(Attributes)

属性部分包括了 class 文件的多个属性信息,例如其中之一是源码属性(source code attribute),表示这个 class 文件是从哪个源文件编译得到的。

属性部分的前两个字节表示属性的数目,接下来的是属性具体内容。JVM 会忽视任何它无法识别的属性。

前面介绍的可以说是背景知识,下面的就是是实际的动手实践

Hacking Into Java Class File

假如我们手里只有一个 HelloWorld.class 文件,我们想在没有源文件的情况下修改类名,例如我想把类改为 CppWorld。该怎么办呢?一般有两种方法:反编译或者修改直接修改 class 文件。

下面是我在 Decompilers online 用 CFR 方法反编译 HelloWorld.class 文件得到的结果:

1/*
2 * Decompiled with CFR 0_110.
3 */
4import java.io.PrintStream;
5 
6public class HelloWorld {
7    private String msg;
8 
9    public HelloWorld(String string) {
10        this.msg = string;
11    }
12 
13    public HelloWorld() {
14        this.msg = "Default message";
15    }
16 
17    public String getMsg() {
18        return this.msg;
19    }
20 
21    public void setMsg(String string) {
22        this.msg = string;
23    }
24 
25    public void printMsg() {
26        System.out.println(this.msg);
27    }
28 
29    public static void main(String[] arrstring) {
30        HelloWorld helloWorld = new HelloWorld("Hello world from Java");
31        helloWorld.printMsg();
32    }
33}

看起来和上面的 HelloWorld.java 完全一样,这时候我们再修改 .java 文件,更改类名,然后再编译得到新的类。这对于一个 Java 新手来说都是轻而易举。但问题是,对于一个复杂的类或者有很多 .class 文件的 jar 包,反编译的结果仍然正确吗?

答案显然是否定的,我尝试了Decompilers online 上面的所有方法去反编译一个 JDBC Jar 包,得到的结果存在一大堆错误,从显而易见的到人肉眼都难以发现的错误都有。如果这时候再去一一修正,显然比较困难。一方面反编译出来的源码比较晦涩难懂,例如它里面使用了非常多的 switch case 语句,或者对于无法简单判断出来的类型,反编译器使用了 Object 类代替;另一方面,反编译出来的源码是没有注释的,一个有上千个文件但却没有一行注释的源码,单只是想想就令人恐惧。

下面我们就尝试第二种方法,直接修改 class文件。显而易见的是我们可以尝试把 class文件中的所有 “HelloWorld” 字符串替换为 “CppWorld” 字符串。这只需要一个支持16进制编辑的文本编辑器就可以实现。例如我使用 UltraEdit 完成这个字符串替换操作,然后把文件名 HelloWorld.class 修改为 CppWorld.class。然后运行,结果如下:

简单字符串替换

是什么原因呢?这里我们只替换了字符串,但没有替换字符串前面的长度。那么如果替换前后字符串长度相同是不是就可以了呢?例如我想替换为 MycppWorld。再来尝试一次,结果在上面的截图中。可以看出,对于相同长度的字符串,简单地进行字符串替换是可以达到 Hack Class File 目的的。同样,对于字符串长度不一样的情况,我们只需要同时修改字符串前面的长度即可。通过阅读 JVMS 中的 Class File Format 章节,发现其实只需要修改 Constant Pool 部分、其余保持不变即可。例如说下面这个简单的事例程序,它实现了 Class 文件 Constant Pool 部分的字符串替换:

1import java.io.BufferedReader;
2import java.io.File;
3import java.io.FileInputStream;
4import java.io.FileOutputStream;
5import java.io.IOException;
6import java.io.InputStreamReader;
7import java.nio.ByteBuffer;
8import java.nio.ByteOrder;
9 
10/**
11 * String replace in Java .class file.
12 * Reference: Java Virtual Machine Specification CLASS file format
13 https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html
14 *
15 * @author luoyuanhao
16 *
17 */
18 
19public class Localization {
20 
21  public static void localize(String path) {
22    FileInputStream fis = null;
23    FileOutputStream fos = null;
24    long totalsize = 0;
25    int aval_buf = 100;
26    byte[] bs = new byte[aval_buf];
27    try {
28      // Output replaced content to file path.out
29      fis =new FileInputStream(
30          new File(path));
31      fos = new FileOutputStream(
32          new File(path + ".out"));
33      System.out.println("Processing: " + path);
34 
35      // Skip magic, max and minus version, 8 bytes
36      fis.read(bs, 08);
37      fos.write(bs, 08);
38      totalsize += 8;
39 
40      // Get number of constant pool entries, 2 bytes
41      fis.read(bs, 02);
42      fos.write(bs, 02);
43      totalsize += 2;
44      short cp_number = bytes2short(bs, 02);
45      System.out.println("Constant pool number: " + cp_number);
46 
47      // Handle each constant pool entry
48      String str = null;
49      for (short i = 1; i < cp_number; i++) {
50        // Read flag, 1 byte
51        fis.read(bs, 01);
52        fos.write(bs, 01);
53        totalsize += 1;
54        // Unless tag value is 1(means utf-8_info where replacement
55        // to be done), just skip specific bytes.
56        short tag = bytes2short(bs, 01);
57        switch (tag) {
58        case 7:
59        case 8:
60        case 16:
61          fis.read(bs, 0 ,2);
62          fos.write(bs, 02);
63          totalsize += 2;
64          break;
65        case 15:
66          fis.read(bs, 03);
67          fos.write(bs, 03);
68          totalsize += 3;
69          break;
70        case 3:
71        case 4:
72        case 9:
73        case 10:
74        case 11:
75        case 12:
76        case 18:
77          fis.read(bs, 04);
78          fos.write(bs, 04);
79          totalsize += 4;
80          break;
81        case 5:
82        case 6:
83          fis.read(bs, 08);
84          fos.write(bs, 08);
85          //  Next cp index must be valid but is considered unusable
86          i++;
87          totalsize += 8;
88          break;
89        case 1:
90        {
91          fis.read(bs, 02);
92          totalsize += 2;
93          short str_len = bytes2short(bs, 0 ,2);
94          while (str_len > aval_buf) {
95            System.out.println("Constant pool number: " + i);
96            System.out.println("Buffer overflow, double it from "+
97                aval_buf + " to " + aval_buf * 2);
98            aval_buf *= 2;
99            bs = new byte[aval_buf];
100          }
101          fis.read(bs, 0, str_len);
102          totalsize += str_len;
103          // There may be '\0' in bytes array, but UTF-8 can't
104          // handle it, so using 'ISO-8859-1' to encode string.
105          str = new String(bs, 0, str_len, "ISO-8859-1");
106          str = localizeInternal(str);
107          str_len = (short)str.length();
108          byte[] new_len = short2bytes(str_len);
109          // Update string and length
110          fos.write(new_len, 02);
111          fos.write(str.getBytes("ISO-8859-1"), 0, str_len);
112          break;
113        }
114        default:
115          System.out.println("File: " + path);
116          System.out.println("Unrecognized tag: " + tag + ", cp num: " + i);
117          System.out.println("After: " + str + ". Byte offset:" + totalsize);
118          System.exit(1);
119        }// end switch
120      }// end for
121      // Read rests
122      byte[] bsrest = new byte[fis.available()];
123      fis.read(bsrest);
124      fos.write(bsrest);
125    catch (Exception e) {
126      e.printStackTrace();
127    finally {
128      if (fis != null) {
129        try {
130          fis.close();
131        catch (IOException e) {
132          e.printStackTrace();
133        }
134      }
135      if (fos != null) {
136        try {
137          fos.close();
138        catch (IOException e) {
139          e.printStackTrace();
140        }
141      }
142    }
143  }
144 
145  private static short bytes2short(byte[] bs, int offset, intlength) {
146    if (length == 1return (short) (bs[0] & 0xFF);
147    ByteBuffer buf = ByteBuffer.wrap(bs, offset, length);
148    buf.order(ByteOrder.BIG_ENDIAN);
149    return buf.getShort();
150  }
151 
152  private static byte[] short2bytes(short val) {
153    ByteBuffer buf = ByteBuffer.allocate(2);
154    buf.putShort(val);
155    return buf.array();
156  }
157 
158  private static String localizeInternal(String str) {
159 
160    // Replace "HelloWorld" whih "CppWorld"
161    String new_str = str.replaceFirst("HelloWorld""CppWorld");
162    while (!new_str.equals(str)) {
163      str = new_str;
164      new_str = str.replaceFirst("HelloWorld""CppWorld");
165    }
166    return str;
167  }
168 
169  public static void main(String args[]) {
170    localize("HelloWorld.class");
171  }
172}

下面是运行的结果,我们首先编译这个工具类 Localization.java,然后使用这个工具类修改 HelloWorld.class 文件生成 HelloWorld.class.out 文件,重命名 HelloWorld.class.out 文件为 CppWorld.class 文件,然后运行 java CppWorld。运行成功!

from: http://www.stay-stupid.com/?p=401 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值