此时,ASLR已被破坏,因为已知共享缓存的基地址,并且可以使用堆喷射将受控数据放置在已知地址。剩下的就是再利用一次漏洞来执行代码。
在介绍了一些ObjC内部相关的结构之后,将介绍没有PAC的设备利用方法。它涉及到创建代码指针,因此它不再与启用PAC时一起使用。将介绍针对PAC和非PAC设备的另一种利用方发。最后,我们将演示一种将所提出的攻击与内核exploit链接起来的技术,该技术涉及在JavaScript中实现内核exploit。
用于远程代码执行的Objective-C
ObjC是C语言的一个超集,它增加了面向对象编程的特性。ObjC将对象、带有方法和属性的类以及继承等概念添加到语言中。ObjC中的大多数对象都是继承NSObject。下面展示一个简单的ObjC代码,它创建并初始化类的实例,然后调用实例上的方法。
Bob* bob = [[Bob alloc] init];
[bob doSomething];
ObjC依赖引用计数来管理对象的生存期。因此,每个对象都有一个refcount,可以作为ISA内联的一部分,或者在全局表中外联。对象进行操作的代码必须对对象执行objc_retain和objc_release调用。如果启用了自动引用计数(ARC),则必须由开发者手动或由编译器自动插入。
ObjC中的对象是一块内存(通常通过calloc分配),它总是以“ISA”值开头,然后是实例变量/属性。ISA值是一个指针大小的值,包含以下信息:
- 1.指向此对象的是其实例类的指针
- 2.内联引用计数器
- 3.一些额外的标志位
ObjC类描述它的实例,但实例本身也是一个ObjC对象。因此,ObjC中的类不仅是编译时存在,而且在运行时(Runtime)也存在,从而支持内省(Introspection)和反射(Reflection) 。一个类包含以下信息:
- 1.ISA,指向专用的元类
- 2.指向父类的指针(如果有)
- 3.一种加速方法查找的方法缓存
- 4.方法表
- 5.每个实例具有的实例变量列表(名称和偏移量)
ObjC中的Method基本上是一个元组:
(Selector, Implementation)
其中Selector是包含方法名的c字符串,而Implementation是指向实现该方法的本地函数指针。
当编译并执行上面的ObjC代码时,将大致发生以下情况:
首先,在编译时,将三个ObjC方法调用转换为以下伪C代码(如果启用了ARC):
Bob* bob = objc_msgSend(BobClass, "alloc");
// Refcount is already 1 at this point, so no need for a objc_retain()
bob = objc_msgSend(bob, "init");
objc_msgSend(bob, "doSomething");
...
objc_release(bob);
然后,在运行时(Runtime),objc_msgSend将大致执行以下操作:
- 1.检测ISA值并依次从中提取类指针
- 2.在类的方法缓存中查找所请求的方法实现。实际上是通过selector地址的hash来获得表索引,然后将selector的条目与请求的selector进行比较来完成的。
- 3.如果失败,继续在方法表中查找
- 4.如果找到了实现的方法,则将调用(tail-call )并将参数传递给objc_msgSend。否则会引发异常。
请注意,由于ObjC Runtime确保selector是唯一的,因此可以通过比较指针值来比较两个selector。
另一方面,objc_release将减少对象的refcount。如果将对象的refcount递减为0,则调用dealloc方法(对象的析构函数),然后释放对象的内存chunk。
在未开启PAC设备上执行Native代码
鉴于以上对ObjC Runtime内部机制的了解,以及本系列第二部分中获得的功能,现在可未启用PAC的设备(例如iPhone X或更早版本)上获取本地代码执行。
下面演示的是来自[NSSharedKeySet indexForKey:]的代码片段,它从攻击者选择的地址(由于_keys 为nullptr且索引受控制)读取待检测的值并对其进行处理。
id candidate = self->_keys[index];
if (candidate != nil) {
if ([key isEqual:candidate]) {
return prevLength + index;
}
}
如果key是NSString,则[NSString isEqual:] 将使用给定参数调用[arg isNSString __],这样,可以通过以下方法实现本地代码执行:
- 1.执行第2部分中描述的堆喷射,堆喷射应该包含一个fake对象,指向一个fake类,这个fake类包含一个fake方法缓存,该方法的条目是“isNSString__”,该条目包含一个受控的IMP指针,堆喷射还应该包含一个指向fake对象的指针.
- 2.触发漏洞读取指针并将其传递给[key isEqual:]。然后在伪造的对象上调用“isNSString__”,从而将指针指向攻击者选择的地址。
- 3.在ROP中执行stack pivot并实现payload
此时,攻击者已成功利用了未开启PAC的设备。但是,启用PAC后,将无法再进行上述攻击,将在稍后介绍。
PAC对用户空间的影响
布兰登·阿扎德(Brandon Azad)此前曾做过一些研究,详细介绍了PAC在内核中的工作方式。用户空间中的PAC没有太大区别。接下来是对PAC如何在用户空间中工作的一个总结。有关更多信息,请参阅llvm的PAC文档。
每个代码指针(函数指针、ObjC方法指针、返回地址,…)都用一个密钥和一个可选的64位“context”值进行签名。得到的签名被截断为24位,然后存储在指针上,通常是未使用的。在跳转到代码指针之前,通过使用相同的key和context值重新计算指针并将结果与指针中存储的位进行比较,验证指针的签名。如果它们匹配,签名位将被清除,留下一个有效的指针进行解引用。如果它们不匹配,则指针将被删除,从而在随后取消引用时导致访问冲突。
context值的主要目的是防止指针交换攻击,即有符号指针从进程中的一个位置复制到另一个位置。例如,存储在方法缓存中的ObjC方法实现指针使用缓存条目的地址作为context,而堆栈上的返回地址使用stackframe中它们自己的地址作为context。因此,两者不能交换。
总而言之,如果启用了PAC,并且PAC本身的实现或签名小工具(一段可以合法调用并将PAC签名添加到任意指针并将结果返回给攻击者的代码)中没有错误,就不可能像前面介绍的攻击那样伪造代码指针。
尽管使用了PAC,但仍然可行的一种攻击是创建ObjC类的伪实例,并调用现有的(合法的)方法。因为PAC目前没有保护标识类实例的isa值(这可能是因为ISA值没有足够的空闲位用于签名)。这样,知道了dyld共享缓存(包含所有ObjC Class对象)的基址,就可以伪造该进程中当前加载的任何库中任何类的实例。
但是,在当前采用的代码路径([NSSharedKeySet indexForKey:]上,只有少数selector被发送到受控对象,例如’__isNSString’。这可能不是很有用,因此必须找到另一个产生不同原语的代码路径。
一个有用的Exploit Primitive
如上所述,ObjC在很大程度上依赖于引用计数,对象(除了ObjC_msgSend)执行的最常见操作之一是ObjC_release。因此,许多内存破坏漏洞可以转换为对受控对象ObjC_release的调用。这里也是这样,尽管需要做更多的工作,因为在[NSSharedKeySet indexworky]期间使用的代码本身不会对攻击者控制的对象调用objc_release。因此,需要一个新的对象图来访问另一段代码,在本例中是在[NSSharedKeyDictionary setObject:ForKey]中,在[NSSharedKeyDictionary initWithCoder]中调用,如下所示。
-[NSSharedKeyDictionary initWithCoder:coder] {
self->_keyMap = [coder decodeObjectOfClass:[NSSharedKeySet class]
forKey:@”Ns.skkeyset”];
self->_values = calloc([self->_keyMap count], 8);
NSArray* keys = [coder decodeObjectOfClasses:[...]
forKey:@”NS.keys”]];
NSArray* values = [coder decodeObjectOfClasses:[...]
forKey:@”NS.values”]];
if ([keys count] != [values count]) { // return error }
for (int i = 0; i < [keys count]; i++) {
[self setObject:[values objectAtIndex:i]
forKey:[keys objectAtIndex:i]];
}
}
-[NSSharedKeyDictionary setObject:obj forKey:key] {
uint32_t index = [self->_keyMap indexForKey:key];
if (index != -1) {
id oldval = self->_values[index];
objc_retain(obj);
self->_values[index] = obj;
objc_release(oldval);
}
}
注意,在[NSSharedKeyDictionary initWithCoder:]中,在第4行中调用calloc()之后,不检查nullptr。在正常情况下,这可能没有问题,因为相同大小的另一个分配(对于SharedKeySet)及其内容(数千兆字节的数据)首先必须通过iMessage发送。但是,结合当前的漏洞,这确实会成为一个问题,因为在运行此代码时,尚未验证sharedKeyset其中一个_numKey值,从而使攻击者可以导致calloc()失败,而不需要先成功执行其他大量分配。然后,可以欺骗[NSSharedKeyDictionary setObject:forKey:]从攻击者控制的地址(第4行)再次读取ObjC id,然后对其调用ObjC release,下图演示了这种情况,在攻击者控制的值上执行objc_release 调用。calloc()的返回值已经根据nullptr进行了检查,在这种情况下,unarchiving将被中止。
当显示的对象图未被归档时,SharedKeyDictionary2将尝试在自身中存储“k2”的值,从而调用[SharedKeySet2 IndexWorky:“k2”]。反过来,这将递归到SharedKeySet1,后者也将找不到key而进一步递归(因为从_rankTable 提取的索引大于_numKey )。最后,SharedKeySet3将能够查找“ k2”并返回当前累积的偏移量,即(1 + 0x41414140 / 8)。然后SharedKeyDictionary2将访问0x41414148,对读取的值调用objc_release,最后将NSString“foobar”写入该地址。这进一步提供了exploitation所需的任意objc_release原语。
这样,现在就可以获得任何合法的’dealloc’或’.cxx_descruct’(编译器自动生成的析构函数)方法。共享缓存中大约有50000个这样的函数。很多 gadgets! 要查找有这样的gadgets,可以在加载的dyld_shared_cache映像上使用以下IDAPython代码段。它枚举可用的析构函数,并将其名称和反编译的代码写入磁盘:
for funcea in Functions():
funcName = GetFunctionName(funcea)
if ‘dealloc]’ in funcName or ‘.cxx_desctruct]’ in funcName:
func = get_func(funcea)
outfile.write(str(decompile(func)) + ‘n’)
找到感兴趣的dealloc“gadget”的一种方法是正则匹配被发送到其他对象的具体的selectors反编译代码,从而进一步增加了可以执行的代码量。下面展示了一个特别有趣的dealloc实现:
-[MPMediaPickerController dealloc](MPMediaPickerController *self, SEL)
{
v3 = objc_msgSend(self->someField, "invoke");
objc_unsafeClaimAutoreleasedReturnValue(v3);
objc_msgSend(self->someOtherField, "setMediaPickerController:", 0);
objc_msgSendSuper2(self, "dealloc");
}
这会将“invoke” 选择器(selector)发送给到伪造的self对象读取对象。例如,“invoke”由NSInvocation实现的对象,这些对象本质上是绑定函数:(目标对象,选择器,参数)的元组,当发送“invoke”选择器时,将使用存储的参数调用目标对象上存储的选择器。因此,可以使用任意参数调用完全受控对象上的任何ObjC方法。事实上,已经足够强可以弹出calc。call:
[UIApplication launchApplicationWithIdentifier:@"com.apple.calculator" suspended:NO]
最终的堆喷射布局大致如下图所示。现在,堆喷射必须更大一些,因为先前使用的地址0x110000000(现在也将用作calloc的size参数),在新设备上不会导致calloc失败,所以需要更高的地址,比如0x140000000。还要注意,在dealloc实现中对“setMediaPickerController”的调用可以通过将someOtherField设置为零而存活,在这种情况下,objc_msgSend通常变成nop。NSInvocation对象由一个“frame”组成,一个指向缓冲区的指针,其中包含调用的参数,以及存储的返回值,一个对NSMethodSignature对象的引用,其中包含关于参数的数量和类型的信息。为了简洁起见,后两个字段在图中省略了。
要从[NSSharedKeyDictionary setObject:obj forKey:key]触发受控制的ObjC方法调用,必须访问地址0x1400003ff8,以便将指向fake MPMediaPickerController的指针传递给ObjC_release。
下面的视频演示了exploit的执行情况。注意,在执行payload后,当SpringBoard正常运行时,不会发生“respring”或类似的崩溃行为。
视频地址:https://v.youku.com/v_show/id_XNDUwNDY3NTM3Mg==.html
虽然打开计算器很不错,但是像这里所演示的这种攻击的最终目标很可能是进行内核攻击。一种选择是使用当前的功能来打开WebView并提供典型的浏览器exploit。但这可能需要一个单独的浏览器漏洞。因此,最后一个问题是在目前给定情况下,是否可以直接实现内核漏洞利用。本文的剩余部分将探讨这些。
面向选择器编程(SLOP)
这里描述的技术不是最初发送给Apple的PoC。因此,它在9月13日被单独报道为一种利用技术。
扩展当前的exploitation功能,即在可控参数的受控对象上调用单个方法的能力,下一步是获得将多个方法调用链接在一起的能力。此外,还应该能够使用方法调用的返回值,例如作为后续方法调用的参数,并且能够读取和写入进程内存。下面的技术先称为“SLOP”(SeLector Oriented Programming)。
首先,可以通过创建一个包含fake NSInvocation对象的fake NSArray,然后对其调用[NSArray makeObjectsPerformSelector:@selector(invoke)](通过在[MPMediaPickerController dealloc]期间调用“bootstrap”NSInvocation),这将调用每个NSInvocations,从而执行多个独立的ObjC方法调用。
接下来,通过使用[NSInvocation getRe turnValue:ptr]方法,可以将之前方法调用的返回值写入内存中的某个位置,例如写入后续调用的参数缓冲区中。这样,可以将方法调用的返回值用作后续调用的参数。
一个简单的SLOP链如下所示:
该chain相当于以下ObjC代码:
id result = [target1 doX];
[target2 doY:result];
对于最后一个原语,即读写进程内存的能力,可以使用[NSInvocation getArgument:atIndex]方法,其简化的伪代码如下所示:
void -[NSInvocation getArgument:(void*)addr atIndex:(uint)idx] {
if (idx >= [self->_signature numberOfArguments]) {
...; // abort with exception
}
memcpy(addr, &self->_frame[idx], 8);
}
由于上面的方法可以使用SLOP,因此可以通过创建fake NSInvocation对象来执行任意内存读写(使用“setArgument:atIndex”),其中的_frame成员指向所需的地址。通常,这还允许在与指针偏移的位置进行读取和写入,这有利于访问或破坏某些数据结构的成员。这将是下一部分重要的原语。
使用SLOP,现在可以执行任意ObjC方法。当这发生在SpringBoard的沙箱外时,就已经能够访问用户数据了。但是,SLOP可能不够完美,无法执行内核攻击,这是全面攻克设备的最后一步。例如,这是由于SLOP缺少控制流(control-flow)原语造成的。
内核攻击链
执行内核攻击的一种方法是使用SLOP将其转换为脚本context,例如转换为JavaScriptCore.framework,并用该脚本语言实现内核攻击。使用以下方法调用可以轻松地从SLOP转换为JavaScript:
JSContext* ctx = [[JSContext alloc] init];
[ctx evaluateScript:scriptContent];
为了在JavaScript中执行内核攻击,必须将以下原语桥接到JavaScript中或在其中重新创建:
- 1.能够读写当前进程的内存
- 2.调用C函数尤其是syscall
使用一种标准的浏览器利用技术来获取JavaScript中的内存读/写非常容易:破坏JavaScript DataViews / TypedArrays。可以在JS中创建两个DataView对象,然后将其返回给ObjC,其中第一个对象已损坏,因此它的尾部内存指针现在指向第二个对象。如上所述,在SLOP中使用内存读/写原语可能会破坏尾部存储指针。结果如下图所示:
现在可以从JavaScript读取/写入任意地址,方法是首先损坏第二个DataView的尾部存储指针,然后访问第二个DataView。通常,Gigacage是JavaScriptCore中的一种缓解措施,目的是阻止arraybuffer的滥用。但在Cocoa构建的JSC中并不起作用。这解决了上面的问题。
之后,通过以下两种ObjC方法可以达到point 2:
- 1.[CNFileServices dlsym::]只是dlsym(3)的一个封装。启用PAC后,dlsym将在返回函数指针之前使用context zero进行签名(因为调用者没有可用的context值)
- 2.[NSInvocation invokeUsingIMP:] 接受一个IMP(用context zero签名的函数指针)作为参数,并使用其缓冲区中的参数进行调用,从而对其进行完全控制
结合这两个ObjC方法,可以调用带有任意参数的导出C函数,最后,还可以将此功能桥接到JavaScript中:JavaScript内核通过实现JSExport协议并指定应公开给JS的方法来支持桥接ObjC对象。以这种方式桥接的ObjC方法是通过为每个方法创建一个NSInvocation对象并在JS中调用该方法时调用该对象来实现的。指定内存读取/写入功能,则可以破坏此NSInvocation对象,以便对不同的对象调用不同的方法,例如[CNFileServices dlsym ::] 或[NSInvocation invokeUsingIMP:] 。
有了这些和一些封装的代码能够轻松地公开所需的功能,Ned Williamson重构了CVE-2019-8605 aka SockPuppet部分代码如下所示(完整代码请参见crash_kernel.js):
let sonpx = memory.alloc(8);
memory.write8(sonpx, new Int64("0x0000000100000001"));
let minmtu = memory.alloc(8);
memory.write8(minmtu, new Int64("0xffffffffffffffff"));
let n0 = new Int64(0);
let n4 = new Int64(4);
let n8 = new Int64(8);
while (true) {
let s = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
setsockopt(s, SOL_SOCKET, SO_NP_EXTENSIONS, sonpx, n8);
setsockopt(s, IPPROTO_IPV6, IPV6_USE_MIN_MTU, minmtu, n4);
disconnectx(s, n0, n0);
// The next setsockopt will write into a freed buffer, so
// give the kernel some time to reclaim it first.
usleep(1000);
setsockopt(s, IPPROTO_IPV6, IPV6_USE_MIN_MTU, minmtu, n4);
close(s);
}
这表明在通过iMessage成功进行远程攻击之后,不需要任何进一步的漏洞, 就可以从JavaScript执行内核攻击(例如,JavaScriptCore RCE exploit)。
POC的源代码可以在这里找到。
改善Messenger安全性
在开发工作中获得的见解,以及许多加强措施的建议,其中大部分已经在本系列文章中提到过,并且已经反馈给了苹果。下面进行总结一下:
- 1.应该尽力将代码放在用户交互之后,特别是在接收来自未知发件人的消息时。
- 2.应删除无交互攻击面中的通信通道,如自动发送收据(automatic delivery receipts)。如果无法实现,那么至少应该以某种方式发送收据,以防止它们被滥用来构造崩溃的oracle,例如通过在服务器端或从专用进程发送它们。
- 3.处理传入数据的进程应该使用沙盒,并且应禁止任何网络交互,以阻止攻击者通过内存破坏或逻辑错误构建通信通道。这还可以防止CVE-2019-8646等漏洞被利用。
- 4.ASLR应尽可能完善。尤其是,在任何远程攻击面上都不应该可以进行堆喷射和暴力破解。同样,在iOS上,也可以改进dyld共享缓存区域的ASLR。
- 5.如果攻击者进行了足够的尝试(例如imagent的情况),则某些概率防御措施可能会被打败。因此,似乎需要尽可能限制攻击者的尝试次数,并部署能够检测失败的监控。
自8月9日向Apple提交报告以来,iOS发生了以下安全相关的变化:
- 1.iMessage中的NSKeyedUnarchiver攻击面的很大一部分被阻止,从而使此bug和其他错误无法通过iMessage进行利用
- 2.已修复此漏洞,并已将其分配为CVE-2019-8641
- 3.传入iMessages中包含的“BP”NSKeyedUnarchiver的payload的解码被移到沙盒进程(IMDPersistenceAgent)中。因此,利用iMessage上的NSKeyedUnarchiver漏洞的攻击者现在也需要突破沙盒。
- 4.在NSInvocation类中添加了一个新的无符号int字段_magic。该字段在初始化期间设置为每个进程随机的全局变量的值,并在[NSInvocation invoke]期间再次声明为该值。这样,就无法在未泄漏此值的情况下创建伪造的NSInvocation实另外,PAC似乎正在努力尝试保护selector和目标字段的NSInvocation,即使在攻击者具有内存读/写功能的情况下,也进一步阻止了NSInvocation的滥用。
- 5.[MPMediaPickerController dealloc]方法曾被用来通过向攻击者控制的对象发送“invoke”选择器来绕过PAC,现在看来已经被删除了。然而,还有其他的dealloc实现在攻击者控制的对象上调用“invoke”。这些也可能在未来被移除。如上所述,现在无论如何都很难创建假的NSInvocation实例。
这个列表可能是不完整的,因为它是通过逆向工程和手动调试而汇编成的,因为Apple目前尚未与安全研究人员分享如何解决其问题的详细信息。
最后要注意的是,虽然沙盒非常重要,但是不应该盲目地依赖它。作为需要思考示例,此处利用的漏洞也很可能用来逃逸设备上的任意沙盒,因为目标API通常也通过IPC公开。
结论
本系列文章演示了,尽管部署了许多漏洞缓解措施,但仍有可能利用非交互设置(如移动消息服务)中的内存破坏漏洞进行攻击,并且通常认为不需要额外的远程信息泄漏漏洞。该漏洞已由苹果公司修复,并已部署了一些进一步的强化措施。这项研究的一个关键发现是,ASLR在理论上应该在这种攻击场景中提供强大的保护,但在实践中却没有那么强大。特别是,ASLR可以通过堆喷射和crash oracles被突败,crash oracles可以从常见的侧通道(如自动送达收据)构建。第二个见解是,可以执行任意代码,其功能足以执行内核攻击,而无需创建或损坏代码指针,从而有效地绕过PAC。最后,基于开发的exploit,提出了许多减少攻击面、强化攻击面和减少漏洞的建议。我希望这项研究最终能够帮助所有厂商,通过强调小的设计问题可以产生重大的安全隐患,并希望更好地保护他们的用户免受此类攻击。
本文翻译自Project Zero Blog