This document explains the internal structure of Dart AOT (Ahead-of-Time) compiled binaries, focusing on the Object Pool and clustered snapshot format used in Flutter applications.
When Flutter compiles a Dart application for release, it uses AOT compilation to generate native machine code. The resulting binary contains two main components:
- Snapshot Data - Serialized Dart objects (strings, classes, functions, constants)
- Snapshot Instructions - Native machine code for all compiled functions
The Object Pool is the runtime data structure that holds references to all Dart objects needed during execution. Understanding its serialization format is key to reverse engineering Flutter apps.
ELF Header
├── .rodata section
│ ├── _kDartVmSnapshotData (VM isolate data)
│ ├── _kDartIsolateSnapshotData (App isolate data) ← Object Pool lives here
│ └── _kDartIsolateSnapshotInstructions (App code)
└── .text section
└── _kDartVmSnapshotInstructions (VM code)
Mach-O Header
├── __DATA segment
│ ├── _kDartVmSnapshotData
│ └── _kDartIsolateSnapshotData ← Object Pool lives here
└── __TEXT segment
├── _kDartVmSnapshotInstructions
└── _kDartIsolateSnapshotInstructions
Every Dart snapshot begins with a fixed header:
Offset Size Field
──────────────────────────────────────────────────────
0x00 4 Magic number (0xf5f5dcdc, little-endian)
0x04 8 Length (total size minus magic, int64)
0x0C 8 Kind (snapshot type, int64)
0x14 32 Version hash (ASCII hex string, NOT null-terminated)
0x34 N Features string (null-terminated)
| Value | Name | Description |
|---|---|---|
| 0 | kFull | Full snapshot with all objects |
| 1 | kFullCore | Core libraries only |
| 2 | kFullJIT | JIT compilation snapshot |
| 3 | kFullAOT | AOT compilation snapshot (Flutter release) |
| 4 | kModule | Deferred module snapshot |
The features string describes build configuration:
product no-code_comments no-dwarf_stack_traces_mode no-lazy_dispatchers
dedup_instructions no-tsan arm64 ios no-compressed-pointers null-safety
Key flags:
product- Release build (no debug info)no-compressed-pointers- Uses full 64-bit pointers (pre-2.18 or iOS)compressed-pointers- Uses 32-bit compressed pointers (Dart 2.18+)null-safety- Null safety enabled
After the header comes the clustered serialization data. This is the heart of the Object Pool.
Immediately after the features string's null terminator:
Field Encoding
───────────────────────────────────────────
num_base_objects ULEB128
num_objects ULEB128
num_clusters ULEB128
instructions_table_len ULEB128
instruction_table_data_offset ULEB128
ULEB128 (Unsigned Little-Endian Base 128) is a variable-length integer encoding:
- Each byte's lower 7 bits contain data
- High bit (0x80) indicates more bytes follow
- Example:
0x89 0x43decodes to0x09 | (0x43 << 7) = 8585
The snapshot contains num_clusters cluster entries, each representing a group of related objects:
┌─────────────────────────────────────┐
│ Cluster Tag (uint32) │
├─────────────────────────────────────┤
│ Object Count (ULEB128) │
├─────────────────────────────────────┤
│ Alloc Data (cluster-specific) │
├─────────────────────────────────────┤
│ Fill Data (cluster-specific) │
└─────────────────────────────────────┘
The 32-bit cluster tag encodes multiple fields:
Bits Field
─────────────────────────────────
0 CanonicalBit (object is canonical/interned)
1 DeeplyImmutableBit
2-7 Reserved flags
8-11 SizeTag (4 bits, encoded object size)
12-31 ClassId (20 bits, identifies object type)
To extract the Class ID:
uint32_t cid = (tag >> 12) & 0xFFFFF;Dart uses a two-phase approach for snapshot deserialization:
During the Alloc phase:
- Read the cluster tag to identify the object type
- Read the object count
- Allocate memory for each object
- Assign sequential reference IDs starting from
num_base_objects + 1
Reference IDs are indices into a global array that allows objects to reference each other before they're fully initialized.
Reference Array Layout:
┌───────────────────────────────────────────────────┐
│ Index 0: Reserved (unreachable sentinel) │
├───────────────────────────────────────────────────┤
│ Index 1 to num_base_objects: VM base objects │
├───────────────────────────────────────────────────┤
│ Index num_base_objects+1 onwards: App objects │
└───────────────────────────────────────────────────┘
During the Fill phase:
- Read actual field values for each object
- Resolve reference IDs to actual object pointers
- Initialize object contents
This two-phase approach handles circular references - objects can reference each other because all objects are allocated (with known reference IDs) before any fields are filled.
Dart has predefined Class IDs for built-in types:
| CID | Class |
|---|---|
| 0 | kIllegalCid (invalid) |
| 5 | kClassCid |
| 7 | kFunctionCid |
| 10 | kFieldCid |
| 12 | kLibraryCid |
| 40 | kCodeCid |
| 45 | kObjectPoolCid |
| 72 | kStringCid |
| 73 | kOneByteStringCid |
| 74 | kTwoByteStringCid |
| 75 | kArrayCid |
CIDs above ~128 are dynamically assigned to user-defined classes.
Strings are among the most important objects for reverse engineering.
String length and type are combined in a single ULEB128:
encoded = (length << 1) | is_two_byte
- Bit 0: 0 = OneByteString (Latin-1), 1 = TwoByteString (UTF-16)
- Bits 1+: String length
┌────────────────────────────────────┐
│ Object Header (tags, etc.) │
├────────────────────────────────────┤
│ Length (Smi - tagged integer) │
├────────────────────────────────────┤
│ Hash (cached, computed on demand) │
├────────────────────────────────────┤
│ Character data (1 byte per char) │
└────────────────────────────────────┘
Same as OneByteString but character data uses 2 bytes per character (UTF-16 encoding).
Represents a Dart function with metadata:
name_→ Reference to String (function name)owner_→ Reference to Class or Librarysignature_→ Reference to FunctionTypecode_→ Reference to Code object (in JIT) or code index (in AOT)
Represents compiled machine code:
entry_point_→ Address of function's first instructionowner_→ Back-reference to Functionobject_pool_→ Reference to ObjectPool for this codeinstructions_→ Pointer to actual machine code bytes
In AOT snapshots, Code objects reference entries in the InstructionsTable rather than storing instructions inline.
The InstructionsTable maps function indices to code entry points. It's stored in the Data Image (rodata section).
Header:
length: uint32 (total entries)
first_with_code: uint32 (first entry with actual code)
Entries[length]:
pc_offset: uint32 (offset from instructions base)
source_map_off: uint32 (offset to source map data)
Some snapshots use LEB128 delta encoding for smaller size:
header_len: ULEB128
first_with_code: ULEB128
Entries[header_len]:
pc_delta: ULEB128 (delta from previous pc_offset)
sm_delta: ULEB128 (delta from previous source_map_off)
To decode: accumulate deltas starting from 0.
Since Dart 2.18, object references use 32-bit compressed pointers instead of full 64-bit addresses.
Compressed: 32-bit offset from heap base
Absolute: heap_base + (compressed_ref << alignment_shift)
The heap base is stored in a dedicated register (x27/PP on ARM64).
- ~30% smaller binary size
- Faster object access (single register + offset)
- Better cache locality
Check the features string for compressed-pointers or no-compressed-pointers.
After the snapshot stream comes the Data Image, containing read-only data:
Snapshot Base
├── Snapshot Stream (clusters, roots)
├── Padding (aligned to kMaxObjectAlignment, typically 16 bytes)
└── Data Image
├── InstructionsTable::Data
├── Object Pool entries
├── String contents
└── Other read-only data
The instruction_table_data_offset field points into the Data Image.
- By symbol name: Look for
_kDartIsolateSnapshotData - By magic scan: Search for
0xf5f5dcdcin rodata sections
- Parse snapshot header
- Skip to clustered data
- Find String clusters (CID 72, 73, or 74)
- Decode string objects
- Parse InstructionsTable from Data Image
- Each entry's
pc_offset+iso_instr= function entry point - Match with Function objects to get names
- Disassemble each function
- Find
bl(branch-link) instructions - Resolve targets via InstructionsTable indices
- Map indices to Function names
Different Dart/Flutter versions may have:
- Different cluster tag encoding
- Different object layouts
- Different CID assignments
- Compressed vs uncompressed pointers
The 32-byte version hash in the header identifies the exact Dart SDK version. Tools should maintain a database of known layouts per version hash.
- Dart SDK source:
runtime/vm/clustered_snapshot.cc - Snapshot header:
runtime/vm/snapshot.h - Class IDs:
runtime/vm/class_id.h - Object layouts:
runtime/vm/raw_object.h - Vyacheslav Egorov's Dart VM internals: https://mrale.ph/dartvm/
- r2flutter: radare2-based Flutter/Dart analyzer (this project)
- blutter: Uses actual Dart VM for deserialization
- unflutter: Pure static analysis without VM dependencies
- reFlutter: Runtime patching and traffic interception