[Flutter翻译]逆向Flutter™应用的现状和未来

3,087 阅读13分钟

本文由 简悦SimpRead 转码,原文地址 www.guardsquare.com

如果没有适当的工具,做逆向工程是很难的。了解当前状况和未来......

如果没有适当的工具,做逆向工程可能是很难的。幸运的是,对于逆向工程师来说,有很多强大的工具可以依靠。因此,他们不必每次开始对不同的软件进行逆向工程时都要重新发明轮子并创建自己的反汇编/反编译器。

例如,所有流行的逆向工程工具(如IDA Pro、Ghidra、JEB、Binary Ninja......)都能够解析ELF/MachO/PE文件,从中提取有用的信息,并且它们会。

  • 使用符号表并对定义的函数进行自动重命名
  • 找到字符串定义和它们使用的地方之间的交叉引用
  • 使用已知的处理器ABI来识别函数参数

此外,人们已经投入时间来开发高级工具,以处理更复杂的主题,如二进制差异和识别二进制中包含的已知函数(如IDA Pro TIL或Lumina服务器)。

但是当涉及到Flutter逆向工程时,这些工具和功能大部分目前还没有,而且没有这些工具和功能,就很难知道从哪里开始。这可能会导致一种误解,认为编写Flutter代码意味着不会被逆向工程,因此,不需要保护。

在这篇博文中,以及未来更多的博文中,我们想证明,帮助Flutter逆向工程的工具其实并不难开发,随着Flutter的普及和不断成熟,会有更多的工具出现。

我要感谢CaramelDunes让我在这篇文章中使用他的开源Flutter游戏NyaNya Rocket!作为例子。虽然这是一个开源的游戏,但我们将把它当作没有获得源代码的情况下进行分析。如果你想跟着做实验,我们已经准备了一个Github repo,里面有所有的应用程序和脚本!

在这第一篇博客中,我们将关注Dart虚拟机快照中包含的信息,并探讨之前提到的工具如何轻松利用它来加速Flutter应用的逆向工程。

为什么目前Flutter的逆向工程是困难的

我们发现目前减缓Flutter逆向工程的三个主要障碍。

  1. Dart AOT快照格式每次更新都会有很大的变化。
  2. 所有的Dart框架都是静态链接在应用程序的二进制文件中。
  3. Dart代码依赖Dart虚拟机来执行。

让我们更详细地看一下每个障碍。

第一个障碍与Dart语言仍然年轻和不断发展的事实有关。正因为如此,Dart快照的格式也在不断变化,它包含了Flutter应用程序的所有编译的机器代码和数据。对逆向工程师的主要影响是,如果他们写一个解析器来提取Flutter应用程序中包含的信息,那么只要有新的Flutter版本发布,他们的解析器就会过时。

第二个障碍是由应用程序使用的所有Dart框架被静态链接到Dart快照中造成的。对于逆向工程师来说,这有三个主要后果。

  1. Dart快照的大小比嵌入Android应用的类似本地库要大得多,这意味着有更多的东西需要被逆向工程。
  2. 很难区分应用程序代码和框架代码,这意味着逆向工程师可能会失去逆向工程开源框架代码的时间。
  3. 通过观察框架的函数调用,不可能直接猜到一个函数在做什么,因为这种调用不是外部的。

第三个障碍是Dart代码对要执行的Dart VM的依赖性。在实践中,它在两个主要方面影响了逆向工程的进程。

  1. 从静态数据定义到它被使用的地方没有直接引用。所有这些都是通过Dart虚拟机对象池的间接方式来隐藏的。因此,我们的逆向工程工具无法定位Dart对象的使用。
  2. Dart VM使用自定义的寄存器布局和ABI。例如,在arm64上,X27被用作对象池指针,X15被用作Dart VM的堆栈指针。
    • 调用者将把函数参数推到Dart VM的堆栈中(这不是常规的程序堆栈),并相应地更新X15
    • 同样的,局部变量将被存储在Dart VM堆栈中。
    • 这种自定义的堆栈和堆栈指针不能被我们的传统工具正确处理,这导致Dart反编译后的代码看起来非常奇怪(更多内容请见未来的博文)。

在这篇博文中,我们将重点讨论前两个障碍,最后一个障碍留待以后的博客讨论。

如果你对Dart虚拟机的内部情况感兴趣,从逆向工程的角度来看,我建议阅读Andre Lipke的系列博文

