W środowisku Javy zawsze irytowała mnie jedna rzecz: brak dopracowanych narzędzi do rewersingu. Na przykład, jedyny debugger oferujący kontrolę na poziomie pojedynczych instrukcji to JDB, który nie potrafi wyświetlić wykonywanych instrukcji.
Z tego powodu postanowiłem napisać swój własny debugger, a do tego potrzebny mi był parser plików .class
, czyli skompilowanych plików Javy.
Czym jest ten cały plik .class?
Najprościej: plik .class
to pojedyncza, skompilowana klasa Javy. Zawiera on:
- 🔤 użyte stringi
- 1️⃣ zmienne klasowe: ich nazwy, typ zmiennej
- ⚙️ funkcje: nazwy, skompilowany kod
- ℹ️ metadane: przykładowo nazwa pliku źródłowego
Poniżej znajdziesz strukturę tego pliku.
Struktura pliku .class
Edytor ImHex (świetne narzędzie swoją drogą) ma gotowy wzór do plików .class
. Oto kawałek klasy hello/HelloWorld
otwarty w edytorze:


A oto mój fragment mojego debuggera (który piszę w języku Rust), określający strukturę pliku .class
:
#[binrw]
#[brw(big)]
pub struct JavaClassFile {
pub version: Version,
pub constant_pool: ConstantPool,
pub access_flags: ClassAccessFlags,
pub this_class: u16,
pub super_class: u16,
interfaces_length: u16,
#[br(count = interfaces_length)]
pub interfaces: Vec<u16>,
fields_length: u16,
#[br(count = fields_length)]
pub fields: Vec<FieldInfo>,
methods_length: u16,
#[br(count = methods_length)]
pub methods: Vec<MethodInfo>,
attributes_length: u16,
#[br(count = attributes_length)]
pub attributes: Vec<AttributeInfo>,
}
version
: wersja kompilatoraconstant_pool
: pula stałych – wszystkie liczby, stringi, odniesienia do innych klasaccess_flags
: czy klasa jest publiczna, finalna itd.this_class
: indeks w puli stałych wskazujący na nazwę klasysuper_class
: indeks w puli stałych wskazujący na nazwę dziedziczonej klasyinterfaces
: interfejsy przypisane do klasyfields
: zmiennemethods
: funkcjeattributes
: atrybuty, takie jak: oryginalna nazwa pliku źródłowego
Plik .class przestrzega porządku big-endian, co oznacza, że każda liczba jest zapisywana od lewej do prawej. Przykładowo:
- liczba 16-bitowa
0xFFEE
zostanie zapisana:FF EE
- w porządku little-endian byłoby to:
EE FF
(od tyłu)
Każda kolekcja (funkcji, zmiennych itd.) jest poprzedzona ich długością. Jest ona potrzebna, aby program przetwarzający wiedział ile funkcji, zmiennych czy atrybutów ma odczytać.
Do zrozumienia wszystkich innych elementów .class
, potrzebna jest znajomość puli stałych, która przechowuje wartości wykorzystywane w całym pliku – nawet w kodzie funkcji.
Pula stałych (constant pool)
Każda wartość w puli jest poprzedzona identyfikatorem (bajtem):
impl TryFrom<u8> for ConstantPoolTag {
type Error = InvalidCpTag;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(ConstantPoolTag::Utf8),
3 => Ok(ConstantPoolTag::Integer),
4 => Ok(ConstantPoolTag::Float),
5 => Ok(ConstantPoolTag::Long),
6 => Ok(ConstantPoolTag::Double),
7 => Ok(ConstantPoolTag::Class),
8 => Ok(ConstantPoolTag::String),
9 => Ok(ConstantPoolTag::FieldRef),
10 => Ok(ConstantPoolTag::MethodRef),
11 => Ok(ConstantPoolTag::InterfaceMethodRef),
12 => Ok(ConstantPoolTag::NameAndType),
15 => Ok(ConstantPoolTag::MethodHandle),
16 => Ok(ConstantPoolTag::MethodType),
17 => Ok(ConstantPoolTag::Dynamic),
18 => Ok(ConstantPoolTag::InvokeDynamic),
19 => Ok(ConstantPoolTag::Module),
20 => Ok(ConstantPoolTag::Package),
other => Err(InvalidCpTag { tag: other }),
}
}
}
Z powyższego kodu wynika, że po napotkaniu bajtu „5”, następujące bajty będą reprezentowały 64-bitową liczbę całkowitą (Long
):
[...]
ConstantPoolTag::Long => {
let v = i64::read_options(reader, endian, args)?;
Ok(ConstantPoolEntry::Long(v))
}
[...]
A oto przykład takiego wpisu w prawdziwym pliku:

