[Flutter翻译]Dart反编译的障碍及对Flutter™应用安全的影响

988 阅读22分钟

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

反编译的代码很难理解。这是否与re...... 目前缺乏对Flutter的支持有关?

在我们最近关于移动安全背景下的Flutter的博客中,我们着手描述了反向的现状工程工具攻击者将面临的问题以及事情可能发展的方向。我们的目标是调查Flutter应用程序是否比其他类型的移动应用程序对反向工程更有弹性。我们已经证明了包括在Dart快照中的元数据可以被提取并用于缓解逆向工程。也就是说,我们表明可以恢复类和函数名称,并利用它们在逆向工程数据库中自动定位和重命名函数,这使得逆向工程师可以利用这些信息来关注重要的东西:特定于应用程序的代码。我们还注意到,即使得到的反编译代码有函数名,但仍然不是很干净,难以理解。

在这篇文章中,我们将研究反编译的代码难以理解这一事实,是否与Dart代码难以逆向工程有根本的联系,还是与目前逆向工程工具对Flutter的支持不足有关。我们做了几个实验来验证这段代码是否可以被清理,以使其更接近于原始的Dart代码。

就像上一篇文章一样,我们将使用NyaNya Rocket!的混淆构建,再次感谢CaramelDunes让我们使用它。使用混淆的构建对我们的实验没有影响,因为Flutter混淆只是重命名实体,并不执行任何实际的代码混淆。

为什么反编译后的代码看起来很奇怪?

首先让我们快速浏览一下重命名后的初始反编译代码,看看我们是否能找出一些使反编译代码看起来很奇怪的潜在问题。

image.png

你能看到的所有看起来很奇怪的工件都与我们在上一篇文章中讨论的第三个障碍有关:Dart代码依赖于Dart VM来执行。

我们在前面的图片中可以看到的三个主要问题都与Dart代码的以下三个特征有关

  • 所有的Dart 对象都是通过对象池访问的。因此,反编译后的代码只包含被访问对象的索引,而不引用对象本身。
  • Dart虚拟机使用自定义堆栈,由寄存器X15索引,这使得逆向工程工具无法正确识别开箱即用的本地堆栈变量。
  • Dart代码使用非标准的ARM64 ABI,其中大部分函数调用的参数被推送到Dart VM堆栈。因此,IDA Pro不能够识别函数参数,它认为函数没有参数。  

Dart代码特征1:对象池的间接性

第一个问题与Dart快照的工作方式密切相关。当你编译一个Flutter应用程序时,所有的Dart对象都被序列化并存储在Dart快照内。因此,Dart代码不能直接访问它们。当Flutter运行时加载您的应用程序时,这些对象被反序列化并存储在Flutter堆中。正因为如此,Dart代码在编译时无法知道这些反序列化的Dart对象的地址,因此它需要在运行时使用间接方式访问它们。 

这就是Dart对象池发挥作用的地方。在编译时,每个Dart对象的引用都被存储在一个叫做Dart对象池的大数组中,它本身也被序列化(因为它是一个Dart对象)。同时,在Dart代码中,所有对这些对象的直接访问都被通过对象池的间接访问所取代,使用它们相应的对象池索引。

下图展示了在运行时发生的情况。

  • 步骤1和2是在Flutter引擎加载Flutter应用程序时执行的。
  • 步骤3和4是在Dart代码想要访问一个Dart对象时执行的。正如你所看到的,Dart代码从不使用序列化的Dart对象。

image.png

这种对象池技术的使用对Dart代码有以下影响。

  • 代码和数据之间没有直接的交叉引用,所有这些链接都被对象池的间接性所隐藏。
  • 这解释了为什么我们在反编译的代码中只能看到Dart对象的索引而不是Dart对象本身。

因此,如果你只看一个特定函数的编译后的Dart代码,你没有办法了解它在访问哪个数据(即Dart对象)。同样,如果你只看一个Dart快照的序列化对象,你就无法弄清楚哪个Dart编译的函数在使用它,因为它从未被直接访问过。

没有这些交叉引用是逆向工程的一个主要障碍,因为它减慢了识别代码和数据依赖关系的速度。

然而这些信息对于使Dart代码在运行时加载它所需要的对象是内在需要的,因此它需要被储存在某个地方。我们将在后面看到,恢复数据和代码之间的这种联系所需的所有信息都可以在Dart元数据中找到。

Dart代码特征2:自定义堆栈

第二个问题是由Dart SDK拥有自己的堆栈引起的,它是由X15寄存器而不是经典的ARM64SP寄存器索引的。 

