A static binary analysis tool that maps managed .NET methods to their native addresses in Xamarin/MAUI AOT-compiled binaries. Supports both iOS and Android. Useful for reverse engineering Xamarin apps in disassemblers like IDA Pro, Ghidra, and Binary Ninja.
The tool parses a Xamarin iOS .app bundle containing:
- A Mach-O binary (ARM64, including FAT/universal binaries)
- .dll files with .NET metadata (type/method definitions)
- .aotdata.arm64 files (AOT compilation data)
It auto-detects the MonoAotFileInfo struct layout embedded in the binary, resolves the ARM64 BL/B call table to find native code addresses for each managed method, and cross-references them with .NET metadata from the DLL files.
The tool processes an extracted APK's native library directory containing:
- libaot-*.dll.so files (one ELF shared object per assembly with AOT code)
- .dll files with .NET metadata (standalone or inside
libassemblies.*.blob.soXABA blobs)
Each .so exports a mono_aot_file_info symbol pointing to the AOT struct. Pointer fields are resolved via R_AARCH64_RELATIVE relocations in .rela.dyn. DLLs packed in XABA blobs are automatically extracted and LZ4-decompressed.
The output is a JSON file mapping every managed method to its native address, plus optional Ghidra/IDA annotation scripts and an IL2CppDumper-style dump.cs with full type definitions.
Covers all major Xamarin.iOS, Xamarin.Android, and .NET MAUI releases:
| Version(s) | Runtime | Era |
|---|---|---|
| 156–162 | Mono 6.0 / 6.4 | Xamarin.iOS 13.x |
| 170–172 | Mono 6.8 / 6.12 | Xamarin.iOS 14.x–15.x |
| 176 | Xamarin fork | Xamarin.iOS custom builds |
| 180–185 | .NET 6 / 7 / 8 | .NET MAUI (iOS + Android) |
| 186–187+ | .NET 9 / 10 | .NET MAUI (iOS + Android) |
Unknown versions in the 150–250 range are detected heuristically via struct field interpolation.
- Python 3.6+
- dnfile — .NET PE file parser
Install dependencies:
pip3 install dnfilepython3 xamarin_aot_dumper.py \
--app "/path/to/MyApp.app" \
--out output/dump.jsonpython3 xamarin_aot_dumper.py \
--app "/path/to/MyApp.app" \
--out output/dump.json \
--ghidra output/ghidra_annotate.py \
--ida output/ida_annotate.pypython3 xamarin_aot_dumper.py \
--app "/path/to/MyApp.app" \
--binary "/path/to/binary" \
--out output/dump.jsonExtract the APK first (e.g., unzip MyApp.apk -d extracted/), then point to the lib/arm64-v8a/ directory:
python3 xamarin_aot_dumper.py \
--android "extracted/lib/arm64-v8a/" \
--out output/dump.jsonThe tool needs libaot-*.dll.so files and matching .dll files. If standalone DLLs aren't present, it automatically extracts them from libassemblies.*.blob.so XABA blobs (LZ4-compressed).
python3 xamarin_aot_dumper.py \
--android "extracted/lib/arm64-v8a/" \
--out output/dump.json \
--dump-cs output/dump.cs \
--ghidra output/ghidra_annotate.pypython3 xamarin_aot_dumper.py \
--app "/path/to/MyApp.app" \
--out output/dump.json \
--assemblies Common.NetStandard,Snap.iOS.Commonpython3 xamarin_aot_dumper.py \
--app "/path/to/MyApp.app" \
--out output/dump.json \
--dump-cs output/dump.cs| Flag | Description |
|---|---|
--app |
Path to iOS .app bundle directory (mutually exclusive with --android) |
--android |
Path to directory with libaot-*.dll.so + .dll files (mutually exclusive with --app) |
--out |
(required) Output JSON file path |
--ghidra |
Generate a Ghidra Python script that renames functions from the JSON |
--ida |
Generate an IDA Python script that renames functions from the JSON |
--dump-cs |
Generate an IL2CppDumper-style C# header file with full type definitions |
--assemblies |
Comma-separated list of assembly names to process (default: all) |
--binary |
Override auto-detection of the main Mach-O binary (iOS only) |
The JSON output contains both a flat methods array (for script compatibility) and a structured types array with full type hierarchies:
{
"generatedBy": "xamarin_aot_dumper.py",
"binary": "/path/to/binary",
"aotVersion": 185,
"vmBase": "0x100000000",
"stats": {
"total_assemblies": 42,
"total_methods": 15000,
"total_compiled": 12000,
"total_types": 5000
},
"types": [
{
"assembly": "MyApp",
"namespace": "MyNamespace",
"name": "MyClass",
"fullName": "MyNamespace.MyClass",
"kind": "class",
"visibility": "public",
"modifiers": "sealed",
"baseType": "System.Object",
"interfaces": ["System.IDisposable"],
"genericParams": ["T"],
"fields": [
{ "name": "_count", "type": "int", "visibility": "private", "isStatic": false, "isReadonly": true, "isConst": false }
],
"properties": [
{ "name": "Count", "type": "int", "hasGetter": true, "hasSetter": false }
],
"events": [
{ "name": "Changed", "type": "System.EventHandler" }
],
"methods": [
{
"name": "DoSomething",
"returnType": "void",
"parameters": [{ "name": "x", "type": "int" }, { "name": "y", "type": "string" }],
"visibility": "public",
"isStatic": false,
"isVirtual": true,
"token": "0x06000001",
"nativeAddress": "0x100abc000",
"isCompiled": true
}
]
}
],
"methods": [
{
"assembly": "MyApp",
"type": "MyNamespace.MyClass",
"method": "DoSomething",
"returnType": "void",
"parameters": [{ "name": "x", "type": "int" }, { "name": "y", "type": "string" }],
"token": "0x06000001",
"methodIndex": 0,
"nativeAddress": "0x100abc000",
"isCompiled": true
}
]
}The --dump-cs output is modeled after IL2CppDumper's dump.cs format:
// Assembly: MyApp
// Namespace: MyNamespace
public sealed class MyClass : IDisposable // TypeDefIndex: 42
{
// Fields
private readonly int _count;
// Properties
int Count { get; }
// Methods
// RVA: 0x1234 VA: 0x100abc000
public virtual void DoSomething(int x, string y) { }
}
public enum MyEnum // TypeDefIndex: 43
{
None = 0,
First = 1,
Second = 2,
}- Open your binary in Ghidra
- Open the Script Manager (Window > Script Manager)
- Run the generated
ghidra_annotate.pyscript - Adjust
DUMP_PATHin the script if needed
- Open your binary in IDA
- File > Script File and select the generated
ida_annotate.py - Adjust
DUMP_PATHin the script if needed
The core of the tool is mapping managed .NET methods to native ARM64 code addresses. This happens in several stages:
1. MonoAotFileInfo discovery — The Mono AOT compiler embeds one MonoAotFileInfo struct per assembly in the binary's __DATA segment. Each struct contains pointers to code regions, the method address table, the assembly name, and scalar metadata (number of methods, flags, etc.). The struct layout varies across Mono/Xamarin/.NET versions (new fields get inserted, shifting offsets), so the tool:
- Finds DLL name strings in the binary's string sections
- Searches DATA segments for 8-byte pointers to those strings
- For each pointer hit, tries all known struct layouts to see if the surrounding bytes validate as a
MonoAotFileInfo(checking version, call table entry size, nmethods sanity, method_addresses pointer validity) - Falls back to heuristic interpolation for unknown versions
All assemblies share a single Mach-O binary, with pointer fields directly embedded as absolute VMAs.
1. MonoAotFileInfo via symbol — Each assembly gets its own libaot-AssemblyName.dll.so ELF shared object. The mono_aot_file_info struct is exported as a dynamic symbol in .dynsym, so finding it is trivial — just look up the symbol name.
Pointer resolution via relocations — Unlike iOS where pointer fields contain absolute VMAs, Android ELF .so files zero out all pointer fields in the struct. The actual target addresses are stored as addends in R_AARCH64_RELATIVE relocation entries in the .rela.dyn section. The tool builds a {VMA → addend} map from all relocations and uses it to resolve each pointer field: assembly_name, method_addresses, jit_code_start, etc.
VMA-to-file-offset mapping — ELF LOAD program headers define the VMA→file offset mapping (each LOAD segment may have a different offset gap). The tool walks all PT_LOAD entries to translate any VMA to its file position.
DLL sourcing — .NET DLL files can be standalone in the directory or packed inside libassemblies.*.blob.so XABA blobs. XABA blobs are ELF files with a payload section containing: a header, hash index tables, a length-prefixed name table, and LZ4-compressed DLL data (XALZ blocks). The tool auto-extracts DLLs from XABA when standalone files aren't present.
2. BL instruction decoding — The method_addresses table is an array of ARM64 BL (Branch with Link) instructions, one per method. Each 4-byte instruction encodes a 26-bit signed PC-relative offset. The tool decodes (instruction & 0x3FFFFFF) * 4 + instruction_VA to get the target native address. Entries where the target points back to the table start are sentinels meaning "not AOT-compiled."
3. DLL metadata cross-reference — Method indices in the AOT table correspond 1:1 to the MethodDef table rows in the .NET DLL metadata. The tool reads each DLL with dnfile, maps method index → (namespace, type, name, signature), then joins with the resolved native addresses.
Full type metadata is extracted from the .NET DLL files using the ECMA-335 metadata tables:
Type declarations — The TypeDef table provides every type's name, namespace, and flags. Flags determine visibility (public/private/nested variants), kind (class/interface via 0x20 bit), and modifiers (abstract/sealed/static). The base type comes from the Extends coded index column (resolves to a TypeDef or TypeRef row). The tool detects enums (extends System.Enum) and structs (extends System.ValueType) from the base type.
Signature blob parsing — Field types, method signatures, and property types are stored as compact binary blobs in the #Blob heap (ECMA-335 §II.23.2). The SigParser class decodes these byte-by-byte:
- Compressed unsigned integers (1/2/4 bytes based on high bits)
TypeDefOrRefcoded indices encoded as(row << 2) | tagwhere tag 0=TypeDef, 1=TypeRef, 2=TypeSpec- Recursive type composition:
GENERICINST(CLASS List<1>, STRING)→List<string>,SZARRAY(I4)→int[],PTR(VOID)→void*,BYREF(I4)→ref int - Generic type variables:
VAR(n)for class-level,MVAR(n)for method-level
A type name map is pre-built from all TypeDef and TypeRef rows, keyed by their coded index values, so blob references resolve to full Namespace.TypeName strings.
Fields — Each TypeDef owns a range of Field rows (via FieldList_Index). Per field: flags give visibility/static/readonly/const, and the Signature blob is parsed for the field type.
Methods — Each TypeDef owns a range of MethodDef rows (via MethodList_Index). The Signature blob gives return type and parameter types. Parameter names come from the Param table (linked via ParamList_Index), matched by Sequence number.
Properties — PropertyMap links each TypeDef to a range in the Property table. Property types come from the Type signature blob. Getter/setter detection uses the MethodSemantics table, which links methods to properties with semantic flags (0x02 = getter, 0x01 = setter).
Interfaces — The InterfaceImpl table stores (TypeDef, Interface) pairs. The Interface column is a TypeDefOrRef coded index resolved to a full name.
Generic parameters — GenericParam rows have an Owner (TypeOrMethodDef coded index) and a Name. The owner is dereferenced to determine if it belongs to a type or method.
Enum values — Enum members are fields with the Literal flag. Their values come from the Constant table, which stores a Type byte and a Value blob. The blob is decoded based on the element type (e.g., 0x08 = int → 4-byte little-endian).
Nested types — The NestedClass table gives child→parent TypeDef index pairs. The dump.cs emitter uses this to render nested types inside their enclosing type.
MIT