第15章:动态框架

如果你开发过任何类型的苹果GUI软件,你肯定会在日常开发中使用动态框架。

动态框架是在运行时加载到可执行文件中的一捆代码,而不是在编译时。iOS中的例子包括UIKit和Foundation框架。诸如此类的框架包含一个动态库和可选的资产,如图片。

选择使用动态框架而不是静态框架有很多好处。最明显的优势是你可以对框架进行更新,而不必重新编译依赖该框架的可执行文件。

想象一下,如果在iOS的每个主要或次要版本中,苹果都说:"嘿,你们,我们需要更新UIKit,所以如果你们能继续更新你们的应用程序,那就太好了。" 这样一来,街上就会血流成河,唯一的竞争就是安卓与Windows Phone的竞争了。

为什么是动态框架?

除了使用动态框架的积极因素外,内核还可以将动态框架映射到依赖该框架的多个进程。以CFNetwork为例:如果每个运行中的iOS应用都在内存中保留一个独特的CFNetwork副本,那将是很愚蠢的,而且会浪费磁盘空间。此外,每个应用程序都可能有不同的CFNetwork编译版本,这使得追踪bug变得异常困难。

从iOS 8开始,苹果决定取消动态库的限制,允许第三方动态库包含在你的应用程序中。最明显的优势是,开发者可以在不同的iOS扩展中共享框架,如今日扩展和动作扩展。

今天,所有的苹果平台都允许包含第三方动态框架,而不会在可爱的苹果审查过程中遭到拒绝。

随着动态框架的出现,在学习、调试和逆向工程中出现了一个非常有趣的方面。由于你有能力在运行时加载框架,你可以使用LLDB在运行时探索和执行代码,这对于在公共和私有框架中进行探索是非常好的。

静态检查一个可执行文件的框架

编入每个可执行文件的是一个动态库(最常见的是框架)的列表,预计将在运行时加载。这可以进一步细分为必要的框架列表和可选的框架列表。将这些动态库加载到内存中是通过一个特殊的框架完成的,这个框架被称为动态加载器,或称dyld。

如果一个必需的框架无法加载,动态库加载器将杀死该程序。如果一个可选的框架加载失败,一切照常进行,但该库的代码显然无法运行

你可能在过去使用过可选框架功能,也许是当你的iOS或Mac应用需要使用比你的应用所针对的版本更新的操作系统版本中添加的库的代码时。在这种情况下,你会围绕对可选库中的代码的调用进行运行时检查,以检查该库是否被加载。

我说了很多这样的理论,但如果你自己去看,会更有意义。

打开Xcode并创建一个新的iOS项目,单视图应用程序名为DeleteMe。是的,这个项目不会存在很久,所以一旦你完成了这一章的内容,就可以随意删除它。

你不会在应用程序中写一行代码(但在加载命令中则是另一回事)。确保你选择Objective-C然后点击下一步。

注意:你使用Objective-C是因为在Swift应用程序中,有更多的事情要做。在写这篇文章的时候,Swift ABI还没有最终确定,所以Swift用来连接Objective-C的每个方法都使用了打包到你的应用程序的动态框架来 "跳过 "Objective-C。这意味着在Swift的桥接框架中,有相应的依赖关系到适当的Objective-C框架。例如,libswiftUIKit.dylib将有一个对UIKit框架的必要依赖。

点击项目导航器顶部的Xcode项目。然后点击DeleteMe目标。接下来,点击Build Phases并打开Link Binary With Libraries。

添加CoreBluetooth和CallKit框架。在CallKit框架的右边,从下拉菜单中选择Optional。确保CoreBluetooth框架设置了Required值,如下图所示。

使用Cmd + B在模拟器上建立项目,先不要运行。一旦项目在模拟器上成功建立,在Xcode项目导航器中打开产品目录。

在制作好的可执行文件DeleteMe上点击右键,选择Show in Finder。

接下来,打开DeleteMe IPA,右击IPA并选择显示软件包内容。

接下来,打开一个新的终端窗口,输入以下内容,但不要按回车键。

otool -L

确保在命令的末尾添加一个空格。接下来,将DeleteMe的可执行文件从Finder窗口拖到终端窗口。完成后,你应该有一个与下面类似的命令。