从Flutter快照中提取信息

现在我们了解了目前使Flutter应用程序的逆向工程工作复杂化的主要障碍,让我们仔细看看我们确定的第一个障碍。哪些信息可以从Flutter快照中检索出来,这样做的先进性是什么?

快照包含Flutter引擎运行Flutter应用程序所需的所有信息;它包括。

  • 将用于运行应用程序的编译代码。它不仅包括特定于应用程序的代码,还包括应用程序使用的所有框架的代码。
  • Flutter应用程序使用的所有字符串或静态数据。
  • 大量的元数据,这些元数据被Flutter引擎用来使Flutter应用程序运行。
    • 有些是必须的,比如Dart对象的定义和Dart VM对象池。
    • 有些是可选的,如类/函数名,但可能很有用,例如,当崩溃发生时打印符号化的堆栈痕迹。

对于逆向工程师来说,类名和函数名是非常有用的信息,因为它们可以用来识别已知的框架,防止在这些框架上损失时间。此外,由于开发人员在编写应用程序时通常使用有意义的名称,他们可能会很幸运地找到一些super_secret_function

Flutter元数据提取的最先进方法

目前,有3种方法可以提取这些信息。

  • 使用Dart快照分析器
  • 使用修改后的Flutter运行库版本
  • 使用调试信息

如果你在网上搜索Dart快照分析器,你会发现其中有几个,包括darterDoldrums。虽然这些都是很好的工具,但它们的问题是,它们必须自己处理第一个障碍:Dart快照格式不断变化,因此每次有新的Dart版本发布时,它们都需要修改。这个过程需要一些时间,因此它们中的大多数都不支持Flutter的最新版本--还没有。

第二种方法是由例如reFlutter项目选择的。它不是试图解析Dart快照,而是修改Flutter运行时库,使应用程序在运行时转储信息。这种方法的最大优点是,当Dart快照格式发生变化时,它所需要的维护工作要少得多!

当在NyaNya Rocket!应用程序上使用时,它将为您提供以下类型的信息(完整的转储可以在这里找到)。

Library:'dart:io' Class: Link extends Object implements Type: FileSystemEntity {
  Function 'Link.': static factory. (dynamic, String) => Link {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000008ba68
  }
  Function 'Link.fromRawPath': static factory. (dynamic, Uint8List) => Link {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000008b9fc
  }
}
 
Library:'package:shared_preferences/shared_preferences.dart' Class: SharedPreferences extends Object {
  Completer? _completer@1038065047 = null ;
  Function 'get:_store@1038065047': static. () => SharedPreferencesStorePlatform {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee000
  }
  Function 'getInstance': static. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee4dc
  }
  Function 'getBool':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee44c
  }
  Function 'getInt':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002eda00
  }
  Function 'getString':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee3b8
  }
 Function 'containsKey':. (SharedPreferences, String) => bool {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002edb10
  }
  Function 'setBool':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee36c
  }
  Function 'setInt':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002eeb50
  }
  Function 'setString':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee31c
  }
  Function '_setValue@1038065047':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee244
  }
  Function '_getSharedPreferencesMap@1038065047': static. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002edb7c
  }
}
 
Library:'package:nyanya_rocket/screens/puzzles/widgets/local_puzzles.dart' Class: LocalPuzzles extends StatelessWidget {
  Function 'build':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000003200f4
  }
  Function '_buildPuzzleTile@1161407169':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000031f574
  }
  Function '_buildPuzzleCard@1161407169':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000031ef6c
  }
  Function '_verifyAndPublish@1161407169':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000031e984
  }
  Function '_openPuzzle@1161407169':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000031edc0
  }
  Function '_handlePublishTapped@1161407169':. String: null {
    Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000031e7c4
  }
}

正如你所看到的,reFlutter为我们提供了比类和函数名称更多的信息:它甚至显示了类的层次结构和内部函数的API。

提取这些信息的第三种方法是利用构建Flutter应用程序时产生的调试信息,即--split-debug-infoflag。使用这个标志会产生一个DWARF文件,该文件可以很容易地被解析,其中包含类/函数名称和它们在libapp.so中的相关偏移。很明显,逆向工程师不能自己构建他们试图逆向工程的应用程序,所以他们无法获得关于它的调试信息。但我们将在这篇博文的最后探讨如何使用这种方法来检测任何应用程序中的框架功能。

实验使用Flutter元数据进行更容易的逆向工程

