第16章:用dlopen和dlsym挂接和执行代码

使用LLDB,你已经看到创建断点和检查感兴趣的东西是多么容易。你还看到了如何创建你通常无法访问的类。不幸的是,你在开发时无法挥舞这种力量,因为如果框架或其任何类或方法被标记为私有,你就无法获得公共API。然而,这一切即将改变。

现在是时候学习使用这些框架开发的互补技能了。在这一章中,你将学习一些方法和策略,以 "钩住 "Swift和C代码,以及执行你在开发时通常无法访问的方法。

当你在使用诸如私有框架之类的东西时,想要在自己的应用程序中执行或增强现有的代码时,这是一项关键的技能。要做到这一点,你要调用两个很棒的函数的帮助:dlopen和dlsym。

Objective-C运行时与Swift和C的对比

Objective-C,由于其强大的运行时间,是一种真正的动态语言。即使在编译和运行时,甚至程序也不知道下一个objc_msgSend出现时将会发生什么。

有不同的策略来钩住和执行Objective-C代码;你将在下一章探讨这些策略。本章的重点是如何在Swift下钩住并使用这些框架。

Swift的行为很像C或C++。如果它不需要Objective-C的动态调度,编译器就不需要使用它。这意味着当你在看一个不需要动态调度的Swift方法的汇编时,汇编可以简单地调用包含该方法的地址。这种 "直接 "的函数调用是dlopen和dlsym组合真正发挥作用的地方。这就是你将在本章中学习的内容。

设置你的项目

在本章中,你将使用一个名为Watermark的启动项目,它位于startter文件夹中。

这个项目非常简单。它所做的就是在一个UIImageView中显示一个有水印的图片。

然而,这个水印图像有一些特别的地方。实际显示的图像被隐藏在一个编译在程序中的字节数中。也就是说,图像不是作为一个单独的文件捆绑在应用程序中。相反,该图像实际上位于可执行文件本身。显然,作者并不想把原始图像交给别人,因为他预计人们会对Assets.car文件进行逆向工程,这通常是应用程序中存放图像的一个常见位置。相反,图像的数据被存储在可执行文件的__TEXT部分,当通过App Store发布时,苹果会对其进行加密。如果__TEXT部分听起来很陌生,你将在第18章 "你好,Mach-O "中了解到它。

首先,你将探索与一个常见的C函数挂钩。一旦你掌握了这些概念,你将执行一个私有的Swift方法,由于Swift编译器的存在,你在开发时无法使用这个方法。使用dlopen和dlsym,你将能够在框架内调用和执行这个私有方法,而不需要修改框架的代码。

现在,你已经得到了比你在介绍中想要的更多的理论,终于到了开始的时候了。

简易模式:挂接C函数

在学习如何使用dlopen和dlsym函数时,你会去找getenv C函数。这个简单的C函数接受一个char *(空尾字符串)作为输入,并返回你提供的参数的环境变量。

当你的可执行程序启动时,这个函数实际上被经常调用。

在Xcode中打开并启动Watermark项目。创建一个新的符号断点,把getenv放在符号部分。接下来,添加一个自定义动作,内容如下。

po (char *)$rdi

现在,确保在断点出现后自动继续执行。

最后,在iPhone XS模拟器上构建并运行该应用程序,然后观察控制台。你会得到一连串的输出,表明这个方法被频繁调用。

它看起来会类似于以下内容。

"DYLD_INSERT_LIBRARIES" 
"NSZombiesEnabled" 
"OBJC_DEBUG_POOL_ALLOCATION" 
"MallocStackLogging" 
"MallocStackLoggingNoCompact" 
"OBJC_DEBUG_MISSING_POOLS" 
"LIBDISPATCH_DEBUG_QUEUE_INVERSIONS" 
"LIBDISPATCH_CONTINUATION_ALLOCATOR" 
... etc ...

注意:一个更优雅的方法是使用DYLD_PRINT_ENV来转储你的应用程序可用的所有环境变量。要设置这个,请进入Product/Manage Scheme,然后在环境变量部分添加这个。你可以简单地添加名称DYLD_PRINT_ENV,不加值,在运行时转出所有环境变量。

