Vulnerable iOS/macOS Kext

In this guide, we'll look at loading a kext that is vulnerable by design in an iPhone and trigger a heap overflow vulnerability.

The kext is available at Vulnerable-Kext

The kext provides these following vulnerbilities to play with:

#define CRASH             0x1
#define HEAP_OVERFLOW     0x2
#define INFO_LEAK         0x3
#define BUFFER_OVERFLOW   0x4
#define USE_AFTER_FREE    0x5   //todo
#define INTEGER_OVERFLOW  0x6   //todo
#define DOUBLE_FETCH      0x7

Before we proceed, we need to collect some symbols from the kernel that are required for the kext.

Fetching Symbols

I'll explain below how to collect the required symbols for iPhone X on iOS version 13.4.1.

Download the firmware from https://ipsw.me/download/iPhone10,3/17E262. Unzip the ipsw.

Now we'll use jtool2 by Jonathan to decompress the kernel cache

#  /Users/ant4g0nist/tools/jtool2/jtool2 -dec kernelcache.release.iphone10b
Decompressed kernel written to /tmp/kernel
#  mv /tmp/kernel kernelcache.decompressed

Open the decompressed kernel in IDA pro or Binary Ninja or whatever you choose and wait for it to finish the analysis.

The symbols we need are:

  • _IOSleep
  • _kernel_map
  • _kernel_thread_start
  • _panic
  • _strncpy
  • _memset
  • _memmove
  • ___stack_chk_fail
  • ___stack_chk_guard
  • _ctl_register
  • ___MALLOC
  • __FREE
  • _current_proc
  • _copyin
  • _copyout

_IOSleep

Function starts with this signature

01 48 88 52 E1 01 A0 72

We'll search for this signature in the text(__TEXT_EXEC:__text) section.

The text section for me is:

__TEXT_EXEC:__text	FFFFFFF007BDC000	FFFFFFF0090BAA74	R	.	X	.	L	para	0D	public	CODE	64	00	0D

A simple script like this should find it:

_IOSleep = "01 48 88 52 E1 01 A0 72"

def search_signature(signature):
    start_address = 0xFFFFFFF007BDC000
    end_address   = 0xFFFFFFF0090BAA74

    data = idc.get_bytes(start_address, end_address - start_address)

    address = start_address

    while address < end_address and address != idc.BADADDR:
        address = idc.find_binary(address, idc.SEARCH_DOWN, signature, 0x10)

        if address != idc.BADADDR:
            func = idaapi.get_func(address)
            try:
                if func.start_ea == address:
                    print(f"{address:x}")
            except:
                pass
        
        address += 0x10

print("_IOSleep")
search_signature(_IOSleep)

kernel_thread_start

Sample usage of kernel_thread_start from XNU kernel: (bsd/kern/kern_memorystatus_freeze.c)

__private_extern__ void
memorystatus_freeze_init(void)
{
	kern_return_t result;
	thread_t thread;

	freezer_lck_grp_attr = lck_grp_attr_alloc_init();
	freezer_lck_grp = lck_grp_alloc_init("freezer", freezer_lck_grp_attr);

	lck_mtx_init(&freezer_mutex, freezer_lck_grp, NULL);

	/*
	 * This is just the default value if the underlying
	 * storage device doesn't have any specific budget.
	 * We check with the storage layer in memorystatus_freeze_update_throttle()
	 * before we start our freezing the first time.
	 */
	memorystatus_freeze_budget_pages_remaining = (memorystatus_freeze_daily_mb_max * 1024 * 1024) / PAGE_SIZE;

	result = kernel_thread_start(memorystatus_freeze_thread, NULL, &thread);
	if (result == KERN_SUCCESS) {
		proc_set_thread_policy(thread, TASK_POLICY_INTERNAL, TASK_POLICY_IO, THROTTLE_LEVEL_COMPRESSOR_TIER2);
		proc_set_thread_policy(thread, TASK_POLICY_INTERNAL, TASK_POLICY_PASSIVE_IO, TASK_POLICY_ENABLE);
		thread_set_thread_name(thread, "VM_freezer");

		thread_deallocate(thread);
	} else {
		panic("Could not create memorystatus_freeze_thread");
	}
}

Assuming the strings are available, search for "Could not create memorystatus_freeze_thread". I found 1 xref in IDA inside sub_FFFFFFF00802E71C function.

