Dekompilacja aplikacji Android napisanej w .NET MAUI 9 (Xamarin)

.NET MAUI, jako następca Xamarin, umożliwia tworzenie aplikacji wieloplatformowych, w tym na system Android, przy użyciu języka C#. W poprzednich wersjach (do .NET MAUI 8) aplikacje przechowywały zestawy bibliotek DLL w plikach assemblies.blob oraz assemblies.manifest, co ułatwiało ich ekstrakcję i analizę. Istniały już narzędzia umożliwiające dekompilację tych plików, takie jak pyxamstore, pozwalające na łatwe odzyskanie oryginalnych bibliotek. Wraz z premierą .NET MAUI 9 wprowadzono istotną zmianę w sposobie pakowania bibliotek .NET w aplikacjach Androidowych. Zamiast przechowywania DLL w osobnych plikach, wszystkie biblioteki są teraz osadzane w plikach ELF (.so) o nazwie libassemblies.<arch>.blob.so, gdzie <arch> odnosi się do architektury procesora, np. arm64-v8a. Ta zmiana miała na celu poprawę wydajności, zwiększenie bezpieczeństwa oraz lepszą kompatybilność z systemem Android. W tym artykule wyjaśnię, jak wydobyć wspomniane pliki DLL.

Dekompilacja aplikacji Android napisanej w .NET MAUI 9 (Xamarin)

Struktura pliku libassemblies.arm64-v8a.blob.so: Co się zmieniło?

W .NET MAUI 9 biblioteki DLL nie są już zapisywane jako oddzielne pliki w katalogu aplikacji Androida. Zamiast tego Microsoft osadził je w plikach ELF - formacie dobrze znanym w systemach uniksowych, w tym w Androidzie. Na pierwszy rzut oka plik libassemblies.arm64-v8a.blob.so wygląda jak standardowa natywna biblioteka współdzielona (z rozszerzeniem .so), podobna do libmonosgen-2.0.so. Ale w rzeczywistości kryje w sobie coś więcej - kompletny zestaw bibliotek .NET dla danej architektury procesora, w tym przypadku 64-bitowego ARMv8-A (oznaczonego jako arm64-v8a).

Gdzie szukać tych plików?

Zacznijmy od podstaw: aby dostać się do tych danych, musimy rozpakować plik APK (Android Package) lub AAB (Android App Bundle). Możesz to zrobić za pomocą narzędzi takich jak apktool albo zwykłego unzip w przypadku APK. Po rozpakowaniu zobaczysz strukturę katalogów podobną do tej:

lib/
 ├── arm64-v8a/
 │   ├── libassemblies.arm64-v8a.blob.so
 │   ├── libmonosgen-2.0.so
 │   ├── libaot-*.so
 ├── x86_64/
 │   ├── [podobne pliki dla innej architektury]
 ├── armeabi-v7a/
 │   ├── [itd.]
  • libassemblies.arm64-v8a.blob.so: Kluczowy plik ELF, który zawiera wszystkie biblioteki .NET dla architektury arm64-v8a.
  • libmonosgen-2.0.so: Silnik Mono, który odpowiada za uruchamianie kodu .NET na Androidzie.
  • libaot-*.so: Pliki związane z kompilacją AOT (Ahead-of-Time), czyli wstępnie skompilowanym kodem natywnym dla lepszej wydajności.

W zależności od tego, jakie architektury wspiera aplikacja, znajdziesz różne wersje tych plików. W tym artykule skupimy się na libassemblies.arm64-v8a.blob.so, bo to najpopularniejsza architektura w nowoczesnych urządzeniach z Androidem.

Co kryje się w pliku ELF?

Pliki ELF to standardowy format binarny używany do przechowywania bibliotek, programów wykonywalnych i innych danych w systemach uniksowych. Aby zajrzeć do środka libassemblies.arm64-v8a.blob.so, możemy użyć narzędzia llvm-readelf (dostępnego w pakiecie LLVM). Wyświetli nam ono nagłówki sekcji (section headers):

$ llvm-readelf --section-headers libassemblies.arm64-v8a.blob.so
There are 11 section headers, starting at offset 0x158ade8:

Section Headers:
  [Nr] Name              Type            Address          Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            0000000000000000 000000 000000 00      0   0  0
  [ 1] .note.gnu.build-id NOTE           0000000000000200 000200 000024 00   A  0   0  4
  [ 2] .dynsym           DYNSYM          0000000000000228 000228 000030 18   A  5   1  8
  [ 3] .gnu.hash         GNU_HASH        0000000000000258 000258 000020 00   A  2   0  8
  [ 4] .hash             HASH            0000000000000278 000278 000018 04   A  2   0  4
  [ 5] .dynstr           STRTAB          0000000000000290 000290 000040 00   A  0   0  1
  [ 6] .dynamic          DYNAMIC         00000000000042d0 0002d0 0000b0 10  WA  5   0  8
  [ 7] .relro_padding    NOBITS          0000000000004380 000380 000c80 00  WA  0   0  1
  [ 8] .data             PROGBITS        0000000000008380 000380 000001 00  WA  0   0  1
  [ 9] .shstrtab         STRTAB          0000000000000000 000381 00005e 00      0   0  1
  [10] payload           PROGBITS        0000000000000000 004000 1586de4 00      0   0 16384
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info), ...

Najciekawsza dla nas jest sekcja payload:

  • Rozmiar: 1586de4 bajtów, czyli około 22 MB.
  • Typ: PROGBITS, co oznacza dane binarne gotowe do załadowania do pamięci.
  • Nazwa: payload - niestandardowa sekcja stworzona specjalnie dla .NET MAUI 9, w której ukryto biblioteki .NET.

Krok 1: Wypakowanie sekcji payload

Aby wydobyć zawartość sekcji payload, użyjemy narzędzia llvm-objcopy. Pozwala ono na wyodrębnienie konkretnej sekcji z pliku ELF do osobnego pliku binarnego:

$ llvm-objcopy --dump-section=payload=payload.bin libassemblies.arm64-v8a.blob.so

Po wykonaniu tego polecenia otrzymujemy plik payload.bin. Sprawdźmy jego rozmiar, aby upewnić się, że wszystko poszło zgodnie z planem:

$ ls -lh payload.bin
-rw-rw-r-- 1 22572516 Mar 14 12:56 payload.bin

Rozmiar (około 22 MB) zgadza się z tym, co widzieliśmy w nagłówkach ELF - mamy więc pewność, że wyodrębniliśmy właściwe dane.

Co jest w środku?

Sprawdźmy pierwsze bajty pliku payload.bin za pomocą hexdump, aby potwierdzić, że mamy do czynienia z formatem .NET:

$ hexdump -c -n 4 payload.bin
0000000   X   A   B   A                                                
0000004

Sekwencja XABA (w hex: 0x41424158) to tzw. magic number, który identyfikuje format assembly store w .NET MAUI 9. To odpowiednik dawnego assemblies.blob.

Krok 2: Rozpracowanie formatu Assembly Store

Plik payload.bin nie jest pojedynczym plikiem DLL, lecz magazynem (store), który zawiera wiele bibliotek .NET wraz z metadanymi. Jego struktura wygląda następująco:

  • Nagłówek (Header): 20 bajtów zawierających podstawowe informacje, takie jak sygnatura (XABA), wersja formatu i liczba bibliotek.
  • Deskryptory (Descriptors): Lista rekordów określających offsety i rozmiary każdej biblioteki w pliku.
  • Nazwy bibliotek: Ciągi znaków w formacie UTF-8 określające nazwy plików DLL (np. System.IO.dll).
  • Dane bibliotek: Surowe dane binarne poszczególnych bibliotek.

Parsowanie nagłówka

Nagłówek ma stały rozmiar 20 bajtów. Możemy go sparsować w Pythonie, definiując klasę AssemblyStoreHeader:

import struct

class AssemblyStoreHeader:
    def __init__(self, magic, version, entry_count, index_entry_count, index_size):
        self.magic = magic              # Sygnatura "XABA" (0x41424158)
        self.version = version          # Wersja formatu
        self.entry_count = entry_count  # Liczba bibliotek w magazynie
        self.index_entry_count = index_entry_count  # Liczba wpisów w indeksie (opcjonalne)
        self.index_size = index_size    # Rozmiar sekcji indeksu w bajtach
    
    @staticmethod
    def from_file(f):
        data = f.read(20)  # Czytamy 20 bajtów
        magic, version, entry_count, index_entry_count, index_size = struct.unpack('<5I', data)
        if magic != 0x41424158:  # Sprawdzamy sygnaturę "XABA"
            raise ValueError("Invalid magic number, not an assembly store file")
        return AssemblyStoreHeader(magic, version, entry_count, index_entry_count, index_size)

Odczyt deskryptorów

Każda biblioteka ma swój deskryptor - rekord o rozmiarze 28 bajtów, który opisuje położenie i rozmiar jej danych. Oto definicja w Pythonie:

