在本章中,你将熟悉 LLDB 并调查一个程序的自省和调试过程。 你会开始反思一个你没有写过的程序 —— Xcode!
你将使用 LLDB 快速浏览一个调试会话,并发现你可以对没有源代码的程序进行惊人的更改。 第一章有大量内容需要学习,因此很多概念和对 LLDB 功能的深入研究将放在后面的章节中。
让我们开始吧。
绕开 Rootless
在开始使用 LLDB 之前,你需要了解 Apple 推出的用于阻止恶意软件的功能。 不幸的是,这个功能也会阻止你使用 LLDB 和 DTrace 等工具进行内省和调试。 不要害怕,因为苹果包含一个方法来关闭这个功能 —— 为那些知道他们在做什么的人。 而且你将成为这些知道自己在做什么的人之一!
阻止自省和调试的功能是 System Integrity Protection,也称为 Rootless。 这个系统限制了程序能够做什么 —— 即使它们拥有根权限 —— 也能阻止恶意软件在系统内深植。
虽然 Rootless 在安全性方面是一个巨大的飞跃,但它引起了一些烦恼,因为它使得程序难以调试。 具体来说,它阻止其他进程在 Apple 标志的程序上添加调试器。
由于本书不仅涉及调试你自己的应用程序,而且涉及调试其他你感兴趣的应用程序,因此在你了解调试知识的同时关闭此功能是非常重要的,这样你就可以检查你选择的任何应用程序。
如果你现在启用了 Rootless,你将无法连接上 Apple 的大部分程序。 但也有例外情况,例如 iOS 模拟器上的应用程序。
例如,尝试将 LLDB 连接到 Finder 应用程序。
打开终端窗口,找到 Finder 进程,如下所示:
lldb -n Finder
你会注意到以下错误:
error: attach failed: cannot attach to process due to System Integrity Protection
注意:连接到一个进程的方法有很多种,以及 LLDB 连接成功后的具体配置。 要了解有关连接到进程的更多信息,请查看第三章,“使用 LLDB 连接”。
禁用 Rootless
要禁用 Rootless,请执行以下步骤:
重新启动你的 macOS 计算机。
当屏幕变为空白时,按住 Command + R 直到 Apple 启动图标出现。 这将使你的电脑进入 Recovery Mode。
现在,从顶部找到 Utilities 菜单,然后选择 Terminal。
终端窗口打开后,输入:
csrutil disable; reboot
- 你的计算机将重新启动,Rootless 已经禁用。
注意:跟随这本书学习时,一个更安全的方式是创建一个使用 VMWare 或 VirtualBox 的专用虚拟机,并且只禁用 Rootless。
你可以通过登录帐户后在终端上再次尝试相同的命令来验证是否成功禁用了 Rootless。
lldb -n Finder
LLDB 现在应该已经连接到当前的 Finder 进程。 成功连接后的输出应该是这样的:
成功连接后,通过关闭终端窗口,或在 LLDB 控制台中键入 quit 后确认来断开 LLDB。
将 LLDB 连接到 Xcode
既然你已经禁用了 Rootless,你可以将 LLDB 连接到你的 macOS 计算机上的任何进程(可能会出现一些障碍,比如 ptrace,我们稍后会介绍)。 你首先要看看你在日常开发中经常使用的应用程序:Xcode! 在继续之前,请确保你的计算机上安装了最新版本的 Xcode 9。
打开一个新的终端窗口。 接下来,按下 ⌘ + Shift + I 来编辑终端选项卡的标题。将会出现一个新的弹出窗口。 将选项卡标题编辑为 LLDB。
接下来,确保 Xcode 没有在运行,否则你最终会得到多个正在运行的 Xcode 实例,这可能会导致混乱。
在终端中输入以下内容:
lldb
启动 LLDB。
按下 ⌘ + T 来创建一个新的终端选项卡。使用 ⌘ + Shift + I 来再次编辑选项卡的标题,并将选项卡命名为 Xcode stderr。 此终端选项卡将包含从调试器打印内容时的所有输出。
确保你在 Xcode stderr 终端选项卡上,并输入以下内容:
~ $ tty
你应该看到类似下面的内容:
/dev/ttys027
如果你的是不同的,不要担心, 如果相同,我会感到惊讶。 把它当作你终端会话的地址。
为了说明你将如何使用 Xcode stderr 选项卡,创建另一个标签并在其中输入以下内容:
echo "hello debugger" 1>/dev/ttys027
确保用你从 tty 命令中获得的唯一地址替换终端路径。
现在切换回 Xcode stderr 选项卡。 上面应该显示 hello debugger。 你将使用相同的技巧将 Xcode 的错误信息的输出显示到此选项卡。
最后,关闭第三个未命名的选项卡,并返回到 LLDB 选项卡。
总而言之:现在你应该有两个终端选项卡:一个名为“LLDB”,它包含一个运行 LLDB 的实例,另一个名为“Xcode stderr”,它包含你之前执行的 tty 命令。
现在输入以下内容到 LLDB:
(lldb) file /Applications/Xcode.app/Contents/MacOS/Xcode
这将把可执行目标设置为 Xcode。
注意:如果你使用的是 Xcode 的预发布版本,那么 Xcode 的名称和路径可能会有所不同。
你可以通过启动 Xcode 并在终端中输入以下内容来检查当前正在运行的 Xcode 的路径:
$ ps -ef `pgrep -x Xcode`
一旦你获取 Xcode 的路径,改为使用新的路径。
现在从 LLDB 启动 Xcode 进程,再次用你的 Xcode stderr 选项卡的 tty 地址替换 /dev/ttys027:
(lldb) process launch -e /dev/ttys027 --
启动参数 e 指定了 stderr 的位置。 常见的日志记录功能,如 Objective-C 的 NSLog 或 Swift 的 print 功能,输出到 stderr —— 是的,不是标准输出! 稍后你将打印自己的日志记录到 stderr。
Xcode 会在稍候启动。 切换到 Xcode,然后单击 File -> New -> Project。接下来,选择 iOS -> Application -> Single View Application,然后单击 Next。 将项目命名为 Hello Debugger。 确保选择 Swift 作为编程语言,并取消选择 Unit 和 UI 测试。 点击 Next,然后将项目保存到你想要保存的地方。
你现在有了一个新的 Xcode 项目。 调整窗口,以便你可以同时看到终端和 Xcode。
导航到 Xcode 并打开 ViewController.swift。
注意:你可能会注意到 Xcode stderr 终端窗口上有一些输出,这是由 Xcode 的作者通过 NSLog 或另一个 stderr 控制台打印功能所记录的内容。
Swift 的快速变更
Apple 在自己的软件中采用 Swift 一直很谨慎 —— 这是可以理解的。 不管 Swift 使用者(如宗教般的)信仰如何,Swift 仍然是一门不成熟的语言,它以惊人的速度发生着颠覆性的变化。 我仍然有 Swift 2.2 到 3.0 大量迁移的闪回,我相信你们中的许多人也有。
这些大规模的代码库迁移在企业环境中并不好,因为企业环境中具有不同依赖关系的框架必须彼此良好地运行。 如果一个包含 Swift 代码的框架发生了重大的变化,它可能会导致整个代码库失效。 想象一下,如果数百个框架包含 Swift 的话,如果官方不对这些框架的改变作出详细说明,将会是一个依赖地狱。
不过,Apple 正在改变。 Apple 现在更积极地在自己的应用程序中使用 Swift,比如 iOS 模拟器,甚至 Xcode!
最新版本的 Xcode 9 拥有超过 40 个包含 Swift 的框架。
你可以通过在 LLDB 中输入以下内容来查询这些信息:
(lldb) script print "\n".join([i.file.basename for i in lldb.target.modules if i.FindSection("__swift3_typeref")])
这将显示出 Xcode 加载包含 Swift 或与 Swift 有关代码过程中的所有动态加载模块。
这里随着 LLDB 的 Python 模块(也被称为“lldb”)使用了 Python。 在学习构建自定义的高级 LLDB 脚本时,你将非常习惯使用本书第四部分的这个模块。
在这个动态 Swift 库的列表中,有一个是感兴趣的特定模块:IDEPegasusSourceEditor。 这是一个动态库(就像 UIKit 或 Foundation),它包含一些与 Xcode 中代码编辑视图有关的有趣的 Swift 类。
注意:Apple 经常使用代码名称作为内部开发功能。 例如,iPad 的画中画功能在内部方法中通常被称为 “Medusa”,并且不是所有 Apple 的代码名都是希腊语。 那这个 Pegasus 的名字是怎么回事呢? Apple 开发团队(或经理)作出了解释。 你猜得和我一样好,但我可以看到象征智慧的神话生物和视觉显示之间的逻辑联系。
记住这个模块的名字,你很快会看到它。
通过点击找到类
既然 Xcode 已经设置好,你的终端调试窗口也已经正确地创建和定位,现在是时候在调试器的帮助下来开始探索 Xcode 了。
在调试的时候,Cocoa SDK 的知识会非常有帮助。 例如,-[NSView hitTest:] 是一个好用的 Objective-C 方法,它返回负责运行循环中点击或手势的事件的类。 该方法首先在包含 NSView 的对象中触发,并递归直到处理此触摸事件的最远的子视图。 你可以使用 Cocoa SDK 的这些知识来帮助确定你所点击视图的类。
在你的 LLDB 选项卡中,按下 Ctrl + C 以暂停调试器。 在那里输入:
(lldb) b -[NSView hitTest:]
Breakpoint 1: where = AppKit`-[NSView hitTest:], address = 0x000000010338277b
这是你将来无数断点中的第一个。 你将在第四章“给代码创建断点”中学习如何创建,修改和删除断点的详细信息,但现在只需知道你已经在 -[NSView hitTest:] 上创建了断点。
Xcode 由于调试器而暂停了。 恢复程序:
(lldb) continue
点击 Xcode 窗口中的任何地方(或者在某些情况下,在 Xcode 上移动光标也可以)。 Xcode 将立即暂停,并且 LLDB 将提示已经遇到一个断点。
hitTest: 断点已经生效。 你可以通过检查 RDI CPU 寄存器来检查在哪个视图中检测到。 在 LLDB 打印出来:
(lldb) po $rdi
这个命令会让 LLDB 打印出存储在 RDI 汇编寄存器中的内存地址处的对象的内容。
注意:想知道为什么命令是 po? po代表打印对象(print object)。 还有 p 命令,它只是简单打印 RDI 的内容。 po 通常更有用,因为它提供了 NSObject 的 description 或 debugDescription 方法,如果有这两个方法的话。
如果你想把你的调试提升到一个新的水平,汇编是一项重要的技能。 它会让你深入了解 Apple 的代码 —— 即使你没有任何源代码可读。 它将使你更好地理解 Swift 编译器团队如何在 Objective-C 中使用 Swift,也将使你更好地了解如何在 Apple 设备上执行各种操作。你将在第十章“汇编寄存器调用约定”中了解更多关于寄存器和汇编的内容。
现在,只需知道上面的 LLDB 命令中的 $rdi 寄存器所包含的 NSView 的子类实例的 hitTest: 方法被调用。
请注意,输出会产生不同的结果,具体取决于你点击的位置以及你使用的 Xcode 版本。 它可以给你一个专用于 Xcode 的私有类,或者它可以给你一个属于 Cocoa 的公共类。
在 LLDB 中,输入以下内容恢复程序:
(lldb) continue
Xcode 可能会遇到另一个 hitTest: 的断点并暂停执行。 这是由于 hitTest: 方法递归调用该父视图中所包含的所有子视图的方法。 你可以检查这个断点的内容,但是由于构成 Xcode 的视图太多了,所以很快就会变得乏味。
为重要内容过滤断点
由于组成 Xcode 的 NSView 非常多,所以你需要一种方法来过滤掉一些干扰,并且只在与你查找的内容相关的 NSView 上停止。 这是一个调试常用方法的例子,在这个例子中,你想找到一个独特的案例来帮助确定你真正需要的东西。
从 Xcode 9 开始,负责在 Xcode IDE 中可视化显示代码的类是 IDEPegasusSourceEditor 模块下的私有 Swift 类,名为 SourceCodeEditorView。 这个类作为可视化协调器把所有的代码交给其他私有类来帮助编译和创建你的应用程序。
假如你想仅在点击一个 NSView 的实例时才停止。 你可以通过使用断点条件(breakpoint condition)来修改现有的断点,以便仅在点击 NSView 时停止。
假如你仍然设置了 -[NSView hitTest:] 的断点,并且它是你的 LLDB 会话中唯一起作用的断点,你可以使用下面的 LLDB 命令修改该断点:
(lldb) breakpoint modify 1 -c '(BOOL)[$rdi isKindOfClass:(id)NSClassFromString(@"IDEPegasusSourceEditor.SourceCodeEditorView")]'
这个命令修改了断点1并创建了一个每次 -[NSView hitTest:] 触发状态时的评估。 如果状态评估为真,则执行将在调试器中暂停。 这个状态检查 NSView 实例的类型是否是 IDEPegasusSourceEditor.SourceCodeEditorView。
在修改上面的断点之后,点击 Xcode 中的代码区域(code area)。 LLDB 应该在 hitTest: 上停止。打印出这个方法被调用的类的实例:
(lldb) po $rdi
你的输出应该是这样的:
SourceCodeEditorView: Frame: (0.0, 0.0, 1140.0, 393.0), Bounds: (0.0, 0.0, 1140.0, 393.0) contentViewOffset: 0.0
这是打印出对象的 description。 你会注意到这里面没有指针引用,因为 Swift 隐藏了指针引用。 如果你需要指针参考,有几种方法可以解决这个问题。 最简单的是使用打印格式(print formatting)。 在 LLDB 中输入以下内容:
(lldb) p/x $rdi
你会得到像这样的内容:
(unsigned long) $3 = 0x00007f96f10b3a00
由于 RDI 指向一个有效的 Objective-C 的 NSObject,所以你也可以通过 po 这个地址而不是寄存器来得到相同的信息。
将以下内容输入到 LLDB 中,同时确保替换成你自己的地址:
(lldb) po 0x00007f96f10b3a00
你会得到和之前一样的输出。
你可能会怀疑 RDI 寄存器指向的这个引用是否真的指向代码显示的 NSView。 通过在 LLDB 中输入以下内容,你可以轻松验证这是否正确:
(lldb) po [$rdi setHidden:!(BOOL)[$rdi isHidden]]; [CATransaction flush]
注意:输入了一个很长的命令,对吧? 在第九章“正则表达式命令”中,你将学习如何构建方便的快捷方式,因此你不必再输入这些 LLDB 长命令。
如果 RDI 指向正确的引用,你的代码编辑器区域的视图将消失!
你可以通过反复按 Enter 键来打开和关闭此视图,LLDB 将自动执行上一个命令。
复制 RDI 引用的地址(复制到剪贴板后粘贴)。 你会马上再次引用。 或者,你是否注意到 p/x $rdi 命令中的十六进制值前面的输出? 在我的输出中,我得到了 $3,这意味着你可以使用 $3 作为你刚才获取的指针值的参考。 当 RDI 寄存器指向别的东西时,这是非常有用的,我仍然想在以后引用这个 NSView。
由于这不是一个明显的 NSView 子类,你可以通过重复计算该类的父类来检查这个实例是否是一个 NSView 子类。
(lldb) po [$rdi superclass]
继续下去,直到你找到它。
(lldb) po [[$rdi superclass] superclass]
等等 —— 我们在 Swift 类里使用 Objective-C? 的确! 你会发现一个 Swift 类大部分都是通过 Objective-C 封装的(但是对于 Swift 结构体来说是不一样的)。 你应该确认这是在 Swift 里的情况。 要做到这一点,首先输入以下内容:
(lldb) ex -l swift -- import Foundation
(lldb) ex -l swift -- import AppKit
ex 命令(expression的简写)让您评估代码,并且是你的 p/po LLDB 命令的基础。 -l swift 告诉 LLDB 将你的命令作为 Swift 代码。 你所做的基本上是导入合适的头文件,通过 Swift 调用这两个模块中的方法。 你会在接下来的两个命令中需要这些。
输入以下内容,用你之前找到的 NSView 子类的内存地址替换 0x14bdd9b50:
(lldb) ex -l swift -o -- unsafeBitCast(0x14bdd9b50, to: NSObject.self)
(lldb) ex -l swift -o -- unsafeBitCast(0x14bdd9b50, to: NSObject.self) is NSView
这些命令打印出 SourceCodeEditorView 实例,然后检查它是否是 NSView 的子类 —— 但这次使用 Swift! 你会看到类似于下面的内容:
(lldb) ex -l swift -o -- unsafeBitCast(0x14bdd9b50, to: NSObject.self)
SourceCodeEditorView: Frame: (0.0, 0.0, 868.0, 524.0), Bounds: (0.0, 0.0, 868.0, 524.0) contentViewOffset: 0.0
(lldb) ex -l swift -o -- unsafeBitCast(0x14bdd9b50, to: NSObject.self) is NSView
true
使用 Swift 需要输入更多内容。 另外,当突然停止调试器或者 Objective-C 代码时,LLDB 将默认设置为 Objective-C。 这是可以改变的,但是这本书更喜欢使用 Objective-C,因为 Swift REPL 对于调试器中的错误检查可能是野蛮的。
现在,你将使用 Objective-C 的调试环境来操作这个 NSView。
由于这是 NSView 的一个子类,所以 NSView 的所有方法都适用。 输入以下内容:
(lldb) po [$rdi string]
这句命令打印出你在 Xcode 中打开的文件的内容。
现在,你如何去查询这个 IDEPegasusSourceEditor.SourceCodeEditorView 的实例上可能被重写的 Objective-C 桥接方法?
你可以使用带有正则表达式选项的 image lookup 命令来搜索所有以 objc 桥接开始并包含单词“getter”的 SourceCodeEditorView 的方法。 在 LLDB 中,输入简短的命令:
(lldb) image lookup -rn objc\sIDEPegasusSourceEditor.SourceCodeEditorView.*getter
这将很快产生大量输出,下面是部分截取。
10 matches found in /Applications/Xcode.app/Contents/PlugIns/IDEPegasusSourceEditor.ideplugin/Contents/MacOS/IDEPegasusSourceEditor:
Address: IDEPegasusSourceEditor[0x00000000000a2020] (IDEPegasusSourceEditor.__TEXT.__text + 656048)
Summary: IDEPegasusSourceEditor`@objc IDEPegasusSourceEditor.SourceCodeEditorView.hostingEditor.getter : weak Swift.Optional Address: IDEPegasusSourceEditor[0x00000000000a2c70] (IDEPegasusSourceEditor.__TEXT.__text + 659200)
Summary: IDEPegasusSourceEditor`@objc IDEPegasusSourceEditor.SourceCodeEditorView.completionController.getter : Swift.ImplicitlyUnwrappedOptional<__ObjC.DVTTextCompletionController> Address: IDEPegasusSourceEditor[0x00000000000a2d00] (IDEPegasusSourceEditor.__TEXT.__text + 659344)
Summary: IDEPegasusSourceEditor`@objc IDEPegasusSourceEditor.SourceCodeEditorView.completionsDataSource.getter : __ObjC.DVTTextCompletionDataSource Address: IDEPegasusSourceEditor[0x00000000000a2d60] (IDEPegasusSourceEditor.__TEXT.__text + 659440)
注意放到 Summary: 部分之后的内容。 这些是应用于 Objective-C 语法的 Objective-C 桥接方法。 例如,你可以执行下面的代码:
(lldb) po [$rdi hostingEditor]
或者还有:
(lldb) po [$rdi language]
你在方法签名中找到包含 @objc 的 Swift 方法,你都可以通过 Objective-C 来使用它。 这是一个方便的解决方法,因为 IDEPegasusSourceEditor 的仅 Swift 可用的代码在 LLDB 中执行起来更加不方便。
执行私有 Swift 方法
一切都很好,但是你怎么执行私有的 Swift 方法呢?
通常,当 LLDB 与代码库交互时,需要知道模块的所有模块映射(module map)(即包含扩展名为 .modulemap 的文件)。 正是这种信息可以帮助你进行典型的 Swift 开发,而且 LLDB 也需要这些信息来计算出它可以执行什么代码。 你还记得导入 Foundation 和 AppKit 模块吗? 这允许 Swift LLDB 的代码执行编译器(JIT)通过模块映射与这些框架内的方法进行交互。
在 LLDB 中执行 Swift 代码时,没有该模块映射将不会执行存在的代码。 这与 Objective-C(甚至只是简单的C)中的编译器有很大不同,Swift 编辑器不会主动来握住你的手。
跳回到 IDEPegasusSourceEditor 模块,你没有一个 .modulemap 文件,通过导入这个文件才能访问这个框架中的 Swift 代码。 这意味着你需要找出你想访问的 Swift 函数的函数名,然后用 C 的 extern 来声明它并执行它。
我们来看一下在 Swift 中实现的 SourceCodeEditorView 类的 Objective-C 桥接方法。 在 LLDB,搜索一个属性:
(lldb) image lookup -rvn objc\sSourceEditor.SourceEditorView.insertText\(Any\)
请记住,你应该多学习。 在接下来的章节中,你将花费一些时间来研究发生的事情。
这将显示出 @objc SourceEditor.SourceEditorView.insertText(Any) 方法的详细输出。 在输出的底部是 Swift 函数名(mangled Swift name)(查找 mangled ="_ T012SourceEditor0aB4ViewC10insertTextyypFTo")。 复制这个值并在 LLDB 中外部声明:
(lldb) po extern void _T012SourceEditor0aB4ViewC10insertTextyypFTo(long, char *,id);
这将告诉 LLDB 存在一个名为 \ _T012SourceEditor0aB4ViewC10insertTextyypFTo 的方法,它有三个参数。 我怎么知道它需要三个参数? 查看第二部分以更好地理解程汇编,以及在调用函数时 Objective-C 和 Swift 中会发生什么。 现在该方法已经被声明,并且 LLDB 知道它,执行下面的命令:
(lldb) po _T012SourceEditor0aB4ViewC10insertTextyypFTo($rdi,0,@"wahahahahah")
让我们来看看会发生什么。 在 LLDB 中恢复执行:
(lldb) continue
在 Xcode 的 SourceCodeEditorView 实例中,找到你的光标所在的位置,并查找代码中不同的内容。
你会在你的 IDE 中看到一个新的字符串 wahahahahah!
我知道 Objective-C 现在不是很流行的语言 https://stackoverflow.blog/2017/10/31/disliked-programming-languages/,但来吧,比起上面描述的方法,编写 [$rdi insertText:@"wahahahahah"] 多么容易。
如果可以的话,我总是会选择在调试器中使用 Objective-C,因为在 LLDB 中 Objective-C 比 Swift 更稳定。 另外,在 Objective-C(以及 Objective-C 的 Swift 桥接方法)中,类的实例(或类本身)总是首先传递给函数。 但不是所有的 Swift 函数都会这样。 最后,在 LLDB 中执行代码时,我不必考虑 Swift 的类型检查和编译慢的问题。
接下来?
以上是广泛而快速的使用 LLDB 的介绍,帮助你连接到没有源代码的进程。 本章涵盖了很多细节,但是目标是让你入门调试/逆向工程。 对于某些人来说,第一章可能会有点吓人,但是我们会放慢速度,从这里开始详细描述方法。 还有很多章节可以帮助你了解细节!
请继续阅读,学习第一部分的其余内容。调试快乐!