按回车键并观察输出。你会看到与下面类似的内容。

你找到了编译好的二进制文件DeleteMe,并使用一直以来非常棒的otool甩出了它链接的动态框架列表。注意到对CallKit和你之前手动添加的CoreBluetooth框架的说明。默认情况下,编译器会自动将 "基本 "框架添加到iOS应用中,如UIKit和Foundation。

请注意负责加载这些框架的目录路径。

/System/Library/Frameworks/ 
/usr/lib/

记住这些目录;你会在稍后的 "灵光一现 "时刻重新审视它们。

让我们再深入一点。还记得你是如何选择性地要求CallKit框架,以及要求CoreBluetooth框架的吗?你可以通过使用otool来查看这些决定的结果。

在终端中,按向上的箭头来调用之前的终端命令。接下来,将大写的L改为小写的l,然后按回车键。你会得到一个较长的输出列表,显示DeleteMe可执行文件的所有加载命令。

通过按Cmd + F并输入CallKit来搜索与CallKit有关的加载命令。你会偶然发现一个与下面类似的加载命令。

接下来,也搜索一下CoreBluetooth框架。

比较加载命令输出中的cmd。在CallKit中,加载命令是LC_LOAD_WEAK_DYLIB,它代表一个可选的框架,而CoreBluetooth加载命令的LC_LOAD_DYLIB则表示一个必需的框架。

这对于一个支持多个iOS版本的应用来说是非常理想的。例如,如果你支持iOS 9及以上版本,你将强链接CoreBluetooth框架,弱链接CallKit框架,因为它只在iOS 10及以上版本中可用。

修改加载命令

有一个很好的小命令,可以让你增强和增加框架的加载命令,名为install_name_tool。

打开Xcode并构建和运行应用程序,这样模拟器就会运行DeleteMe。一旦运行,暂停执行,在LLDB终端,验证CallKit框架是否加载到DeleteMe地址空间。暂停调试器,然后在LLDB中键入以下内容。

(lldb) image list CallKit

如果CallKit模块被正确加载到进程空间,你会得到类似于以下的输出。

是时候找出DeleteMe应用程序的运行位置了。打开一个新的终端窗口,输入以下内容。

pgrep -fl DeleteMe

如果DeleteMe正在运行,这将给你提供DeleteMe在模拟器应用程序下的完整路径。你会得到类似于以下的输出。

你现在要修改这个可执行文件的加载命令以指向不同的框架。

抓住DeleteMe可执行文件的全路径,并将其分配给一个名为app的终端变量,像这样。

当你在做的时候,把CK和NC终端变量也分配给各自的框架路径,像这样。

停止DeleteMe可执行文件的执行,暂时关闭Xcode。如果你不小心在以后的时间里通过Xcode构建并运行DeleteMe应用程序,它将撤销你即将进行的任何调整。

在同一个终端窗口中,使用install_name_tool命令,以及三个新创建的终端变量,来改变CallKit的加载命令以调用NotificationCenter框架。

如果这是一个真正的iOS设备上的应用程序,这实际上将无法运行,因为这是使应用程序的代码签名无效。你对应用程序进行了修改,却没有辞退它,这就破坏了加密的密封性。幸运的是,这是一个iOS模拟器应用程序,所以规则没有那么严格。你将在本节的最后一章中进一步探讨代码签名。

验证你的修改是否被实际应用。

otool -L "$app"

如果一切都很顺利,你会注意到现在链接的框架有些不同。

验证这些变化在运行时是否存在。

你在这里遇到了一点困境。如果你使用Xcode构建并运行一个新版本的DeleteMe,它将抹去这些变化。相反,通过模拟器启动DeleteMe应用程序,然后在一个新的LLDB终端窗口中附加到它。要做到这一点,在模拟器中启动DeleteMe。接下来,在终端中键入以下内容。

lldb -n DeleteMe

在LLDB中,检查CallKit框架是否仍然被加载。

(lldb) image list CallKit

你会得到一个错误的输出。

error: no modules found that match 'CallKit'

你能猜到你接下来要做什么吗?是的! 验证NotificationCenter框架现在已经加载。

