Safari沙箱逃逸与权限提升:深入解析CVE-2019-8603和CVE-2019-8606

本文详细分析了CVE-2019-8603和CVE-2019-8606两个macOS漏洞,涉及Safari沙箱逃逸和权限提升技术,包括堆越界读取、CFRelease调用控制、MIG协议利用和内核扩展加载的竞态条件漏洞。

Attribution is hard — at least for Dock: A Safari sandbox escape & LPE

2019年5月26日

• 作者:niklasb

这是关于CVE-2019-8603的快速分析报告,该漏洞是Dock和com.apple.uninstalld服务中的一个堆越界读取漏洞,可能导致受控的CFRelease调用,从而逃逸macOS上的WebContent沙箱,最终获得root权限。作为额外收获,CVE-2019-8606通过kextutil中的竞态条件为我们提供了从root到内核代码执行的途径。结合qwertyoruiopz和bkth提供的WebKit中的RCE漏洞,我们在今年的Pwn2Own中利用这些漏洞完全控制了Safari。这两个漏洞已在macOS 10.14.5中修复,让我们深入分析。

漏洞

我在开发覆盖率引导的模糊测试工具并在AXUnserializeCFType函数上测试时发现了这个漏洞。这是一个简单的解析器,在去年的Pwn2Own中出现过,我原本没想到它会包含任何漏洞。结果我错了。出于某种原因,这个函数是CoreFoundation对象序列化的另一个实现。它是HIServices框架的一部分,代码包含在相应的dylib中。

该函数可以反序列化的类型之一是CFAttributedString,这是一种字符串,其中每个字符都与一个CFDictionary关联,该字典包含任意属性来描述给定字符。这些属性可以是颜色、字体或用户关心的任何其他内容。在我们的案例中,这关乎代码执行。

为了高效表示这些信息,数据结构使用游程长度压缩,而不是为每个字符分配一个字典:

 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
// from CFAttributedString.c
struct __CFAttributedString {
    CFRuntimeBase base;
    CFStringRef string;
    CFRunArrayRef attributeArray;  // <- CFRunArray of CFDictinaryRef's
};

// from CFRunArray.c
typedef struct {
    CFIndex length;
    CFTypeRef obj;
} CFRunArrayItem;

typedef struct _CFRunArrayGuts {	/* Variable sized block. */
    CFIndex numRefs;                        /* For "copy on write" behavior */
    CFIndex length;                         /* Total count of values stored by the CFRunArrayItems in list */
    CFIndex numBlocks, maxBlocks;           /* These describe the number of CFRunArrayItems in list */
    CFIndex cachedBlock, cachedLocation;    /* Cache from last lookup */
    CFRunArrayItem list[0]; /* GCC */
} CFRunArrayGuts;

/* Definition of the CF struct for CFRunArray */
struct __CFRunArray {
    CFRuntimeBase base;
    CFRunArrayGuts *guts;
};

例如,字符串“attribution is hard”在内部可以表示为3个CFRunArrayItem:

  • 从索引0开始,长度11,设置“bold”属性
  • 从索引11开始,长度4,无属性
  • 从索引15开始,长度4,“italic”属性

显然有一些不变量必须维护,例如所有游程的并集覆盖整个字符串(且不多!)并且没有游程重叠。

反序列化函数cfAttributedStringUnserialize有两个路径。第一个很简单:它只读取一个字符串并使用NULL作为属性字典调用CFAttributedStringCreate。这意味着第二个路径才是关键所在,确实如此:它解析一个字符串,以及一系列范围及其关联的字典,然后调用内部函数_CFAttributedStringCreateWithRuns:

1
2
3
4
5
6
CFAttributedStringRef _CFAttributedStringCreateWithRuns(
        CFAllocatorRef alloc,
        CFStringRef str,
        const CFDictionaryRef *attrDictionaries,
        const CFRange *runRanges,
        CFIndex numRuns) { ...

解析器正确确保游程和字典的数量匹配,但对实际范围信息未进行任何验证。_CFAttributedStringCreateWithRuns也未进行验证:

1
2
3
4
5
6
    for (cnt = 0; cnt < numRuns; cnt++) {
	CFMutableDictionaryRef attrs = __CFAttributedStringCreateAttributesDictionary(alloc, attrDictionaries[cnt]);
	__CFAssertRangeIsWithinLength(len, runRanges[cnt].location, runRanges[cnt].length); // <- ouch
	CFRunArrayReplace(newAttrStr->attributeArray, runRanges[cnt], attrs, runRanges[cnt].length);
	CFRelease(attrs);
    }

断言在发布版本中不存在。因此,CFRunArrayReplace使用完全受控的范围和newLength值调用:

 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
27
28
29
void CFRunArrayReplace(CFRunArrayRef array, CFRange range, CFTypeRef newObject, CFIndex newLength) {
    CFRunArrayGuts *guts = array->guts;
    CFRange blockRange;
    CFIndex block, toBeDeleted, firstEmptyBlock, lastEmptyBlock;

    // [[ 1 ]]

    // ??? if (range.location + range.length > guts->length) BoundsError;
    if (range.length == 0) return;

    if (newLength == 0) newObject = NULL;

    // [...]

    /* This call also sets the cache to point to this block */

    // [[ 2 ]]
    block = blockForLocation(guts, range.location, &blockRange);
    guts->length -= range.length;

    /* Figure out how much to delete from this block */
    toBeDeleted = blockRange.length - (range.location - blockRange.location);
    if (toBeDeleted > range.length) toBeDeleted = range.length;

    /* Delete that count */

    // [[ 3 ]]
    if ((guts->list[block].length -= toBeDeleted) == 0) FREE(guts->list[block].obj);
    ...

在[[ 1 ]]处,很明显编写此代码的人对传入无效参数持怀疑态度,但未能更改函数签名以返回错误。在[[ 2 ]]处,事情开始失控:如果range.location太大,blockForLocation可能返回越界索引。[[ 3 ]]处的FREE通过调用从越界索引获取的指针上的CFRelease,最终完成了致命一击。这反过来可能导致调用objc_release,它咨询vtable以找到release选择器的Objective-C函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
id objc_msgSend(id obj, SEL sel, ...)
{
  __objc2_class *cls; // r10
  __int128 *v3; // r11
  __int64 i; // r11

  if ( !obj )
    return 0LL;
  if ( obj & 1 )
  {
    // [...]
  }
  else
  {
    cls = (*obj & 0x7FFFFFFFFFF8LL);
  }
  v3 = &cls->vtab[cls->mask & sel];
  if ( sel == *v3 )
    return (*(v3 + 1))();   // <- OUCH!!

请注意,如果我们完全控制传入的obj值,我们可以轻松进入第一个检查的else情况并到达间接调用,前提是我们可以将假对象放置在已知位置,并且我们知道release选择器的地址。幸运的是,在沙箱逃逸场景中,后者不是问题:所有库都在系统范围内映射到相同的地址,包括选择器。

堆布局

为了将此漏洞转化为对CFRelease的受控调用,我们必须在CFRunArray之后放置某些值,该数组被越界访问。我们通过使用解析器本身的分配和释放原语来实现这一点。具体来说,解析器允许我们创建一个字典并重复设置从输入流解析的条目。通过向字典添加新条目,我们可以分配一个对象。通过稍后覆盖该条目,对象被释放。这个原语足以创建非常可预测的孔序列,其中一个将被CFRunArray占用,后跟一个包含我们控制数据的CFString对象。

确切的布局有点微妙,但正确设置只是一些试验和错误以及找出用于喷洒的好对象的问题。我们最终得到一个解析器的单一输入,完成所有布局,然后可靠地触发漏洞。

Dock

我们两次使用此漏洞:首先利用由Dock托管且可从WebContent沙箱访问的com.apple.dock.server服务。其基于mach的协议通过MIG(Mach Interface Generator)创建。我们攻击消息ID 96508的处理程序,我并未真正反编译它。重要的是它期望通过AXUnserializeCFType解析一些数据作为外联内存描述符,我们将乐意提供。MIG还乐意将我们提供的千兆字节数据映射到接收者的地址空间中,这是一种众所周知的堆喷洒技术,允许我们将任意数据放置在已知位置。

我们确保在我们的大型喷洒(约800 MiB)的每个页面上重复相同的数据。它包括:

  • 触发间接调用并进入小型JOP存根以旋转堆栈的假对象。
  • 完成所有繁重工作的ROP链。

由于缺乏熵,我们知道这些页面中的一个将结束。注意此利用中明显缺乏任何信息泄漏。

uninstalld的情况

我们可以在这里停止并以普通用户权限弹出计算器。但我们真正想要的是内核代码执行。在分析接近尾声时,我在Google中输入AXUnserializeCFType并注意到Project Zero问题1219,这是关于2017年同一函数中一个非常浅的越界漏洞。事后看来,Ian Beer的以下引用非常准确:

从粗略检查来看,很明显这些方法不期望解析不受信任的数据。第一种方法cfStringUnserialize信任序列化表示中的长度字段,并使用该字段进行字节交换而无需任何边界检查,导致内存损坏。我猜所有其他反序列化方法也应该仔细检查。[…]有趣的是,这个开源的Facebook代码在github上包含了对cfAttributedStringUnserialize中内存安全问题的解决方法:https://github.com/facebook/WebDriverAgent/pull/99/files

真的很有趣。至今我不知道Facebook通过内存修补解决的漏洞是否具有相同的根本原因。但无论如何,Ian Beer当时还提到,以root身份运行的com.apple.uninstalld服务反过来与Dock通信,并调用AXUnserializeCFType处理Dock提供的数据。因此,我们可能可以冒充Dock并向uninstalld提供我们的有效负载,只需重复相同的利用。

在尝试此操作时,我遇到了几个问题:

  • 为了让uninstalld执行任何操作,我们必须提供一个授权令牌,该令牌在Dock二进制文件中嵌入了某个权利。
  • 我实际上未能将自己的代码映射到Dock内部,我假设是由于2018年某个时候添加了某些代码签名机制。
  • 当Dock运行时,我们无法注册com.apple.dock.server端点,因为Dock占用了它。

我不记得为什么我不能只是杀死Dock并从不同的进程注册此端点,在创建和转储授权令牌之后。可能有一个原因,或者也许我只是渴望了解Mach服务的工作原理。无论如何,我最终从在Dock内部运行的ROP链中执行了以下所有操作:

  • 调用AuthorizationCreate和AuthorizationMakeExternalForm以生成具有uninstalld权利的令牌。
  • 生成一个名为fakedock的二进制文件,该文件注册一个mach服务。
  • 查找fakedock服务。
  • 将com.apple.dock.server服务的接收端以及授权令牌发送到fakedock。
  • 永远睡眠。

fakedock将等待接收权和令牌,并从那时起冒充com.apple.dock.server服务。然后它将与uninstalld通信以导致其卸载应用程序,这将反过来触发它“回连”并通过某些MIG调用序列接收我们的利用有效负载,我们需要以适当的方式处理和响应。uninstalld的ROP链只是使用我们的最终root有效负载调用system。

从root到内核:kextutil中的TOCTOU

好的,这个漏洞不需要很长时间来描述。kextutil允许您以root身份加载内核扩展,但它执行某些检查,例如代码签名和用户批准。我们显然需要绕过这些检查以加载我们自己的未签名代码而无需用户交互。我们绕过文件检查的首选方法是竞态条件。通常使用符号链接。这在这里也有效。

在完成所有检查之后(逻辑错误专家CodeColorist最近详细描述了这些检查),kextutil -load最终将调用IOKit!OSKextLoadWithOptions以向内核发送加载请求。但是,如果提供的kext路径是符号链接,我们可以在这些操作之间将其指向其他位置。

需要满足几个条件才能使此过程完全按计划进行,其中之一是交换符号链接目标的正确时机。为了实现这一点,我运行了kextutil -verbose 6 -load /path/to/kext,它打印大量调试信息,并提供几乎完整的POSIX管道作为STDOUT。这样,它将在执行过程中的特定点溢出管道并暂停,直到我替换符号链接并清除管道。最终结果是加载未签名的kext,我们仅用于禁用SIP作为简单演示。这是最终结果的样子。

在此利用完成后,我的朋友和CTF队友Linus Henze向我指出了一种更简单的方法来可靠地触发竞态条件:kextutil实际上有一个标志-i,它将在安全检查之后但在加载kext之前提示用户。它并没有问“您现在想更改符号链接并恢复加载其他内容吗?”但那会更切题:)

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计