[Flutter翻译]Flutter应用程序的逆向工程(第2部分)。

550 阅读27分钟

image.png

原文地址:blog.tst.sh/reverse-eng…

原文作者:blog.tst.sh

发布时间:2021年2月24日

这是第一部分的延续,其中介绍了Flutter如何编译应用程序以及内部快照的样子。

到目前为止,你可能已经猜到了,逆向工程不是一件容易的事情。


调用惯例

我们先来介绍一下Dart类型系统的一些基本知识。

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());
}

你认为哪些函数能打印真值?

事实证明,Dart的类型系统比你想象的要灵活得多,只要一个函数接受相同的位置参数,并且具有兼容的返回类型,它就是一个有效的函数子类型。

正因为如此,除了 baz 之外,所有的函数都打印为真。

下面是另一个实验。

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}));
}

这段代码检查函数是否有一个有效的子类型,当函数有一个命名参数的子集时,除了最后一个参数外,其他都打印为真。

关于函数类型的正式描述,请参见Dart语言规范中的 "9.3 Type of a Function",你可能还想看看我关于类型系统的配方

混合和匹配参数签名是一个很好的特性,但在低级别的实现时,会带来一些问题,比如说。

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);
}

为了实现这个功能,foo需要通过某种方式知道调用者提供了a和c但没有提供b,这条信息被称为参数描述符。

内部的参数描述符由vm/dart_entry.h定义,实现方式只是在常规的Array对象上提供一个接口,被调用者通过参数描述符寄存器提供。

例如

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

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

我将不使用Dart内置的拆解器,而是使用一个自定义的拆解器,为调用、对象池条目和其他常量提供适当的注释。

foo的拆解,调用者。

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 | ...

调用bar的参数描述符为以下RawArray。

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

描述符在callee的序言中用于将堆栈指数映射到各自的参数槽,并验证是否收到了正确的参数。下面是callee的拆解过程。

// 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

总结一下,它循环数组,将槽分配给任何匹配的参数,如果有任何参数不属于函数类型,则抛出NoSuchMethodError。另外,请记住,参数检查只需要对多态调用进行,大多数调用(包括hello world的例子)都是单态的。

这段代码是在vm/compiler/frontend/prologue_builder.cc PrologueBuilder::BuildOptionalParameterHandling中高级生成的,这意味着寄存器和子程序可能会根据参数的类型和感觉做的优化而有不同的布局。

整数运算

在Dart类型系统中,num、int和double类是特殊的,出于性能考虑,它们不能被扩展或实现。

这个怪癖保证了一个非空的num总是可以直接用于运算,如果缺少了这个限制,编译器将不得不生成相对昂贵的方法调用来代替。

dart中的所有对象都以RawObject*的形式存储,然而只有带有kHeapObjectTag标签的指针才会指向实际的内存,否则它们就是smis,小英特的简称。

由于指针标记,你会在生成的代码中看到很多tst r0,#1和类似的指令,其中大部分是简单的区分smis和堆对象。你还会看到很多奇数偏移的加载和存储,这些只是在减去堆标志。

有一个很好的文档详细介绍了如何以及为什么引入这个优化,你可以在这里找到它:github.com/dart-lang/s…

有趣的是:在Dart 2.0之前,核心int类型曾经是一个bigint。

任何能在字大小减去1位(A32上的31位)的范围内的整数都可以存储为smi,否则更大的整数就会在堆上存储为64位的mint(中int)实例。

如果LSB是清的,你就知道一个对象是smi。

image.png

Smis当然也可以包含负数, 它使用一个算术右移来将数字扩展回原位.

例如,这里有一个简单的函数,它将两个ints相加。

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

这看起来似乎很简单,但它的背后可能有点混乱。

首先,x和y分别被解压成一对寄存器,Dart ints是64位的,所以A32上的每个arg都需要两个寄存器。

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在寄存器中成对后,它就可以执行实际的64位加法。

070 | adds r7, r4, r6 // bottom half
074 | adcs r2, r3, r1 // carry into top half

在返回之前,结果被重新装箱。

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   

Boxing看起来比实际要贵,大多数时候,值会以smi的形式立即返回,只有当数字大于31位时,才会打入二级码路。


实例

下面的代码通过调用分配存根和调用构造函数来创建一个实例。

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

拆解后的。

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 | ...

每个类都有一个相应的分配存根,它可以分配和初始化一个实例(非常类似于装箱创建对象的方式),这些存根是为任何可以构造的类生成的。