__int64 sub_FFFFFFF00802E71C()
{
  _DWORD *v0; // x0
  _DWORD *v1; // x20
  __int64 v2; // x0
  __int64 v3; // x19
  unsigned int v4; // w8
  __int64 v5; // x19
  __int64 result; // x0
  unsigned int v7; // w9
  unsigned int v8; // off
  __int64 v9; // [xsp+0h] [xbp-30h] BYREF
  unsigned __int64 v10; // [xsp+8h] [xbp-28h] BYREF

  v9 = 0LL;
  v10 = 4LL;
  v0 = kalloc_canblock(&v10, 1LL, &unk_FFFFFFF0090C85D8);
  v1 = v0;
  if ( v0 )
    *v0 = (dword_FFFFFFF0092557C0 >> 1) & 1;
  qword_FFFFFFF009270068 = v0;
  v10 = 168LL;
  v2 = kalloc_canblock(&v10, 1LL, &unk_FFFFFFF0090C85F0);
  v3 = v2;
  if ( v2 )
    sub_FFFFFFF007C224E0(v2, "freezer", v1);
  qword_FFFFFFF009270070 = v3;
  qword_FFFFFFF0092DAC28 = 570425344LL;
  qword_FFFFFFF0092DAC20 = 0LL;
  v4 = atomic_fetch_add_explicit((v3 + 16), 1u, memory_order_relaxed);
  if ( !v4 )
    sub_FFFFFFF00821F2FC(v3 + 16);
  if ( v4 >= 0xFFFFFFF )
    sub_FFFFFFF00821F318(v3 + 16);
  atomic_fetch_add_explicit((v3 + 24), 1u, memory_order_relaxed);
  qword_FFFFFFF009270030 = (dword_FFFFFFF0090E7AE4 << 6) & 0x3FFC0;
  if ( sub_FFFFFFF007C4F540(sub_FFFFFFF00802E8C0, 0LL, &v9) )                       <----- As it can be observed, this is the call to kernel_thread_start. thread_t thread = v9.
    sub_FFFFFFF0090BAA08("\"Could not create memorystatus_freeze_thread\"");        <----- this should be panic
  v5 = v9;                                                                          <----- v5 = v9 = thread
  sub_FFFFFFF007C59340(v9, 0LL, 35LL, 2LL);                                         <----- sub_FFFFFFF007C59340 = proc_set_thread_policy
  result = sub_FFFFFFF007C59340(v5, 0LL, 36LL, 1LL);
  if ( v5 )
  {
    result = *(v5 + 960);
    if ( result )
      result = sub_FFFFFFF00809100C(result, "VM_freezer");                          <---- sub_FFFFFFF00809100C = thread_set_thread_name
    v7 = atomic_fetch_add_explicit((v5 + 204), 0xFFFFFFFF, memory_order_release);   
    if ( v7 == 1 )
    {
      v8 = __ldar((v5 + 204));
      result = sub_FFFFFFF007C4DCAC(v5);                                            <------ thread_deallocate_complete
    }
    else if ( !v7 )
    {
      sub_FFFFFFF00821F2E0((v5 + 204));
    }
  }
  return result;
}
kernel_thread_start = 0xFFFFFFF007C4F540
panic               = 0xFFFFFFF0090BAA08

_strncpy

We can find strncpy by searching for xrefs of some strings as we did above or search for the signature:

signature = "F6 03 00 AA E0 03 13 AA E1 03 15 AA E2 03 16 AA"

Pseudo code indeed resembles implementation from strncpy.c :

char *__cdecl sub_FFFFFFF007D2BC4C(char *__dst, const char *__src, size_t __n)
{
  unsigned __int64 v6; // x0
  unsigned __int64 v7; // x22

  v6 = str_len(__src, __n);
  if ( v6 >= __n )
  {
    memmove(__dst, __src, __n);
  }
  else
  {
    v7 = v6;
    memmove(__dst, __src, v6);                  <---- memmove
    memset(&__dst[v7], 0, __n - v7);            <---- memset
  }
  return __dst;
}
  • strncpy = sub_FFFFFFF007D2BC4C
  • memmove = sub_FFFFFFF00820B550
  • memset = sub_FFFFFFF00820B780

_stack_chk_fail

Just search for Kernel stack memory corruption detected and we end in _stack_chk_fail at stack_protector.c

void __noreturn _stack_chk_fail()
{
  panic("\"Kernel stack memory corruption detected\"");
}

___stack_chk_fail = 0xFFFFFFF00821F40C

_stack_chk_fail is called after ___stack_chk_guard is compared with the canary. So, the calls/xrefs to ___stack_chk_fail should look like this:

  if ( qword_FFFFFFF009275908 != v26 )          <----- __stack_chk_guard = qword_FFFFFFF009275908
    j_stack_check_fail();
  • ___stack_chk_guard = 0xFFFFFFF009275908