然而,需要注意的一点是,所有这些对getenv的调用都是在你的可执行程序启动之前发生的。你可以通过在getenv上设置断点并查看堆栈跟踪来验证这一点。请注意,main并没有出现。这意味着在你的代码能够被执行之前,你无法改变这些函数调用。

因为C语言不使用动态调度,所以钩住一个函数需要你在函数被加载之前拦截它。从好的方面来说,C语言的函数相对来说很容易抓取。你所需要的只是不带任何参数的C语言函数的名称,以及实现C语言函数的动态框架的名称。

然而,由于C语言是无所不能的,而且几乎到处都在使用,所以你可以探索不同复杂程度的战术来钩住一个C函数。如果你想在你自己的可执行文件中钩住一个C函数,这并不是一件很困难的事情。然而,如果你想在你的代码(主可执行文件或框架)被dyld加载之前钩住一个被调用的函数,那么复杂程度无疑会上升一个档次。

当你的可执行文件执行main时,它已经导入了所有在加载命令中指定的动态框架,正如你在前一章学到的那样。动态链接器将以一种深度优先的方式递归加载框架。如果你要调用一个外部框架,它可以被懒惰地加载或在模块加载时被dyld立即加载。通常情况下,除非你指定特殊的链接器标志,否则大多数外部函数都是懒惰加载的。

对于懒惰加载的函数,在第一次调用该函数时,dyld会在查找负责该函数的模块和位置时发生一系列活动。这个值会被放入内存中的一个特定区域(__DATA.__la_symbol_ptr,但我们稍后会讨论这个问题)。一旦外部函数被解析,今后对该函数的所有调用将不需要由dyld来解析。

这意味着如果你想在应用程序启动前就把函数钩住,你需要创建一个动态框架,把钩住的逻辑放进去,以便在主函数被调用前就能使用。你将在自己的可执行文件中探索这个简单的钩住C函数的案例。

回到 "Watermarks"项目!

打开AppDelegate.swift,并替换为

这创建了一个对getenv的调用,以获得HOME环境变量。

接下来,移除你之前创建的符号getenv断点,并构建和运行该应用程序。

控制台的输出将类似于以下内容。

这是为你所运行的模拟器设置的HOME环境变量。

假设你想钩住getenv函数,使其完全正常运行,但当且仅当HOME为参数时,返回与上述输出不同的东西。

如前所述,你需要创建一个Watermark可执行文件依赖的框架,以抓取getenv的地址,并在主可执行文件中解决之前改变它。

在Xcode中,导航到File ▸ New ▸ Target并选择Cocoa Touch Framework。选择HookingC作为产品名称,并设置语言为Objective-C。

一旦这个新的框架被创建,创建一个新的C文件。在Xcode中,选择File\NewFile,然后选择C文件。将这个文件命名为getenvhook。取消勾选 "同时创建一个头文件 "的复选框。将该文件与项目的其他部分一起保存。

请确保这个文件属于你刚刚创建的HookingC框架,而不是Watermark。

好了......你终于要写一些代码了......。我发誓。

打开getenvhook.c,将其内容替换为以下内容。

#import <dlfcn.h> 
#import <assert.h> 
#import <stdio.h> 
#import <dispatch/dispatch.h> 
#import <string.h>
char * getenv(const char *name) { 
    return "YAY!"; 
}

最后,构建并运行你的应用程序,看看会发生什么。你会得到以下输出。

HOME env: YAY!

棒极了! 你能够成功地用你自己的函数替换这个方法。然而,这并不完全是你想要的。你想调用原来的getenv函数,并在 "HOME "作为输入时增加其返回值。

如果你试图在你的getenv函数中调用原来的getenv函数,会发生什么?试试吧,看看会发生什么。添加一些临时代码,使getenv看起来像下面这样。

char * getenv(const char *name) { 
  return getenv(name); 
  return "YAY!"; 
}

你的程序将......某种程度上......运行,然后最终崩溃。这是因为你刚刚创建了一个堆栈溢出。由于你创建了自己的getenv函数,所有对先前链接的getenv的引用都消失了。

