17 KiB
Dart FFI and Native Function Notes
This document records the current understanding of Dart FFI, VM native
functions, ObjectPool native_function entries, and how r2flutter should expose
that information. It is written for static analysis of Flutter AOT snapshots,
not for running an isolate or linking symbols at runtime.
Vocabulary
PP is the Dart object-pool register. On ARM64 AOT it is x27. A PP-relative
load such as ldr x0, [x27, #0x1234] reads a slot from the current Dart
ObjectPool.
NativeFunction is overloaded terminology:
- In
dart:ffi,Pointer<NativeFunction<T>>is a typed native C function pointer that can be converted to a Dart callable withasFunction(). - In a Dart VM ObjectPool,
native_functionmeansObjectPoolBuilderEntry::kNativeFunction. That is an ObjectPool entry kind, not a Dart object and not a loader import entry. - In the Dart embedding API,
Dart_NativeFunctionis a C callback with theDart_NativeArgumentscalling convention. This is the older VM native-entry mechanism, distinct from normal C ABI FFI calls.
Do not assume these three meanings identify the same thing in the file.
FFI Lookup Model
The public Dart FFI API is in dart:ffi.
DynamicLibrary.open(path) loads a native library file and stores a runtime
library handle. On Unix-like systems this is backed by dlopen; on Windows by
the platform loader. The path string may be a literal string in the snapshot,
or it may be constructed dynamically.
DynamicLibrary.process() creates a library handle for global symbols already
visible in the current process. On Linux, macOS, Android, and Fuchsia this uses
the equivalent of RTLD_DEFAULT. There is no library path string for this case.
DynamicLibrary.executable() creates a library handle for the running
executable. This is useful when native code is statically linked into the app or
host executable. Again, there may be no library path string.
DynamicLibrary.lookup<T>(symbolName) resolves a symbol and returns a
Pointer<T>. For FFI functions this is commonly used as
lookup<NativeFunction<CSignature>>("symbol").
DynamicLibrary.lookupFunction<T, F>(symbolName, isLeaf: ...) resolves a symbol
and directly returns a Dart callable. T is the native C signature and F is
the Dart signature.
DynamicLibrary.providesSymbol(symbolName) checks whether a handle can resolve
the symbol.
The important static point is that symbolName and path are Dart String
values. If they are constants, they are usually recoverable from string
clusters, object pools, or direct snapshot string scans. If they are built at
runtime, static recovery becomes partial.
@Native and Native Assets
Modern Dart also supports @Native<T>() annotations on external functions and
variables. This path is related to FFI but does not always look like an
explicit DynamicLibrary.lookup("name") call in optimized AOT code.
For an @Native declaration, the symbol name is either:
- the annotation's
symbol:value, or - the annotated Dart declaration name when
symbol:is omitted.
The asset is either:
- the annotation's
assetId:, - the library-level
@DefaultAsset, or - the library URI string when no explicit asset is given.
The VM resolution order for @Native is:
- resolve the symbol in the provided/default asset,
- use the library's resolver installed with
Dart_SetFfiNativeResolver, - fall back to the current process.
The native-assets path can map an asset ID to an absolute path, relative path, system library, executable, or process handle. The embedder may provide this map at runtime, so a static tool may see only the asset ID, not the final library path.
Dart_SetFfiNativeResolver is especially important for reverse engineering:
the library may deserialize with its resolver fields set to null, and the
embedder or runtime can install a resolver later. If a resolver synthesizes or
maps symbols dynamically, the final address and sometimes the final name are not
present as a normal import table entry.
ObjectPool native_function
In the VM's ObjectPool builder, each entry has an entry type, patchability bit, and snapshot behavior:
- entry type:
0:immediate1:tagged_object2:native_function
- patchability:
0:patchable1:not_patchable
- snapshot behavior:
0:snapshotable1:not_snapshotable2:reset_to_bootstrap_native3:reset_to_switchable_call_miss_entrypoint4:set_to_zero
The low four bits encode the entry type. The next bit encodes patchability. The next three bits encode snapshot behavior.
Example from -HHH:
[931] pool_off=0x1d27 pp_off=0x1d28 bits=0x02 type=native_function patch=patchable behavior=snapshotable stream=0x385d35
This decodes as:
bits=0x02: entry type2, sonative_function- patch bit
0:patchable - snapshot behavior
0:snapshotable pool_off=0x1d27: the VM's ObjectPool offset form for this slotpp_off=0x1d28: the offset normally observed in PP-relative code loads
r2flutter prints both offsets because the serialized ObjectPool layout and the
machine-code PP addressing convention use adjacent offset forms. The pp_off
value is the one to compare with ldr ..., [x27, #imm] instructions.
For kNativeFunction, snapshot serialization writes the entry bits but writes
no payload value. During deserialization the VM initializes the slot with
NativeEntry::LinkNativeCallEntry(), a lazy native-call linker. The first call
can resolve the real native target and patch the call site or pool slot.
That means a static native_function ObjectPool entry is not itself a pointer
to the final native target. It tells us "this slot participates in VM native
call resolution", not "this slot imports symbol X from library Y".
Patchable Meaning
patchable means the VM is allowed to rewrite the entry or the call site after
loading or after first execution. This is common for lazy native calls because
the VM starts with a linker entry and later installs the resolved target and the
right trampoline.
not_patchable means the compiler/VM expects the value to stay stable after
setup. It does not mean "more static" in the sense of being easier to name; it
only describes the runtime mutation policy for that ObjectPool entry.
For static analysis, a patchable native_function should be reported as a
candidate with no concrete target address unless we can correlate it with other
metadata or runtime information.
Is It Like an Import Table?
Only partially.
A normal ELF/Mach-O/PE import table is owned by the platform loader. It maps external symbol names to relocation slots and can usually tell us the imported library and symbol name.
Dart FFI and VM native calls add extra layers:
DynamicLibrary.open("libfoo.so")pluslookup("bar")is import-like, but the string lookup happens in Dart code at runtime.DynamicLibrary.process()andDynamicLibrary.executable()have no library path string and resolve against process/executable visibility.@Nativemay resolve through a native asset ID, an embedder-provided native assets table, aDart_SetFfiNativeResolvercallback, or process lookup.- ObjectPool
native_functionentries store no symbol payload in the snapshot. - VM bootstrap natives and runtime helpers may be resolved through VM resolver tables, not the binary's loader imports.
So native_function is an import-like signal, but it is not equivalent to an
ELF/Mach-O import entry. A good r2flutter report should keep loader imports,
FFI lookups, @Native declarations, and ObjectPool native-function slots as
separate evidence sources.
What Names Can Be Found Statically
The following sources can expose library or symbol names:
- loader imports from radare2/RBin (
ii,iij): useful for actual platform imports, Dart API exports, plugin registrants, and dynamic-loader calls - Dart strings in the AOT snapshot:
libfoo.so,foo.framework/foo,libfoo.dylib,kernel32.dll,bar_symbol, asset IDs, package URIs - calls or object-pool references to
DynamicLibrary.open,process,executable,lookup,lookupFunction, andprovidesSymbol @Nativeannotation data:symbol,assetId,DefaultAsset, and function names when the symbol defaults to the declaration name- native-assets metadata: asset IDs and possibly resolved paths, depending on what is serialized and what the embedder supplies
- ObjectPool entry details:
native_functionslots,tagged_objectstrings,immediatecode/stub values, and PP offsets used by machine code - FFI callback metadata:
FfiTrampolineDatastores callback IDs and function kind values for native-to-Dart callbacks
The strongest static case is:
- a constant library path string reaches
DynamicLibrary.open, - a constant symbol string reaches
lookuporlookupFunction, - a call site uses the returned pointer/function,
- the same symbol is present in a loaded binary's export table or imports.
The weakest static case is:
- a
native_functionObjectPool slot appears, - no nearby strings or function metadata identify it,
- no runtime resolver table is available.
In that weak case we can report a native-call candidate and PP offset, but not a symbol or library name.
Static Recovery Workflow
A practical static pipeline for r2flutter should be:
- Decode the AOT header and cluster layout.
- Decode ObjectPool fill payloads when the snapshot version/layout is supported.
- Build an ObjectPool slot map keyed by pool index,
pool_off, andpp_off. - Decode or reference tagged string objects reachable from ObjectPool entries.
- Scan code for PP-relative loads and indirect calls using the existing AArch64 analysis path.
- Correlate call sites with ObjectPool slots and nearby string loads.
- Search for strings that look like dynamic libraries, asset IDs, and C symbol names.
- Correlate those strings with Dart FFI API names and recovered function names.
- Merge in loader import/export information from RBin.
- Emit candidates with confidence, not as unconditional facts.
-HHH now covers the exploratory form of steps 2-4 for supported ObjectHeader
snapshots: it decodes ObjectPool entries and annotates tagged-object slots with
resolved_kind, resolved_cid, resolved_name, and function code indexes when
those can be recovered from the cluster streams. The remaining work is to turn
that per-command decoder into a reusable slot table for xrefs, FFI reporting,
and plugin-side queries.
Confidence should be explicit. For example:
high: constantDynamicLibrary.openpath and constantlookupFunctionsymbol are both recovered and tied to a call sitemedium: symbol string is adjacent to alookupuse, but library handle isprocessor unknownlow: ObjectPool slot isnative_function, but no symbol string is attachedruntime_only: resolver/native-assets path requires runtime embedder data
Current r2flutter State
-HHH now gives the low-level structure needed for this work. For ObjectPool
clusters whose fill range has been parsed, it can emit entries like:
type=native_function patch=patchable behavior=snapshotable pool_off=... pp_off=...
If an ObjectPool allocation exists but the fill payload was not parsed, it emits
object_pool_decode: fill_not_parsed.
This is structural data. It does not yet name native symbols, identify dynamic libraries, or build an FFI call graph.
-x already works on the code-analysis side: it can collect PP-relative slot
uses, call xrefs, and comments around indirect calls when enough radare2 context
is available. The missing link is a richer ObjectPool and FFI metadata layer to
attach semantic names to those PP offsets.
CLI Exposure Options
Keep -HHH as the structural cluster/object-pool decoder. It should answer
"what is serialized here?" and stay useful even when no semantic inference is
possible.
Add a separate native/FFI report flag. The best short flag is probably -N
because it is mnemonic for native and does not overload -H or -x.
Proposed commands:
bin/r2flutter -N app.so
bin/r2flutter -j -N app.so
bin/r2flutter -r -N app.so
Text output should group by evidence source:
ffi lookup candidates
symbol=foo library=libbar.so source=dynamic_library_lookup confidence=high
symbol=baz library=process source=dynamic_library_process confidence=medium
object pool native functions
pool_ref=123 entry=931 pp_off=0x1d28 source=object_pool confidence=low
native callbacks
callback_id=7 kind=... source=ffi_trampoline_data confidence=medium
JSON output should be stable and machine-readable:
{
"ffi": [
{
"kind": "lookup",
"symbol": "foo",
"library": "libbar.so",
"library_kind": "open",
"source": "dynamic_library_lookup",
"confidence": "high",
"pool_ref": 123,
"pool_index": 931,
"pp_offset": 7464,
"callsites": [4198400]
}
],
"native_functions": [
{
"kind": "object_pool_native_function",
"pool_ref": 123,
"pool_index": 931,
"pool_offset": 7463,
"pp_offset": 7464,
"patch": "patchable",
"behavior": "snapshotable",
"confidence": "low"
}
]
}
Radare2 output should create flags and comments rather than pretending these are real loader imports:
f dart.ffi.lookup.foo @ 0x...
CCu base64:<symbol/library note> @ 0x...
f dart.pool.native.123.931 @ 0x...
CC Dart ObjectPool native_function pp_off=0x1d28 patchable snapshotable @ 0x...
For the core plugin, mirror the CLI spelling:
r2flutter -N
r2flutter -j -N
r2flutter -r -N
The plugin can also use live RCore data to improve the report:
- RBin imports/exports for the current binary
- flags already created for Dart functions/classes/strings
anal.gpwhen the user has provided the ObjectPool base- disassembly and xref information from the current radare2 analysis session
Why Not Put This Only in -x
-x is about xrefs: code-to-code, code-to-pool, and code-to-object
relationships. FFI/native reporting is a higher-level correlation problem that
mixes ObjectPool entries, Dart strings, API calls, loader imports, and native
asset metadata.
-x should consume FFI information once it exists. For example, an indirect
call through a known FFI symbol can get a better comment. But the discovery and
reporting of FFI candidates should live behind a dedicated native/FFI command.
Why Not Put This Only in -HHH
-HHH should remain a bounded structural decoder for the AOT file. It is the
right place to show:
- cluster order and ranges
- ObjectPool allocation/fill status
- ObjectPool entry bits
pool_offandpp_off- raw references or immediates present in the serialized stream
It is not the right place to decide that a string is definitely a C symbol or
that an ObjectPool slot is definitely libfoo.so!bar. Those claims require
cross-correlation with code and loader data.
Recommended Implementation Plan
- Extend the ObjectPool decoder into a reusable internal table, not only
-HHHrendering. - Add a string classifier for likely library paths, asset IDs, and C symbols.
- Add a native candidate model with fields for source, confidence, pool slot, symbol, library, callsites, and notes.
- Teach the code-analysis pass to attach PP-relative callsites to ObjectPool slots.
- Add
dart_pool_dump_native (ctx, fmt)for CLI text/JSON/r2 output. - Add
-Ntobin/r2flutterandr2flutter -Nto the core plugin. - Keep
-HHHoutput unchanged except for future low-level entry fields. - Add small r2r fixtures for JSON shape and one text smoke test.
This sequence lets -HHH keep serving as the manual reasoning tool while -N
becomes the higher-level report.
Limitations
Obfuscation can hide Dart function and class names, but literal FFI symbol strings often remain because the native loader needs exact names.
Tree shaking can remove unused FFI bindings and strings.
Dynamic string construction can hide library and symbol names from simple constant scanning.
DynamicLibrary.process() and DynamicLibrary.executable() intentionally avoid
a concrete library path.
Native-assets and Dart_SetFfiNativeResolver may depend on embedder data that
is not serialized in the AOT snapshot.
The platform binary may be stripped, so even a resolved native address may not map back to a symbol name.
Some Flutter plugin calls are not Dart FFI at all. They can go through platform
channels, JNI, Objective-C/Swift, or generated registrant code. Those should be
reported separately from dart:ffi.
Useful Local Sources
third_party/sdk/sdk/lib/ffi/dynamic_library.dartthird_party/sdk/sdk/lib/ffi/ffi.dartthird_party/sdk/runtime/lib/ffi_dynamic_library.ccthird_party/sdk/runtime/vm/compiler/assembler/object_pool_builder.hthird_party/sdk/runtime/vm/app_snapshot.ccthird_party/sdk/runtime/vm/native_entry.ccdoc/xrefs.mddoc/objpool.mddoc/learn.md