大多数逆向工程工具能够通过分析堆栈的操作和访问来推断出很多东西。例如,它们可以自动计算函数框架的大小,并识别局部变量的读和写。此外,他们还可以通过查看函数调用前推到堆栈上的变量来找出函数参数。

由于Dart虚拟机用于堆栈操作的寄存器不是经典的ARM64 SP寄存器,所有这些分析通道对Dart代码不起作用。这就是为什么在反编译的代码中,没有检测到局部变量和函数没有任何参数的原因。

了解堆栈操作和函数间的数据交换方式是逆向工程的一个关键部分。没有这些信息,就很难正确理解代码。为了与开发相提并论,这就有点像试图理解一个库,其中。

  • 所有的函数都有0个参数,但使用全局变量来相互传输数据。
  • 每个函数的所有局部变量都存储在全局变量中。

Dart代码特征3:自定义ABI

第三个问题是由Dart SDK不使用标准的ARM64调用惯例造成的。它没有使用寄存器X0-X7将参数传递给被调用的函数,而是将大多数参数推送到自定义的Dart VM堆栈中。主要影响是,逆向工程工具无法正确识别函数参数。

让我们看看为什么会发生这种情况,使用逆向工程工具可以用来识别函数输入参数的启发式方法之一(假设是默认的ARM64 ABI)。

  • 它寻找是否有一个X0-X7寄存器在被写入之前被使用,如果是这样,就意味着它可能是一个函数参数(例如,如果X3在被写入之前被使用,就意味着该函数至少有4个参数X0-X3
  • 然后,它寻找堆栈访问以检测参数(对于有8个以上参数的函数)

因此,由于Dart自定义ABI不使用X0-X7,函数不会使用任何这些寄存器,除非首先自己写一个值,所以逆向工程工具会认为前8个参数没有被使用。此外,由于Dart的自定义堆栈,真正的参数不会被推到系统堆栈上,因此它们不会被逆向工程工具检测为函数参数。

例如,在上面的反编译代码图片中,你可以看到IDA Pro认为没有一个函数有参数。

需要记住的一件事是,由于Dart SDK是开源的,所有这些信息都是公开的。例如,所有的特殊寄存器值和用途都可以在这里找到。此外,在试图获得第二和第三个问题的更多信息时,我们在Dart SDK的GitHub repo上发现了一个开放的问题,它正在推动采用标准的ARM64 ABI和使用SP而不是X15。如果这些变化得以实施,它们将直接解决所发现的第二和第三个问题。

实验Dart代码清理

现在我们明白了是什么让反编译的Dart代码看起来很奇怪并且难以处理,让我们来研究一下这些是否是语言固有的基本问题,或者说是现有工具的症状。通过一些实验,我们希望了解自动和持续地克服所发现的问题的可行性。这些结果应该给我们一个指示和粗略的时间表,说明未来工具的能力和对Flutter应用程序的安全的影响。

我们将解决这3个特点中的每一个,并具体地尝试实现以下各自的目标。

  1. 增加Dart对象和Dart代码之间的交叉引用,并使其在反编译的代码中可见。
  2. 确保Dart VM自定义堆栈被逆向工程工具视为常规堆栈。
  3. 修正逆向工程工具中Dart函数的ABI,使函数参数被正确识别。

为Dart对象添加交叉引用

Dart快照包含序列化的Dart对象,而Dart代码使用反序列化的Dart对象。此外,Dart代码并不直接访问反序列化的Dart对象,它使用Dart对象池,通过其对象池索引间接访问它们。因此,重新引入交叉引用将是一个多步骤的过程。

  1. 获取反序列化的Dart对象。

  2. 将这些对象添加到逆向工程数据库中。

  3. 为Dart对象添加交叉引用。

  4. 检测Dart代码在哪里访问Dart对象库

  5. 找出哪个Dart对象被访问

  6. 在Dart汇编代码和相应的反序列化的Dart对象之间添加一个交叉引用。

  7. 使使用的Dart对象在反编译的代码中可见。

恢复反序列化的Dart对象

正如我们在之前的文章中所解释的,可以使用解析器从Dart快照中静态恢复序列化的Dart对象。因此,可以直接扩展解析器,让它也能反序列化这些对象。由于反序列化代码对每个人都可用,这里的主要挑战是支持多个版本的Dart SDK,因为Dart快照格式经常变化。

与其这样做,不如让Flutter运行时解析并反序列化所有的Dart对象,然后从内存中转储反序列化的对象。 只要你能做动态分析,使用这种方法是相当简单的。例如,可以用一个简单的Frida脚本来完成,钩住一个Dart函数,读取Dart对象池指针(即读取X27的值),并转储Flutter堆。这个Frida脚本看起来是这样的(完整版本这里)。

var FLUTTER_MEM_START = 0x7200000000
var FLUTTER_MEM_END = 0x7300000000
var FLUTTER_MEM_MASK = 0xff00000000
var SHARED_PREF_GET_INSTANCE_OFFSET = 0x6D4F88
var APP_DATA_DIR = "/data/data/fr.carameldunes.nyanyarocket/"

function hook_libapp() {
   var base_address = Module.findBaseAddress("libapp.so");
   console.log(`Hooking libapp: ${base_address} `);
   Interceptor.attach(base_address.add(SHARED_PREF_GET_INSTANCE_OFFSET), {
       onEnter: function (args) {
           console.log(`Calling SharedPreferences::getInstance()`);
           console.log(` Object Pool pointer (X27): ${this.context.x27}`)
           dump_memory(FLUTTER_MEM_START, FLUTTER_MEM_END, APP_DATA_DIR)
       }
   });
}

当该脚本在NyaNyaRocket上使用时,它成功地将包含反序列化的Dart对象的内存转储到磁盘。

➜  obfu frida -U -f fr.carameldunes.nyanyarocket -l dump_flutter_memory.js --no-pause
…
Spawned `fr.carameldunes.nyanyarocket`. Resuming main thread!           
[Pixel 6::fr.carameldunes.nyanyarocket ]-> Hooking libapp: 0x73428a4000 
Calling SharedPreferences::getInstance() 
 Object Pool pointer (X27): 0x7200600040
Dumping memory into /data/data/fr.carameldunes.nyanyarocket/0x7200000000
Dumping memory into /data/data/fr.carameldunes.nyanyarocket/0x7200080000

然后,这个内存转储可以导入IDA,使用这个脚本可以访问所有反序列化的Dart对象。

image.png

注意,一些反序列化的Dart对象包括指向libapp.so的指针。因此,最好重新确定libapp.so的基址,使其与内存被转储时使用的基址一致(在Frida的输出中显示)。这样做将使IDA能够自动识别这些指针。

创建Dart对象

在这一点上,所有的Dart对象都被映射到IDA的数据库中。然而,它们仍然被认为是原始数据。

image.png

为了更好地解析和理解这些Dart对象,我们需要对它们进行反序列化。我们决定通过在IDA中为它们创建结构来自动完成这项任务。因此,我们查看了Dart SDK的源代码,它执行了Dart对象的反序列化,以找到所有对象的共同特征。

每个Dart对象开始都有一个4字节的标签,包含它的类IDcid

struct DartObjectTag
{
 char is_canonical_and_gc;
 char size_tag;
 __int16 cid;
};

Dart对象池本身就是一个Dart对象,因此它以一个DartObjectTag开始,后面是池子里的Dart对象的数量(在偏移量8),最后是一个指向Dart对象的指针阵列。

struct DartObjectPool
{
 DartObjectTag tag; 
 
 __int64 nb_dart_objects_in_object_pool;
 DartObject *object_pool_array[];
};

你也可以观察到,存储在Dart对象池阵列中的地址是奇数。这与Dart指针的标记有关。因此,这个值必须解开标签(即从中减去1)才能找到Dart对象的实际地址。这项技术是用来避免为小整数创建Dart对象(又称装箱),通过使用其LSB将其标记为指针。 

所有这些信息可以用来在IDA中创建自定义结构,并将所有Dart对象反序列化到这些结构中。通过使用之前Frida脚本恢复的Dart对象池的地址(object_pool_ptr),我们可以做到以下几点。

  • 获取对象池中的对象数量(nb_dart_objects_in_object_pool)。

  • 对于object_pool_array中的每个项目。

    • 如果它是一个奇数,那么它是一个指向Dart对象的指针,我们首先取消它的标签,之后我们可以解析它,得到它的类ID,并将它反序列化到相应的结构中。
    • 否则,我们就忽略它,因为它是一个小的整数,不是一个Dart对象。

作为第一步,我们决定为每个类的ID创建一个结构,每个结构都有相同的通用Dart对象内容,还没有包含任何类的特定字段。

// Will be used to deserialize Dart object with class ID 45
struct DartUnkObj45
{
 char is_canonical_and_gc;
 char size_tag;
 __int16 cid;
 <int padding>
 <unknown class specific data>
};
 
// Will be used to deserialize Dart object with class ID 11
struct DartUnkObj11
{
 char is_canonical_and_gc;
 char size_tag;
 __int16 cid;
 <int padding>
 <unknown class specific data>
};
 
// Will be used to deserialize Dart object with class ID 85
struct DartUnkObj85
{
 char is_canonical_and_gc;
 char size_tag;
 __int16 cid;
 <int padding>
 <unknown class specific data>
};

当我们解析一个Dart对象时,我们使用与它的类ID相关的结构对它进行反序列化。因为这仍然只是一个通用结构,反序列化将是不完整的。然而,它仍然是有用的。

  • 每个结构都可以在以后的IDA中进行扩展,IDA会自动将扩展应用于与之相关的所有Dart对象。
  • IDA Pro有一个功能,允许它列出所有特定类型的对象。因此,已经可以快速找到指定类ID的所有Dart对象。

由于我们对Dart字符串对象最感兴趣,我们决定为Dart字符串添加完整的结构,这意味着我们用DartUnkObj85结构来代替。

struct DartString
{
 char is_canonical_and_gc;
 char size_tag;
 __int16 cid;
 <int padding>
 __int64 s_len;
 char s[];
};

运行这个脚本后,很多新的信息被添加到IDA数据库中。

image.png

正如你所看到的,所有被Dart对象池引用的Dart对象都被创建了(尽可能使用有意义的名字),一些方便的功能,如获得某类ID的所有对象的列表,已经可以使用

在汇编代码中添加Dart对象交叉引用

现在所有的Dart对象都已创建,下一步是将Dart对象与Dart代码联系起来。

如前所述,对Dart对象的所有访问都是通过Dart对象池使用间接方式进行的。用于此的三种ARM64汇编模式如下所示(请记住,指向Dart对象池的指针存储在X27寄存器中)。

image.png

  • 注意,我们需要从地址中减去16来计算对象索引,因为X27指向Dart对象池的开始,但是object_pool_array从偏移量16开始。
  • 这些模式可以在Dart SDK源代码中找到。

基于这一观察,添加交叉引用的策略是简单明了的。

  • 在所有Dart代码中搜索这些模式

  • 每次发现其中一个模式时。 

  • 计算找到的模式中使用的X27的相对偏移量

  • 使用之前Frida脚本恢复的Dart对象池的地址来计算指向被引用Dart对象的指针的地址。

  • 在模式地址和Dart对象地址之间添加一个交叉引用。

找到这些模式并为Dart对象添加交叉引用的完整脚本可以在这里找到。 

运行该脚本后,在IDA中添加了交叉引用,突然间发现哪个Dart函数在使用哪个Dart对象就变得非常容易。例如,我们现在可以通过IDA内置的交叉引用搜索找到所有使用gameDataDart字符串的函数。

image.png

然而,交叉引用只被添加到汇编中,它不会被添加到反编译的代码中,相反,我们仍然看到对象池访问,而不是直接引用Dart对象。

解决反编译代码中对象池访问的问题

Dart对象还没有出现在反编译的代码中的原因是,IDA Pro在反编译函数时不知道X27寄存器的初始值,因此它不能解决指示问题。

然而,这可以通过编写一个反编译插件轻松解决(正如Rolf Rolles on StackExchange所指出的)。这个想法很简单:每次IDA在反编译过程中看到X27寄存器时,该插件就用Dart对象池的地址(由Frida脚本恢复)来替换它。相关的微码插件可以在这里找到。 

在下面的图片中,你可以看到对反编译的代码的影响。在左边,v5变量被用来间接访问对象池中的Dart对象。在右侧,该插件被启用,所有的间接访问都被对Dart对象的直接引用所取代,这大大简化了该函数的逆向工程。

image.png

处理Dart VM栈

现在三个问题中的第一个已经解决了,我们可以研究第二个问题:Dart VM使用的自定义堆栈。

作为提醒,在ARM64上,Dart代码不使用标准的ARM64系统堆栈,它使用由Dart VM管理的独立堆栈,并且X15寄存器被用作Dart VM的堆栈指针。因此,当一个函数想要调用另一个函数时,它将参数推送到Dart VM堆栈并相应地更新X15。同样,一个函数的所有局部变量都存储在Dart VM堆栈中,并通过X15或框架指针(X29)访问。

这对IDA和其他反编译器用来分析和反编译代码的启发式方法有很大影响。具体来说,IDA无法识别任何函数参数和局部变量。

对堆栈访问进行修补

为了自动处理Dart VM的堆栈,我们最初尝试了几种方法,比如。

  • 使用IDA中的__usercallABI功能,它可以让我们定义自定义调用约定。
  • 为Dart VM栈创建一个自定义结构。

但是这些方法都没有带来满意的结果。

我们决定尝试一个更基本的方法:找到所有使用Dart VM堆栈指针寄存器X15的指令,并给它们打上补丁,使它们改用SP寄存器(脚本可以在这里找到)。 

一旦程序在打完补丁后被重新分析,反编译后的代码看起来更干净,IDA能够(几乎)自动检测到函数参数和局部变量。

image.png

注意,在一些角落里,这个补丁脚本可能会产生不正确的代码,例如在处理Dart和本地代码的边界时。然而,它在我们的实验中运行良好,并且一般不会对应用程序代码的逆向工程产生重大影响。

处理Dart的自定义函数ABI

在修补了堆栈指针后,Dart堆栈被IDA认为是常规堆栈。但正如你在前面的图片上看到的,现在的函数有太多的参数。这与第三个问题有关。Dart的自定义ABI有它自己的调用惯例。

在最初的分析中,IDA Pro使用了标准的ARM64 ABI,它假定函数参数在推入堆栈之前被存储到寄存器X0-X7。因此,IDA Pro检测到(真正的)参数被推入堆栈,但它也仍然认为X0-X7中存在(错误的)参数。

当涉及到Dart代码时,默认情况下,所有参数都被推到Dart VM堆栈上,但由于我们的补丁,它们现在都被推到了普通堆栈上。因此,我们必须通过指定一个自定义的调用约定来解决这个问题,指定参数只在堆栈上,而不是在通常的寄存器中。在IDA中,这可以用__usercall关键字来完成(关于如何使用它的更多信息,请看这个帖子)。例如,"handlePublishTapped "函数的结果是这样的。

void __usercall LocalPuzzles___handlePublishTapped(void *context@<^16.8>, void 
*uuid@<^8.8>, void *user@<^0.8>)xs

应用正确的调用约定后,函数参数被正确识别。

image.png

回到经典的本地反向工程

在经历了所有这些步骤之后,我们最终回到了经典的本地代码逆向工程的场景。逆向工程师现在可以按照通常的方式工作,专注于理解应用代码。

让我们看看下面这个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

正如你所看到的,我们可以在产生的反编译代码中识别原始Dart代码的所有部分。但是反编译后的代码要比Dart源代码大得多,包含的操作也多。主要原因是Dart代码抽象了很多细节,而在反编译的代码中,一切都很明确:

  • 我们可以看到本地Dart对象的分配(例如:SnackBar,Text)。
  • 同样,调用_verifyAndPublish的闭包是一个单独的函数。我们还可以看到它本身是分配在一个ClosureStub对象中的。
  • 我们可以看到loginPromptText的解析,以及根据语言为英语、法语或德语的三种可能值。

结论

在这篇文章中,我们深入研究了编译后的Dart代码的一些特点。我们调查了Dart代码和对象之间没有交叉引用的原因,并证明可以通过分析Dart对象库来重新引入这些交叉引用。我们还探讨了自定义Dart虚拟机栈对逆向工程工具的影响,并展示了如何通过在工具中考虑到自定义的调用惯例来轻松解决这个问题。在这个分析的最后,我们最终得到了反编译的Dart代码,它可以以几乎与本地代码相同的方式进行逆向工程。 

根据我们的结果,似乎加强逆向工程工具,使其能够正确分析和反编译Dart代码并不是一项不可逾越的任务,我们可能很快就会看到新的功能或插件来帮助逆向工程Flutter应用程序。 本博文中介绍的所有实验都是自动化的,所以所有这些转换都可以在几分钟内完成,同时逆向工程任何其他Flutter应用程序。此外,Dart SDK的变化应该对所采取的方法影响有限,因为它只依赖Dart的内部结构来正确解析Dart字符串。 

也就是说,所提供的代码应被视为一个概念证明,而不是Flutter逆向工程解决方案。它只提供了一组有限的功能,而且有很多角落的情况下它不会像预期的那样工作。

在下一篇文章中,我们将专注于动态分析,探讨如何利用篡改和挂钩在Flutter游戏中作弊。

注意事项

本博文中对演示的一些评论。

  • 我们使用ARM64作为调查的基础,因为这是移动平台中最常见的架构,但在所有的架构上都可以实现类似的结果。
  • 我们使用IDA Pro进行本博客中的实验,用于复制我们的结果的材料已被提供。所有讨论的事情都可以用其他逆向工程工具来完成。

在这篇文章中,我们使用了真实的函数名称,以方便阅读。请记住,当在一个被混淆的构建上执行逆向工程时,你只能检索框架的函数名。如果你想拥有与本帖所示相同的函数名,你可以使用上一篇文章中的script,根据debug symbols添加类和函数名。


www.deepl.com 翻译