撤销之前的那行代码。这个想法是行不通的。你将需要一个不同的策略来获取原来的getenv函数。

首先,你需要弄清楚哪个库拥有getenv函数。确保那行有问题的代码被删除,并再次构建和运行该应用程序。暂停执行,调出LLDB控制台。一旦控制台弹出,输入以下内容。

(lldb) image lookup -s getenv

你会得到与下面类似的输出。

你会得到两个结果。其中一个将是你自己创建的getenv函数。更重要的是,你会得到你真正关心的getenv函数的位置。看起来这个函数位于libsystem_c.dylib中,它的完整路径是/usr/lib/ system/libsystem_c.dylib。记住,模拟器在这些目录中预置了那么长的路径,但动态链接器很聪明,会在正确的区域进行搜索。iPhoneSimulator.sdk之后的所有内容都是这个框架在真正的iOS设备上实际存储的地方。

现在你知道了这个函数被加载的确切位置,是时候拿出神奇的 "dl "二元组中的第一个,dlopen。它的函数签名看起来像下面这样。

extern void * dlopen(const char * __path, int __mode);

dlopen期望一个char *形式的全路径和第二个参数,它是一个以整数表示的模式,决定dlopen应该如何加载模块。如果成功,dlopen返回一个不透明的句柄(一个void *),如果失败则返回NULL。

在dlopen(希望)返回一个模块的引用后,你将使用dlsym来获得getenv函数的引用。dlsym的函数签名如下。

extern void * dlsym(void * __handle, const char * __symbol);

dlsym希望把dlopen生成的引用作为第一个参数,把函数的名称作为第二个参数。如果一切顺利,dlsym将返回第二个参数中指定的符号的函数地址,如果失败则返回NULL。

用下面的方法替换你的getenv函数。

你用dlopen的RTLD_NOW模式说,"嘿,不要等待或做任何可爱的懒惰加载的东西。现在就打开这个模块"。在通过C语言断言确保句柄不是NULL之后,你调用dlsym来获得 "真正的 "getenv的句柄。

构建并运行该应用程序。你会得到类似于以下的输出。

Real getenv: 0x10d2451c4 
Fake getenv: 0x10a8f7de0 
2016-12-19 16:51:30.650 Watermark[1035:19708] HOME env: YAY!

你的函数指针会和我的输出不同,但要注意真假getenv之间的地址差异。

你已经开始知道你将如何去做了。然而,你需要先对上述代码做一些修饰。例如,你可以将函数指针投到你期望使用的确切的函数类型。现在,real_getenv的函数指针是void *,意味着它可以是任何东西。你已经知道getenv的函数签名,所以你可以简单地把它投给它。

最后一次将你的getenv函数替换为以下内容。

你可能不习惯这么多的C语言代码,所以让我们把它分解一下。

  1. 这将创建一个名为handle的静态变量。它是静态的,所以这个变量将在函数的范围内生存。也就是说,这个变量在函数退出时不会被清除,但你只能在getenv函数中访问它。

  2. 你在这里做了同样的事情,你将real_getenv变量声明为静态变量,但是你对real_getenv函数指针做了其他的改变。你对这个函数指针进行了转换,以正确匹配getenv的签名。这将允许你通过real_getenv变量调用真正的getenv函数。很酷,对吗?

  3. 你使用了GCD的dispatch_once,因为你真的只需要调用一次setup。这很好地补充了你在上面几行中声明的静态变量。你不希望每次运行增强的getenv时都要进行查找逻辑。

  4. 你正在使用C的strcmp来查看你是否在查询 "HOME "环境变量。如果是真的,你就简单地返回"/WOOT",以表明你可以改变这个值。本质上,你正在覆盖getenv函数的返回值。

  5. 5.如果 "HOME "没有作为一个输入参数提供,那么就回到默认的getenv。

打开AppDelegate.swift,并替换application(_:didFinishLaunchingWithOptions:)改为以下内容。

构建并运行该应用程序。如果一切顺利的话,你会得到类似于以下的输出。

