Swift的高级技巧 - 动态注入和更改代码
虽然Xcode为lldb命令提供了几个可视化抽象,例如通过单击代码行添加断点并通过单击播放按钮来运行,但lldb提供了一些Xcode UI中不存在的有用命令。这可以是从即时创建方法到甚至更改CPU的寄存器以强制应用程序上的特定流而无需重新编译它,并且了解它们可以极大地改善您的调试体验。
并非所有Swift都是在Xcode中开发的 - 像Swift编译器或Apple的SourceKit-LSP这样的东西通过其他方式更好地工作,这些方法通常最终会让你手动使用lldb 。如果没有Xcode来帮助您,其中一些技巧可能会阻止您再次编译应用程序以测试某些更改。
注入属性和方法
您可能已经知道po(“打印对象”的缩写) - 通常用于打印属性内容的友好命令:
func foo() {
var myProperty = 0
} // a breakpoint
po myProperty
0
然而,po比这更强大 - 尽管名称暗示它打印的东西,po是一个别名,更原始(或只是)命令的论证版本,使输出更加开放:expression --object-description -- expression e
e myProperty
(Int) $R4 = 0 // not very pretty!
因为它是别名,po所以可以做任何事情e。e用于评估表达式,表达式的范围可以从打印属性到更改其值,甚至可以定义新类。作为一个简单的用法,我们可以在代码中更改属性的值以强制新流而无需重新编译代码:
po myProperty
0
po myProperty = 1
po myProperty
1
除此之外,如果你po单独写,你将能够编写这样的多线表达式。我们可以使用它在我们的调试会话中创建全新的方法和类:
po
Enter expressions, then terminate with an empty line to evaluate:
1 class $BreakpointUtils {
2 static var $counter = 0
3 }
4 func $increaseCounter() {
5 $BreakpointUtils.$counter += 1
6 print("Times I've hit this breakpoint: \($BreakpointUtils.$counter)")
7 }
8
(这里使用美元符号表示这些属性和方法属于lldb,而不是实际代码。)
前面的例子允许我直接从lldb 调用,这将在我的“我无法处理这个bug”计数器上加1。$increaseCounter()
po $increaseCounter()
Times I've hit this breakpoint: 1
po $increaseCounter()
Times I've hit this breakpoint: 2
这样做的能力可以与lldb导入插件的能力相结合,这可以大大增强您的调试体验。一个很好的例子就是Chisel,这是一个由Facebook制作的工具,它包含许多lldb插件 - 就像border命令一样,它增加了一个明亮的边框,UIView这样你就可以在屏幕上快速定位它们,并且它们都通过巧妙的用法来实现。e/ po。
然后,您可以使用lldb的断点操作在命中断点时自动触发这些方法。结合po的属性更改功能,您可以创建特殊的断点,这些断点将改变您尝试执行的测试的应用流程。
通常,所有高级断点命令都非常痛苦地在lldb中手动编写(这就是为什么我会在本文中避免它们),但幸运的是,您可以轻松地在Xcode中设置断点操作:
v- 避免po动态
如果你已经使用po了一段时间,你可能在过去看到过这样一个神秘的错误信息:
error: Couldn't lookup symbols:
$myProperty #1 : Swift.Int in __lldb_expr_26.$__lldb_expr(Swift.UnsafeMutablePointer<Any>) -> ()
这是因为po通过编译来评估您的代码,不幸的是,即使您尝试访问的代码是正确的,仍然存在可能出错的情况。
如果你正在处理不需要评估的东西(比如静态属性而不是方法或闭包),你可以使用v命令(简称frame variable)作为打印的替代,po以便立即获取内容。宾语。
v myProperty
(Int) myProperty = 1
disassemble - 打破内存地址以更改其内容
注意:以下命令仅在极端情况下有用。你不会在这里学习一个新的Swift技巧,但你可能会学到一些有趣的软件工程!
我通过使用越狱的iPad来使用流行的应用程序进入逆向工程,当你这样做时,你没有选择重新编译代码 - 你需要动态地改变它。例如,如果我无法重新编译代码,isSubscribed即使我没有订阅,如何强制以下方法进入条件?
var isSubscribed = false
func run() {
if isSubscribed {
print("Subscribed!")
} else {
print("Not subscribed.")
}
}
我们可以通过使用应用程序的内存来解决 - 在任何堆栈框架内,您可以调用该disassemble命令来查看该堆栈的完整指令集:
myapp`run():
-> 0x100000d60 <+0>: push rbp
0x100000d61 <+1>: mov rbp, rsp
0x100000d64 <+4>: sub rsp, 0x70
0x100000d68 <+8>: lea rax, [rip + 0x319]
0x100000d6f <+15>: mov ecx, 0x20
...
0x100000d9c <+60>: test r8, 0x1
0x100000da0 <+64>: jne 0x100000da7
0x100000da2 <+66>: jmp 0x100000e3c
0x100000da7 <+71>: mov eax, 0x1
0x100000dac <+76>: mov edi, eax
...
0x100000ec7 <+359>: call 0x100000f36
0x100000ecc <+364>: add rsp, 0x70
0x100000ed0 <+368>: pop rbp
0x100000ed1 <+369>: ret
这里整洁的东西不是命令本身,而是你可以用这些信息做些什么。我们习惯在Xcode中设置断点到代码行和特定选择器,但在lldb的控制台中你也可以使用断点特定的内存地址。
我们需要知道一些汇编来解决这个问题:如果我的代码包含一个if,那么该代码的结果汇编肯定会有一个跳转指令。在这种情况下,跳转指令将跳转到存储器地址,如果寄存器(在前一条指令中设置)不等于零(那么,为真)。由于我没有订阅,肯定会为零,这将阻止该指令被触发。0x100000da0 <+64>: jne0x100000da7 0x100000da7 r8 0x100000d9c <+60>: test r8, 0x1 r8
要看到这种情况发生并修复它,让我们首先断点并将应用程序放在jne指令处:
b 0x100000da0
continue
//Breakpoint hits the specific memory address
如果我disassemble再次运行,小箭头将显示我们在正确的内存地址处开始操作。
-> 0x100000da0 <+64>: jne 0x100000da7
有两种方法可以解决这个问题:
方法1:更改CPU寄存器的内容
该register read和register write命令由LLDB提供,让您检查和修改的CPU寄存器的内容,并解决这个问题的第一种方式是简单地改变的内容r8。
通过定位jne指令,register read将返回以下内容:
General Purpose Registers:
rax = 0x000000010295ddb0
rbx = 0x0000000000000000
rcx = 0x00007ffeefbff508
rdx = 0x0000000000000000
rdi = 0x00007ffeefbff508
rsi = 0x0000000010000000
rbp = 0x00007ffeefbff520
rsp = 0x00007ffeefbff4b0
r8 = 0x0000000000000000General Purpose Registers:
因为r8为零,jne指令不会触发,从而使代码输出"Not subscribed."。但是,这是一个简单的修复 - 我们可以r8通过运行register write和恢复应用程序设置为不为零的东西:
register write r8 0x1
continue
"Subscribed!"
在日常的iOS开发中,register write可以用来替换代码中的整个对象。如果某个方法要返回你不想要的东西,你可以在lldb中创建一个新对象,获取其内存地址e并将其注入所需的寄存器。
方法2:更改指令本身
解决这个问题的第二种也可能是最疯狂的方法是实时重写应用程序本身。
就像寄存器一样,lldb提供memory read并memory write允许您更改应用程序使用的任何内存地址的内容。这可以用作动态更改属性内容的替代方法,但在这种情况下,我们可以使用它来更改指令本身。
这里可以做两件事:如果我们想要反转if指令的逻辑,我们可以改为(所以它检查一个条件),或者(跳空不是)to (跳空,或)。我发现后者更容易,所以这就是我要遵循的。如果我们阅读该指令的内容,我们会看到如下内容:test r8, 0x1 test r8, 0x0 false jne 0x100000da7 je 0x100000da7 if!condition
memory read 0x100000da0
0x100000da0: 75 05 e9 95 00 00 00 b8 01 00 00 00 89 c7 e8 71
这看起来很疯狂,但我们不需要了解所有这些 - 我们只需要知道指令的OPCODE对应于开头的两位(75)。按照这个图表,我们可以看到OPCODE for je是74,所以如果我们想要jne成为je,我们需要将前两位与74交换。
为此,我们可以使用memory write与该地址完全相同的内容,但前两位更改为74。
memory write 0x100000da0 74 05 e9 95 00 00 00 b8 01 00 00 00 89 c7 e8 71
dis
0x100000da0 <+64>: je 0x100000da7
现在,运行应用程序将导致"Subscribed!"打印。
结论
虽然拆解和写入内存对于日常开发来说可能过于极端,但您可以使用一些更高级的lldb技巧来提高工作效率。更改属性,定义辅助方法并将它们与断点操作混合将允许您更快地导航和测试代码,而无需重新编译它。
转自:https://www.jianshu.com/p/281a2f61937e