不管上一节的信息是如何被检索到的,让我们实验一下如何利用它来解决我们提到的第二个障碍。

回到NyaNya Rocket!应用程序,分析的第一步是使用上一节中讨论的技术之一提取应用程序的元数据。然后,我们可以在例如IDA Pro Python脚本中使用提取的元数据,以自动重命名和排序函数。

play.hubspotvideo.com/03679a18-dc…

如上面的视频所示,最初IDA数据库包含了20,000多个未知函数。在运行了带有元数据的脚本后,几乎所有的函数都被重新命名,并根据其包和类的名称进行了排序。这对逆向工程师来说将是一个巨大的时间收益,因为现在非常容易找到所有的框架函数,忽略它们,并专注于分析具体的应用代码。

此外,在检查特定应用的代码时,逆向工程师现在可以识别它对不同框架的调用,这使他们回到了更经典的场景,即他们可以使用导入的函数来快速理解一个函数的行为。

然而,反编译后的代码仍然有很多奇怪的Dart工件。例如,视频最后显示的_handlePublishTapped的Dart代码是。

 void _handlePublishTapped(BuildContext context, String uuid, User user) {
   if (user.isConnected) {
     PuzzleStore.read(uuid).then((NamedPuzzleData? puzzle) {
       if (puzzle != null) {
         _verifyAndPublish(context, puzzle);
       }
     });
   } else {
     final snackBar = SnackBar(
         content: Text(NyaNyaLocalizations.of(context).loginPromptText));
     ScaffoldMessenger.of(context).showSnackBar(snackBar);
   }
 }

而重命名后的相关反编译代码看起来是这样的。

image.png

如果你想自己尝试,你可以在这里找到我们使用的脚本

在下一篇文章中,我们将解释如何清理这段代码,使其在外观上更接近我们习惯的本地代码。

Flutter的内置混淆功能如何?

Flutter有一个内置选项,可以在Flutter应用内自动混淆Dart代码。当这个选项被启用时,大多数模块/类/函数的名字都被随机的名字所取代。因此,尽管同样的提取元数据的方法仍然有效,但它们只会提供(被混淆的)名称,而这些名称并不像以前未被混淆的名称那样有用。

但这并不是游戏结束,因为没有对代码本身进行混淆,经典的二进制差异工具,如BinDiffDiaphora可以用来恢复函数的原始名称。

play.hubspotvideo.com/75ba56de-41…

在上面的视频中,我们用BinDiff恢复了NyaNya Rocket!应用程序的混淆版本的函数名称,使用的是以前的(非混淆)构建,这解释了为什么有非常多的成功识别的函数。

在现实生活中,如果开发者之前发布了一个没有混淆的构建,后来决定启用Flutter内置的混淆功能,这种攻击场景就会发生。但是,即使一个应用程序一直是在启用Flutter内置混淆选项的情况下发布的,反向工程师仍然可以使用这种二进制差异技术来识别应用程序使用的常见Flutter框架。

例如,他们可以在没有obfuscate选项的情况下,生成几个使用大量Flutter框架的Flutter应用程序,这样最初的重命名脚本就可以识别所有的框架功能。后来,当他们面对一个新的未知应用程序时,他们可以使用二进制差异工具,这将识别其中包含的大部分框架函数。一旦这样做了,大部分没有被识别的函数将是应用程序的特定代码。

最后,由于这个Dart框架的代码变化不大,所以很多逆向工程工具可能会包括一些签名,使其能够直接检测这些框架函数。

结论

在这篇文章中,我们研究了Dart快照中包含的信息,以及如何提取这些信息,我们看到它包含了很多有趣的元数据,供逆向工程师使用。我们还展示了,只需几行代码,这些信息就可以用来大大加快Flutter应用程序的逆向工程。我们表明,随着Dart和Flutter的进一步成熟,逆向工程的工具也将进一步成熟,目前认为的任何困难都将被消除。

此外,我们评估了内置的Flutterobfuscate选项。虽然它确实删除了一些元数据,但代码本身并没有被混淆,这意味着识别使用该选项构建的Flutter应用程序所使用的所有框架函数仍然比较容易。这使得逆向工程师能够极大地限制范围,并使用已知的函数来尝试理解未知的函数在做什么。

在这个主题的下一篇博文中,我们将重点讨论如何使反编译的代码看起来更好,以及如何处理Dart VM对象池。


www.deepl.com 翻译