本文为JAVA安全系列文章第二十四篇,学习常见字节码指令并理解它们是如何工作的。
0x00 前言
在字节码文件中,方法表的Code区存储的是.java文件中的方法源代码编译后让JVM真正执行的操作码。为了帮助人们理解,通过javap解析后我们看到的是十六进制操作码所对应的助记符,即JVM指令,也叫字节码指令。
0x01 JVM执行模型
在学习字节码指令之前,我们有必要先理解JVM的执行模型,没接触过数据结构的读者可以先简单了解下栈这种数据结构,入栈(压栈)、出栈(弹栈)以及栈的先进后出,后入先出的特点。
参考链接:https://juejin.cn/post/6944900891044479006
一、Java代码在内存中的执行
我们的Java代码是在线程内部执行的,每个线程都有自己的执行栈,栈由帧组成。
每个帧表示一个方法调用:每次调用一个方法时,会将一个新帧压入当前线程的执行栈。当方法返回时(正常返回或异常返回),会将这个帧从执行栈中弹出,然后返回发出调用的方法中继续执行(这个方法的帧当前位于栈的顶端),依次重复,直到程序执行完毕。
二、帧
每一帧包括两部分:局部变量和操作数栈。
局部变量部分包含可根据索引以随机顺序访问的变量。
操作数栈部分是一个栈,其中包含了供字节码指令用作操作数的值。这意味着这个栈中的值只能按照“后入先出”的顺序访问。
局部变量部分与操作数栈部分的大小取决于方法的代码。这一大小是在编译时计算的,并随JVM操作码一起存储在已编译类中。因此,对于某一给定方法调用的所有帧,其局部变量与操作数栈部分的大小相同,但对应于不同方法的帧,这一大小可能不同。
注意:不要将操作数栈和线程的执行栈相混淆:执行栈中的每一帧都包含自己的操作数栈。
一个具有 3 帧的执行栈示意图:
第一帧包含 3 个局部变量,其操作数栈的最大值为 4,其中包含两个值。
第二帧包含 2 个局部变量,操作数栈中有两个值。
第三帧,位于执行栈的顶端,包含 4 个局部变量和两个操作数。
三、帧的创建
在创建一个帧时,会将其初始化,提供一个空栈,并用目标对象 this(对于非静态方法) 及该方法的参数来初始化其局部变量。比如调用方法 a.equals(b)将创建一帧,它有一个空栈,前两个局部变量被初始化为 a 和 b(其他局部变量未被初始化)。
局部变量部分和操作数栈部分中的每个槽(slot)可以保存除 long 和 double 变量之外的任意 Java 值。long 和 double 变量需要两个槽。这使局部变量的管理变得复杂:可能第i个参数不一定存储在局部变量 i 中。例如:调用 Math.max(1L, 2L)创建一个帧,1L 值位于前两个局部变量槽中,值 2L 存储在第三和第四个槽中。
0x02 常见字节码指令
一、介绍
字节码指令由一个标识该指令的操作码和固定数目的参数组成:操作码opcode是一个无符号字节值,为方便记忆由助记符号标识。比如,操作码 0 用助记符号 NOP 表示,对应于不做任何操作的指令。
参数是静态值,确定了精确的指令行为。它们紧跟在操作码之后给出。比如 GOTO 标记指令(其操作码的值为 167)以一个指明下一条待执行指令的标记作为参数标记。
不要将指令参数与指令操作数相混淆:参数值是静态已知的,存储在编译后的代码中,而操作数值来自操作数栈,只有到运行时才能知道。
字节码指令可以分为两类:
一类设计用来在局部变量和操作数栈之间传送值;
另一类仅用于操作数栈:它们从栈中弹出一些值,根据这些值计算一个结果,并将它压回栈中。
二、用于传送值的字节码指令
1.xLOAD
ILOAD, LLOAD, FLOAD, DLOAD 和 ALOAD 指令读取一个局部变量,并将它的值压到操作数栈中。它们的参数是必须读取的局部变量的索引 i。
ILOAD 用于加载一个 boolean、byte、 char、short 或 int 局部变量。
LLOAD、FLOAD 和 DLOAD 分别用于加载 long、float 或 double 值。(LLOAD 和 DLOAD 实际加载两个槽 i 和 i+1)。
ALOAD 用于加载任意非基元值,即对象和数组引用。
2.xSTORE
与xLOAD对应,ISTORE、LSTORE、FSTORE、DSTORE 和 ASTORE 指令从操作数栈中弹出一个值,并将它存储在由其索引 i 指定的局部变量中。
xLOAD 和 xSTORE 指令被赋入了类型(事实上,几乎所有指令都被赋予了类型)。它用于确保不会执行非法转换。实际上,将一个值存储在局部变量中,然后再以不同类型加载它,是非法的。
例如:ISTORE 1 ALOAD 1 序列是非法的,因为它允许将一个任意内存位置存储在局部变量 1 中,并将这个地址转换为对象引用!
但是,如果向一个局部变量中存储一个值,而这个值的类型不同于该局部变量中存储的当前值的类型,却是完全合法的。这意味着一个局部变量的类型,即这个局部变量中所存值的类型可以在方法执行期间发生变化。比如,在一些情况下,会将char转化为int类型。
三、仅用于操作数栈的字节码指令
1.栈-用于处理栈上的值
POP 弹出栈顶部的值
DUP 压入顶部栈值的一个副本
SWAP弹出两个值,并按逆序压入它们
2.常量-向操作数栈压入一个常量值
ACONST_NULL 压入 null
ICONST_0 压入 int 值 0
FCONST_0 压入 0f
DCONST_0 压入 0d
BIPUSH b 压入字节值 b
SIPUSH s 压入 short 值 s
LDC cst 压入任意 int、float、long、double、String 或 class 常量 cst
3.算术与逻辑-弹出数值并将计算结果压入栈中
xADD、xSUB、xMUL、xDIV 和 xREM 对应于+、-、*、/和%运算,其中 x 为 I、 L、F 或 D 之一。
类似地,还有其他对应于<<、>>、>>>、|、&和^运算的指令,用于处理 int 和 long 值。
4.类型变换-弹出值并将转换结果压入栈中
对应于 Java中的类型转换表达式。I2F, F2D, L2D 等将数值由一种数值类型转换为另一种类型。CHECKCAST t 将一个引用值转换为类型 t。
5.对象-对象的创建、锁定,类型检测等
NEW type 将一个type 类型的新对象压入栈中(其中 type 是一个内部名)。
6.字段-读或写一个字段的值
GETFIELD owner name desc 弹出一个对象引用,并压入该对象的name 字段中的值。
PUTFIELD owner name desc 弹出一个值和一个对象引用,并将这个值存储在它的name字段中。
在这两种情况下,该对象都必须是 owner 类型,它的字段必须为 desc 类型。
GETSTATIC 和 PUTSTATIC 是类似指令,但用于静态字段。
7.方法-调用方法或构造器并将结果压入栈中
弹出值的个数等于其方法参数个数加 1 (用于目标对象)
INVOKEVIRTUAL owner name desc 调用在类 owner 中定义的 name 方法,其方法描述符为 desc。INVOKESTATIC 用于静态方法
INVOKESPECIAL 用于构造器和私有方法
INVOKEINTERFACE 用于接口中定义的方法。
对于 Java 7 中的类,INVOKEDYNAMIC 用于新动态方法调用机制。
8.数组-读写数组中的值
xALOAD 弹出一个索引和一个数组,并压入此索引处数组元素的值。
xASTORE 指令弹出一个值、一个索引和一个数组,并将这个值存储在该数组的这一索引处。
这里的 x 可以是 I、L、F、D 或 A,还可以是 B、C 或 S。
9.跳转
这些指令无条件地或者在某一条件为真时跳转到一条任意指令。
它们用于编译 if、 for、do、while、break 和 continue 指令。
例如,IFEQ label 从栈中弹出一个 int 值,如果这个值为 0,则跳转到由这个 label 指定的指令处(否则,正常执行下一条指令)。还有许多其他跳转指令,比如 IFNE 或 IFGE。
TABLESWITCH 和 LOOKUPSWITCH 对应于 switch Java 指令
10.返回
xRETURN 和 RETURN 指令用于终止一个方法的执行,并将其结果返回给调用者。
RETURN 用于返回 void 的方法,xRETURN 用于其他方法。
以上仅列举了常见的一些字节码指令,如果想全面了解,参阅Java虚拟机规范:
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-7.html
0x03 实例分析
个人认为,对字节码指令的学习,不光要知道各个指令的含义,还要理解它们是如何工作的。下面就用一些案例来分析分析。
一、Bean类
编写如下一个简单的Bean类并将其编译:
public class Bean {
private int f;
public int getF() {
return this.f;
}
public void setF(int f) {
this.f = f;
}
}
1.getter()方法指令解读
getter() 方法的字节码指令如下:
aload_0
getfield #2 //jvm_Instruction/Bean.f:I
ireturn
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ljvm_Instruction/Bean;
第一条指令读取索引为0的局部变量(在为这个方法调用创建帧期间被初始化为 this),并将这个值压入操作数栈中。
第二个指令从栈中弹出这个值,即 this,并将这个对象的 f 字段压入栈中, 即 this.f。
最后一条指令从栈中弹出这个值,并将其返回给调用者。
该方法执行帧的持续状态(栈映射帧)如下图所示:
2.setter()方法指令解读
setter() 方法的字节码指令如下:
aload_0
iload_1
putfield #2 //jvm_Instruction/Bean.f:I
return
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this Ljvm_Instruction/Bean;
0 6 1 f I
第一条指令读取索引为0的局部变量this压入操作数栈。
第二条指令读取索引为1的局部变量 (在为这个方法调用创建帧期间,以 f 参数初始化该变量),并将这个值压入操作数栈中。
第三条指令弹出这两个值,并将 int 值存储在被引用对象的 f 字段中,即存储在 this.f 中。
最后一条指令在源代码中是隐式的,但在编译后的代码中却是强制的,销毁当前执行帧,并返回调用者。
该方法执行帧的持续状态(栈映射帧)如下图所示:
3.默认无参构造方法指令解读
Bean 类还有一个默认的公有构造器,由于我们没有显式的定义构造器,故而会由编译器生成一个默认的公有构造器 Bean() { super(); }
:
这个构造器的字节码指令如下:
aload_0
invokespecial #1 //java/lang/Object."<init>":()V
return
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ljvm_Instruction/Bean;
第一条指令将 this 压入操作数栈中。
第二条指令从栈中弹出这个值,并调用在Object对象中定义的<init>方法。这对应于 super()调用,也就是对超类Object构造器的调用。在这里可以看到,在已编译类和源类中对构造器的命名是不同的:在编译类中,它们总是被命名为<init>,而在源类中,它们的名字与定义它们的类同名。
最后一条指令返回调用者。
二、if...else条件句
有如下方法:
public void checkAndSetF(int f) {
if (f >= 0) {
this.f = f;
} else {
throw new IllegalArgumentException();
}
}
该方法字节码指令如下:
0: iload_1
1: iflt 12(label)
4: aload_0
5: iload_1
6: putfield #2 //jvm_Instruction/Bean.f:I
9: goto 20(end)
12: new #3 //Class java/lang/IllegalArgumentException
15: dup
16: invokespecial #4 //java/lang/IllegalArgumentException."<init>":()V
19: athrow
20: return
LocalVariableTable:
Start Length Slot Name Signature
0 21 0 this Ljvm_Instruction/Bean;
0 21 1 f I
1.第一条指令将初始化为 f 的局部变量 1 压入操作数栈。
2.IFLT 指令从栈中弹出这个值,并将它与 0 进行比较。如果它小于(LT)0,则跳转到label(此处为12)指定的指令,否则不做任何事情,继续执行下一条指令。
3.接下来的三条指令与 setF 方法中相同。
4.GOTO 指令无条件跳转到由 end(此处为20)标记指定的指令,也就是RETURN 指令。
5.label 和 end 标记(此处为[12,20))之间的指令创建和抛出一个异常:
NEW 指令创建一个异常对象,并将它压入操作数栈中。
DUP 指令在栈中重复这个值。
INVOKESPECIAL 指令弹出这两个副本之一,并对其调用异常构造器。
最后,ATHROW 指令弹出剩下的副本,并将它作为异常抛出(所以不会继续执行下一条指令)。
三、异常处理器
不存在用于捕获异常的字节码指令:而是将一个方法的字节码与一个异常处理器列表关联在一起,这个列表规定了在某方法中的给定部分抛出异常时必须执行的代码。
异常处理器类似于 try catch 块:它有一个范围,也就是与 try 代码块内容相对应的一个指令序列,还有一个处理器,对应于 catch 块中的内容。
这个范围由一个起始标记和一个终止标记指定,处理器也是由一个起始标记指定。
比如下面的源代码:
public static void sleep(long d) {
try {
Thread.sleep(d);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
其字节码指令如下:
0: lload_0
1: invokestatic #5 // Method java/lang/Thread.sleep:(J)V
4: goto 12(end)
7: astore_2
8: aload_2
9: invokevirtual #7 //Method java/lang/InterruptedException.printStackTrace:()V
12: return
Exception table:
from to target type
0 4 7 Class java/lang/InterruptedException
LocalVariableTable:
Start Length Slot Name Signature
8 4 2 e Ljava/lang/InterruptedException;
0 13 0 d J
可以看见多了一个Exception table,这就是异常处理器列表:起始为0,终止为4,跳转为7。即表示[0,4]之间的代码对应于 try 块,7及之后的代码对应于 catch。
也可理解为如下:
TRYCATCHBLOCK try catch catch java/lang/InterruptedException
try:
0 LLOAD 0
1 INVOKESTATIC //Method java/lang/Thread.sleep:(J)V
4 goto 12
12 RETURN
catch:
7 astore_2
8 aload_2
9 INVOKEVIRTUAL //Method java/lang/InterruptedException.printStackTrace:()V
12 RETURN
TRYCATCHBLOCK 行指定了一个异常处理器,覆盖了 try 和 catch 标记之间的范围,有一个开始于 catch 标记的处理器,用于处理一些异常,这些异常的类是 InterruptedException 的子类。这意味着,如果在 try 和 catch 之间抛出了这样一个异常,栈将被清空,异常被压入这个空栈中,执行过程在 catch 处继续。
四、SuperMan类
最后一个案例,我们就来分析在上一篇文章JAVA安全|字节码篇:字节码文件结构与解读中用到的SuperMan类
1.有参构造器指令解读
public SuperMan(char sex, String name, int age) {
super(sex, name);
this.age = age;
}
字节码指令如下:
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
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
0: aload_0 读取索引为0的局部变量this(表示bytecodefile/SuperMan类型)压入操作数栈中;
1: iload_1 读取索引为1的局部变量sex(char类型)的值压入操作数栈中;
2: aload_2 读取索引为2的局部变量name(java/lang/String类型)的值压入操作数栈中;
3: invokespecial 从栈中依次弹出name和sex的值以及this(对应前面说的方法/构造器弹出值的个数等于其方法参数个数加1(用于目标对象))并调用在bytecodefile/Human对象中定义的<init>方法。这对应于super(sex, name);
调用,也就是对父类Human构造器的调用。最后将结果压回栈中。
6: aload_0、7: iload_3 将this和索引为3的局部变量age(int类型)的值压入栈中
8: putfield 从栈中依次弹出age的值和this,并将值存储在被引用对象的 age 字段中,即存储在 this.age中,对应this.age = age;
11: return 将结果返回给调用者。
2.打印语句指令解读
fly()和hi()均为打印语句,此处分析fly()即可。
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
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lbytecodefile/SuperMan;
第一条指令获取java/lang/System
对象的静态字段out(java/io/PrintStream
类型)的值压入操作数栈中;
第二条指令将字符串“I can fly!!!”
压入栈中
第三条指令依次从栈中弹出“I can fly!!!”
,out的值,调用java/io/PrintStream
中定义的println
方法,方法描述符为(Ljava/lang/String;)V
,并将结果压入栈中
最后一条指令将结果返回给调用者。
0x04 帧的补充—栈映射帧(了解)
一、概念
除了字节码指令外,用 Java 6 或更高版本编译的类中还包含一组栈映射帧,用于加快 Java 虚拟机中类验证过程的速度。
栈映射帧给出一个方法的执行帧在执行过程中某一时刻的状态。更准确地说,它给出了在即将执行某一特定字节码指令之前,每个局部变量槽和每个操作数栈槽中包含的值的类型。
二、getF()的栈映射帧
比如,上面的getF方法,可以定义三个栈映射帧,给出执行帧在即将执行 ALOAD、即将执行 GETFIELD 和即将执行 IRETURN 之前的状态。这三个栈映射帧已在图中给出。
可描述如下:
执行之前的帧状态 指令
[pkg/Bean] [] ALOAD 0
[pkg/Bean] [pkg/Bean] GETFIELD
[pkg/Bean] [I] IRETURN
其中第一个方括号中的类型对应于局部变量,其他类型对应于操作数栈。
三、checkAndSetF的栈映射帧
同样的checkAndSetF的栈映射帧:
执行之前的帧状态 指令
[pkg/Bean I] [] ILOAD 1
[pkg/Bean I] [I] IFLT label
[pkg/Bean I] [] ALOAD 0
[pkg/Bean I] [pkg/Bean] ILOAD 1
[pkg/Bean I] [pkg/Bean I] PUTFIELD
[pkg/Bean I] [] GOTO end
[pkg/Bean I] [] label :
[pkg/Bean I] [] NEW
[pkg/Bean I] [Uninitialized(label)] DUP
[pkg/Bean I] [Uninitialized(label) Uninitialized(label)] INVOKESPECIAL
[pkg/Bean I] [java/lang/IllegalArgumentException] ATHROW
[pkg/Bean I] [] end :
[pkg/Bean I] [] RETURN
除了 Uninitialized(label)类型之外,其他与前面的方法均类似。这是一种仅在栈映射帧中使用的特殊类型,它指定了一个对象,已经为其分配了内存,但还没有调用其构造器。参数规定了创建此对象的指令。对于这个类型的值,只能调用一种方法,那就是构造器。在调用它时,在帧中出现的所有这一类型都被代以一个实际类型,这里是 IllegalArgumentException。
栈映射帧可使用的其他三种特殊类型:
UNINITIALIZED_THIS 是构造器中局部变量0的初始类型
TOP 对应于一个未定义的值
NULL 对应于 null。
前面说过,从 Java 6 开始,除了字节码指令外,已编译类中还包含了一组栈映射帧。为节省空间,已编译方法中并没有为每条指令包含一个帧,而是仅为那些对应于跳转目标或异常处理器的指令,或者跟在无条件跳转指令之后的指令包含帧,其实可以容易地由这些帧推断出其他帧。
比如,上面的方法中checkAndSetF和sleep使用javap解析它们的字节码后会有一个StackMapTable,这就是栈映射帧。
在checkAndSetF 方法的情景中,就仅存储两个帧:
一个用于NEW 指令,因为它是 IFLT 指令的目标,还因为它跟在无条件跳转 GOTO 指令之后
另一个用于 RETURN 指令,因为它是 GOTO 指令的目标,还因为它跟在“无条件跳转”ATHROW 指令之后:
ILOAD 1
IFLT label
ALOAD 0
ILOAD 1
PUTFIELD pkg/Bean f I
GOTO end
label:
NEW java/lang/IllegalArgumentException
DUP
INVOKESPECIAL java/lang/IllegalArgumentException <init> ()V
ATHROW
end:
RETURN
为节省更多空间,对每一帧都进行压缩:仅存储它与前一帧的差别,而初始帧根本不用存储,可以轻松地由方法参数类型推导得出。在checkAndSetF 方法中,必须存储的两帧是相同的,都等于初始帧,所以它们被存储为单字节值,由 F_SAME 助记符表示。可以在与这些帧相关联的字节码指令之前给出这些帧。这就给出了 F_SAME方法的最终字节代码:
ILOAD 1
IFLT label
ALOAD 0
ILOAD 1
PUTFIELD pkg/Bean f I
GOTO end
label:
F_SAME
NEW java/lang/IllegalArgumentException
DUP
INVOKESPECIAL java/lang/IllegalArgumentException <init> ()V
ATHROW
end:
F_SAME
RETURN
0x05 总结
一、本文应重点掌握常见字节码指令(JVM指令)代表的含义,以及会分析简单代码对应的字节码指令以理解它们是怎么工作的。
另外还需要理解JVM执行模型,了解栈这种数据结构,执行栈,帧等的概念,理解帧的局部变量部分和操作数栈部分。栈映射帧可仅作为扩展阅读。
二、可能有读者会问,我们搞安全不是搞开发,需要对java学习得这么深么?
第一,本文和上一篇讲字节码文件结构的文章都是为了下一篇学习另一个字节码操作框架—ASM做铺垫的,这是一个不同于javassist的字节码操作框架,它是直接从字节码的层面来操作字节码的,学习门槛自然比javassist高;
第二,理解了字节码,理解了JVM指令,也就对java程序的运行原理有了更深入理解,对于后面的学习也会更有益处;
第三,理解了java程序的运行原理,是不是也可以为二进制安全打下一定基础呢?虽然汇编指令比JVM指令复杂多了,但基本原理应该也差不多。
三、JVM的指令集是基于栈而不是寄存器,基于栈可以具备很好的跨平台性(因为寄存器指令集往往和硬件挂钩),但缺点在于,要完成同样的操作,基于栈的实现需要更多指令才能完成(因为栈只是一个FILO结构,需要频繁压栈出栈)。
另外,由于栈是在内存实现的,而寄存器是在CPU的高速缓存区,相较而言,基于栈的速度要慢很多,这也是为了跨平台性而做出的牺牲。本文所说的字节码指令,其实大多控制的就是这个JVM的操作数栈。
四、本文内容偏原理,理论性,可能理解起来比较难,不用着急学会,反复看,另外本文内容主要来自《ASM4-guide》的3.1章节,有兴趣的读者可以去读读原手册。
参考:
栈:https://juejin.cn/post/6944900891044479006
JVM规范:https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html
https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-7.html
《ASM4-guide》:https://asm.ow2.io/asm4-guide.pdf
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
如果喜欢小编的文章,记得多多转发,点赞+关注支持一下哦~,您的点赞和支持是我最大的动力~