Tag: Pliki binarne

  • Plik .class (Java)

    Plik .class (Java)

    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:

    Zrzut ekranu z edytora ImHex z otwartym plikiem .class. Zaznaczone kolorowe fragmenty.
    Surowe bajty w pliku .class z zaznaczonymi danymi
    Zrzut ekranu z edytora ImHex z otwartym plikiem .class. Wyświetlony fragment przetworzonych danych.
    Odczytane dane z pliku .class

    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 kompilatora
    • constant_pool: pula stałych – wszystkie liczby, stringi, odniesienia do innych klas
    • access_flags: czy klasa jest publiczna, finalna itd.
    • this_class: indeks w puli stałych wskazujący na nazwę klasy
    • super_class: indeks w puli stałych wskazujący na nazwę dziedziczonej klasy
    • interfaces: interfejsy przypisane do klasy
    • fields: zmienne
    • methods: funkcje
    • attributes: 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:

    Zrzut ekranu z edytora ImHex. Zaznaczony wpis z puli stałych z pliku .class.
    Widoczny wpis 64-bitowej liczby całkowitej w puli stałych

    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 typ Long
    • 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