不幸的是,字段信息被从快照中删除了,所以我们无法直接获得它们的名称。然而,你可以看到隐式getter和setter方法的名称(假设它们没有被内联)。

字段的偏移量在Class::CalculateFieldOffsets中计算,规则如下。

从超级类的结尾开始计算, 否则从 sizeof(RawInstance) 开始计算.

  1. 使用父类的类型参数字段,否则就放在开头。
  2. 按顺序布置其余(非静态)字段。
  3. 因为类型参数是与超级类共享的,所以实例化下面的类,就会得到一个包含<String,int>的类型参数字段。
class Foo<T> extends Bar<String> {}
var x = Foo<int>(); // instance type arguments are <String, int>

而如果父类和子类的类型参数相同,则列表将只包含<int>

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

Dart的另一个有趣的特性是所有的字段访问都是通过setter和getter来完成的,这听起来可能很慢,但实际上dart通过以下优化消除了大量的开销。

  1. 整个程序的静态分析
  2. 对已知类型进行内联调用
  3. 删减代码
  4. 在线缓存(通过ICData)

这些优化适用于所有方法,包括getter和setter,在下面的例子中,setter是内联的。

class Foo {
  int x;
}

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

拆卸。

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

但是当我们通过一个接口调用这个setter时。

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

class FooImpl extends Foo {
  int x;
}

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

拆卸。

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 | ...

在这里,它调用了一个unlinkedCall stub,它是一个处理多态方法调用的神奇代码,它会给自己的对象池条目打补丁,这样进一步的调用就会更快。

我很想了解更多关于这在运行时如何工作的细节,但我们需要知道的是,它调用RawUnlinkedCall中指定的方法。如果你有兴趣,有一篇关于DartVM内部的文章可以解释更多:mrale.ph/dartvm/

检查类型

类型检查是多态性的一个基本组成部分,dart通过is和as操作符提供了这个功能。

这两个运算符都会进行子类型检查,除了as运算符允许空值,下面是is运算符的操作。

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

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

拆解。

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 | ...

由于全程序分析确定Foo只有一个实现者,所以可以简单地检查类ID的平等性,但如果它有一个子类呢?

class Baz extends Foo {}

我们现在得到。

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 | ...

嘎! 这段代码很糟糕,所以这里有一个基本的翻译。

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

它在这里所做的就是检查类id是否在一组范围内,在这种情况下,只有一个范围需要检查。

这绝对是DartVM在ARM上可以改进的地方,它对16位类id进行64位smi范围检查,而不是直接比较。

范围检查也没有考虑其比较的超级类型,这可能会导致一个范围被一个没有实现超级的类型分割,也许是不健全的结果。

控制流程

Dart使用了比较先进的流程图,用SSA(Single Static Assignment,单静态赋值)中间件表示,类似于gcc和clang等现代编译器。它可以进行很多优化,改变程序的控制流结构,使其生成的代码推理变得更难。

下面是一个简单的if语句。

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

拆卸。

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 | ...

那个null检查是一个 "运行时条目 "动态调用的例子,这是从dart代码到vm/runtime_entry.cc中定义的子程序的桥梁。

在这种情况下,它是一个专门的条目,它抛出了一个Failed断言:布尔表达式必须不为空,就像你所期望的if语句的条件为空一样。

整个程序的优化(以及未来健全的非空值性)允许这个空值检查被省略,例如,如果hello从来没有被调用过一个可能的空值,那么它根本不会做检查。

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

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

拆卸。

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 | ...

闭包

闭包是Function类型下的一级函数的实现,你可以通过创建一个匿名函数或提取一个方法来获得。

一个简单的函数hi,返回一个匿名函数。

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

拆卸。

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 | ...

而且调用闭包。

// 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 | ...

很简单,但是如果lambda依赖于父函数的局部变量呢?

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

拆卸的。

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 | ...

函数不会像普通的本地变量那样将变量i存储在堆栈框架中,而是将其存储在RawContext中,并将该上下文传递给闭包。

当调用时,闭包可以从闭包参数中访问该变量。

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 | ...

另一种获得闭包的方法是方法提取。

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

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

当你在Potato上调用get:foo时,Dart将生成如下的getter方法。

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调用buildMethodExtractor,最终返回一个RawClosure,并将receiver(this)存储在其上下文中,当被调用时再将其加载回r0中,就像一个普通的实例调用一样。

有趣的地方开始了

有了一个好的起点,就可以对现实世界的应用进行逆向工程,第一个想到的Flutter大应用就是Stadia

那么我们就来试试吧,第一步是从apkmirror上抓取一个APK,本例中是2.2.289534823版本。

