让我们来搞崩 Cocoa 吧(黑暗代码)

原文链接=https://mikeash.com/pyblog/friday-qa-2014-01-10-lets-break-cocoa.html
作者=Mikeash
原文日期=2014/01/10


译者:在传统的文章中,我们一直致力于如何编写高效稳定的代码,努力提高代码的鲁棒性。然而在本文中,我们将会改变一下思维方式,采用破坏的方式去挖掘 Cocoa 的一些特性,虽然文中作者表现出一种“病态”的破坏心理,但正因为有这种精神,通过文中那些黑暗代码,可以让我们更加深刻地理解 Cocoa 。


让我们编写系列文章是这个博客中我最喜欢的部分。但是,有时候搞崩程序比编写他们更有趣。现在,我将要开发一些好玩且不同寻常的方式去让 Cocoa 崩溃。

带有 NUL 的字符串

NUL(译者:应该为 ‘\0’) 字符在 ASCII 和 Unicode 中代表 0,是一个不寻常的麻烦鬼。当在 C 字符串中时,它不作为一个字符,而是一个代表字符串结束的标识符。在其他的上下文环境中,它就会跟其他字符一样了。

当你混合 C 字符串和其它上下文环境,就会产生很有趣的结果。例如:NSString 对象,使用 NUL 字符毫无问题:

1
NSString *s = @"abc\0def";

如果我们认真的话,我们可以使用 lldb 打印它:

1
2
(lldb) p (void)[[NSFileHandle fileHandleWithStandardOutput] writeData: [s dataUsingEncoding: 5]]
abcdef

然而,展示这个字符串更为典型的方式是,字符串被当做 C 字符串在某个点结束。由于 ‘\0’ 字符意味着 C 字符串的结尾,因此字符串会在转换时缩短:

1
2
3
4
(lldb) po s
abc
(lldb) p (void)NSLog(s)
LetsBreakCocoa[16689:303] abc

原始的字符已然包含预计的字符数量:

1
2
(lldb) p [s length]
(unsigned long long) $1 = 7

试图对这个字符串进行操作会让你真正感到困惑:

1
2
(lldb) po [s stringByAppendingPathExtension: @"txt"]
abc

如果你不知道字符串的中间包含一个 NUL ,这类问题会让你感到这个世界满满的恶意。

一般来说,你不会遇到 NUL 字符,但是它很有可能通过加载外部资源的数据进来。-initWithData:encoding: 会很轻易地读入零比特并且在返回的 NSString 中产生 NUL 字符。

循环容器

这里有一个数组:

1
NSMutableArray *a = [NSMutableArray array];

这里有一个数组包含其他的数据:

1
2
3
NSMutableArray *a = [NSMutableArray array];
NSMutableArray *b = [NSMutableArray array];
[a addObject: b];

目前为止,看起来还不错。现在我们让一个数组包含自身:

1
2
NSMutableArray *a = [NSMutableArray array];
[a addObject: a];

猜猜会打印出什么?

1
NSLog(@"%@", a);

以下就是调用堆栈的信息(译者:bt 命令为打印调用堆栈的信息):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(lldb) bt
* thread #1: tid = 0x43eca, 0x00007fff8952815a CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 154, queue = 'com.apple.main-thread, stop reason = EXC_BAD_ACCESS (code=2, address=0x7fff5f3ffff8)
frame #0: 0x00007fff8952815a CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 154
frame #1: 0x00007fff895282da CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 538
frame #2: 0x00007fff895282da CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 538
frame #3: 0x00007fff895282da CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 538
frame #4: 0x00007fff895282da CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 538
frame #5: 0x00007fff895282da CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 538
frame #6: 0x00007fff895282da CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 538
frame #7: 0x00007fff895282da CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 538
frame #8: 0x00007fff895282da CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 538
frame #9: 0x00007fff895282da CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 538
frame #10: 0x00007fff895282da CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 538
frame #11: 0x00007fff895282da CoreFoundation`-[NSArray descriptionWithLocale:indent:] + 538

这里还删除了上千个栈帧。描述方法无法处理递归容器,所以它持续尝试去追踪到“树”的结束,并最终发生异常。

我们可以用它跟自身比较对等性:

1
NSLog(@"%d", [a isEqual: a]);

这姑且看起来是 YES。让我们创造另一个结构上相同的数组 b 然后用 a 和它比较:

1
2
3
NSMutableArray *b = [NSMutableArray array];
[b addObject: b];
NSLog(@"%d", [a isEqual: b]);