class AssemblyStoreEntryDescriptor:
    def __init__(self, mapping_index, data_offset, data_size, debug_offset, debug_size, config_offset, config_size):
        self.mapping_index = mapping_index  # Indeks mapowania (nieużywany w ekstrakcji)
        self.data_offset = data_offset      # Offset danych biblioteki
        self.data_size = data_size          # Rozmiar danych biblioteki
        self.debug_offset = debug_offset    # Offset danych debugowania (opcjonalne)
        self.debug_size = debug_size        # Rozmiar danych debugowania
        self.config_offset = config_offset  # Offset danych konfiguracyjnych (opcjonalne)
        self.config_size = config_size      # Rozmiar danych konfiguracyjnych
    
    @staticmethod
    def from_file(f):
        data = f.read(28)  # 7 pól po 4 bajty = 28 bajtów
        return AssemblyStoreEntryDescriptor(*struct.unpack('<7I', data))

Odczyt nazw bibliotek

Po deskryptorach znajdują się nazwy bibliotek w formacie: długość ciągu (4 bajty) + ciąg znaków w UTF-8. Kod do ich odczytu wygląda tak:

def read_names(f, entry_count):
    names = []
    for _ in range(entry_count):
        name_length = struct.unpack('<I', f.read(4))[0]  # Długość nazwy w bajtach
        name = f.read(name_length).decode('utf-8')       # Odczyt nazwy
        names.append(name)
    return names

Krok 3: Ekstrakcja bibliotek

Teraz, gdy mamy wszystkie metadane, możemy wydobyć dane bibliotek i zapisać je jako osobne pliki DLL. Oto pełna metoda ekstrakcji:

import os

def extract_assemblies(file_path, output_dir):
    os.makedirs(output_dir, exist_ok=True)
    with open(file_path, 'rb') as f:
        # Odczyt nagłówka
        header = AssemblyStoreHeader.from_file(f)
        print(f"Found {header.entry_count} assemblies in store")

        # Odczyt deskryptorów
        descriptors = [AssemblyStoreEntryDescriptor.from_file(f) for _ in range(header.entry_count)]
        
        # Przesunięcie do nazw (po pominięciu indeksu, jeśli istnieje)
        f.seek(header.index_size, 1)
        names = read_names(f, header.entry_count)

        # Ekstrakcja bibliotek
        for desc, name in zip(descriptors, names):
            f.seek(desc.data_offset)
            assembly_data = f.read(desc.data_size)
            
            # Dodajemy rozszerzenie .dll, jeśli brakuje
            if not name.endswith('.dll'):
                name += '.dll'
            
            out_path = os.path.join(output_dir, name)
            with open(out_path, 'wb') as out_file:
                out_file.write(assembly_data)
            print(f"Extracted: {out_path}")

Po uruchomieniu tego kodu otrzymujemy katalog z wyodrębnionymi plikami DLL, np. System.IO.dll, MainApp.dll itp.

Krok 4: Dekompresja LZ4

Jeśli spróbujemy otworzyć te pliki w narzędziu do dekompilacji, jak dotPeek, możemy zauważyć, że coś jest nie tak. Sprawdźmy pierwsze bajty jednego z plików:

$  hexdump -c -n 4 ./output/System.IO.FileSystem.Primitives.dll
0000000   X   A   L   Z                                                
0000004

Sygnatura XALZ oznacza, że dane są dodatkowo skompresowane algorytmem LZ4. Aby je zdekompresować, potrzebujemy biblioteki lz4 w Pythonie:

def decompress_lz4(compressed_data):
    uncompressed_size = struct.unpack('<I', compressed_data[8:12])[0]
    compressed_payload = compressed_data[12:]
    return lz4.block.decompress(compressed_payload, uncompressed_size=uncompressed_size)

Po dekompresji otrzymujemy poprawne pliki DLL, które można otworzyć w narzędziach do dekompilacji .NET, takich jak dotPeek (od JetBrains).

Przykład zdekompilowanego pliku DLL wyodrębnionego z <code>libassemblies.arm64-v8a.blob.so</code>.
Przykład zdekompilowanego pliku DLL wyodrębnionego z libassemblies.arm64-v8a.blob.so.

Skrypt automtyzujący cały proces możesz znaleźć tutaj.

Bibliografia

  1. https://github.com/dotnet/android/blob/main/Documentation/project-docs/ApkSharedLibraries.md
  2. https://github.com/dotnet/android/blob/main/Documentation/project-docs/AssemblyStores.md
  3. https://github.com/dotnet/android/blob/main/Documentation/project-docs/ExploringSources.md
  4. https://github.com/jakev/pyxamstore/blob/master/pyxamstore/explorer.py
  5. https://github.com/dotnet/maui/releases/tag/9.0.0
  6. https://github.com/mwalkowski/pymauistore/tree/main