Skip to content

jsherman212/xnuspy

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

xnuspy

alt text

Output from the kernel log after compiling and running example/open1_hook.c

xnuspy is a pongoOS module that installs a new system call, xnuspy_ctl, which allows you to hook kernel functions from userspace. It supports iOS 13.x, iOS 14.x, and iOS 15.x on checkra1n 0.12.2 and up. 4K devices are not supported.

This module completely neuters KTRR/KPP and makes it possible to create RWX memory inside EL1. Do not use this on your daily driver.

Requires libusb: brew install libusb

Building

Run make in the top level directory. It'll build the loader and the module.

Build Options

Add these before make.

  • XNUSPY_DEBUG=1
    • Send debug output from xnuspy to the kernel log (kprintf).
  • XNUSPY_SERIAL=1
    • Send debug output from xnuspy to IOLog.
  • XNUSPY_LEAKED_PAGE_LIMIT=n
    • Set the number of pages xnuspy is allowed to leak before its garbage collection thread starts deallocating them. Default is 64. More info can be found under Debugging Kernel Panics.
  • XNUSPY_TRAMP_PAGES=n
    • Set the number of pages xnuspy will reserve for its trampoline structures. Default is 1. More info can be found under Limits.

XNUSPY_DEBUG and XNUSPY_SERIAL do not depend on each other.

Usage

After you've built everything, have checkra1n boot your device to a pongo shell: /Applications/checkra1n.app/Contents/MacOS/checkra1n -p

In the same directory you built the loader and the module, do loader/loader module/xnuspy. After doing that, xnuspy will do its thing and in a few seconds your device will boot. loader will wait a couple more seconds after issuing xnuspy-getkernelv in case SEPROM needs to be exploited.

Known Issues

Sometimes a couple of my phones would get stuck at "Booting" after checkra1n's KPF runs. I have yet to figure out what causes this, but if it happens, try again. Also, if the device hangs after bootx, try again. Finally, marking the compiled xnuspy_ctl code as executable on my iPhone X running iOS 13.3.1 is a bit spotty, but succeeds 100% of the time on my other phones. If you panic with a kernel instruction fetch abort when you execute your hook program, try again.

xnuspy_ctl

xnuspy will patch an enosys system call to point to xnuspy_ctl_tramp. This is a small trampoline which marks the compiled xnuspy_ctl code as executable and branches to it. You can find xnuspy_ctl's implementation at module/el1/xnuspy_ctl/xnuspy_ctl.c and examples in the example directory.

Inside include/xnuspy/ is xnuspy_ctl.h, a header which defines constants for xnuspy_ctl. It is meant to be included in all programs which hook kernel functions.

You can use sysctlbyname to figure out which system call was patched:

size_t oldlen = sizeof(long);
long SYS_xnuspy_ctl = 0;
sysctlbyname("kern.xnuspy_ctl_callnum", &SYS_xnuspy_ctl, &oldlen, NULL, 0);

This system call takes four arguments, flavor, arg1, arg2, and arg3. The flavor can either be XNUSPY_CHECK_IF_PATCHED, XNUSPY_INSTALL_HOOK, XNUSPY_REGISTER_DEATH_CALLBACK, XNUSPY_CALL_HOOKME, XNUSPY_CACHE_READ, XNUSPY_KREAD, XNUSPY_KWRITE, or XNUSPY_GET_CURRENT_THREAD. The meaning of the next three arguments depend on the flavor.

XNUSPY_CHECK_IF_PATCHED

This exists so you can check if xnuspy_ctl is present. Invoking it with this flavor will cause it to return 999. The values of the other arguments are ignored.

XNUSPY_INSTALL_HOOK

I designed this flavor to match MSHookFunction's API. arg1 is the UNSLID address of the kernel function you wish to hook. If you supply a slid address, you will most likely panic. arg2 is a pointer to your ABI-compatible replacement function. arg3 is a pointer for xnuspy_ctl to copyout the address of a trampoline that represents the original kernel function. This can be NULL if you don't intend to call the original.

XNUSPY_REGISTER_DEATH_CALLBACK

This flavor allows you to register an optional "death callback", a function xnuspy will call when your hook program exits. It gives you a chance to clean up anything you created from your kernel hooks. If you created any kernel threads, you would tell them to terminate in this function.

Your callback is not invoked asynchronously, so if you block, you're preventing xnuspy's garbage collection thread from executing.

arg1 is a pointer to your callback function. The values of the other arguments are ignored.

XNUSPY_CALL_HOOKME

