Decompiling an Android Application Written in .NET MAUI 9 (Xamarin)

.NET MAUI, as the successor to Xamarin, enables the development of cross-platform applications, including Android, using C#. In previous versions (up to .NET MAUI 8), applications stored their DLL libraries in assemblies.blob and assemblies.manifest files, making extraction and analysis easier. Tools already existed to decompile these files, such as pyxamstore, which allowed for easy recovery of original libraries. With the release of .NET MAUI 9, a significant change was introduced in the way .NET libraries are packaged in Android applications. Instead of storing DLLs in separate files, all libraries are now embedded in ELF (.so) files named libassemblies.<arch>.blob.so, where <arch> refers to the processor architecture, e.g., arm64-v8a. This change aimed to improve performance, enhance security, and provide better compatibility with Android. In this article, I will explain how to extract these DLL files.

Decompiling an Android Application Written in .NET MAUI 9 (Xamarin)

Structure of the libassemblies.arm64-v8a.blob.so File: What has changed?

In .NET MAUI 9, DLL libraries are no longer stored as separate files in the Android application directory. Instead, Microsoft has embedded them within ELF files—a format well-known in Unix-like systems, including Android. At first glance, the libassemblies.arm64-v8a.blob.so file looks like a standard native shared library (with a .so extension), similar to libmonosgen-2.0.so. However, it hides something more - a complete set of .NET libraries tailored for a specific processor architecture, in this case, the 64-bit ARMv8-A (denoted as arm64-v8a).

Where to find these files?

Let’s start with the basics: to access this data, we need to unpack an APK (Android Package) or AAB (Android App Bundle) file. You can do this using tools like apktool or simply unzip for an APK. After unpacking, you’ll see a directory structure similar to this:

lib/
 ├── arm64-v8a/
 │   ├── libassemblies.arm64-v8a.blob.so
 │   ├── libmonosgen-2.0.so
 │   ├── libaot-*.so
 ├── x86_64/
 │   ├── [similar files for another architecture]
 ├── armeabi-v7a/
 │   ├── [and so on]
  • libassemblies.arm64-v8a.blob.so: The key ELF file containing all .NET libraries for the arm64-v8a architecture.
  • libmonosgen-2.0.so: The Mono engine, responsible for running .NET code on Android.
  • libaot-*.so: Files related to Ahead-of-Time (AOT) compilation, i.e., precompiled native code for improved performance.

Depending on the architectures supported by the application, you’ll find different versions of these files. In this article, we’ll focus on libassemblies.arm64-v8a.blob.so, as it’s the most common architecture in modern Android devices.

What’s inside an ELF file?

ELF files are a standard binary format used to store libraries, executable programs, and other data in Unix-like systems. To peek inside libassemblies.arm64-v8a.blob.so, we can use the llvm-readelf tool (available in the LLVM package). It will display the 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), ...

The most interesting section for us is payload:

  • Size: 1586de4 bytes, approximately 22 MB.
  • Type: PROGBITS, indicating binary data ready to be loaded into memory.
  • Name: payload non-standard section created specifically for .NET MAUI 9, where the .NET libraries are hidden.

Step 1: Extracting the payload section

To extract the contents of the payload section, we’ll use the llvm-objcopy tool. It allows us to dump a specific section from an ELF file into a separate binary file:

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

After running this command, we get a payload.bin file. Let’s check its size to ensure everything went as planned:

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

The size (around 22 MB) matches what we saw in the ELF headers, so we’re confident we’ve extracted the correct data.

What’s Inside?

Let’s examine the first few bytes of payload.bin using hexdump to confirm it’s in a .NET format:

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

The XABA sequence (in hex: 0x41424158) is a so-called magic number that identifies the assembly store format in .NET MAUI 9. It’s the equivalent of the older assemblies.blob.

Step 2: Breaking down the Assembly Store format