image.png

(我不建议从第三方网站下载应用,这只是在没有兼容安卓设备的情况下抓取apk文件的最简单方法)

这里重要的是版本信息包含arm64-v8a+armeabi-v7a,分别是A64和A32。

image.png

有趣的部分在lib文件夹中,比如libflutter.so是flutter引擎,libproduction_android_library.so只是一个重命名的libapp.so。

在能够对快照做任何事情之前,我们必须知道用于构建应用程序的Dart的确切版本,在十六进制编辑器中快速搜索libflutter.so,我们就可以得到一个版本字符串。

image.png

c547f5d933是Dart SDK中的一个提交哈希值,你可以在GitHub上查看:github.com/dart-lang/s… ,经过挖掘,这对应的是Flutter版本v1.13.6或提交659dc8129d。

知道dart的确切版本是很重要的,因为它给了你一个参考,让你知道对象是如何布局的,并提供了一个测试平台。

一旦解码,下一步就是搜索根库,在这个版本的dart中,它位于根对象列表的索引66。

image.png

很好,我们可以看到这个应用的包名是chrome.cloudcast.client.mobile.app,你可能会注意到这个包对于一个pub包来说是无效的,这是怎么回事?

包名怪异的原因是Google其实并没有将pub用于内部项目,而是使用了它内部的Google3仓库。你偶尔可以在Flutter GitHub上看到标有customer:......(g3)的问题,这就是它的所指。

通过从应用中定义的每个库中提取uris,我们可以查看其包含的每个包的完整文件结构。

正如你所期望的那样,从一个大型项目中,它依赖于相当多的包:gist.github.com/PixelToast/…

我们可以收集到它使用了以下一些技术。

  • protobuf
  • markdown模板(来自AdWords?)
  • firebase
  • rx
  • bloc
  • provider

其中大部分似乎是内部实现。

再深入一点,这里是lib文件夹的根目录:gist.github.com/PixelToast/…

我随便挑了一个小工具来看,SocialNotificationCard来自profile/view/social_notification_card.dart。

包含这个widget的库结构如下。

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

这些参数的类型信息是缺失的,但由于它们是构建方法,我们可以假设它们都需要一个BuildContext。

_buildPartyIcon方法的完整拆解如下。

// 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

这个很容易手工变回代码,因为它构造了一个单一的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
  );
}

注意,对象的构造一般发生在3个部分。

  1. 调用分配存根,必要时传递类型参数
  2. 评估参数表达式,并按顺序将其分配给字段。
  3. 如果有的话,调用构造体

初始化器列表和默认参数似乎是无条件内联给调用者的,导致噪音比较大。

最后我们来拆解一下SocialNotificationCard的实际构建方法。

// 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

这次又多了一些GC相关的代码,如果你有兴趣的话,由于三色不变,这些写障碍是必须的。遇到写障碍的情况其实很罕见,所以对性能的影响微乎其微,好处是可以并行收集垃圾。

相当于Dart的代码。

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),
      ],
    ),
  );
}

由于代码量大,逆向操作比较繁琐,但考虑到识别对象池条目和调用目标的工具,还是比较容易的。

结束语

这是一个超级有趣的项目,我非常享受拆解汇编代码的过程。我希望这个系列能激励其他人也多了解编译器和Dart的内部结构。

有人能偷走我的应用吗?

从技术上讲总是可能的,只要有足够的时间和资源。

在实践中,这不是你应该担心的事情(还没有),我们离拥有一个完整的反编译套件,允许某人窃取整个应用程序还很远。

我的代币和API密钥安全吗?

不安全!

在任何客户端应用中,永远没有办法完全隐藏秘密。请注意,像google_maps_flutter API密钥这样的东西其实并不是私有的。

如果你目前在你的应用程序中使用硬编码的凭证或第三方apis的令牌,你应该尽快切换到一个真正的后台或云函数

混淆会有帮助吗?

是的,也不是。

混淆会随机化类和方法等的标识符名称,但不会阻止我们查看类结构、库结构、字符串、汇编代码等。

一个称职的逆向工程师仍然可以寻找常见的模式,比如http API层、状态管理和小部件。也可以对使用公开的包的代码进行部分符号化,例如,你可以为package:flutter中的函数建立签名,并将它们与混淆快照中的函数进行关联。

我一般不推荐混淆Flutter应用,因为这会让错误信息的读取变得更困难,而对安全性却没有什么帮助,你可以在这里阅读更多的信息。


通过www.DeepL.com/Translator(免费版)翻译