虽然很烦人,但这一点还是值得再次强调的。如果你调用了一个UIKit方法,而UIKit调用了getenv,你的增强的getenv函数在这段时间内不会被调用,因为当UIKit的代码加载时,getenv的地址已经被解决了。

为了改变UIKit对getenv的调用,你需要了解间接符号表并修改存储在UIKit模块的__DATA.__la_symbol_ptr部分的getenv地址。这一点你会在后面的章节中了解到。

困难模式:钩住 Swift 方法

追踪非动态的 Swift 代码很像追踪 C 函数。然而,这种方法有一些复杂的地方,使得钩住Swift方法变得有点困难。

首先,Swift在典型开发中经常使用类或结构。这是一个独特的挑战,因为dlsym只会给你一个C函数。你需要增强这个函数,如果你要抓取一个实例方法,那么Swift方法可以引用自我,如果你要调用一个类方法,则可以引用类。当访问一个属于类的方法时,汇编在执行方法时通常会引用self或类的偏移量。由于dlysm会抓取你的C型函数,你需要创造性地利用你对汇编、参数和寄存器的知识,将该C型函数变成Swift方法。

你需要担心的第二个问题是,Swift会把方法的名字弄混。你在代码中看到的快乐、漂亮的名字,在模块的符号表中其实是一个可怕的长名字。你需要找到这个方法的正确混合名称,以便通过dlysm引用Swift方法。

如你所知,这个项目产生并显示了一个水印的图像。下面是对你的挑战:只用代码,在UIImageView中显示原始图像。你不允许自己使用LLDB来执行命令,也不允许在程序运行后修改内存中的任何内容。

你准备好迎接这个挑战了吗?别担心,我将向你展示如何做!

首先,打开AppDelegate.swift,删除在application(_:didFinishLaunchingWithOptions:)里面发现的所有打印逻辑。接下来,打开CopyrightImageGenerator.swift。 正如你所看到的,你挂钩的getenv增强了HOME环境变量,但默认为PATH的正常getenv。

在这个类中,有一个包含原始图像的私有计算属性。此外,还有一个包含watermarkedImage的公共计算属性。正是这个方法调用了originalImage并叠加了水印。这就需要你想出一个方法来调用这个OriginalImage方法,而不需要改变HookingSwift动态库。

打开ViewController.swift,在viewDidLoad()的末尾添加以下代码。

if let handle = dlopen("", RTLD_NOW) {}

这次你使用的是Swift,但你将使用你之前看到的同样的dlopen & dlsym技巧。你现在需要获得HookingSwift框架的正确位置。dlopen的好处是你可以提供相对路径而不是绝对路径。

是时候找出框架相对于Watermark可执行文件的位置了。

在Xcode中,确保项目导航器是可见的(通过Cmd + 1)。接下来,打开Products目录,右键点击Watermark.app。接下来,选择在Finder中显示。

一旦Finder窗口弹出,右击Watermark包,选择显示包的内容。

实际的Watermark可执行文件就在这个目录中,所以你只需要找到HookingSwift框架的可执行文件相对于这个Watermark可执行文件的位置。

接下来,选择Frameworks目录。最后选择HookingSwift.framework。在这个目录中,你会遇到HookingSwift的二进制文件。

这意味着你已经找到了你可以提供给dlopen的相对路径。修改你刚刚添加的dlopen函数调用,使其看起来像下面这样。

if let handle = dlopen("./Frameworks/HookingSwift.framework/ HookingSwift", RTLD_NOW) { 
}

现在到了困难的部分。你想在CopyrightImageGenerator类中抓取负责originalImage属性的方法的名称。现在,你知道你可以使用图像查询LLDB函数来搜索编译到可执行文件中的方法名称。

由于你知道originalImage是在Swift中实现的,所以用图像查找命令进行 "Swift风格 "的搜索。确保应用程序正在运行,然后在LLDB中键入以下内容。

(lldb) image lookup -rn HookingSwift.*originalImage

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

在输出中,搜索包含Address的那一行。HookingSwift[0x0000000000001550]. 这就是这个方法在

HookingSwift框架内实现的。对你来说,这可能是一个不同的地址。