hookme is a small assembly stub which xnuspy exports through the xnuspy cache for you to hook. Invoking xnuspy_ctl with this flavor will cause hookme to get called, providing a way for you to easily gain kernel code execution without having to hook an actual kernel function.

arg1 is an argument that will be passed to hookme when it is invoked. This can be NULL.

XNUSPY_CACHE_READ

This flavor gives you a way to read from the xnuspy cache. It contains many useful things like kprintf, current_proc, kernel_thread_start, some libc functions, and the kernel slide so you don't have to find them yourself. For a complete list of cache IDs, check out example/xnuspy_ctl.h.

arg1 is one of the cache IDs defined in xnuspy_ctl.h and arg2 is a pointer for xnuspy_ctl to copyout the address or value of what you requested. The values of the other arguments are ignored.

XNUSPY_KREAD

This flavor gives you an easy way to read kernel memory from userspace without tfp0.

arg1 is a kernel virtual address, arg2 is the address of a userspace buffer, and arg3 is the size of that userspace buffer. arg3 bytes will be written from arg1 to arg2.

XNUSPY_KWRITE

This flavor gives you an easy way to write to kernel memory from userspace without tfp0.

arg1 is a kernel virtual address, arg2 is the address of a userspace buffer, and arg3 is the size of that userspace buffer. arg3 bytes will be written from arg2 to arg1.

XNUSPY_GET_CURRENT_THREAD

This flavor provides userspace the kernel address of the calling thread.

arg1 is a pointer for xnuspy_ctl to copyout the return value of current_thread. The values of the other arguments are ignored.

Errors

For all flavors except XNUSPY_CHECK_IF_PATCHED, 0 is returned on success. Upon error, -1 is returned and errno is set. XNUSPY_CHECK_IF_PATCHED does not return any errors. XNU's mach_to_bsd_errno is used to convert a kern_return_t to the appropriate errno.

Errors Pertaining to XNUSPY_INSTALL_HOOK

errno is set to...

  • EEXIST if:
    • A hook already exists for the unslid kernel function denoted by arg1.
  • ENOMEM if:
    • unified_kalloc returned NULL.
  • ENOSPC if:
    • There are no free xnuspy_tramp structs, a data structure internal to xnuspy. This shouldn't happen unless you're hooking hundreds of kernel functions at the same time. If you need more function hooks, check out Limits.
  • ENOTSUP if:
    • The caller is not from a Mach-O executable or dynamic library.
  • ENOENT if:
    • mh_for_addr was unable to determine the Mach-O header corresponding to arg2 inside the caller's address space.
  • EFAULT if:
    • The determined Mach-O header is not actually a Mach-O header. This will probably never happen.
  • EIO if:
    • mach_make_memory_entry_64 did not return a memory entry for the entirety of the determined Mach-O header's __TEXT and __DATA segments.

errno also depends on the return value of vm_map_wire_external, mach_vm_map_external, mach_make_memory_entry_64, copyin, copyout, and if applicable, the one-time initialization function.

If this flavor returns an error, the target kernel function was not hooked. If you passed a non-NULL pointer for arg3, it may or may not have been initialized. It's unsafe to use if it was.

Errors Pertaining to XNUSPY_REGISTER_DEATH_CALLBACK

errno is set to...

  • ENOENT if:
    • The calling process hasn't hooked any kernel functions.

If this flavor returns an error, your death callback was not registered.

Errors Pertaining to XNUSPY_CALL_HOOKME

errno is set to...

  • ENOTSUP if:
    • hookme is too far away from the memory containing the xnuspy_tramp structures. This is determined inside of pongoOS, and can only happen if xnuspy had to fallback to unused code already inside of the kernelcache. In this case, calling hookme would almost certainly cause a kernel panic, and you'll have to figure out another kernel function to hook.

If this flavor returns an error, hookme was not called.

Errors Pertaining to XNUSPY_CACHE_READ

errno is set to...

  • EINVAL if:
    • The constant denoted by arg1 does not represent anything in the cache.
    • arg1 was IO_LOCK, but the kernel is iOS 14.4.2 or below or iOS 15.x.
    • arg1 was IPC_OBJECT_LOCK, but the kernel is iOS 15.x.
    • arg1 was IPC_PORT_RELEASE_SEND, but the kernel is iOS 14.5 or above.
    • arg1 was IPC_PORT_RELEASE_SEND_AND_UNLOCK, but the kernel is iOS 14.4.2 or below.
    • arg1 was KALLOC_CANBLOCK, but the kernel is iOS 14.x or above.
    • arg1 was KALLOC_EXTERNAL, but the kernel is iOS 13.x.
    • arg1 was KFREE_ADDR, but the kernel is iOS 14.x or above.
    • arg1 was KFREE_EXT, but the kernel is iOS 13.x.
    • arg1 was PROC_REF, but the kernel is iOS 14.8 or below.
    • arg1 was PROC_REF_LOCKED, but the kernel is iOS 15.x.
    • arg1 was PROC_RELE, but the kernel is iOS 14.8 or below.
    • arg1 was PROC_RELE_LOCKED, but the kernel is iOS 15.x.
    • arg1 was VM_MAP_UNWIRE, but the kernel is iOS 15.x.
    • arg1 was VM_MAP_UNWIRE_NESTED, but the kernel is iOS 14.8 or below.