_ctl_register

Search and get xrefs to ctl_register failed and pseudo code should like this:

   v10 = sub_FFFFFFF008983D28;
    v11 = sub_FFFFFFF008983D44;
    v1 = sub_FFFFFFF007FFC6F4(v4, &unk_FFFFFFF0092E0D80);                               <---- _ctl_register
    v2 = v1;
    if ( v1 )
      sub_FFFFFFF007C29D28("%s - ctl_register failed: %d\n", "en_register", v1);
  • _ctl_register = 0xFFFFFFF007FFC6F4

___MALLOC

Code from (kern_malloc.c)[https://github.com/apple/darwin-xnu/blob/master/bsd/kern/kern_malloc.c#L573]

void *
__MALLOC(
	size_t          size,
	int             type,
	int             flags,
	vm_allocation_site_t *site)
{
	void    *addr = NULL;
	vm_size_t       msize = size;

	if (type >= M_LAST) {
		panic("_malloc TYPE");
	}

	if (size == 0) {
		return NULL;
	}

	if (msize != size) {
		panic("Requested size to __MALLOC is too large (%llx)!\n", (uint64_t)size);
	}

	if (flags & M_NOWAIT) {
		addr = (void *)kalloc_canblock(&msize, FALSE, site);
	} else {
		addr = (void *)kalloc_canblock(&msize, TRUE, site);
		if (addr == NULL) {
			/*
			 * We get here when the caller told us to block waiting for memory, but
			 * kalloc said there's no memory left to get.  Generally, this means there's a
			 * leak or the caller asked for an impossibly large amount of memory. If the caller
			 * is expecting a NULL return code then it should explicitly set the flag M_NULL.
			 * If the caller isn't expecting a NULL return code, we just panic. This is less
			 * than ideal, but returning NULL when the caller isn't expecting it doesn't help
			 * since the majority of callers don't check the return value and will just
			 * dereference the pointer and trap anyway.  We may as well get a more
			 * descriptive message out while we can.
			 */
			if (flags & M_NULL) {
				return NULL;
			}
			panic("_MALLOC: kalloc returned NULL (potential leak), size %llu", (uint64_t) size);
		}
	}
	if (!addr) {
		return 0;
	}

	if (flags & M_ZERO) {
		bzero(addr, size);
	}

	return addr;
}

Again, xrefs to _malloc TYPE will lead you to __MALLOC

  • __MALLOC = 0xFFFFFFF00800CDC0

_FREE

Code from (kern_malloc.c)[https://github.com/apple/darwin-xnu/blob/master/bsd/kern/kern_malloc.c#L624]

void
_FREE(
	void            *addr,
	int             type)
{
	if (type >= M_LAST) {
		panic("_free TYPE");
	}

	if (!addr) {
		return; /* correct (convenient bsd kernel legacy) */
	}
	kfree_addr(addr);
}

Xrefs to _free TYPE will lead you to __FREE

  • __FREE = 0xFFFFFFF00800CE6C

current_proc

Code for (current_proc)[bsd/kern/bsd_stubs.c]

struct proc *
current_proc(void)
{
	/* Never returns a NULL */
	struct uthread * ut;
	struct proc * p;
	thread_t thread = current_thread();

	ut = (struct uthread *)get_bsdthread_info(thread);
	if (ut && (ut->uu_flag & UT_VFORK) && ut->uu_proc) {
		p = ut->uu_proc;
		if ((p->p_lflag & P_LINVFORK) == 0) {
			panic("returning child proc not under vfork");
		}
		if (p->p_vforkact != (void *)thread) {
			panic("returning child proc which is not cur_act");
		}
		return p;
	}

	p = (struct proc *)get_bsdtask_info(current_task());

	if (p == NULL) {
		return kernproc;
	}

	return p;
}

Xrefs will again land you in current_proc()

  • current_proc = 0xFFFFFFF0081025E4

The functions copyin and copyout are defined in copyio.c. Xrefs to these 2 functions can be found by searching for example necp_client_claim copyin client_id error and %s: %s copyout() error %d respectively.

copyin

__int64 __fastcall copyin(__int64 *a1, __int64 *a2, unsigned __int64 a3)
{
  __int64 result; // x0

  if ( !a3 )
    return 0LL;
  result = copy_validate(a1, a2, a3, 5);                        <-----  5 = COPYIO_IN | COPYIO_ALLOW_KERNEL_TO_KERNEL
  if ( result == 18 )
  {
    memmove(a2, a1, a3);
    result = 0LL;
  }
  else if ( !result )
  {
    __asm { MSR             #4, #0 }
    result = sub_FFFFFFF00821CABC(a1, a2, a3);
    __asm { MSR             #4, #1 }
  }
  return result;
}

copyout

__int64 __fastcall copyout(__int64 *a1, __int64 *a2, unsigned __int64 a3)
{
  __int64 result; // x0

  if ( !a3 )
    return 0LL;
  result = copy_validate(a2, a1, a3, 6);                        <-----  6 = COPYIO_OUT | COPYIO_ALLOW_KERNEL_TO_KERNEL
  if ( result == 18 )
  {
    memmove(a2, a1, a3);
    return 0LL;
  }
  if ( !result )
  {
    __asm { MSR             #4, #0 }
    result = sub_FFFFFFF00821CC40(a1, a2, a3);
    __asm { MSR             #4, #1 }
  }
  return result;
}

Now that we have all the required symbols, we create a .txt file inside kernel_symbols folder with these symbols

Loading the kext on the device

We use the kext loader from ktrw, an iOS kernel debugger made by @bazad. He uses checkra1n and the pongoOS to load a kext.

Our setup now consists of 2 components. 1) kext loader from ktrw and 2) vulnerable kext

These can be built by running make on the projects root directory.

To load the vulnerable kext, we'll run 2 utilities: checkra1n and the kext_loader.

Running the following command causes checkra1n to listen for attached iOS devices in DFU mode and boot pongoOS:

/Applications/checkra1n.app/Contents/MacOS/checkra1n -c -p

Run run.sh to build kext_loader and the vulnerable kext and to start kext_loader.

./run.sh

Note for advanced Usage:

  • Disable the patches (jailbreak) by checkra1n, modify DISABLE_CHECKRA1N_KERNEL_PATCHES to 1 in Makefile before running run.sh.
  • This makes checkra1n just inject the vulnerable kext driver and boot into xnu without modiying or disabling any security features inside XNU.
  • This can be then be used to write a full chain exploit to jailbreak for teaching/practice! :)

kext_loader waits for a device that's booted pongo shell!

Finally, connect an iOS device in DFU mode using a USB cable. Now, checkra1n will boot pongoOS, then kext_loader will insert the vulnerable kext, and boot to XNU.

Lets now trigger a heap overflow

  • The vulnerable kext uses kernel control API. The kernel control API is a bidirectional communication mechanism between a user space application and a KEXT.
  • XNU defines PF_SYSTEM domain to provide a way for applications to configure and control KEXTs.
  • The PF_SYSTEM domain, in turn, supports two protocols, SYSPROTO_CONTROL and SYSPROTO_EVENT.

We use PF_SYSTEM and SYSPROTO_CONTROL to connect to the vulnerable kext. int sock = socket(PF_SYSTEM, SOCK_DGRAM, SYSPROTO_CONTROL);

We need the control id of the kext assigned by the kernel. We can fetch it using ioctl on the sock

	struct ctl_info info;
	memset(&info, 0, sizeof(info));
	strncpy(info.ctl_name, CONTROL_NAME, sizeof(info.ctl_name));

	int err = ioctl(sock, CTLIOCGINFO, &info);
    if (err)
	{
		perror("Could not get ID for kernel control.\n");
		exit(-1);
	}

We use the info.ctl_id kernel control id and create a struct sockaddr_ctl and connect to the sock:

	struct sockaddr_ctl addr;
	
	addr.sc_len 	= sizeof(addr);
	addr.sc_family  = AF_SYSTEM;
	addr.ss_sysaddr = AF_SYS_CONTROL;
	addr.sc_id 		= info.ctl_id;
	addr.sc_unit 	= 0; /* allocate dynamically */

	int err = connect(sock, (struct sockaddr*) &addr, sizeof(addr));

We can now trigger one of the defined vulnerabilitiesby calling setsockopt on the above sock int setsockopt(int socket, int level, int option_name, const void *option_value, socklen_t option_len);

Example to trigger heap overflow, we set the opt to HEAP_OVERFLOW, pass in the user data to kernel inside data

size_t size = 1024;
char address[size];

memset(address, 0x42, size);

setsockopt(ctrl, SYSPROTO_CONTROL, HEAP_OVERFLOW, address, size);

Complete code is available at: kext_client

Todo

  • Fix the bugs in the vulnerabilities I implemented 🧐
  • Add more vulnerabilities
  • Add Writeups for exploitation