Intro
目前在CTF比赛中,对于Java反序列化基本上靠codeql、tabby等工具分析利用链,tabby基于字节码的特性会更准确一些。而gadgetinspector作为一个有些年头的基于ASM对字节码进行分析的自动化反序列化链挖掘工具,虽然在实际场景使用中用到的不算很多,但是经过一些功能上的补足和二开后也提高了一部分的准确率。我们主要通过二开后的gadgetinspector来学习一下作者是如何通过ASM来对字节码进行处理并跟踪污点流进行分析。在分析gadgetInspector之前,我们要先对字节码的相关结构有一些了解,所以我们可以按照字节码的固定架构使用十六进制编辑器查看一下字节码中到底存储了些什么东西。
二开后的GadgetInspector:github.com/threedr3am/...
字节码分析
我们以如下类进行分析:
typescript
package com.y1zh3e7.Test;public class ClassTest { public static void main(String[] args) { String sayHello = "Hello World!"; }}
编译后class文件扔到hex编辑器里查看十六进制方便分析:
r
CA FE BA BE 00 00 00 34 00 18 0A 00 04 00 14 08 00 15 07 00 16 07 00 17 01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65 01 00 12 4C 6F 63 61 6C 56 61 72 69 61 62 6C 65 54 61 62 6C 65 01 00 04 74 68 69 73 01 00 1C 4C 63 6F 6D 2F 79 31 7A 68 33 65 37 2F 54 65 73 74 2F 43 6C 61 73 73 54 65 73 74 3B 01 00 04 6D 61 69 6E 01 00 16 28 5B 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B 29 56 01 00 04 61 72 67 73 01 00 13 5B 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B 01 00 08 73 61 79 48 65 6C 6C 6F 01 00 12 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B 01 00 0A 53 6F 75 72 63 65 46 69 6C 65 01 00 0E 43 6C 61 73 73 54 65 73 74 2E 6A 61 76 61 0C 00 05 00 06 01 00 0C 48 65 6C 6C 6F 20 57 6F 72 6C 64 21 01 00 1A 63 6F 6D 2F 79 31 7A 68 33 65 37 2F 54 65 73 74 2F 43 6C 61 73 73 54 65 73 74 01 00 10 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74 00 21 00 03 00 04 00 00 00 00 00 02 00 01 00 05 00 06 00 01 00 07 00 00 00 2F 00 01 00 01 00 00 00 05 2A B7 00 01 B1 00 00 00 02 00 08 00 00 00 06 00 01 00 00 00 03 00 09 00 00 00 0C 00 01 00 00 00 05 00 0A 00 0B 00 00 00 09 00 0C 00 0D 00 01 00 07 00 00 00 3C 00 01 00 02 00 00 00 04 12 02S 4C B1 00 00 00 02 00 08 00 00 00 0A 00 02 00 00 00 05 00 03 00 06 00 09 00 00 00 16 00 02 00 00 00 04 00 0E 00 0F 00 00 00 03 00 01 00 10 00 11 00 01 00 01 00 12 00 00 00 02 00 13
class文件结构如下

0x01 魔术头 Magic Number-4Byte
class文件的魔术头为四字节并且值固定,可以看到为如下内容,这个十六进制表达还是挺有意思的
CA FE BA BE
0x02 版本号 Version-2+2Byte
十六进制对应内容为
00 00 00 34
前面的0000为次版本号,后面的0034为主版本号,0x0034对应十进制为52,对应版本为jdk1.8,对应的我IDEA中的jdk版本也是1.8

0x03 常量池 Constant Pool-2+nByte
常量池的2+n指的是两字节的常量数量,加上nByte的常量内容,常量池存储如下内容:

接下来我们继续分析十六进制并以此说明:
首先的两个字节代表常量数量,0x0018转换为十进制为24。这里需要注意的是,常量池的常量索引并不是从0开始而是从1开始,因此24表示常量池中共有23个常量,索引以此为1-23,并且在.class文件中,只有常量池的下标是从0开始,后面的接口、属性、方法等下表依然都是从0开始计数:
00 18
CONSTANT-1
根据上面的表格,我们可以发现不论是何种类型的常量,都是以u1(1字节)的tag位作为起始,因此我们向下读取一字节,为第一个常量的tag,为0x0A:
0A
0x0A对应十进制10,我们在表格中寻找值为10的索引,可以找到该常量类型为CONSTANT_Methodref_info,并且接下来还分别有两个u2的index,我们继续向下读取两个字节,则对应表格中指向声明方法的类描述符的索引项,这些东西的作用我们到后面就会知道了,先继续往下看
00 04
继续向下读取两个字节,对应指向名称及类型描述符索引项,值为20
00 14
constant#1:
0x0a:Methodref_info
0x00 04:Class_info索引项#4
0x00 14:NameAndType索引项#20
CONSTANT-2
向下读取1B,即为第二个常量的TAG位,值为08,对应表格中CONSTANT_Fieldref_info,依旧是两个u2的index
constant#2:
0x08:String_info
0x00 15::指向字符串字面量#21
CONSTANT-3
0x07:Class_info
0x00 16:全局限定名常量项索引#22
CONSTANT-4
0x07:Class_info
0x00 17:全局限定名常量项索引#23
CONSTANT-5
0x01:Utf8_info
0x00 06:字符串长度为6
0x3C 69 6E 69 74 3E:字符串<init>
CONSTANT-6
0x01:Utf8-info
0x00 03:字符串长度为3
0x28 29 56:字符串()V
CONSTANT-7
0x01:Utf8-info
0x00 04:字符串长度为4
0x43 6F 64 65:字符串Code
CONSTANT-8
0x01:Utf8-info
0x00 0F:字符串长度为15
0x4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65:字符串LineNumberTable
CONSTANT-9
0x01:Utf8-info
0x00 12:字符串长度为18
0x4C 6F 63 61 6C 56 61 72 69 61 62 6C 65 54 61 62 6C 65:字符串LocalVariableTable
CONSTANT-10
0x01:Utf8-info
0x00 04:字符串长度为4
0x74 68 69 73:字符串this
CONSTANT-11
0x01:Utf8-info
0x00 1C:字符串长度为28
0x4C 63 6F 6D 2F 79 31 7A 68 33 65 37 2F 54 65 73 74 2F 43 6C 61 73 73 54 65 73 74 3B:字符串Lcom/y1zh3e7/Test/ClassTest;
CONSTANT-12
0x01:Utf8-info
0x00 04:字符串长度为4
0x6D 61 69 6E:字符串main
CONSTANT-13
0x01:Utf8-info
0x00 16:字符串长度为22
0x28 5B 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B 29 56:字符串([Ljava/lang/String;)V
CONSTANT-14
0x01:Utf8-info
0x00 04:字符串长度为4
0x61 72 67 73:字符串args
CONSTANT-15
0x01:Utf8-info
0x00 13:字符串长度为19
0x5B 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B:字符串[Ljava/lang/String;
CONSTANT-16
0x01:Utf8-info
0x00 08:字符串长度为8
0x73 61 79 48 65 6C 6C 6F:字符串sayHello
CONSTANT-17
0x01:Utf8-info
0x00 08:字符串长度为18
0x4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 6E 67 3B:字符串Ljava/lang/String;
CONSTANT-18
0x01:Utf8-info
0x00 0A:字符串长度为10
0x53 6F 75 72 63 65 46 69 6C 65:字符串SourceFile
CONSTANT-19
0x01:Utf8-info
0x00 0A:字符串长度为14
0x43 6C 61 73 73 54 65 73 74 2E 6A 61 76 61:字符串ClassTest.java
CONSTANT-20
0x0C:NameAndType_info
0x00 05:字段或方法名常量项索引#5
0x00 06:字段或方法描述符常量索引#6
CONSTANT-21
0x01:Utf8-info
0x00 0C:字符串长度为12
0x48 65 6C 6C 6F 20 57 6F 72 6C 64 21:字符串Hello World!
CONSTANT-22
0x01:Utf8-info
0x00 1A:字符串长度为26
0x63 6F 6D 2F 79 31 7A 68 33 65 37 2F 54 65 73 74 2F 43 6C 61 73 73 54 65 73 74:字符串com/y1zh3e7/Test/ClassTest
CONSTANT-23
0x01:Utf8-info
0x00 10:字符串长度为16
0x6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74:字符串java/lang/Object
0x04 访问标志位 Access Flags-2Byte
访问标志位包括一个class文件的属性(如是类还是接口,是否被定义成public,是否是abstract,是否是final)

我们向下读取两个Byte0x0021
,代表的是0x0020和0x0001的集合,意思是该类为public,并且继承object(0x06父类索引)
0x05 类索引-2Byte
类索引可以确定类的全局限定名称,我们读取两个字节为0x00 03
,对应常量池第三个常量CONSTANT-3
,可以发现CONSTANT-3:0x00 16:全局限定名常量项索引#22,所以继续去CONSTANT-22查找对应常量,得到全局限定类名com/y1zh3e7/Test/ClassTest
0x06 父类索引-2Byte
0X00 04
,对应CONSTANT-4
,0x00 17:全局限定名常量项索引#23,对应java/lang/Object
0X07 接口索引-2+n
2+n依旧指两个字节代表接口数量,n代表接口表,我们向下读取两个字节0X00 00
,即接口数量为0,自然也没有n了
0x08 字段表集合-2+nByte
字段表中包含了类中声明的变量,以及实例化后的变量,但是不包括方法内声明的局部变量,因此继续向下读取两个字节,可以发现也是0x00 00
,因为我们的变量是定义在psvm中,如果将代码修改如下:
arduino
public class ClassTest { String sayHello = "Hello World!";}
那么此处的2byte则为0x00 01
0x09 方法-2+nByte
继续读取2Byte,0X00 02
,说明我们的类中有两个方法,但是代码中我们明明只有一个方法psvm,其实是因为除了接口和抽象类,在javac时会自动生成一个无参构造,我们可以反编译看到他,也可以javap后看到这个构造器:

vbnet
{ public com.y1zh3e7.Test.ClassTest(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lcom/y1zh3e7/Test/ClassTest; public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=2, args_size=1 0: ldc #2 // String Hello World! 2: astore_1 3: return LineNumberTable: line 5: 0 line 6: 3 LocalVariableTable: Start Length Slot Name Signature 0 4 0 args [Ljava/lang/String; 3 1 1 sayHello Ljava/lang/String;}
我们继续向下读取两个方法,方法表结构如下:
css
method_info { u2 access_flags; u2 name_index; u2 descriptor_index; u2 attributes_count; attribute_info attributes[attributes_count];}
构造方法解析
我们按照格式来读取第一个方法: 0x00 01
,访问标志位,代表public方法,给出以下访问标志控制符掩码解析:
java
十六进制值 名称 说明0x0001 ACC_PUBLIC 方法为 public 权限0x0002 ACC_PRIVATE 方法为 private 权限0x0004 ACC_PROTECTED 方法为 protected 权限0x0008 ACC_STATIC 方法为 static 静态方法0x0010 ACC_FINAL 方法为 final(不可被覆盖)0x0020 ACC_SYNCHRONIZED 方法为 synchronized(同步方法)0x0040 ACC_BRIDGE 方法是由编译器生成的桥接方法(用于泛型类型擦除)0x0080 ACC_VARARGS 方法接受可变参数(如 String... args)0x0100 ACC_NATIVE 方法为 native(由本地代码实现)0x0400 ACC_ABSTRACT 方法为 abstract(抽象方法,无实现)0x0800 ACC_STRICT 方法为 strictfp(严格浮点模式)0x1000 ACC_SYNTHETIC 方法是由编译器生成的(如默认构造方法、枚举类的 values() 方法等)
控制符可以组合使用,如
-
public static
方法:0x0001 (ACC_PUBLIC) | 0x0008 (ACC_STATIC) = 0x0009
-
private final synchronized
方法:0x0002 | 0x0010 | 0x0020 = 0x0032
其中某些标志不能同时存在(如 public
、private
、protected
只能三选一)。
0x00 05
,name_index代表方法索引名,我们去CONSTANT-5进行查找为<init>
,这是字节码中对构造方法的专用描述。
0x00 06
,方法描述符索引。查找CONSTANT-6,为()V。方法描述符的语法是 (参数类型)返回类型
,其中 V
表示 void
(即无返回值)。()
:表示方法没有参数。V
:表示方法的返回类型为 void
。
-
为什么构造方法的返回类型是
void
?虽然构造方法在 Java 语法中没有显式返回值,但在字节码层面,构造方法的返回类型被标记为
void
。实际上,构造方法隐式返回构造的实例对象(this
),但这一过程由 JVM 自动处理,不需要在描述符中体现。
0x00 01
,attributes_count,这里引入属性表的概念。属性表可以描述方法的专有信息,这里则代表了该方法的属性表数量为一个。
通用属性表结构如下:
css
attribute_info { u2 attribute_name_index; u4 attribute_length; u1 info[attribute_length];}
根据通用属性表结构,我们读取一个u2,0x00 07
到CONSTANT-7中查找,发现是Code。
在 JVM 的 .class
文件中,Code
、LineNumberTable
、LocalVariableTable
和 SourceFile
是类文件属性的重要组成部分,分别用于描述方法的行为、调试信息、局部变量与源码的映射关系,以及源码文件的元数据。
Code属性:
Code
属性是方法表(method_info
)中的核心属性,作用如下:
-
存储字节码 :包含方法的具体指令(如
aload_0
,invokespecial
等)。 -
定义执行环境 :通过
max_stack
和max_locals
告诉 JVM 如何分配栈帧内存。 -
异常处理 :通过
exception_table
定义try-catch
块的范围和异常类型。 -
关联调试信息 :通过子属性(如
LineNumberTable
)将字节码与源码关联。
Code属性结构如下:
css
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];}
继续读取一个u4,attribute_length,0x0000002F
代表接下来的47个字节为Code属性的指令字节码。
读取一个u2,0x00 01
,max_stack,代表操作数栈最大深度1,一会我们在分析字节码指令时就知道这是什么意思了。
0X00 01
,max_locals,代表方法的局部变量表大小为1,局部变量为this,因为所有实例方法 (非静态方法)和构造方法 的第一个局部变量槽位(索引 0
)都存储了当前对象的引用(即 this
)。这是 JVM 的隐式规则,无需在代码中显式声明,因此在psvm这个静态方法中就不会包含this了。此外如果该构造方法为有参构造,那么max_locals数量会+n(参数列表的参数数量)
0x00 00 00 05
,code_length为指令长度,也就是说接下来的五个字节为指令。
2A B7 00 01 B1
,我们分别来分析这几条指令的作用。2A对应指令aload_0,用于加载局部方法表中的参数到操作数栈中,因此这一步会将this加载到操作数栈上。B7 对应指令invokespecial ,00 01对应CONSTANT-1,即调用父类构造方法。B1对应指令return,方法返回。
0x00 00
,exception_table_length,代表异常表为空。
0x00 02
,attributes_count,代表该Code属性中还包含了两个子属性。
0x00 08
,对应CONSTANT-8,LineNumberTable,则说明该子属性为一个LineNumberTable。
LineNumberTable 属性:
Code
属性的子属性,记录 字节码偏移量 与 源码行号 的映射关系,作用如下:
-
调试支持 :在 IDE 或异常堆栈中显示源码行号(如
Exception in thread "main" java.lang.NullPointerException at Test.java:12
)。 -
反编译辅助 :帮助工具(如
javap
)生成更易读的反编译结果。 -
优化限制:若省略此属性,JIT 编译器可能无法进行某些优化(如基于行号的 Profiling)。
LineNumberTable属性结构如下:
css
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];}
0x00 00 00 06
,attribute_length,代表接下来的六字节为属性。
00 01
,line_number_table_length为1,代表了下面的line_number_table长度为1。
每个line_number_table包含两个字段,0x00 00
对应start_pc,0x00 03
对应line_number,这两个字段负责将字节码偏移量与源码行数进行映射,start_pc对应字节码偏移量,line_number对应源码行数,因此0003意思是将第0行开始的字节码指令全部与第三行源码进行对应。如果line_number_table长度不为1,还会有多个start_pc来负责映射字节码指令和源码的关系。比如如果还有一组start_pc=3,line_number=4,那么两组映射关系意思是字节码偏移量0-2对应源码第三行,字节码偏移量3及之后的指令对应源码第四行。
我们继续向下读取第Code的第二个子属性,0x00 09
,对应CONSTANT-9,LocalVariableTable。
LocalVariableTable属性:
Code
属性的子属性,记录 局部变量名 、类型 及其在局部变量表中的 槽位 和作用域,作用如下:
-
调试支持 :在 IDE 中显示局部变量名和值(如调试时查看
sayHello
变量的内容)。 -
反射支持 :通过
Method.getParameters()
获取参数名(需编译时启用-parameters
选项)。 -
反编译辅助 :帮助反编译器还原变量名(否则变量名会变成
var1
,var2
)。
LocalVariableTable属性结构如下:
perl
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];}
0x00 0000 0C
,attribute_length,代表接下来12字节为属性长度。
0x00 01
,代表一个局部变量条目,因此下面的local_variable_table[1]中即为描述局部变量this的相关信息。 0x00 00
,start_pc,代表this的作用域从字节码偏移量0开始,作用域覆盖0x00 05
length,共五个字节。
0x00 0A
,name_index,指向CONSTANT-10,局部变量名为this。
0x00 0B
,类的全局限定名,指向CONSTANT-11,Lcom/y1zh3e7/Test/ClassTest
0x00 00
,index,指该局部变量存储在局部变量表的槽位 0
(实例方法的 this
固定占用槽位 0)
main方法解析
0x00 09
:访问标志,0x01和0x08的集合,即public static。
0x00 0C
:name_index,指向CONSTANT-12,类名main,
0x00 0D
:descriptor_index,指向CONSTANT-13,([Ljava/lang/String;)V,方法接收参数为String,返回类型为viod。
0x00 01
:attributes_count,属性数量为1。
继续解析属性:
字段
十六进制值
十进制值/说明
attribute_name_index
00 07
指向常量池第 7 项("Code")
attribute_length
00 00 00 3C
属性总长度:60 字节
max_stack
00 01
操作数栈最大深度:1
max_locals
00 02
局部变量表大小:2(args
和 sayHello
)
code_length
00 00 00 04
字节码长度:4 字节
字节码
12 02 4C B1
指令解析:
12 02
ldc #2
(加载常量 "Hello World!")
4C
astore_1
(存储到局部变量 1)
B1
return
(方法返回)
exception_table_length
00 00
异常表为空
attributes_count
00 02
包含 2 个子属性
子属性 1:LineNumberTable
字段
十六进制值
说明
attribute_name_index
00 08
常量池第 8 项("LineNumberTable")
attribute_length
00 00 00 0A
长度 10 字节
line_number_table_length
00 02
2 个行号条目
条目 1:start_pc
00 00
字节码偏移 0 → 源码第 5 行
条目 1:line_number
00 05
条目 2:start_pc
00 03
字节码偏移 3 → 源码第 6 行
条目 2:line_number
00 06
子属性 2:LocalVariableTable
字段
十六进制值
说明
attribute_name_index
00 09
常量池第 9 项("LocalVariableTable")
attribute_length
00 00 00 16
长度 22 字节
local_variable_table_length
00 02
2 个局部变量条目
条目 1:start_pc
00 00
变量 args
作用域起始偏移 0
length
00 04
作用域长度 4 字节
name_index
00 0E
常量池第 14 项(变量名 args
)
descriptor_index
00 0F
常量池第 15 项(类型 [Ljava/lang/String;
)
index
00 00
局部变量槽位 0
条目 2:start_pc
00 03
变量 sayHello
作用域起始偏移 3
length
00 01
作用域长度 1 字节
name_index
00 10
常量池第 16 项(变量名 sayHello
)
descriptor_index
00 11
常量池第 17 项(类型 Ljava/lang/String;
)
index
00 01
局部变量槽位 1
0x10 属性Attribute-2+nByte
0x00 01
:属性数量1
0x0012
:属性名称,CONSTANT-18,SourceFile。
SourceFile属性:
类文件的顶级属性,记录 源码文件名,作用如下:
-
调试支持 :在异常堆栈中显示源码文件名(如
Test.java
)。 -
代码溯源:帮助开发者快速定位源码文件。
-
可读性:反编译时显示原始文件名,而非匿名类名。
SourceFile文件结构如下:
ini
SourceFile_attribute { u2 attribute_name_index; u4 attribute_length; u2 sourcefile_index; }
0x00 00 00 02
,attribute_length,属性长度2.
0x00 13
,sourcefile_index,指向CONSTANT-19,为ClassTest.java。