很抱歉:

1
2
3
4
5
6
7
8
9
10
11
12
(lldb) bt
* thread #1: tid = 0x4412a, 0x00007fff8946a8d7 CoreFoundation`-[NSArray isEqualToArray:] + 103, queue = 'com.apple.main-thread, stop reason = EXC_BAD_ACCESS (code=2, address=0x7fff5f3fff28)
frame #0: 0x00007fff8946a8d7 CoreFoundation`-[NSArray isEqualToArray:] + 103
frame #1: 0x00007fff8946f6b7 CoreFoundation`-[NSArray isEqual:] + 71
frame #2: 0x00007fff8946aa07 CoreFoundation`-[NSArray isEqualToArray:] + 407
frame #3: 0x00007fff8946f6b7 CoreFoundation`-[NSArray isEqual:] + 71
frame #4: 0x00007fff8946aa07 CoreFoundation`-[NSArray isEqualToArray:] + 407
frame #5: 0x00007fff8946f6b7 CoreFoundation`-[NSArray isEqual:] + 71
frame #6: 0x00007fff8946aa07 CoreFoundation`-[NSArray isEqualToArray:] + 407
frame #7: 0x00007fff8946f6b7 CoreFoundation`-[NSArray isEqual:] + 71
frame #8: 0x00007fff8946aa07 CoreFoundation`-[NSArray isEqualToArray:] + 407
frame #9: 0x00007fff8946f6b7 CoreFoundation`-[NSArray isEqual:] + 71

对等性检查同样也不知道如何处理递归容易。

循环视图

你可以用NSView实例做同样的实验:

1
2
3
4
NSWindow *win = [self window];
NSView *a = [[NSView alloc] initWithFrame: NSMakeRect(0, 0, 1, 1)];
[a addSubview: a];
[[win contentView] addSubview: a];

为了让这个程序崩溃,你只需要尝试去显示视窗。你甚至不需要去打印一个描述或者做对等性比较。当试图去显示视窗时,应用就会由于尝试去追踪底部的视图结构而崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(lldb) bt
* thread #1: tid = 0x458bf, 0x00007fff8c972528 AppKit`NSViewGetVisibleRect + 130, queue = 'com.apple.main-thread, stop reason = EXC_BAD_ACCESS (code=2, address=0x7fff5f3ffff8)
frame #0: 0x00007fff8c972528 AppKit`NSViewGetVisibleRect + 130
frame #1: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #2: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #3: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #4: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #5: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #6: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #7: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #8: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #9: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #10: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #11: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #12: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #13: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #14: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #15: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #16: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #17: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #18: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #19: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288
frame #20: 0x00007fff8c9725c6 AppKit`NSViewGetVisibleRect + 288

Hash Abuse

滥用 Hash

让我们创建一个实例一直等于其他类的类 AlwaysEqual,但是 hash 值并不一样:

1
2
3
4
5
6
7
@interface AlwaysEqual : NSObject @end
@implementation AlwaysEqual
- (BOOL)isEqual: (id)object { return YES; }
- (NSUInteger)hash { return random(); }
@end

这显然违反了 Cocoa 的要求,当两个对象被认为是相等时,他们的 hash 应该总是返回相等的值。当然,这不是非常严格的强制要求,所以上述代码依然可以编译和运行。

让我们添加一个实例到 NSMutableSet 中:

1
2
3
4
5
6
7
NSMutableSet *set = [NSMutableSet set];
for(;;)
{
AlwaysEqual *obj = [[AlwaysEqual alloc] init];
[set addObject: obj];
NSLog(@"%@", set);
}

这产生了一个有趣的日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
LetsBreakCocoa[17069:303] {(
<AlwaysEqual: 0x61000001ed70>
)}
LetsBreakCocoa[17069:303] {(
<AlwaysEqual: 0x61000001ec40>,
<AlwaysEqual: 0x61000001ed70>
)}
LetsBreakCocoa[17069:303] {(
<AlwaysEqual: 0x61000001ec40>,
<AlwaysEqual: 0x61000001ed70>
)}
LetsBreakCocoa[17069:303] {(
<AlwaysEqual: 0x61000001ec40>,
<AlwaysEqual: 0x61000001ed70>,
<AlwaysEqual: 0x61000001f930>
)}
LetsBreakCocoa[17069:303] {(
<AlwaysEqual: 0x61000001ec40>,
<AlwaysEqual: 0x61000001ed70>,
<AlwaysEqual: 0x61000001f930>
)}
LetsBreakCocoa[17069:303] {(
<AlwaysEqual: 0x61000001ec40>,
<AlwaysEqual: 0x61000001ed70>,
<AlwaysEqual: 0x61000001f930>
)}

