原文链接= 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 字符毫无问题:
NSString *s = @"abc/0def";
如果我们认真的话,我们可以使用 lldb 打印它:
(lldb) p (void)[[NSFileHandle fileHandleWithStandardOutput] writeData: [s dataUsingEncoding: 5]]
abcdef
然而,展示这个字符串更为典型的方式是,字符串被当做 C 字符串在某个点结束。由于 ‘/0’ 字符意味着 C 字符串的结尾,因此字符串会在转换时缩短:
(lldb) po s
abc
(lldb) p (void)NSLog(s)
LetsBreakCocoa[16689:303] abc
原始的字符已然包含预计的字符数量:
(lldb) p [s length]
(unsigned long long) $1 = 7
试图对这个字符串进行操作会让你真正感到困惑:
(lldb) po [s stringByAppendingPathExtension: @"txt"]
abc
如果你不知道字符串的中间包含一个 NUL ,这类问题会让你感到这个世界满满的恶意。
一般来说,你不会遇到 NUL 字符,但是它很有可能通过加载外部资源的数据进来。 -initWithData:encoding:
会很轻易地读入零比特并且在返回的 NSString
中产生 NUL 字符。
循环容器
这里有一个数组:
NSMutableArray *a = [NSMutableArray array];
这里有一个数组包含其他的数据:
NSMutableArray *a = [NSMutableArray array];
NSMutableArray *b = [NSMutableArray array];
[a addObject: b];
目前为止,看起来还不错。现在我们让一个数组包含自身:
NSMutableArray *a = [NSMutableArray array];
[a addObject: a];
猜猜会打印出什么?
NSLog(@"%@", a);
以下就是调用堆栈的信息(译者:bt 命令为打印调用堆栈的信息):
(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
这里还删除了上千个栈帧。描述方法无法处理递归容器,所以它持续尝试去追踪到“树”的结束,并最终发生异常。
我们可以用它跟自身比较对等性:
NSLog(@"%d", [a isEqual: a]);
这姑且看起来是 YES。让我们创造另一个结构上相同的数组 b 然后用 a 和它比较:
NSMutableArray *b = [NSMutableArray array];
[b addObject: b];
NSLog(@"%d", [a isEqual: b]);
很抱歉:
(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
实例做同样的实验:
NSWindow *win = [self window];
NSView *a = [[NSView alloc] initWithFrame: NSMakeRect(0, 0, 1, 1)];
[a addSubview: a];
[[win contentView] addSubview: a];
为了让这个程序崩溃,你只需要尝试去显示视窗。你甚至不需要去打印一个描述或者做对等性比较。当试图去显示视窗时,应用就会由于尝试去追踪底部的视图结构而崩溃。
(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 值并不一样:
@interface AlwaysEqual : NSObject @end
@implementation AlwaysEqual
- (BOOL)isEqual: (id)object { return YES; }
- (NSUInteger)hash { return random(); }
@end
这显然违反了 Cocoa 的要求,当两个对象被认为是相等时,他们的 hash 应该总是返回相等的值。当然,这不是非常严格的强制要求,所以上述代码依然可以编译和运行。
让我们添加一个实例到 NSMutableSet
中:
NSMutableSet *set = [NSMutableSet set];
for(;;)
{
AlwaysEqual *obj = [[AlwaysEqual alloc] init];
[set addObject: obj];
NSLog(@"%@", set);
}
这产生了一个有趣的日志:
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 去搞崩程序是很好玩儿的事。
马上行动,下面就是一个例子:
SEL sel = (SEL)"";
[NSObject performSelector: sel];
当编译和运行之后,在运行期产生了很令人费解的错误:
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,会产生真正奇怪的错误:
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 :
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 依然是分配给所有对象类的大内存中的一小块内存。在这样的思维下,我们就可以创造一个伪造对象:
id obj = (__bridge id)(void *)&(Class){ [NSObject class] };
这些伪造对象也完全能工作:
NSMutableArray *array = [NSMutableArray array];
for(int i = 0; i < 10; i++)
{
id obj = (__bridge id)(void *)&(Class){ [NSObject class] };
[array addObject: obj];
}
NSLog(@"%@", array);
上述代码不仅可以运行并且打印日志如下:
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 试图去清理时:
(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
下面是一个类数组:
NSArray *classes = @[
[NSObject class],
[NSString class],
[NSView class]
];
NSLog(@"%@", classes);
LetsBreakCocoa[17726:303] (
NSObject,
NSString,
NSView
)
下面一个这些类实例的数组:
NSArray *instances = [classes valueForKeyPath: @"alloc.init.autorelease"];
NSLog(@"%@", instances);
LetsBreakCocoa[17726:303] (
"<NSObject: 0x61000000a600>",
"",
"<NSView: 0x610000136bc0>"
)
键值编码并不意味着要这样使用,但是看起来也可以正常运行。
调用者检查
编译器的 builtin __builtin_return_address
方法可以返回调用你的代码的地址:
void *addr = __builtin_return_address(0);
因此,我们可以获取调用者的信息,包括它的名字:
Dl_info info;
dladdr(addr, &info);
NSString *callerName = [NSString stringWithUTF8String: info.dli_sname];
通过这个,我们可以做一些穷凶极恶的事(译者:并不认为是穷凶极恶的事,反而可作为调用动态方法的一种可选方法,虽然并不可靠),比如说完全可以根据不同的调用者调用合适的方法:
@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
这里是一些测试的代码:
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) 有点棘手,因为我们不能直接读取它了:
Method m = class_getInstanceMethod([NSObject class], sel_getUid("dealloc"));
method_setImplementation(m, imp_implementationWithBlock(^{}));
现在我们坐下来欣赏这个例子所产生的混乱(简直就是代码界的黑暗料理):
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!