对于这个特殊的例子,该函数在HookingSwift框架内的偏移量0x0000000000001550处实现。复制这个地址并在LLDB中输入以下命令。

(lldb) image dump symtab -m HookingSwift

这将转储HookingSwift框架的符号表。除了转储符号表外,你还告诉LLDB显示Swift函数的杂乱名称。会有相当多的符号在显示中弹出。把你复制的那个地址粘贴到 LLDB 的搜索栏中,这样就可以把可怕的输出量变得可控。

你会得到一个与你复制的地址相匹配的地址。

这是你感兴趣的一行。

是的,最后那个巨大的愤怒的字母数字块是Swift的错误的函数名称。你要把这个怪胎塞进dlsym,以获取originalImage getter方法的地址。

打开ViewController.swift,在你刚才添加的if let里面添加以下代码。

let sym = dlsym(handle, 
"$S12HookingSwift23CopyrightImageGeneratorC08originalD033_71AD57F3ABD678B 113CF3AD05D01FF41LLSo7UIImageCSgvg")!

print("\(sym)")

注意:在Swift停止玩ABI命名的转瓶子游戏之前,这些符号名称可能(而且已经!)在不同的版本中发生变化,这意味着对你来说混杂的函数可能不同。

你选择了一个隐式解包的可选函数,因为你希望应用程序在得到错误的符号名称时崩溃。

构建并运行该应用程序。如果一切顺利,你会在控制台输出的尾部得到一个内存地址(你的可能会不同)。

0x0000000103105770

这个地址是CopyrightImageGeneratorg的originalImage方法的位置,该方法由

dlsym所提供的位置。你可以通过在LLDB的这个地址上创建一个断点来验证这一点。

(lldb) b 0x0000000103105770

LLDB在以下函数上创建一个断点。

太好了! 你可以在运行时调出这个函数的地址,但你如何去调用它呢?值得庆幸的是,你可以使用Swift的typealias关键字来铸造函数签名。

打开ViewController.swift,在你刚才添加的打印调用下直接添加以下内容。

typealias privateMethodAlias = @convention(c) (Any) -> UIImage? // 1 
let originalImageFunction = unsafeBitCast(sym, to: privateMethodAlias.self) // 2 
let originalImage = originalImageFunction(imageGenerator) // 3 
self.imageView.image = originalImage // 4

下面是这个的作用。

  1. 这声明了函数的类型,在语法上等同于Swift函数中的originalImage属性获取器。这里有一些非常重要的东西需要注意,privateMethodAlias被设计成只接受一个参数类型Any,但实际的Swift函数却不需要任何参数。为什么会这样呢?

    这是因为通过查看这个方法的程序集,在RDI寄存器中预期会有对self的引用。这意味着你需要提供类的实例作为函数的第一个参数,以欺骗这个C函数,让它认为这是一个Swift方法。如果你不这样做,应用程序就有可能崩溃。

  2. 现在你已经做了这个新的别名,你把sym地址转换成这个新的类型,并调用originalImageFunction。

  3. 你执行这个方法并提供类的实例作为函数的第一个也是唯一的参数。这将导致RDI寄存器被正确设置为该类的实例。它将返回没有水印的原始图像。

  4. 你将UIImageView的图像分配给没有水印的原始图像。

有了这些新的变化,构建并运行该应用程序。正如预期的那样,原始的、没有水印的图像现在将显示在应用程序中。

恭喜你--你已经发现了两个新的惊人的功能以及如何正确使用它们。在运行时抓取代码的位置是一个强大的功能,它可以让你访问编译器通常对你封锁的隐藏代码。此外,它还可以让你钩住代码,从而在运行时执行你自己的修改。

接下来该怎么做?

你正在学习如何玩转动态框架。上一章告诉你如何在LLDB中动态加载它们。这一章向你展示了如何修改或执行你通常无法执行的Swift或C代码。在下一章中,你将玩转Objective-C运行时,动态加载一个框架,并使用Objective-C的动态调度来执行你没有API的类。

这是逆向工程最激动人心的功能之一--所以请做好准备,并为进入下一章的学习提供咖啡因吧!


上一章 目录 下一章