Reverse engineering Flutter apps (Part 2)
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 | ...
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
]
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
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 | ...
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
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
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 | ...
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:
- Start at end of super class, otherwise start at
sizeof(RawInstance)
- Use the type arguments field of parent, else put it at the start
- 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:
- Whole-program static analysis
- Inlining calls on known types
- Code de-duplication
- 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 | ...
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 | ...
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 | ...
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 | ...
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 | ...
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 | ...
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 | ...
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 | ...
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 | ...
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 | ...
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
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) { }
}
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}
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:
- Invoke allocation stub, passing type arguments if needed
- Evaluate parameter expressions and assigning them to fields in-order
- 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} //
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.