每次运行都不能保证一样,但是综合看起来就是这样。addObject:通常先添加一个新对象,然后在更多的对象添加进来的时候很少成功,最后顶部只有三个对象。现在这个集合包含三个看起来是独一无二的对象,而且看起来应该不会包含更多的对象了。所以,在重写 isEqual: 时总是应该重写 hash方法。

滥用 Selector

Selector 是一个特殊的数据类型,在运行期用于表示方法名。在我们习惯中,它们必须是独一无二的字符串,尽管它们并不是严格地要求是字符串。在现在的 Objective-C 运行期,它们是字符串,并且我们都知道利用 Selector 去搞崩程序是很好玩儿的事。

马上行动,下面就是一个例子:

1
2
SEL sel = (SEL)"";
[NSObject performSelector: sel];

当编译和运行之后,在运行期产生了很令人费解的错误:

1
2
LetsBreakCocoa[17192:303] *** NSForwarding: warning: selector (0x100001f86) for message '' does not match selector known to Objective C runtime (0x6100000181f0)-- abort
LetsBreakCocoa[17192:303] +[NSObject ]: unrecognized selector sent to class 0x7fff75570810

通过创建奇怪的 selector,会产生真正奇怪的错误:

1
2
3
4
SEL sel = (SEL)"]: unrecognized selector sent to class 0x7fff75570810";
[NSObject performSelector: sel];
LetsBreakCocoa[17262:303] +[NSObject ]: unrecognized selector sent to class 0x7fff75570810]: unrecognized selector sent to class 0x7fff75570810

你甚至让错误看起来像是停止响应完整信息的 NSObject :

1
2
3
4
5
SEL sel = (SEL)"alloc";
[NSObject performSelector: sel];
LetsBreakCocoa[46958:303] *** NSForwarding: warning: selector (0x100001f77) for message 'alloc' does not match selector known to Objective C runtime (0x7fff8d38d879)-- abort
LetsBreakCocoa[46958:303] +[NSObject alloc]: unrecognized selector sent to class 0x7fff75570810

显然,这不是真正的 alloc selector,它是一个碰巧指向一个包含 “alloc” 字符串的伪装 selector。但是,runtime 依然把它打印为 alloc 。

伪造对象

虽然现在越来越复杂,但是 Objective-C 依然是分配给所有对象类的大内存中的一小块内存。在这样的思维下,我们就可以创造一个伪造对象:

1
id obj = (__bridge id)(void *)&(Class){ [NSObject class] };

这些伪造对象也完全能工作:

1
2
3
4
5
6
7
NSMutableArray *array = [NSMutableArray array];
for(int i = 0; i < 10; i++)
{
id obj = (__bridge id)(void *)&(Class){ [NSObject class] };
[array addObject: obj];
}
NSLog(@"%@", array);

上述代码不仅可以运行并且打印日志如下:

1
2
3
4
5
6
7
8
9
10
11
12
LetsBreakCocoa[17543:303] (
"<NSObject: 0x7fff5fbfe760>",
"<NSObject: 0x7fff5fbfe760>",
"<NSObject: 0x7fff5fbfe760>",
"<NSObject: 0x7fff5fbfe760>",
"<NSObject: 0x7fff5fbfe760>",
"<NSObject: 0x7fff5fbfe760>",
"<NSObject: 0x7fff5fbfe760>",
"<NSObject: 0x7fff5fbfe760>",
"<NSObject: 0x7fff5fbfe760>",
"<NSObject: 0x7fff5fbfe760>"
)

可惜的是,看起来所有伪造对象都是以同样的地址结束的。但是还是可以继续工作的。好了,当你退出方法并且 autorelease pool 试图去清理时:

