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_SYSTEMdomain to provide a way for applications to configure and control KEXTs. - The
PF_SYSTEMdomain, in turn, supports two protocols,SYSPROTO_CONTROLandSYSPROTO_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