The payload.bin file isn’t a single DLL but a store containing multiple .NET libraries along with metadata. Its structure is as follows:

  • Header: 20 bytes containing basic information such as the signature (XABA), format version, and number of libraries.
  • Descriptors: A list of records specifying the offsets and sizes of each library in the file.
  • Library Names: UTF-8 encoded strings specifying the names of DLL files (e.g., System.IO.dll).
  • Library Data: The raw binary data of the individual libraries.

Parsing the header

The header has a fixed size of 20 bytes. We can parse it in Python by defining an AssemblyStoreHeader class:

import struct

class AssemblyStoreHeader:
    def __init__(self, magic, version, entry_count, index_entry_count, index_size):
        self.magic = magic              # "XABA" signature (0x41424158)
        self.version = version          # Format version
        self.entry_count = entry_count  # Number of libraries in the store
        self.index_entry_count = index_entry_count  # Number of index entries (optional)
        self.index_size = index_size    # Size of the index section in bytes
    
    @staticmethod
    def from_file(f):
        data = f.read(20)  # Read 20 bytes
        magic, version, entry_count, index_entry_count, index_size = struct.unpack('<5I', data)
        if magic != 0x41424158:  # Check the "XABA" signature
            raise ValueError("Invalid magic number, not an assembly store file")
        return AssemblyStoreHeader(magic, version, entry_count, index_entry_count, index_size)

Reading descriptors

Each library has a descriptor-a 28-byte record that describes the location and size of its data. Here’s the Python definition:

class AssemblyStoreEntryDescriptor:
    def __init__(self, mapping_index, data_offset, data_size, debug_offset, debug_size, config_offset, config_size):
        self.mapping_index = mapping_index  # Mapping index (unused in extraction)
        self.data_offset = data_offset      # Offset of library data
        self.data_size = data_size          # Size of library data
        self.debug_offset = debug_offset    # Offset of debug data (optional)
        self.debug_size = debug_size        # Size of debug data
        self.config_offset = config_offset  # Offset of configuration data (optional)
        self.config_size = config_size      # Size of configuration data
    
    @staticmethod
    def from_file(f):
        data = f.read(28)  # 7 fields of 4 bytes each = 28 bytes
        return AssemblyStoreEntryDescriptor(*struct.unpack('<7I', data))

Reading library names

Following the descriptors are the library names, stored in the format: length of the string (4 bytes) + UTF-8 encoded string. Here’s the Python code to read them:

def read_names(f, entry_count):
    names = []
    for _ in range(entry_count):
        name_length = struct.unpack('<I', f.read(4))[0]  # Length of the name in bytes
        name = f.read(name_length).decode('utf-8')       # Read the name
        names.append(name)
    return names

Step 3: Extracting libraries

Now that we have all the metadata, we can extract the library data and save it as individual DLL files. Here’s the complete extraction method:

import os

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

        # Read the descriptors
        descriptors = [AssemblyStoreEntryDescriptor.from_file(f) for _ in range(header.entry_count)]
        
        # Skip to the names (after skipping the index, if present)
        f.seek(header.index_size, 1)
        names = read_names(f, header.entry_count)

        # Extract libraries
        for desc, name in zip(descriptors, names):
            f.seek(desc.data_offset)
            assembly_data = f.read(desc.data_size)
            
            # Add .dll extension if missing
            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}")

Running this code produces a directory with extracted DLL files, such as System.IO.dll, MainApp.dll, etc.

Step 4: LZ4 decompression

If you try opening these files in a decompilation tool like dotPeek, you might notice something’s off. Let’s check the first few bytes of one of the files:

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

The XALZ signature indicates that the data is additionally compressed using the LZ4 algorithm. To decompress it, we need the lz4 library in Python:

import lz4.block

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)

After decompression, we obtain valid DLL files that can be opened in .NET decompilation tools like dotPeek (from JetBrains).

Example of a decompiled DLL extracted from <code>libassemblies.arm64-v8a.blob.so</code>.
Example of a decompiled DLL extracted from libassemblies.arm64-v8a.blob.so.

You can find a script automating the entire process here.

References

  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