Z powyższego zrzutu ekranu wynika, że wpis #143 w puli to 64-bitowa liczba całkowita 1000 (0x3E8).
Pola i metody
Pola i metody w pliku .class są zapisywane praktycznie tak samo. Składają się z:
- flag dostępowych (publiczne, prywatne itd.)
- indeksu wskazującego na nazwę
- indeksu wskazującego na deskryptor
- atrybutów
#[binrw]
pub struct FieldInfo {
pub access_flags: FieldAccessFlags,
pub name_index: u16,
pub descriptor_index: u16,
attributes_length: u16,
#[br(count = attributes_length)]
pub attributes: Vec<AttributeInfo>,
}
#[binrw]
pub struct MethodInfo {
pub access_flags: MethodAccessFlags,
pub name_index: u16,
pub descriptor_index: u16,
attributes_length: u16,
#[br(count = attributes_length)]
pub attributes: Vec<AttributeInfo>,
}
Wspomniane dwa indeksy muszą wskazywać na element UTF-8 w puli stałych (na ciąg znaków).
Deskryptor określa typ zmiennej, albo parametry i zwracany typ metody. Jest to ciąg znaków składający się głównie z liter:
match descriptor.chars().next().unwrap() {
'L' => {
let semicolon_pos = descriptor
.find(';')
.ok_or(DescriptorError::ClassTerminatorNotFound)?;
let class_name = &descriptor[1..semicolon_pos];
Ok(ComponentType::Object {
class_name: class_name.to_string(),
})
}
'B' => Ok(ComponentType::Base(Type::SignedByte)),
'C' => Ok(ComponentType::Base(Type::Char)),
'D' => Ok(ComponentType::Base(Type::Double)),
'F' => Ok(ComponentType::Base(Type::Float)),
'I' => Ok(ComponentType::Base(Type::Integer)),
'J' => Ok(ComponentType::Base(Type::Long)),
'S' => Ok(ComponentType::Base(Type::Short)),
'Z' => Ok(ComponentType::Base(Type::Boolean)),
other => Err(DescriptorError::InvalidChar(other)),
}
Oprócz wyżej wymienionych liter w deskryptorze może znajdować się „[
„, co oznaczałoby, że typ będzie tablicą elementów. Przykładowo:
- deskryptor „
J
” oznacza typLong
- deskryptor „
[[J
” oznacza dwuwymiarową tablicęLongów
- deskryptor „
Lhello/World;
” oznacza klasęhello.World
Atrybuty
Mogłeś zauważyć, że atrybuty są wykorzystywane w wielu miejscach w plikach .class: w FieldInfo
, MethodInfo
i nawet w samej klasie.
Zbudowane są z indeksu wskazującego na nazwę atrybutu oraz danych:
#[binrw]
pub struct AttributeInfo {
pub name_index: u16,
data_length: u32,
#[br(count = data_length)]
pub data: Vec<u8>,
}
Program przetwarzający .class
przetwarza dane na podstawie nazwy odczytanej z puli stałych.
Przykładowo, jeżeli name_index
wskazuje na string SourceFile
, parser będzie wiedział, że data
zawiera string UTF-8, który jest nazwą oryginalnego pliku źródłowego (np. HelloWorld.java
).
Wszystkie atrybuty są zdefiniowane w specyfikacji JVM.
Ważnym atrybutem jest Code
, który zawiera skompilowany bytecode funkcji:
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];
}
Dlaczego to ważne?
Zrozumienie struktury plików .class
ma praktyczne zastosowania:
- Reverse engineering i debugging – pozwala analizować, jak dokładnie działa kod Javy po kompilacji, a także pisać własne narzędzia do debugowania czy analizy
- Bezpieczeństwo – analiza plików
.class
pomaga w badaniu malware pisanego w Javie albo w szukaniu luk w kodzie - Narzędzia deweloperskie – wiele popularnych narzędzi (np. dekompilatory, frameworki testowe czy biblioteki do instrumentacji kodu) bazuje na parsowaniu plików
.class
. Wiedząc, jak one są zbudowane, łatwiej jest pisać własne rozszerzenia - Głębsze zrozumienie JVM – dzięki znajomości szczegółów formatu
.class
programista lepiej rozumie, jak Java „od środka” reprezentuje klasy, metody i typy
Podsumowanie
- Pliki
.class
to skompilowane pliki Javy - Wszystkie literały (liczby, stringi) i odniesienia do klas są w jednej, indeksowanej puli
- Funkcje i pola są zdefiniowane w listach
- Klasy, funkcje oraz pola posiadają atrybuty, które definiują np. kod funkcji, albo to, czy dana zmienna jest przestarzała
- Cała specyfikacji pliku .class jest dostępna tutaj