errno also depends on the return value of copyout and if applicable, the return value of the one-time initialization function.

If this flavor returns an error, the pointer you passed for arg2 was not initialized.

Errors Pertaining to XNUSPY_KREAD and XNUSPY_KWRITE

errno is set to...

  • EFAULT if:
    • Address translation failed for arg1 or arg2. If you compiled with XNUSPY_DEBUG=1, a message about it is printed to the kernel log.

If this flavor returns an error, kernel memory was not read/written.

Errors Pertaining to XNUSPY_GET_CURRENT_THREAD

If copyout fails, errno is set to its return value.

Important Information

Common Pitfalls

While writing replacement functions, it was easy to forget that I was writing kernel code. Here's a couple things to keep in mind when you're writing hooks:

  • You cannot execute any userspace code that lives outside your program's __TEXT segment. You will panic if, for example, you accidentally call printf instead of kprintf. You need to re-implement any libc function you want to call if that function is not already available via XNUSPY_CACHE_READ. You can create function pointers to other kernel functions and call those, though.
  • Many macros commonly used in userspace code are unsafe for the kernel. For example, PAGE_SIZE expands to vm_page_size, not a constant. You need to disable PAN (on A10+, which I also don't recommend doing) before reading this variable or you will panic.
  • Make sure to compile your code with -fno-stack-protector and -D_FORTIFY_SOURCE=0 In some cases, the device will have to read ___stack_chk_guard by dereferencing another userspace pointer, which will panic on A10+.
  • Just to be safe, don't compile your hook programs with compiler optimizations.

Skimming https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/KernelProgramming/style/style.html is also recommended.

Debugging Kernel Panics

Bugs are inevitable when writing code, so eventually you're going to cause a kernel panic. A panic doesn't necessarily mean there's a bug with xnuspy, so before opening an issue, please make sure that you still panic when you do nothing but call the original function and return its value (if needed). If you still panic, then it's likely an xnuspy bug (and please open an issue), but if not, there's something wrong with your replacement.

Since xnuspy does not actually redirect execution to EL0 pages, debugging a panic isn't as straightforward. Open up module/el1/xnuspy_ctl/xnuspy_ctl.c, and right before the only call to kwrite_instr in xnuspy_install_hook, add a call to IOSleep for a couple seconds. This is done to make sure there's enough time before the device panics for logs to propagate. Re-compile xnuspy with XNUSPY_DEBUG=1 make -B and load the module again. After loading the module, if you haven't already, compile klog from klog/. Upload it to your device and do stdbuf -o0 ./klog | grep shared_mapping_kva. Run your hook program again and watch for a line from klog that looks like this:

shared_mapping_kva: dist 0x7af4 uaddr 0x104797af4 umh 0x104790000 kmh 0xfffffff00c90c000

If you're installing more than one hook, there will be more than one occurrence. In that case, dist and uaddr will vary, but umh and kmh won't. kmh points to the beginning of the kernel's mapping of your program's __TEXT segment. Throw your hook program into your favorite disassembler and rebase it so its Mach-O header is at the address of kmh. For IDA Pro, that's Edit -> Segments -> Rebase program... with Image base bubbled. After your device panics and reboots again, if there are addresses which correspond to the kernel's mapping of your replacement in the panic log, they will match up with the disassembly. If there are none, then you probably have some sort of subtle memory corruption inside your replacement.

xnuspy also has no way of knowing if a kernel thread is still executing (or will execute) on the kernel's mapping of your program's __TEXT segment after your hooks are uninstalled. One of the things xnuspy does to deal with this is to not deallocate this mapping immediately after your hook program dies. Instead, it's added to the end of a queue. Once xnuspy's garbage collection thread notices a set limit has been exceeded regarding how many pages worth of mappings are held in that queue, it will start to deallocate from the front of the queue and will continue until that limit is no longer exceeded. By default, this limit is 1 MB, or 64 pages.

While this does help enormously, the larger the __TEXT and __DATA segments of your hook program become, the less likely xnuspy wins this race. If you are panicking regularly and have a somewhat large hook program, try increasing this limit by adding XNUSPY_LEAKED_PAGE_LIMIT=n before make. This will set this limit to n pages rather than 64.

Limits

xnuspy reserves one page of static kernel memory before XNU boots for its xnuspy_tramp structs, letting you simultaneously hook around 225 kernel functions. If you want more, you can add XNUSPY_TRAMP_PAGES=n before make. This will tell xnuspy to reserve n pages of static memory for xnuspy_tramp structures. However, if xnuspy has to fall back to unused code already inside the kernelcache, then this is ignored. When this happens is detailed in How It Works.

Logging

For some reason, logs from os_log_with_args don't show up in the stream outputted from the command line tool oslog. Logs from kprintf don't make it there either, but they can be seen with dmesg. However, dmesg isn't a live feed, so I wrote klog, a tool which shows kprintf logs in real time. Find it in klog/. I strongly recommend using that instead of spamming dmesg for your kprintf messages.

If you get open: Resource busy after running klog, run this command launchctl unload /System/Library/LaunchDaemons/com.apple.syslogd.plist and try again.

Unfortunately, you won't be able to see any NSLog's if atm_diagnostic_config=0x20000000 is set in XNU's bootargs. klog depends on this boot argument being present. If you want NSLog back, remove that boot argument from pongo_send_command inside loader.c.

Hook Uninstallation

xnuspy will manage this for you. Once a process exits, all the kernel hooks that were installed by that process are uninstalled within a second or so.

Hookable Kernel Functions

Most function hooking frameworks have some minimum length that makes a given function hookable. xnuspy has this limit only if you plan to call the original function and the first instruction of the hooked function is not B. In this case, the minimum length is eight bytes. Otherwise, there is no minimum length.

xnuspy uses X16 and X17 for its trampolines, so kernel functions which expect those to persist across function calls cannot be hooked (there aren't many which expect this). If the function you want to hook begins with BL, and you intend to call the original, you can only do so if executing the original function does not modify X17.

Thread-safety

xnuspy_ctl will perform one-time initialization the first time it is called after a fresh boot. This is the only part of xnuspy which is raceable since I can't statically initialize the read/write lock I use. After the first call returns, any future calls are guarenteed to be thread-safe.

How It Works

This is simplified, but it captures the main idea well. A function hook in xnuspy is a structure that resides on writeable, executable kernel memory. In most cases, this is memory returned by alloc_static inside of pongoOS. It can be boiled down to this:

struct {
	uint64_t replacement;
	uint32_t tramp[2];
	uint32_t orig[10];
};

Where replacement is the kernel virtual address (elaborated on later) of the replacement function, tramp is a small trampoline that re-directs execution to replacement, and orig is a larger, more complicated trampoline that represents the original function.

One of the first things xnuspy does is determine where the EL0 replacement resides inside the calling processes' address space. This is done so kernel functions can be hooked from dynamic libraries. The Mach-O header which corresponds to the address of that replacement is saved.

After, a shared user-kernel mapping of that header's __TEXT and __DATA segments (as well as any segment in between those, if any) is created. __TEXT is shared so you can call other functions from your hooks. __DATA is shared so changes to global variables are seen by both EL1 and EL0.

Since this mapping is a one-to-one copy of __TEXT and __DATA, it's easy to figure out the address of the user's replacement function on it. Given the address of the calling processes' Mach-O header u, the address of the start of the shared mapping k, and the address of the user's replacement function r, we apply the following formula: replacement = k + (r - u)

After that, replacement is the kernel virtual address of the user's replacement function on the shared mapping and is written to the function hook structure. xnuspy does not re-direct execution to the EL0 address of the replacement function because that's extremely unsafe: not only does that put us at the mercy of the scheduler, it gives us no control over the scenario where a process with a kernel hook dies while a kernel thread is still executing on the replacement.

Finally, the shared mapping is marked as executable and a unconditional immediate branch (B) is assembled. It directs execution to the start of tramp, and is what replaces the first instruction of the now-hooked kernel function. Unfortunately, this limits us from branching to hook structures more than 128 MB away from a given kernel function. xnuspy does check for this scenario before booting and falls back to unused code already in the kernelcache for the hook structures to reside on instead if it finds that this could happen.

Other Notes

I do my best to make sure the patchfinders work, so if something isn't working, please open an issue.

About

an iOS kernel function hooking framework for checkra1n'able devices

Topics

Resources

License

Stars

Watchers

Forks