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
0xFFEEzostanie 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
.classpomaga 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
.classprogramista lepiej rozumie, jak Java „od środka” reprezentuje klasy, metody i typy
Podsumowanie
- Pliki
.classto 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