1
2
3
4
5
6
7
8
9
10
(lldb) bt
* thread #1: tid = 0x46790, 0x00007fff8b3d55c9 libobjc.A.dylib`realizeClass(objc_class*) + 156, queue = 'com.apple.main-thread, stop reason = EXC_BAD_ACCESS (code=1, address=0x7fff00006000)
frame #0: 0x00007fff8b3d55c9 libobjc.A.dylib`realizeClass(objc_class*) + 156
frame #1: 0x00007fff8b3d820c libobjc.A.dylib`lookUpImpOrForward + 98
frame #2: 0x00007fff8b3cb169 libobjc.A.dylib`objc_msgSend + 233
frame #3: 0x00007fff8940186f CoreFoundation`CFRelease + 591
frame #4: 0x00007fff89414ad9 CoreFoundation`-[__NSArrayM dealloc] + 185
frame #5: 0x00007fff8b3cd65a libobjc.A.dylib`(anonymous namespace)::AutoreleasePoolPage::pop(void*) + 502
frame #6: 0x00007fff89420d72 CoreFoundation`_CFAutoreleasePoolPop + 50
frame #7: 0x00007fff8551ada7 Foundation`-[NSAutoreleasePool drain] + 147

因为这些伪造对象没有合适分配内存,所以一旦autorelease pool 试图在方法返回时去操作它们,就会出现严重的错误,并且内存会被重写。

KVC

下面是一个类数组:

1
2
3
4
5
6
7
8
9
10
11
NSArray *classes = @[
[NSObject class],
[NSString class],
[NSView class]
];
NSLog(@"%@", classes);
LetsBreakCocoa[17726:303] (
NSObject,
NSString,
NSView
)

下面一个这些类实例的数组:

1
2
3
4
5
6
7
NSArray *instances = [classes valueForKeyPath: @"alloc.init.autorelease"];
NSLog(@"%@", instances);
LetsBreakCocoa[17726:303] (
"<NSObject: 0x61000000a600>",
"",
"<NSView: 0x610000136bc0>"
)

键值编码并不意味着要这样使用,但是看起来也可以正常运行。

调用者检查

编译器的 builtin __builtin_return_address 方法可以返回调用你的代码的地址:

1
void *addr = __builtin_return_address(0);

因此,我们可以获取调用者的信息,包括它的名字:

1
2
3
Dl_info info;
dladdr(addr, &info);
NSString *callerName = [NSString stringWithUTF8String: info.dli_sname];

通过这个,我们可以做一些穷凶极恶的事(译者:并不认为是穷凶极恶的事,反而可作为调用动态方法的一种可选方法,虽然并不可靠),比如说完全可以根据不同的调用者调用合适的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@interface CallerInspection : NSObject @end
@implementation CallerInspection
- (void)method
{
void *addr = __builtin_return_address(0);
Dl_info info;
dladdr(addr, &info);
NSString *callerName = [NSString stringWithUTF8String: info.dli_sname];
if([callerName isEqualToString: @"__CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__"])
NSLog(@"Do some notification stuff");
else
NSLog(@"Do some regular stuff");
}
@end

这里是一些测试的代码:

1
2
3
4
5
6
7
id obj = [[CallerInspection alloc] init];
[[NSNotificationCenter defaultCenter] addObserver: obj selector: @selector(method) name: @"notification" object: obj];
[[NSNotificationCenter defaultCenter] postNotificationName: @"notification" object: obj];
[obj method];
LetsBreakCocoa[47427:303] Do some notification stuff
LetsBreakCocoa[47427:303] Do some regular stuff

当然,这种方式不是很可靠,因为 __CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__是 Apple 的内部符号,并且很有可能在未来修改。

Dealloc Swizzle

让我们使用 swizzle (方法调配技术)去调配-[NSObject dealloc]到一个不做任何事情的方法。在 ARC 下获得 @selector(dealloc) 有点棘手,因为我们不能直接读取它了:

1
2
Method m = class_getInstanceMethod([NSObject class], sel_getUid("dealloc"));
method_setImplementation(m, imp_implementationWithBlock(^{}));

现在我们坐下来欣赏这个例子所产生的混乱(简直就是代码界的黑暗料理):

1
2
3
4
for(;;)
@autoreleasepool {
[[NSObject alloc] init];
}

调配 dealloc 方法导致这个代码完美且合理地疯狂泄露,因为对象不能在任何地方被摧毁。

总结

用全新和有趣的方法搞崩 Cocoa 能够提供无尽的娱乐性。这也在真实的代码里体现出来了。想起我第一次遇到字符串中嵌入了 NUL ,那是充满痛苦的调试经历。其他只是为了好玩和适当的教学目的。

That’s it for today! Come back next time for more fun and games.
Friday Q&A is driven by reader suggestions, as always, so if you have
something you’d like to see discussed here, send it in!