12 KiB
Dart AOT Object Pool Internals
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.
Overview
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.
Binary Layout
Android (libapp.so)
ELF Header
├── .rodata section
│ ├── _kDartVmSnapshotData (VM isolate data)
│ ├── _kDartIsolateSnapshotData (App isolate data) ← Object Pool lives here
│ └── _kDartIsolateSnapshotInstructions (App code)
└── .text section
└── _kDartVmSnapshotInstructions (VM code)
iOS (App.framework/App)
Mach-O Header
├── __DATA segment
│ ├── _kDartVmSnapshotData
│ └── _kDartIsolateSnapshotData ← Object Pool lives here
└── __TEXT segment
├── _kDartVmSnapshotInstructions
└── _kDartIsolateSnapshotInstructions
Snapshot Header Format
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)
Snapshot Kinds
| 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 |
Features String
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
Clustered Data Format
After the header comes the clustered serialization data. This is the heart of the Object Pool.
Clustered Header
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
Cluster Structure
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) │
└─────────────────────────────────────┘
Cluster Tag Encoding
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;
Two-Phase Deserialization
Dart uses a two-phase approach for snapshot deserialization:
Phase 1: Alloc (Allocation)
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 │
└───────────────────────────────────────────────────┘
Phase 2: Fill
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.
Class IDs (CIDs)
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.
String Serialization
Strings are among the most important objects for reverse engineering.
Encoding
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
OneByteString Layout
┌────────────────────────────────────┐
│ Object Header (tags, etc.) │
├────────────────────────────────────┤
│ Length (Smi - tagged integer) │
├────────────────────────────────────┤
│ Hash (cached, computed on demand) │
├────────────────────────────────────┤
│ Character data (1 byte per char) │
└────────────────────────────────────┘
TwoByteString Layout
Same as OneByteString but character data uses 2 bytes per character (UTF-16 encoding).
Function and Code Objects
Function Object
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)
Code Object
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.
InstructionsTable
The InstructionsTable maps function indices to code entry points. It's stored in the Data Image (rodata section).
Fixed-Size Format
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)
Variable-Length Format (Delta-Encoded)
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.
Compressed Pointers
Since Dart 2.18, object references use 32-bit compressed pointers instead of full 64-bit addresses.
How It Works
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).
Benefits
- ~30% smaller binary size
- Faster object access (single register + offset)
- Better cache locality
Detection
Check the features string for compressed-pointers or no-compressed-pointers.
Data Image Layout
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.
Practical Extraction
Finding Snapshots
- By symbol name: Look for
_kDartIsolateSnapshotData - By magic scan: Search for
0xf5f5dcdcin rodata sections
Extracting Strings
- Parse snapshot header
- Skip to clustered data
- Find String clusters (CID 72, 73, or 74)
- Decode string objects
Extracting Functions
- Parse InstructionsTable from Data Image
- Each entry's
pc_offset+iso_instr= function entry point - Match with Function objects to get names
Building Call Graphs
- Disassemble each function
- Find
bl(branch-link) instructions - Resolve targets via InstructionsTable indices
- Map indices to Function names
Version Differences
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.
References
- 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/
Tools
- 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