This is a continuation of Part 1 which covered how Flutter compiles apps and what snapshots look like internally.

As you have probably guessed so far, reverse engineering its not an easy task.


Calling conventions

Let's first cover some basics about Dart's type system:

void main() {
  void foo() {}
  int bar([int aaa]) {}
  Null biz({int aaa}) {}
  int baz(int aa, {int aaa}) {}
  
  print(foo is void Function());
  print(bar is void Function());
  print(biz is void Function());
  print(baz is void Function());
}

Which functions do you think print true?

It turns out the Dart type system is much more flexible than you might expect, as long as a function takes the same positional arguments and has compatible return type it is a valid function subtype. Because of this, all but the baz print true.

Here's another experiment:

void main() {
  int foo({int a}) {}
  int bar({int a, int b}) {}
  
  print(foo is int Function());
  print(foo is int Function({int a}));
  print(bar is int Function({int a}));
  print(bar is int Function({int b}));
  print(bar is int Function({int b, int c}));
}

Here we check if functions have a valid subtype when they have a subset of named arguments, all but the last prints true.

For a formal description of function types, see "9.3 Type of a Function" in the Dart language specification.

Mixing and matching parameter signatures are a nice feature but pose some problems when implementing them at a low level, for example:

void main() {
  void Function({int a, int c}) foo;
  
  foo = ({int a, int b, int c}) {
    print("Hi $a $b $c");
  };
  
  foo(a: 1, c: 2);
}

In order for this to work, foo needs some way of knowing the caller provided a and c but not b, this piece of information is called an argument descriptor.

Internally argument descriptors are defined by vm/dart_entry.h. The implementation is just an interface over a regular Array object which the callee provides via the argument descriptor register.

For example:

void bar({int a}) {
  print("Hi $a");
}

void foo() {
  bar(a: 42);
}

Rather than using Dart's built-in disassembler I'll be using a custom one that provides proper annotations for calls, object pool entries, and other constants.

Disassembly of foo, the caller:

#lint dartdec-dasm
034 | ...
038 | mov ip, #0x54        // 
03c | str ip, [sp, #-4]!   // push smi 42
040 | add r4, pp, #0x2000  //
044 | ldr r4, [r4, #0x4a3] // load the argument descriptor into r4
048 | bl 0x1b8             // call bar
04C | ...
foo

The argument descriptor for the call to bar is the following RawArray:

[
  0, // type arguments length
  1, // argument count
  0, // positional arg count
  
  // named arguments (name, position)
  "x", 0,
  
  null, // null terminator
]
r4

The descriptor is used in the prologue of the callee to map stack indices to their respective argument slots and verify the proper arguments were received. Here is the disassembly of the callee:

#lint dartdec-dasm
// prologue, polymorphic entry

000 | stmdb sp!, {fp, lr}
004 | add fp, sp, #0
008 | sub sp, sp, #4

// optional parameter handling

00c | ldr r0, [r4, #0x13] // arr[2] (positional arg count)
010 | ldr r1, [r4, #0xf]  // arr[1] (argument count)
014 | cmp r0, #0          // check if we have positional args
018 | bgt 0x74            // jump to 08c

// check named args

01c | ldr r0, [r4, #0x17]  // arr[3] (first arg name)
020 | add ip, pp, #0x2000  // 
024 | ldr ip, [ip, #0x4a7] // string "x"
028 | cmp r0, ip           // check if arg present
02c | bne 0x20             // jump to 04c

030 | ldr r0, [r4, #0x1b]    // arr[4] (first arg position)
034 | sub r2, r1, r0         // r2 = arg_count - position
038 | add r0, fp, r2, lsl #1 // r0 = fp + r2 * 2
    |                        // this is really r2 * 4 because it's an smi
03c | ldr r0, [r0, #4]       // read arg
040 | mov r2, r0             // 
044 | mov r0, #2             // 
048 | b 12                   // jump to 054

04c | ldr r2, [thr, #0x68] // thr->objectNull
050 | mov r0, #0           // 

054 | str r2, [fp, #-4] // store arg in local

// done loading args

058 | cmp r1, r0 // check if we have read all args
05c | bne 0x30   // jump to 08c

// continue prologe

060 | ldr ip, [thr, #0x24] // thr->stackLimit
064 | cmp sp, ip           //
068 | blls -0x5af00        // stackOverflowStubWithoutFpuRegsStub

// rest of function

06c | ...

// incompatible args path

08c | ldr r6, [pp, #0x33] // Code* callClosureNoSuchMethod
090 | sub sp, fp, #0      // 
094 | ldmia sp!, {fp, lr} // exit frame
098 | ldr pc, [r6, #3]    // invoke stub
bar

To summarize, it loops the array assigning slots to any matching arguments, throwing a NoSuchMethodError if any are not part of the function type. Also keep in mind argument checking is only required for polymorphic calls, most (including the hello world example) are monomorphic.

This code is generated at a high level in vm/compiler/frontend/prologue_builder.cc PrologueBuilder::BuildOptionalParameterHandling meaning registers and subroutines may be layed out differently depending on the types of arguments and what optimizations it feels like doing.


Integer arithmetic

The num, int, and double classes are special in the Dart type system, for performance reasons they cannot be extended or implemented.

Because of this restriction we never have to check the type of an int before doing arithmetic, if that wasn't the case the compiler would have to generate relatively expensive method calls instead.

All objects in dart are pointers to RawObject however only pointers tagged with kHeapObjectTag are actual heap objects, objects without the tag are signed ints shifted to the left by one.

Because of pointer tagging you will see a lot of tst r0, #1 and similar instructions in generated code, these are for discriminating between smis and heap objects. You will also see a lot of odd-numbered offset loads and stores to subtract the heap flag.

Fun fact: The core int type used to be a bigint before Dart 2.0, you can find the writeup by the Dart team here: https://github.com/dart-lang/sdk/blob/2.15.1/docs/language/informal/int64.md

Any integer that can fit within the word size minus one bit (31 bits on A32) can be stored as an smi, otherwise larger integers are stored as 64 bit mint (medium int) instances on the heap.

Smis can contain negative numbers too of course, it uses an arithmetic right shift to sign extend the number back into place.

For example, here is a simple function that adds two ints:

int hello(int x, int y) => x + y;

To start, x and y are each unboxed into pairs of registers, Dart ints are 64 bit so two registers are needed for each arg on A32:

#lint dartdec-dasm
024 | ...
028 | ldr r1, [fp, #12]    // load argument x
02c | ldr ip, [thr, #0x68] // thr->objectNull
030 | cmp r1, ip           // check if x is null
034 | bleq -0x50954        // nullErrorStubWithoutFpuRegsStub
038 | ...
048 | mov r3, r1, asr #0x1f // sign-extend top half
04c | movs r4, r1, asr #1   // shift heap flag into carry
050 | bcc 12                // jump to 05c if heap flag is clear
054 | ldr r4, [r1, #7]      // load lower half from mint
058 | ldr r3, [r1, #11]     // load upper half from mint
05c | ...
x + y

After x and y are in pairs of registers it can perform the actual 64 bit add:

#lint dartdec-dasm
070 | adds r7, r4, r6 // bottom half
074 | adcs r2, r3, r1 // carry into top half
x + y

Before returning the result gets re-boxed:

#lint dartdec-dasm
074 | ...
078 | mov r0, r7, lsl #1      // create smi from lower half
07c | cmp r7, r0, asr #1      // check if MSB of smi isn't clobbered
080 | cmpeq r2, r0, asr #0x1f // check if upper half is empty
084 | beq 0x34                // jump to 0b8 if smi is valid
  
// construct mint
  
088 | ldr r0, [thr, #0x3c] // thr->top
08c | adds r0, r0, #0x10   // add size of mint
090 | ldr ip, [thr, #0x40] // thr->end
094 | cmp ip, r0           // check if mint fits in pool
098 | bls 0x28             // jump to 0c0 (slow path)

// construct mint in pool

09c | str r0, [thr, #0x3c] // shift down pool start
0a0 | sub r0, r0, #15      // go back to original top
0a4 | mov ip, #0x2204      // misc tags 
0a8 | movt ip, #0x31       // mint object id
0ac | str ip, [r0, #-1]    // write tags

// store values in new mint

0b0 | str r7, [r0, #7]  // write lower half
0b4 | str r2, [r0, #11] // write upper half

// function epilogue

0b8 | sub sp, fp, #0
0bc | ldmia sp!, {fp, pc}
  
// slow path, invoke mint constructor

0c0 | stmdb sp!, {r2, r7} //
0c4 | bl 0x651f4          // new dart:core::Mint_at_0150898
0c8 | ldmia sp!, {r2, r7} //
0cc | b -0x1c             // jump to 0b0
x + y

Boxing looks more expensive than it actually since the value will be returned immediately as an smi and only hits the slow code paths when the result is larger than 31 bits.


Instances

The code below creates an instance by calling an allocation stub followed by a call to the constructor:

makeFoo() => Foo<int>();

Disassembled:

#lint dartdec-dasm
014 | ...
018 | ldr ip, [pp, #0x93] //
01c | str ip, [sp, #-4]!  // push type args <int>
020 | bl -0x628           // Foo allocation stub
024 | add sp, sp, #4      // pop arg
028 | str r0, [fp, #-4]   // store object in frame
02c | str r0, [sp, #-4]!  // push object as arg
030 | bl -0x9f0           // Foo::Foo()
034 | add sp, sp, #4      // pop arg
038 | ldr r0, [fp, #-4]   // load object from frame into return reg
03c | ...
makeFoo

Each class has a corresponding allocation stub that allocates and initializes an instance (very similar to how boxing creates an object), these stubs are generated for any classes that can be constructed.

Unfortunately for us, field information is removed from the snapshot so we can't directly get their names. You can however see the names of implicit getter and setter methods (assuming they haven't been inlined).

Offsets for fields are calculated at Class::CalculateFieldOffsets, the rules go as follows:

  1. Start at end of super class, otherwise start at sizeof(RawInstance)
  2. Use the type arguments field of parent, else put it at the start
  3. Lay out remaining (non static) fields sequentially

Because type arguments are shared with the super, instantiating the following class gives us a type arguments field containing <String, int>:

class Foo<T> extends Bar<String> {}
var x = Foo<int>(); // instance type arguments are <String, int>

Whereas if the type arguments are the same for parent and child, the list will only contain <int>:

class Foo<T> extends Bar<T> {}
var x = Foo<int>(); // instance type arguments are <int>

Another fun feature of Dart is that all field access is done via setters and getters, this may sound very slow but in practice dart eliminates a ton of overhead with the following optimizations:

  1. Whole-program static analysis
  2. Inlining calls on known types
  3. Code de-duplication
  4. Inline cache (via ICData)

These optimizations apply to all methods including getters and setters, in the following example the setter is inlined:

class Foo {
  int x;
}

Foo bar() => Foo()..x = 42;

Disassembled:

#lint dartdec-dasm
028 | ...
02c | ldr r0, [fp, #-4] // load foo
030 | mov ip, #0x54     // smi 42
034 | str ip, [r0, #3]  // store first field
038 | ...
bar

But when we call this setter through an interface:

abstract class Foo {
  set x(int x);
}

class FooImpl extends Foo {
  int x;
}

void bar(Foo foo) {
  foo.x = 42;
}

Disassembled:

#lint dartdec-dasm
010 | ...
014 | ldr ip, [fp, #8]     // 
018 | str ip, [sp, #-4]!   // push foo
01c | mov ip, #0x54        // 
020 | str ip, [sp, #-4]!   // push smi 42
024 | ldr r0, [sp, #4]     // load foo into receiver
028 | add lr, pp, #0x2000  // 
02c | ldr lr, [lr, #0x4a3] // unlinkedCall stub
030 | add r9, pp, #0x2000  // 
034 | ldr r9, [r9, #0x4a7] // RawUnlinkedCall set:a
038 | blx lr               // invoke stub
03c | ...
bar

Here it invokes an unlinkedCall stub which is a magic bit of code that handles polymorphic method invocation, it will patch its own object pool entry so that further calls are quicker.

I'd love to get into more detail about how this works at runtime but all we need to know is that it invokes the method specified in the RawUnlinkedCall. If you are interested, there is a great article on the internals of DartVM that explains more: https://mrale.ph/dartvm/


Type Checking

Type checking is a fundamental component of polymorphism, dart provides this through the is and as operators.

Both operators do a subtype check with the exception of as allowing null values, here is the is operator in action:

class FooBase {}
class Foo extends FooBase {}
class Bar extends FooBase {}

bool isFoo(FooBase x) => x is Foo;

Disassembled:

#lint dartdec-dasm
024 | ...
028 | ldr r1, [fp, #8]       // load x
02c | ldrh r2, r3, [r1, #1]  // read classid
030 | mov r2, r2, lsl #1     // make smi, suboptimal
034 | cmp r2, #0x12c         // Foo classid (as smi)
038 | ldreq r0, [thr, #0x6c] // thr->boolTrue
03c | ldrne r0, [thr, #0x70] // thr->boolFalse
040 | ...
x is Foo

Since whole-program analysis determined Foo only has one implementer, it can simply check equality of the class ID, but what if it has a child class?

class Baz extends Foo {}

We now get:

#lint dartdec-dasm
028 | ...
02c | ldr r1, [fp, #8]      // load x
030 | ldrh r2, r3, [r1, #1] // read classid
034 | mov r2, r2, lsl #1    // make smi
038 | mov r1, #0x12c        // Foo smi classid
03c | mov r4, r1, asr #1    // unbox smi (redundant)
040 | mov r3, r4, asr #0x1f // 64 bit sign extend (redundant)
044 | mov r6, r2, asr #1    // unbox smi (redundant)
048 | mov r1, r6, asr #0x1f // 64 bit sign extend (redundant)
04c | cmp r1, r3            // always equal since top half is clear
050 | bgt 0x10              // jump to 0x60 (never)
054 | blt 0x40              // jump to 0x94 (never)
058 | cmp r6, r4            // compare x and Foo
05c | blo 0x38              // jump to 0x94 if x < Foo
060 | mov r2, #0x12e        // smi 0x97
064 | mov r4, r2, asr #1    // unbox smi (redundant)
068 | mov r3, r4, asr #0x1f // 64 bit sign extend (redundant)
06c | cmp r1, r3            // always equal since top half is clear
070 | blt 0x18              // jump to 0x88 (never)
074 | bgt 12                // jump to 0x80 (never)
078 | cmp r6, r4            // compare x and 0x97
07c | bls 12                // jump to 0x88
080 | ldr r2, [thr, #0x70]  // thr->boolFalse
084 | b 8                   // jump to 0x8c
088 | ldr r2, [thr, #0x6c]  // thr->boolTrue
08c | mov r0, r2            // 
090 | b 8                   // jump to 0x98
094 | ldr r0, [thr, #0x70]  // thr->boolFalse
098 | ...
x is Foo

Gah! This code is awful so here is a basic translation:

bool isFoo(FooBase* x) {
  if (x.classId < FooClassId) return false;
  return x.classId <= BazClassId;
}

All it is doing here is checking if the class id falls within a set of ranges, in this case there is only one range to check.

This is definitely a place where DartVM could improve on ARM, it's doing 64 bit smi range checks for 16 bit class ids instead of just comparing it directly.

The range checks also do not take into consideration the super type its comparing from which can cause a range to be split by a type that does not implement the super, perhaps as a result of unsoundness.


Control flow

Dart uses a relatively advanced flow graph, represented as an SSA (Single Static Assignment) intermediate similar to modern compilers like gcc and clang. It can perform many optimizations that change the control flow structure of the program, making reasoning about its generated code a bit harder.

Here is a simple if statement:

void hello(bool condition) {
  if (condition) {
    print("foo");
  } else {
    print("bar");
  }
}

Disassembled:

#lint dartdec-dasm
010 | ...
014 | ldr r0, [fp, #8]      // load condition
018 | ldr ip, [thr, #0x68]  // thr->objectNull
01c | cmp r0, ip            // 
020 | bne 0x18              // jump to 038 if condition != null
024 | str r0, [sp, #-4]!    // push condition
028 | ldr r9, [thr, #0x178] // thr->nonBoolTypeErrorEntryPoint
02c | mov r4, #1            // entry argument count
030 | ldr ip, [thr, #0xd0]  // thr->callToRuntimeEntryPoint
034 | blx ip                // invoke stub
038 | ldr r0, [fp, #8]      // load condition
03c | ldr ip, [thr, #0x6c]  // thr->boolTrue
040 | cmp r0, ip            // 
044 | bne 0x1c              // jump to 060 if condition != true
048 | add ip, pp, #0x2000   // 
04c | ldr ip, [ip, #0x4a3]  // 
050 | str ip, [sp, #-4]!    // push string "foo"
054 | bl -0x33b1c           // call print
058 | add sp, sp, #4        // pop arg
05c | b 0x18                // jump to 074
060 | add ip, pp, #0x2000   // 
064 | ldr ip, [ip, #0x4a7]  // 
068 | str ip, [sp, #-4]!    // push string "bar"
06c | bl -0x33b34           // call print
070 | add sp, sp, #4        // pop arg
074 | ...
hello

That null check is an example of a "runtime entry" dynamic call, this is the bridge from dart code to subroutines defined in vm/runtime_entry.cc.

In this case it is a specialized entry that throws a Failed assertion: boolean expression must not be null, as you would expect if the condition of an if statement is null.

Whole program optimization (and sound non-nullability in the future) allows this null check to be elided, for example if hello never gets called with a possible null value then it won't do the check at all:

void main() {
  hello(true);
  hello(false);
}

void hello(bool condition) {
  if (condition) {
    print("foo");
  } else {
    print("bar");
  }
}

Disassembled:

#lint dartdec-dasm
010 | ...
014 | ldr r0, [fp, #8]     // load condition
018 | ldr ip, [thr, #0x6c] // thr->boolTrue
01c | cmp r0, ip           //
020 | bne 0x1c             // jump to 03c if condition != true
024 | add ip, pp, #0x2000  // 
028 | ldr ip, [ip, #0x4a3] // 
02c | str ip, [sp, #-4]!   // push string "foo"
030 | bl -0x33a90          // call print
034 | add sp, sp, #4       // pop arg
038 | b 0x18               // jump to 050
03c | add ip, pp, #0x2000  // 
040 | ldr ip, [ip, #0x4a7] // 
044 | str ip, [sp, #-4]!   // push string "bar"
048 | bl -0x33aa8          // call print
04c | add sp, sp, #4       // pop arg
050 | ldr r0, [thr, #0x68] // thr->objectNull
054 | ...
hello

Closures

Closures are the implementation of first-class functions under the Function type, you can acquire one by creating an anonymous function or extracting a method.

A simple function hi that returns an anonymous function:

void Function() hi() {
  return (x) { print("Hi $x"); };
}

Disassembled:

#lint dartdec-dasm
024 | ...
028 | bl 0x3a6e0           // new dart:core::Closure_at_0150898
02c | add ip, pp, #0x2000  //
030 | ldr ip, [ip, #0x2cf] // RawFunction instance
034 | str ip, [r0, #15]    // RawClosure->function
038 | ...
hi

And to call the closure:

#lint dartdec-dasm
// Call hi

010 | ...
014 | bl -0x6c           // call hi
018 | str r0, [sp, #-4]! // push arg

// Null check

01c | ldr ip, [thr, #0x68] // thr->objectNull
020 | cmp r0, ip
024 | bleq -0x3fdc4 // nullErrorStubWithoutFpuRegsStub

// Call closure

028 | ldr r1, [r0, #15]   // RawClosure->function
02c | mov r0, r1          //
030 | ldr r4, [pp, #0xfb] // arg desc [0, 1, 1, null]
034 | ldr r6, [r0, #0x2b] // RawFunction->code
038 | ldr r2, [r0, #7]    // RawFunction->uncheckedEntryPoint
03c | mov r9, #0          // null ICData
040 | blx r2              // invoke entry point
044 | add sp, sp, #4      // pop arg
048 | ...
hi()()

Pretty simple, but what if the lambda depends on a local variable from the parent function?

int Function() hi() {
  int i = 123;
  return () => ++i;
}

Disassembled:

#lint dartdec-dasm
014 | ...
018 | mov r1, #1          // number of variables
01c | ldr r6, [pp, #0x1f] // RawCode allocateContext
020 | ldr lr, [r6, #3]    // RawCode->entryPoint
024 | blx lr              // invoke stub
028 | str r0, [fp, #-4]   //
02c | ...

040 | ldr r0, [fp, #-4]    // load context
044 | mov ip, #0xf6        // smi 123
048 | str ip, [r0, #11]    // store first variable

04c | bl 0x3a84c           // new dart:core::Closure_at_0150898
050 | add ip, pp, #0x2000  //
054 | ldr ip, [ip, #0x2cf] // RawFuntion instance
058 | str ip, [r0, #15]    // RawClosure->function
05c | ldr r1, [fp, #-4]    // load context
060 | str r1, [r0, #0x13]  // RawClosure->context
064 | ...
hi

Instead of storing the variable i in the stack frame like a regular local variable, the function will store it in a RawContext and pass that context to the closure.

When called, the closure can access that variable from the closure argument:

#lint dartdec-dasm
028 | ...
02c | ldr r1, [fp, #8]    // load first arg
030 | ldr r2, [r1, #0x13] // RawClosure->context
034 | ldr r0, [r2, #11]   // load first variable
038 | ...
() => ++i

Another way to get a closure is method extraction:

class Potato {
  int _foo = 0;
  int foo() => _foo++;
}

int Function() extractFoo() => Potato().foo;

When you call get:foo on Potato, Dart will generate that getter method as follows:

#lint dartdec-dasm
010 | ...
014 | mov r4, #0           // entry args
018 | add ip, pp, #0x2000  // 
01c | ldr r1, [ip, #0x303] // RawFunction Potato.foo
020 | add ip, pp, #0x2000  //
024 | ldr r6, [ip, #0x2ff] // buildMethodExtractorCode
028 | ldr pc, [r6, #11]    // direct jump
get:foo

get:foo invokes buildMethodExtractor, which eventually returns a RawClosure and stores the receiver (this) in its context and loads that back into r0 when called, just like a regular instance call.


Where the fun starts

With a good starting point to reverse engineer real world applications, the first big Flutter app that comes to mind is Stadia.

So let's take a crack at it, first step is to grab an APK off of apkmirror, in this case version 2.2.289534823:

(I don't recommend downloading apps from third party websites, it's just the easiest way to grab an apk file without a compatible android device)

The important part here is that the version information contains arm64-v8a + armeabi-v7a which are A64 and A32 respectively.

The interesting bits are in the lib folder like libflutter.so which is the flutter engine, and libproduction_android_library.so which is just a renamed libapp.so.

Before being able to do anything with the snapshot we must know the exact version of Dart that was used to build the app, a quick search of libflutter.so in a hex editor gives us a version string:

That c547f5d933 is a commit hash in the Dart SDK which you can view on GitHub: https://github.com/dart-lang/sdk/tree/c547f5d933, after some digging this corresponds to Flutter version v1.13.6 or commit 659dc8129d.

Knowing the exact version of dart is important because it gives you a reference to know how objects are layed out and provides a testbed.

Once decoded, the next step is to search for the root library, in this version of dart it's located at index 66 of the root objects list:

Neat, we can see the package name of this app is chrome.cloudcast.client.mobile.app, which you might notice is not actually valid for a pub package, what's going on here?

The reason for the weird package name is that Google doesn't actually use pub for internal projects and instead uses it's internal Google3 repository. You can occasionally see issues on the Flutter GitHub labelled customer: ... (g3), this is what it refers to.

By extracting uris from every library defined in the app, we can view the complete file structure for every packages it contains.

As you might expect from a large project, it depends on quite a few packages: https://gist.github.com/pingbird/7503be142607df38582b454f3b1e8153

We can gather it uses some of the following technologies:

  • protobuf
  • markdown boilerplate (from AdWords?)
  • firebase
  • rx
  • bloc
  • provider

Most of which appear to be internal implementations.

Going deeper, here is the root of the lib folder: https://gist.github.com/pingbird/f2029cd88d5343c0991f706403012f62

I picked a random widget to look at, SocialNotificationCard from profile/view/social_notification_card.dart.

The library containing this widget is structured as follows:

enum SocialNotificationIconType {
  avatarUrl,
  apiImage,
  defaultIcon,
  partyIcon,
}

class SocialNotificationCard extends StatelessWidget {
  SocialNotificationCard({
    dynamic socialNotificationIconType,
    dynamic title,
    dynamic body,
    dynamic timestamp,
    dynamic avatarUrl,
    dynamic apiImage,
  }) { }
  
  NessieString get title { }

  Widget _buildNotificationMessage(dynamic arg1) { }
  Widget _buildNotificationTimestamp(dynamic arg1) { }
  Widget _buildGeneralNotificationIcon() { }
  Widget _buildPartyIcon(dynamic arg1) { }
  Widget _buildAvatarUrlIcon(dynamic arg1) { }
  Widget _buildApiImage(dynamic arg1) { }
  Widget _buildNotificationIconImage(dynamic arg1) { }
  Widget _buildNotificationIcon(dynamic arg1) { }
  Widget build(dynamic arg1) { }
}
profile/view/social_notification_card.dart

The type information on these parameters is missing, but since they are build methods we can assume they all take a BuildContext.

The full disassembly of the _buildPartyIcon method goes as follows:

#lint dartdec-dasm
// prologue

000 | tst r0, #1
004 | ldrhne ip, [r0, #1]
008 | moveq ip, #0x30
00c | cmp r9, ip, lsl #1
010 | ldrne pc, [thr, #0x108]
014 | stmdb sp!, {fp, lr}
018 | add fp, sp, #0

// function body

01c | bl 0x27ef84          // Image allocation stub
020 | add ip, pp, #0x54000 //
024 | ldr ip, [ip, #0xdc3] // const AssetImage<AssetBundleImageKey>{"assets/social/party_invite.png", null, null}
028 | str ip, [r0, #7]     // assign field 1 (image)
02c | ldr ip, [thr, #0x70] // thr->false
030 | str ip, [r0, #0x43]  // assign field 16 (excludeFromSemantics)
034 | add ip, pp, #0x44000 // 
038 | ldr ip, [ip, #0x6b]  // int 48
03c | str ip, [r0, #0x13]  // assign field 4 (width)
040 | add ip, pp, #0x44000 //
044 | ldr ip, [ip, #0x6b]  // int 48
048 | str ip, [r0, #0x17]  // assign field 5 (height)
04c | add ip, pp, #0x46000 //
050 | ldr ip, [ip, #0xe2b] // const BoxFit.cover
054 | str ip, [r0, #0x27]  // assign field 9 (fit)
058 | add ip, pp, #0xd000  //
05c | ldr ip, [ip, #0xacf] // const Alignment{0, 0}
060 | str ip, [r0, #0x2b]  // assign field 10 (alignment)
064 | add ip, pp, #0x38000 //
068 | ldr ip, [ip, #0x653] // const ImageRepeat.noRepeat
06c | str ip, [r0, #0x2f]  // assign field 11 (repeat)
070 | ldr ip, [thr, #0x70] // thr->false
074 | str ip, [r0, #0x37]  // assign field 13 (matchTextDirection)
078 | ldr ip, [thr, #0x70] // thr->false
07c | str ip, [r0, #0x3b]  // assign field 14 (gaplessPlayback)
080 | add ip, pp, #0x38000 //
084 | ldr ip, [ip, #0x657] // const FilterQuality.low
088 | str ip, [r0, #0x1f]  // assign field 7 (filterQuality)

// epilogue

08c | sub sp, fp, #0
090 | ldmia sp!, {fp, pc}
SocialNotificationCard._buildPartyIcon

This one is quite easy to turn back into code by hand since it constructs a single Image widget:

Widget _buildPartyIcon(BuildContext context) {
  return Image.asset(
    // The `name` parameter is converted into the const AssetImage we
    // saw above at compile-time, by the Image.asset constructor.
    "assets/social/party_invite.png",
    fit: BoxFit.cover,
    width: 48,
    height: 48,
    // All of the other fields were assigned to their default value
  );
}

Note that object construction generally happens in 3 parts:

  1. Invoke allocation stub, passing type arguments if needed
  2. Evaluate parameter expressions and assigning them to fields in-order
  3. Calling the constructor body, if any

The initializer list and default parameters seem to be unconditionally inlined to the caller, leading to a bit more noise.

Finally let's disassemble the actual build method of SocialNotificationCard:

#lint dartdec-dasm
// prologue

000 | tst r0, #1              //
004 | ldrhne ip, [r0, #1]     //
008 | moveq ip, #0x30         //
00c | cmp r9, ip, lsl #1      //
010 | ldrne pc, [thr, #0x108] // thr->monomorphicMissEntry
014 | stmdb sp!, {fp, lr}     //
018 | add fp, sp, #0          //
01c | sub sp, sp, #0x14       // allocate space for local variables
020 | ldr ip, [thr, #0x24]    // thr->stackLimit
024 | cmp sp, ip              //
028 | blls -0x52ee40          // stack overflow

// construct Padding

02c | bl 0x3e3ce4          // Padding allocation stub
030 | str r0, [fp, #-4]    // assign Padding to local 0

// construct EdgeInsets

034 | bl 0x3e11f4          // EdgeInsets allocation stub
038 | str r0, [fp, #-8]    // assign EdgeInsets to local 1
03c | add ip, pp, #0x5000  //
040 | ldr ip, [ip, #0x9db] // int 0
044 | str ip, [r0, #3]     // assign field 0 (left)
048 | add ip, pp, #0xf000  //
04c | ldr ip, [ip, #0xd7]  // int 16
050 | str ip, [r0, #7]     // assign field 1 (top)
054 | add ip, pp, #0x5000  //
058 | ldr ip, [ip, #0x9db] // int 0
05c | str ip, [r0, #11]    // assign field 2 (right)
060 | add ip, pp, #0xf000  //
064 | ldr ip, [ip, #0xd7]  // int 16
068 | str ip, [r0, #15]    // assign field 3 (bottom)

// construct Row

06c | bl 0x217984          // Row allocation stub
070 | str r0, [fp, #-12]   // assign Row to local 2

// construct List<Widget>

074 | add ip, pp, #0x31000 //
078 | ldr ip, [ip, #0x677] // type args <Widget>
07c | str ip, [sp, #-4]!   // push to stack, this is used at 1cc
080 | add r1, pp, #0x31000 //
084 | ldr r1, [r1, #0x677] // type args <Widget>
088 | mov r2, #6           // smi 3 (new List length)
08c | ldr r6, [pp, #7]     // code allocateArray
090 | ldr lr, [r6, #3]     //
094 | blx lr               // call stub, putting new List into r0
098 | str r0, [fp, #-0x10] // assign List<Widget> to local 3

// call _buildNotificationIcon

09c | ldr ip, [fp, #12]    // argument 0 (self)
0a0 | str ip, [sp, #-4]!   // push
0a4 | ldr ip, [fp, #8]     // argument 1 (context)
0a8 | str ip, [sp, #-4]!   // push
0ac | bl -0x180            // call _buildNotificationIcon + 0x14
0b0 | add sp, sp, #8       // pop arguments

// add notification icon to list

0b4 | ldr r1, [fp, #-0x10]   // load local 3 (List<Widget>)
0b8 | add r9, r1, #11        //
0bc | str r0, [r9]           // list[0] = r0

// garbage collection stuff

0c0 | tst r0, #1             //
0c4 | beq 0x1c               // skip if smi
0c8 | ldrb ip, [r1, #-1]     //
0cc | ldrb lr, [r0, #-1]     //
0d0 | and ip, lr, ip, lsr #2 //
0d4 | ldr lr, [thr, #0x30]   // thr->writeBarrierMask
0d8 | tst ip, lr             //
0dc | blne -0x52f1ac         // call arrayWriteBarrier stub

// construct Expanded

0e0 | add ip, pp, #0x31000 //
0e4 | ldr ip, [ip, #0xaab] // type args <Flex> (it extends ParentDataWidget<Flex>)
0e8 | str ip, [sp, #-4]!   // push type arguments
0ec | bl 0x39ae5c          // Expanded allocation stub
0f0 | add sp, sp, #4       // pop type arguments
0f4 | str r0, [fp, #-0x14] // assign Expanded to local 4

// call _buildNotificationMessage

0f8 | ldr ip, [fp, #12]  // argument 0 (self)
0fc | str ip, [sp, #-4]! // push
100 | ldr ip, [fp, #8]   // argument 1 (context)
104 | str ip, [sp, #-4]! // push
108 | bl -0x1ae634       // _buildNotificationMessage + 0x14
10c | add sp, sp, #8     // pop arguments

// fill in Expanded

110 | ldr r1, [fp, #-0x14] // load local 4 (Expanded)
114 | mov ip, #2           // smi 1
118 | str ip, [r1, #15]    // assign field 3 (flex)
11c | add ip, pp, #0x31000 //
120 | ldr ip, [ip, #0xab7] // const FlexFit.tight
124 | str ip, [r1, #0x13]  // assign field 4 (fit)
128 | str r0, [r1, #7]     // assign field 1 (child)

// garbage collection stuff

12c | ldrb ip, [r1, #-1]     //
130 | ldrb lr, [r0, #-1]     //
134 | and ip, lr, ip, lsr #2 //
138 | ldr lr, [thr, #0x30]   // thr->writeBarrierMask
13c | tst ip, lr             //
140 | blne -0x52f020         // call WriteBarrierWrappers stub (r1 object)

// finalize construction of Expanded, this is simply a call to the
// Diagnosticable constructor since none of its other ancestors
// have constructor bodies

144 | str r1, [sp, #-4]!   // push (Expanded)
148 | bl -0x529018         // call Diagnosticable ctor
14c | add sp, sp, #4       // pop

// add Expanded to list

150 | ldr r1, [fp, #-0x10] // load local 3 (List<Widget>)
154 | ldr r0, [fp, #-0x14] // load local 4 (Expanded)
158 | add r9, r1, #15      //
15c | str r0, [r9]         // list[1] = r0

// garbage collection stuff

160 | tst r0, #1             //
164 | beq 0x1c               // skip if smi
168 | ldrb ip, [r1, #-1]     //
16c | ldrb lr, [r0, #-1]     //
170 | and ip, lr, ip, lsr #2 //
174 | ldr lr, [thr, #0x30]   // thr->writeBarrierMask
178 | tst ip, lr             //
17c | blne -0x52f24c         // call arrayWriteBarrier stub

// call _buildNotificationTimestamp

180 | ldr ip, [fp, #12]  // argument 0 (self)
184 | str ip, [sp, #-4]! // push
188 | ldr ip, [fp, #8]   // argument 1 (context)
18c | str ip, [sp, #-4]! // push
190 | bl -0x894          // call _buildNotificationTimestamp + 0x14
194 | add sp, sp, #8     // pop arguments

// add result to list

198 | ldr r1, [fp, #-0x10] // load local 3 (List<Widget>)
19c | add r9, r1, #0x13    //
1a0 | str r0, [r9]         // list[2] = r0

// garbage collection stuff

1a4 | tst r0, #1             //
1a8 | beq 0x1c               // skip if smi
1ac | ldrb ip, [r1, #-1]     //
1b0 | ldrb lr, [r0, #-1]     //
1b4 | and ip, lr, ip, lsr #2 //
1b8 | ldr lr, [thr, #0x30]   // thr->writeBarrierMask
1bc | tst ip, lr             //
1c0 | blne -0x52f290         // call arrayWriteBarrier stub

// finalize construction of list (note push at 07c)

1c4 | ldr ip, [fp, #-0x10] // load local 3 (List<Widget>)
1c8 | str ip, [sp, #-4]!   // push
1cc | bl -0x52948c         // call List._fromLiteral
1d0 | add sp, sp, #8       // pop arguments

// fill in Row

1d4 | ldr r1, [fp, #-12]   // load local 2 (Row)
1d8 | add ip, pp, #0x3a000 //
1dc | ldr ip, [ip, #0x1a3] // const Axis.horizontal
1e0 | str ip, [r1, #11]    // assign field 2 (direction)
1e4 | add ip, pp, #0x31000 //
1e8 | ldr ip, [ip, #0xa8b] // const MainAxisAlignment.start
1ec | str ip, [r1, #15]    // assign field 3 (mainAxisAlignment)
1f0 | add ip, pp, #0x31000 //
1f4 | ldr ip, [ip, #0xa93] // const MainAxisSize.max
1f8 | str ip, [r1, #0x13]  // assign field 4 (mainAxisSize)
1fc | add ip, pp, #0x31000 //
200 | ldr ip, [ip, #0xa77] // const CrossAxisAlignment.start
204 | str ip, [r1, #0x17]  // assign field 5 (crossAxisAlignment)
208 | add ip, pp, #0x31000 //
20c | ldr ip, [ip, #0xa9b] // VerticalDirection.down
210 | str ip, [r1, #0x1f]  // assign field 7 (verticalDirection)
214 | str r0, [r1, #7]     // assign List<Widget> to field 1 (children)

// garbage collection stuff

218 | tst r0, #1             //
21c | beq 0x1c               // skip if smi
220 | ldrb ip, [r1, #-1]     //
224 | ldrb lr, [r0, #-1]     //
228 | and ip, lr, ip, lsr #2 //
22c | ldr lr, [thr, #0x30]   // thr->writeBarrierMask
230 | tst ip, lr             //
234 | blne -0x52f114         // call WriteBarrierWrappers stub (r1 object)

// finalize construction of Row

238 | str r1, [sp, #-4]! // push Row
23c | bl -0x52910c       // call Diagnosticable ctor
240 | add sp, sp, #4     // pop

// fill in Padding

244 | ldr r0, [fp, #-8]  // load local 1 (EdgeInsets)
248 | ldr r1, [fp, #-4]  // load local 0 (Padding)
24c | str r0, [r1, #11]  // assign field 2 (padding)

// garbage collection stuff

250 | ldrb ip, [r1, #-1]     //
254 | ldrb lr, [r0, #-1]     //
258 | and ip, lr, ip, lsr #2 //
25c | ldr lr, [thr, #0x30]   // thr->writeBarrierMask
260 | tst ip, lr             //
264 | blne -0x52f144         // call WriteBarrierWrappers stub (r1 object)

// fill in Padding

268 | ldr r0, [fp, #-12] // load local 2 (Row)
26c | str r0, [r1, #7]   // assign field 1 (child)

// garbage collection stuff

270 | ldrb ip, [r1, #-1]     //
274 | ldrb lr, [r0, #-1]     //
278 | and ip, lr, ip, lsr #2 //
27c | ldr lr, [thr, #0x30]   // thr->writeBarrierMask
280 | tst ip, lr             //
284 | blne -0x52f164         // call WriteBarrierWrappers stub (r1 object)

// epilogue

288 | mov r0, r1          // return Padding
28c | sub sp, fp, #0      //
290 | ldmia sp!, {fp, pc} //
SocialNotificationCard.build

There was a bit more GC related code this time, if you are interested these write barriers are required due to the tri-color invariant. Hitting a write barrier is actually pretty rare, so it has minimal impact on performance with the benefit of allowing parallel garbage collection.

The equivalent dart code:

Widget build(BuildContext context) {
  return Padding(
    padding: EdgeInsets.symmetric(vertical: 16.0),
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        _buildNotificationIcon(context),
        Expanded(
          child: _buildNotificationMessage(context),
        ),
        _buildNotificationTimestamp(context),
      ],
    ),
  );
}

A little more tedious to reverse due to the amount of code, but still relatively easy given tools to identify object pool entries and call targets.


Conclusion

This was a super fun project and I thoroughly enjoyed picking apart assembly code. I hope this series inspires others to also learn more about compilers and the internals of Dart.

Can someone steal my app?

Technically was always possible, given enough time and resources.

In practice this is not something you should worry about (yet), we are far off from having a full decompilation suite that allows someone to steal an entire app.

Are my tokens and API keys safe?

Nope!

There will never be a way to fully hide secrets in any client-side application. Note that things like the google_maps_flutter API key is not actually private.

If you are currently using hard coded credentials or tokens for third party apis in your app you should switch to a real backend or cloud function ASAP.

Will obfuscation help?

Yes and no.

Obfuscation will randomize identifier names for things like classes and methods, but it won't prevent us from viewing class structure, library structure, strings, assembly code, etc.

A competent reverse engineer can still look for common patterns like http API layers, state management, and widgets. It is also possible to partially symbolize code that uses publicly available packages, e.g. you can build signatures for functions in package:flutter and correlate them to ones in an obfuscated snapshot.

I generally don't recommend obfuscating Flutter apps because it makes reading error messages harder without doing much for security, you can read more about it here.