本文为JAVA安全系列文章第二十三篇,学习掌握.class文件的结构,能根据文档对16进制字节码文件进行解读。
0x01 字节码文件结构
一、字节码文件
java是一门跨平台的语言,我们使用javac将.java文件编译为.class文件后,该.class文件便可以在任意平台(Linux,Windows,Macos等)上运行,即“一次编译,到处运行”。.class文件由十六进制值组成,而JVM以两个十六进制值为一组,即以字节为单位进行读取,故而.class文件又被称为字节码文件。
java之所以可以“一次编译,到处运行”,主要有两个原因:第一,JVM针对各种操作系统、平台都进行了定制;第二,无论在什么平台,都可以编译生成固定格式的字节码(.class文件)供JVM使用。
在学动态字节码加载时,我们说过只要编译器能够将代码编译成.class文件,都可以在JVM虚拟机中运行。这是由于JVM规范的存在,只要最终可以生成符合规范的字节码就可以在JVM上运行。因此这就给了各种运行在JVM上的语言(如Scala、Groovy、Kotlin)一种契机,可以扩展Java所没有的特性或者实现各种语法糖。由此可以看出字节码对于Java生态的重要性。下面就来详细学习字节码文件结构。
二、字节码结构
先编写一个类Human和接口IA:
package bytecodefile;
/*
* @author walker1995
* @version v1.0
*/
public class Human {
private char sex;
private String name;
public Human(char sex, String name) {
this.sex = sex;
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
package bytecodefile;
/*
* @author walker1995
* @version v1.0
*/
public interface IA {
void fly();
}
再写一个类SuperMan继承Human,实现IA接口:
package bytecodefile;
/*
* @author walker1995
* @version v1.0
*/
public class SuperMan extends Human implements IA {
private int age;
public SuperMan(char sex, String name, int age) {
super(sex, name);
this.age = age;
}
public void fly() {
System.out.println("I can fly!!!");
}
public void hi(){
System.out.println("hi~");
}
}
然后编译,使用sublime打开编译好的SuperMan.class文件,看到的是16进制数据:
jvm是如何解读这一长串16进制数据的呢?首先附上一张字节码的结构简图:
下面就来进行详细说明。
0x02 字节码解读
一、魔数和版本号
1.魔数(Magic Number)
位于.class文件的开头,占四个字节,固定为0xCAFEBABE。JVM根据文件的开头来判断这个文件是否可能是一个.class文件,如果是,才会继续进行之后的操作。
值得说明的是,魔数的固定值是Java之父James Gosling制定的,为CafeBabe(咖啡宝贝),而Java的图标为一杯咖啡。
2.版本号(Version)
位于魔数之后的4个字节,前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version)。此处我的版本号为“00 00 00 34”,即次版本号转化为十进制为0,主版本号转化为十进制为16 x 3 + 4 = 52,对应的Java版本为1.8.0。
二、常量池(constant pool)
主版本号之后的字节为常量池入口。常量池中存储两类常量:字面量与符号引用。
字面量为代码中声明为Final的常量值
符号引用如类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符。
常量池是一个数组,其中包含了在类中出现的所有数值、字符串和类型常量。这些常量仅在常量池中定义一次,然后可以利用其索引,在类文件中的所有其他各部分进行引用。
常量池整体上分为两部分:常量池计数器以及常量池数据区,如下图所示:
1.常量池计数器(constant_pool_count)
占两个字节,由于常量的数量不固定,所以在主版本号后先放置两个字节来表示常量池容量计数值。此处常量池计数为0x002e,即 16 x 2 + 14 = 46。排除掉下标“0”,实际常量个数为46 - 1 =45。
2.常量池数据区
不定长,数据区是由(constant_pool_count - 1)个cp_info结构组成,一个cp_info结构对应一个常量。在字节码中共有14种类型的cp_info,每种类型的结构都是固定的,如下图所示:
从16进制解读所描述的常量的思路是先通过tag(一个字节)确定其常量类型,再根据该类型后面连续几个字节确定其具体的值含义。
以常量池计数器后的一个cp_info为例,先读取tag为0x0a(十进制为10),对应CONSTANT_Methodref_info(方法引用);然后读取两字节表示指向声明方法的类描述符CONSTANT_Class_info的索引项,该值为0x0008(十进制为8);再读取两字节表示指向名称及类型描述符CONSTANT_NameAndType的索引项,该值为0x001c(十进制为28)。
这里有很多个常量,我们可以使用JDK自带的javap -v .class文件路径
解析得到完整的常量池表:
Constant pool:
#1 = Methodref #8.#28 // bytecodefile/Human."<init>":(CLjava/lang/String;)V
#2 = Fieldref #7.#29 // bytecodefile/SuperMan.age:I
#3 = Fieldref #30.#31 // java/lang/System.out:Ljava/io/PrintStream;
#4 = String #32 // I can fly!!!
#5 = Methodref #33.#34 // java/io/PrintStream.println:(Ljava/lang/String;)V
#6 = String #35 // hi~
#7 = Class #36 // bytecodefile/SuperMan
#8 = Class #37 // bytecodefile/Human
#9 = Class #38 // bytecodefile/IA
#10 = Utf8 age
#11 = Utf8 I
#12 = Utf8 <init>
#13 = Utf8 (CLjava/lang/String;I)V
#14 = Utf8 Code
#15 = Utf8 LineNumberTable
#16 = Utf8 LocalVariableTable
#17 = Utf8 this
#18 = Utf8 Lbytecodefile/SuperMan;
#19 = Utf8 sex
#20 = Utf8 C
#21 = Utf8 name
#22 = Utf8 Ljava/lang/String;
#23 = Utf8 fly
#24 = Utf8 ()V
#25 = Utf8 hi
#26 = Utf8 SourceFile
#27 = Utf8 SuperMan.java
#28 = NameAndType #12:#39 // "<init>":(CLjava/lang/String;)V
#29 = NameAndType #10:#11 // age:I
#30 = Class #40 // java/lang/System
#31 = NameAndType #41:#42 // out:Ljava/io/PrintStream;
#32 = Utf8 I can fly!!!
#33 = Class #43 // java/io/PrintStream
#34 = NameAndType #44:#45 // println:(Ljava/lang/String;)V
#35 = Utf8 hi~
#36 = Utf8 bytecodefile/SuperMan
#37 = Utf8 bytecodefile/Human
#38 = Utf8 bytecodefile/IA
#39 = Utf8 (CLjava/lang/String;)V
#40 = Utf8 java/lang/System
#41 = Utf8 out
#42 = Utf8 Ljava/io/PrintStream;
#43 = Utf8 java/io/PrintStream
#44 = Utf8 println
#45 = Utf8 (Ljava/lang/String;)V
想要完全看懂常量池表中描述的信息,这里还需要补充下内部名,类型描述符,方法描述符的概念:
3.内部名
在许多情况下,一种类型只能是类或接口类型。例如,一个类的超类、由一个实现接口的类, 或者由一个方法抛出的异常就不能是基元类型或数组类型,必须是类或接口类型。这些类型在已编译类中用内部名字表示。一个类的内部名就是这个类的完全限定名,其中的点号用斜线代替。 例如,String 的内部名为 java/lang/String。
4.类型描述符
内部名只能用于类或接口类型。所有其他 Java 类型,比如字段类型,在已编译类中都是用类型描述符表示的,如下图:
一个类类型的描述符是这个类的内部名, 前面加上字符 L , 后面跟有一个分号。例如, String 的类型描述符为 Ljava/lang/String;。而一个数组类型的描述符是一个方括号后面跟有该数组元素类型的描述符。
5.方法描述符
方法描述符是一个类型描述符列表,它用一个字符串描述一个方法的参数类型和返回类型。 方法描述符以左括号开头,然后是每个形参的类型描述符,然后是一个右括号,接下来是返回类型的类型描述符,如果该方法返回 void,则是 V(方法描述符中不包含方法的名字或参数名)。示例如下图:
一旦知道了类型描述符如何工作,方法描述符的理解就容易了。例如,(I)I 描述一个方法, 它接受一个 int 类型的参数,返回一个 int。
理解了上面的概念,此时查看完整的常量池表,可知第一个cp_info表示的是Human这个类的Human(char sex, String name)
构造器。
由于常量池表比较庞大,此处不一一解读了,各位读者可以多解读几个来练习练习。
三、访问标志、当前类索引、父类索引、接口索引
1.访问标志(access_flag)
常量池结束之后的两个字节,描述该类、接口的访问类型。JVM规范规定了如下访问标志:
需要注意的是,JVM并没有穷举所有的访问标志,而是使用按位或操作来进行描述的。
比如此处的修饰符为Public Super,则对应的访问修饰符的值为ACC_PUBLIC | ACC_SUPER,即0x0001 | 0x0020 = 0x0021:
2.当前类索引(this_class)
访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。
此处为0x0007,即索引为十进制的7,查看常量池表为bytecodefile/SuperMan
3.父类索引(super_class)
当前类名后的两个字节,描述父类的全限定名,保存的也是常量池中的索引值
此处为0x0008,即索引为十进制的8,查看常量池表为bytecodefile/Human
4.接口索引(interfaces)
父类名称后为两字节的接口计数器,描述了该类或父类实现的接口数量。紧接着的n个字节是所有接口名称的字符串常量的索引值
此处接口计算器为0x0001,即仅有一个接口;索引为0x0009,即十进制的9,查看常量池表为bytecodefile/IA。
如下图所示:
四、字段表(fields)
字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。
字段表也分为两部分,第一部分为两个字节,描述字段个数;第二部分是每个字段的详细信息fields_info。字段表结构如下图所示:
此处的字段表计数器为0x0001,即仅有一个字段;权限修饰符为0x0002,查看访问标志表为ACC_PRIVATE;字段名索引为0x000A,即十进制的10,查看常量池表为age;描述符索引为0x000B,即十进制的11,查看常量池表为I,即int类型。属性个数为0x0000,即没有,故而属性列表也就没有了。
所以,我们的private int age
在字节码中的描述就如下图所示:
五、方法表(methods)
字段表结束后为方法表,方法表也是由两部分组成,第一部分为两个字节描述方法的个数;第二部分为每个方法的详细信息。方法的详细信息较为复杂,包括方法的访问标志、方法名、方法的描述符以及方法的属性,如下图所示:
1.方法计数器、权限修饰符、方法名索引、描述符索引
此处有3个方法,第一个方法权限修饰符为ACC_PUBLIC的;方法名为<init>表示该类的构造方法;方法描述符为(CLjava/lang/String;I)V,即表示该方法接收char,String,int类型的三个参数,返回类型为void。如下图所示:
2.方法的属性
“方法的属性”这一部分较为复杂,此处属性个数为1,属性列表结构参照https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.7.3,如下:
Code_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 max_stack;
u2 max_locals;
u4 code_length;
u1 code[code_length];
u2 exception_table_length;
{ u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
} exception_table[exception_table_length];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
而Code_attribute中的attribute_info的结构又要参照https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.7.12:
LineNumberTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 line_number_table_length;
{ u2 start_pc;
u2 line_number;
} line_number_table[line_number_table_length];
}
和https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.7.13:
LocalVariableTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 local_variable_table_length;
{ u2 start_pc;
u2 length;
u2 name_index;
u2 descriptor_index;
u2 index;
} local_variable_table[local_variable_table_length];
}
很繁琐,此处不进行人工分析,想更深入了解可以查看文档
这里直接借助javap将其反编译为我们可以读懂的信息进行解读:
{
public bytecodefile.SuperMan(char, java.lang.String, int);
descriptor: (CLjava/lang/String;I)V
flags: ACC_PUBLIC
Code:
stack=3, locals=4, args_size=4
0: aload_0
1: iload_1
2: aload_2
3: invokespecial #1 // Method bytecodefile/Human."<init>":(CLjava/lang/String;)V
6: aload_0
7: iload_3
8: putfield #2 // Field age:I
11: return
LineNumberTable:
line 11: 0
line 12: 6
line 13: 11
LocalVariableTable:
Start Length Slot Name Signature
0 12 0 this Lbytecodefile/SuperMan;
0 12 1 sex C
0 12 2 name Ljava/lang/String;
0 12 3 age I
public void fly();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #4 // String I can fly!!!
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 16: 0
line 17: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lbytecodefile/SuperMan;
public void hi();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #6 // String hi~
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 20: 0
line 21: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lbytecodefile/SuperMan;
}
看下每个attribute_info的描述:
(1)Code区
源代码对应的JVM指令操作码,在进行字节码增强时重点操作的就是“Code区”这一部分。
(2)LineNumberTable
行号表,将Code区的操作码和源代码中的行号对应,Debug时会起到作用(源代码走一行,需要走多少个JVM指令操作码)。
(3)LocalVariableTable
本地变量表,包含This和局部变量,之所以可以在每一个方法内部都可以调用This,是因为JVM将This作为每一个方法的第一个参数隐式进行传入。当然,这是针对非Static方法而言。
Code区的JVM指令是否感觉一脸懵,有木有种汇编指令的感觉?JVM指令又是一个庞大的知识体系,我们将在下一篇文章中对常见JVM指令进行学习。
六、附加属性表(additional attributes)
字节码的最后一部分,附加的该.class文件的其他信息,完整的attributes包括 ClassFile
, field_info
, method_info
, 和 Code_attribute
等,其通用结构为:
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
详见https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.7
0x03 总结
一、本文通过将自写的代码进行编译,根据网上文章和相关文档来从十六进制数据中尝试解读.class文件,其中的繁琐部分采用了javap来帮助我们进行解读。
二、推荐一个Idea插件:jclasslib(https://plugins.jetbrains.com/plugin/9248-jclasslib-bytecode-viewer)。可通过file-->setting-->plugins搜索安装,代码编译后在菜单栏”View”中选择”Show Bytecode With jclasslib”,可以很直观地看到当前字节码文件的类信息、常量池、方法区等信息:
三、字节码文件我认为需要掌握两个重点:
1.掌握理解.class文件结构和知道如何去解读,以及能够读懂常量池中的描述信息;
2.读懂方法表中Code区的JVM指令,并能理解JVM指令是如何工作的。我们将在下一篇文章中进行详细学习。
参考:
https://tech.meituan.com/2019/09/05/java-bytecode-enhancement.html
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html
《ASM4 手册》2.1章节
扩展阅读:https://blog.csdn.net/hosaos/article/details/100990954
Java安全系列文集
第6篇:JAVA安全|基础篇:反射机制之常见ReflectionAPI使用
第8篇:JAVA安全|Gadget篇:TransformedMap CC1链
第10篇:JAVA安全|Gadget篇:LazyMap CC1链
第11篇:JAVA安全|Gadget篇:无JDK版本限制的CC6链
第14篇:JAVA安全|Gadget篇:CC3链及其通杀改造
第15篇:JAVA安全|Gadget篇:CC依赖下为shiro反序列化利用而生的CCK1 CC11链
第17篇:JAVA安全|Gadget篇:CC2 CC4链—Commons-Collections4.0下的特有链
第19篇:JAVA安全|Gadget篇:Ysoserial CB1链
第20篇:JAVA安全|Gadget篇:shiro无依赖利用与常见shiro利用工具对比浅析
第21篇:JAVA安全|Gadget篇:JDK原生链—JDK7u21
第22篇:JAVA安全|字节码篇:字节码操作库—javassist
如果喜欢小编的文章,记得多多转发,点赞+关注支持一下哦~,您的点赞和支持是我最大的动力~