最近由于项目要求,需要对 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 个基本组成部分:
- 魔数: 0xCAFEBABE
- Class 文件格式版本号:class 文件的主次版本号(the minor and major versions)
- 常量池(Constant Pool):包含 class 中的所有常量
- 访问标记(Access Flags):例如该 class 是否为抽象类、静态类,等等。
- 该类(This Class):当前类的名称
- 父类(Super Class):父类的名称
- 接口(Interfaces):该类的所有接口
- 字段(Fields):该类的所有字段
- 方法(Methods):该类的所有方法
- 属性(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 文件的长度。在这些部分的前面都有长度信息。这样 JVM 在真正加载这些部分之前就可以知道可变长度部分的大小。
Class 文件中的数据都是按照单字节对齐并且紧密压缩。这使得 Class 文件能尽可能小。
Java Class 文件中不同部分的顺序是严格定义的,因此 JVM 知道 Class 文件中每个部分分别是什么、要按照什么顺序加载。
下面来详细看看一个 Class 文件中的每个部分。
魔数(Magic number)
魔数(Magic number)用来唯一确定格式并和其它格式区别开来。 Class 文件的头四个字节是0xCAFEBABE。
Class 文件版本号
Class 文件接下来的 4 个字节表示主次版本号。这个数字使得 JVM 可以识别和验证 class 文件。如果数字比 JVM 能够加载的还要大,就会拒接加载该 class 文件并抛出 java.lang.UnsupportedClassVersionError 异常。
你可以使用 javap 命令行工具查看任意 Java Class 文件的版本号。例如:
假设我们有如下一个 Java 类:
1 | public class HelloWorld { |
3 | public HelloWorld(String msg) { |
7 | this .msg = "Default message" ; |
9 | public String getMsg() { |
12 | public void setMsg(String msg) { |
15 | public void printMsg() { |
16 | System.out.println(msg); |
18 | public static void main(String args[]) { |
19 | HelloWorld hw = new HelloWorld( "Hello world from Java" ); |
我们用命令 javac HelloWorld.java 编译创建 class 文件。然后执行 javap -verbose HelloWorld命令查看 class 文件的版本号:
下面是一个主版本号(Major version)和 class 文件对应 JDK 版本号的列表。
Major Version | Hex | JDK version |
---|
51 | 0x33 | J2SE 7 |
50 | 0x32 | J2SE 6.0 |
49 | 0x31 | J2SE 5.0 |
48 | 0x30 | JDK 1.4 |
47 | 0x2F | JDK 1.3 |
46 | 0x2E | JDK 1.2 |
45 | 0x2D | JDK 1.1 |
常量池(Constant Pool)
所有和类或者接口相关的常量都保存在常量池里。这些常量包括类名、变量名、接口名称、方法名称、签名和字符串常量等。
常量在常量池中以一个可变长数组的元素形式保存。常量数组前面有一个数组大小,因此 JVM 知道加载 class 文件的时候需要加载多少个常量。
对于每一个数组元素,第一个字节是一个标记(tag),表示该位置常量的类型。JVM 通过读取这个字节确定常量的类型。如果单字节标记表示是一个字符串字面值,就会读取后两个字节,表示字符串字面值的长度,根据长度再从后面读取对应长度的字符串的实际值。
你可以使用 javap 命令分析任何 class 文件的常量池。如果对上面的 HelloWorld.class 文件执行 javap 命令,我们可以获得下面的符号表。
常量池总共有 42 个元素。注意:constant_pool_count 的值是常量池的数目再加上1,例如这里是 43。一个常量池索引只有大于0且小于 constant_pool_count 时才认为有效。
下面是单字节标记对应的值及其解释,对于每个类型对应的结构体,可以参考 JVMS The Constant Pool。
常量类型 | 值 |
CONSTANT_Class | 7 |
CONSTANT_Fieldref | 9 |
CONSTANT_Methodref | 10 |
CONSTANT_InterfaceMethodref | 11 |
CONSTANT_String | 8 |
CONSTANT_Integer | 3 |
CONSTANT_Float | 4 |
CONSTANT_Long | 5 |
CONSTANT_Double | 6 |
CONSTANT_NameAndType | 12 |
CONSTANT_Utf8 | 1 |
CONSTANT_MethodHandle | 15 |
CONSTANT_MethodType | 16 |
CONSTANT_InvokeDynamic | 18 |
访问标记(Access flags)
常量池后面的就是访问标记。它由两个字节组成,表示该文件定义的是类还是接口、如果是个类,是 public、abstract还是 final 等。下面是访问标记列表及其对应的解释:
标记名称 | 值 | 解释 |
---|
ACC_PUBLIC | 0x0001 | 表示public/strong>;包外的类也可以访问。 |
ACC_FINAL | 0x0010 | 表示 final;不允许有任何子类。 |
ACC_SUPER | 0x0020 | 通过 invokespecial 指令调用时调用父类的方法。 |
ACC_INTERFACE | 0x0200 | 是一个接口而不是类 |
ACC_ABSTRACT | 0x0400 | 表示 抽象类,不能被实例化。 |
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 文件得到的结果:
2 | * Decompiled with CFR 0_110. |
4 | import java.io.PrintStream; |
6 | public class HelloWorld { |
9 | public HelloWorld(String string) { |
14 | this .msg = "Default message" ; |
17 | public String getMsg() { |
21 | public void setMsg(String string) { |
25 | public void printMsg() { |
26 | System.out.println( this .msg); |
29 | public static void main(String[] arrstring) { |
30 | HelloWorld helloWorld = new HelloWorld( "Hello world from Java" ); |
31 | helloWorld.printMsg(); |
看起来和上面的 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 部分的字符串替换:
1 | import java.io.BufferedReader; |
3 | import java.io.FileInputStream; |
4 | import java.io.FileOutputStream; |
5 | import java.io.IOException; |
6 | import java.io.InputStreamReader; |
7 | import java.nio.ByteBuffer; |
8 | import java.nio.ByteOrder; |
11 | * String replace in Java .class file. |
12 | * Reference: Java Virtual Machine Specification CLASS file format |
19 | public class Localization { |
21 | public static void localize(String path) { |
22 | FileInputStream fis = null ; |
23 | FileOutputStream fos = null ; |
26 | byte [] bs = new byte [aval_buf]; |
28 | // Output replaced content to file path.out |
29 | fis = new FileInputStream( |
31 | fos = new FileOutputStream( |
32 | new File(path + ".out" )); |
33 | System.out.println( "Processing: " + path); |
35 | // Skip magic, max and minus version, 8 bytes |
40 | // Get number of constant pool entries, 2 bytes |
44 | short cp_number = bytes2short(bs, 0 , 2 ); |
45 | System.out.println( "Constant pool number: " + cp_number); |
47 | // Handle each constant pool entry |
49 | for ( short i = 1 ; i < cp_number; i++) { |
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, 0 , 1 ); |
85 | // Next cp index must be valid but is considered unusable |
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 ); |
99 | bs = new byte [aval_buf]; |
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, 0 , 2 ); |
111 | fos.write(str.getBytes( "ISO-8859-1" ), 0 , str_len); |
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); |
122 | byte [] bsrest = new byte [fis.available()]; |
125 | } catch (Exception e) { |
131 | } catch (IOException e) { |
138 | } catch (IOException e) { |
145 | private static short bytes2short( byte [] bs, int offset, int length) { |
146 | if (length == 1 ) return ( short ) (bs[ 0 ] & 0xFF ); |
147 | ByteBuffer buf = ByteBuffer.wrap(bs, offset, length); |
148 | buf.order(ByteOrder.BIG_ENDIAN); |
149 | return buf.getShort(); |
152 | private static byte [] short2bytes( short val) { |
153 | ByteBuffer buf = ByteBuffer.allocate( 2 ); |
158 | private static String localizeInternal(String str) { |
160 | // Replace "HelloWorld" whih "CppWorld" |
161 | String new_str = str.replaceFirst( "HelloWorld" , "CppWorld" ); |
162 | while (!new_str.equals(str)) { |
164 | new_str = str.replaceFirst( "HelloWorld" , "CppWorld" ); |
169 | public static void main(String args[]) { |
170 | localize( "HelloWorld.class" ); |
下面是运行的结果,我们首先编译这个工具类 Localization.java,然后使用这个工具类修改 HelloWorld.class 文件生成 HelloWorld.class.out 文件,重命名 HelloWorld.class.out 文件为 CppWorld.class 文件,然后运行 java CppWorld。运行成功!
from: http://www.stay-stupid.com/?p=401