(lldb) image list NotificationCenter

轰!轰!轰 你会得到类似以下的输出。

在已经编译好的二进制文件中改变框架(或添加框架!)是很酷的,但这需要一点工作来设置。幸运的是,LLDB对于在运行时将框架加载到进程中非常棒,这就是你接下来要做的。保持LLDB终端会话的活力,因为你将了解到一种更简单的加载框架的方法。

在运行时加载框架

在你进入学习如何在运行时加载和探索命令的乐趣之前,让我给你一个命令来帮助使用LLDB探索目录。首先,在你的~/.lldbinit文件中加入以下内容。

command regex ls 's/(.+)/po @import Foundation; [[NSFileManager defaultManager] contentsOfDirectoryAtPath:@"%1" error:nil]/'

这将创建一个名为ls的命令,它将接受你给它的目录路径并转储出内容。这个命令将在被调试的设备的目录下工作。例如,由于你是在你的计算机本地驱动器上的模拟器上运行,它将转储该目录。如果你在连接的iOS、tvOS或其他appleOS设备上运行这个命令,它将转储你给它的那个设备的目录,有一个小的注意事项,你很快就会知道。

由于LLDB已经运行并连接到DeleteMe,你需要手动将这个命令加载到LLDB中,因为LLDB已经读取了~/.lldbinit `文件。在你的LLDB会话中输入以下内容。

(lldb) command source ~/.lldbinit

这只是重新加载你的lldbinit文件。

接下来,通过输入以下内容找到模拟器中框架目录的完整路径。

(lldb) image list -d UIKit

这将转储出存放UIKit的目录。

你实际上想再高一级到Frameworks目录。复制那个完整的目录路径,使用你刚刚创建的新命令ls,像这样。

这将转储所有可用的公共框架给模拟器。还有很多框架可以在不同的目录中找到,但你将首先从这里开始。

从框架列表中,像这样将Speech框架加载到DeleteMe进程空间中。

LLDB会给你一些快乐的输出,说Speech框架已经加载到你的进程空间。耶!

这里有更酷的东西。默认情况下,如果找不到框架的位置,dyld会搜索一系列的目录。你不需要指定框架的完整路径,只需要指定框架库和框架的名称。

通过加载MessagesUI框架来试试。

(lldb) process load MessageUI.framework/MessageUI

你会得到以下输出。

Loading "MessageUI.framework/MessageUI"...ok
Image 1 loaded.

很好。

探索框架

逆向工程的基础之一是探索动态框架。由于动态框架需要将二进制文件编译成与位置无关的可执行文件的代码,你仍然可以查询动态框架中的大量信息--即使编译器将框架中的调试符号剥离。二进制文件需要使用与位置无关的代码,因为编译器不知道代码在dyld完成工作后在内存中的确切位置。

掌握应用程序如何与框架互动的扎实知识也能让你深入了解应用程序本身的工作方式。例如,如果一个被剥离的应用程序正在使用UITableView,我会在UIKit的某些方法中设置断点查询,以确定哪些代码负责UITableViewDataSource。

通常,当我探索一个动态框架时,我会简单地将其加载到进程地址空间,并开始运行各种图像查找查询(或我的自定义LLDB查找命令,可在https://github.com/DerekSelander/lldb),看看该模块拥有什么。

从那里,我将执行各种有趣的方法,这些方法看起来很有趣,可以玩一玩。

这里有一个很好的LLDB命令重码,你可能想把它插入你的~/.lldbinit文件。它转储了Objective-C容易访问的类方法(即Singletons),以供探索。

在你的 ~/.lldbinit文件中加入以下内容。

command regex dump_stuff "s/(.+)/image lookup -rn '\+\[\w+(\(\w+\))?\ \w+ \]$' %1 /"

这个命令,dump_stuff,期望一个或多个框架作为输入,并将转储有零参数的Objective-C类方法。这当然不是所有Objective-C命名规则的总和,但这是一个很好的、简单的命令,可以在探索一个框架时用于快速的第一手资料。

把这个命令加载到活动的LLDB会话中,然后用这个框架试一下。

(lldb) command source ~/.lldbinit 
(lldb) dump_stuff CoreBluetooth

你可能会在输出中找到一些有趣的方法来玩...

如果你跳过几章,对图像查找命令有那种无知的表情,请查看第7章,"图像"。你将从该章中发现的私有自省方法中添加一些辅助LLDB命令的词条。

在你的~/.lldbinit文件中也添加以下命令。

command regex ivars 's/(.+)/expression -lobjc -O -- [%1 _ivarDescription]/'

这将转储一个继承的NSObject实例的所有ivars。

command regex methods 's/(.+)/expression -lobjc -O -- [%1 _shortMethodDescription]/'

这将转储所有由继承的NSObject实例实现的方法,或者NSObject的类。

command regex lmethods 's/(.+)/expression -lobjc -O -- [%1 _methodDescription]/'

这将递归地转储由继承的NSObject实现的所有方法,并递归地继续到其超类。

使用这些命令可以很容易地加载、扫描和检查来自不同框架的有趣的类。

例如,你可能会选择检查UIPhotos框架中的类。你可以做以下工作。

(lldb) process load PhotosUI.framework/PhotosUI

从那里,转储没有参数的类方法。

(lldb) dump_stuff PhotosUI

探索PUScrubberSettings类中的方法和参数。

或者,你只是好奇这个类实现了哪些动态方法。

或者通过这个类和超类获得所有可用的方法。

(lldb) lmethods PUScrubberSettings

注意:你只探索了公共框架目录System/Library/Frameworks中的框架。在System/Library开始的其他子目录下还有许多其他有趣的框架可以探索。例如,你会在System/Library/PrivateFrameworks中找到一些娱乐项目。

在实际的iOS设备上加载框架

如果你有一个有效的iOS开发者账户,一个你写的应用程序和一个设备,你可以在设备上做你在模拟器上做的同样事情。唯一的区别是系统/库路径的位置。

如果你在模拟器上运行一个应用程序,公共框架目录将位于以下位置。

但是一些超级观察家可能会说,"等一下,在模拟器上使用otool -L给我们的绝对路径是/System/Library/Frameworks,而不是上面那个大的长路径。这是怎么回事?"

还记得我说过dyld会为这些框架搜索一组特定的目录吗?有一个特殊的模拟器版本,名为dyld_sim,它可以查找正确的模拟器位置。这就是这些框架在实际iOS设备上的正确路径。

因此,如果你在实际的iOS设备上运行,框架的路径将位于。

/System/Library/Frameworks/

但是,等等,我听到一些人说,"那沙盒呢?"

iOS内核对不同的目录位置有不同的限制。在iOS 12和更早的版本中,/System/Library/目录是可以被你的进程读取的!

这是有道理的,因为你的进程需要从进程的地址空间内调用相应的公共和私有框架。

如果沙盒限制对这些目录的读取,那么应用程序将无法将它们加载进来,然后应用程序将无法启动。

你可以通过让Xcode启动和运行并连接到你的任何一个iOS应用程序来尝试这一点。当LLDB连接到一个iOS设备时,尝试在根目录上运行ls。

(lldb) ls /

现在试试/System/Library/目录。

(lldb) ls /System/Library/

有些目录会加载失败。这是内核在说 "不!" 然而,一些目录可以被转储。

你有能力查看实时框架,并在你的应用程序中动态加载它们,这样你就可以玩弄和探索它们。有一些有趣而强大的框架隐藏在/System/Library子目录中,供你在iOS、tvOS或watchOS设备上探索。

接下来去哪里?

那个/System/Library目录真的很了不起。你可以花很多时间来探索该子目录中的不同内容。如果你有一个iOS设备,去探索它吧

在本章中,你学到了如何通过LLDB加载和执行框架。然而,你在确定如何在代码中使用动态加载的私有框架进行开发时,却有些力不从心。

在接下来的两章中,你将探索在运行时通过代码加载框架,使用Objective-C的方法swizzling,以及函数interposition,这是一种更加Swifty风格的策略,用于在运行时改变方法。

如果你要拉入一个私有框架,这就特别有用。我认为这是苹果软件逆向工程中最令人兴奋的事情之一。


上一章 目录 下一章