Jailed mid function hooking on iOS

Today we are going to talk about mid function hooking on iOS. While being fairly straightforward with a jailbreak it can be pretty tricky on jailed iOS devices because iOS prevents any changes to the text section during runtime by default unless you have access to the JIT entitlement which is highly restricted and can only be enabled using a PC. So what can we do if we need modifications in the middle of functions on jailed iOS devices? In the following I will explain multiple methods to achieve this.

1. Statically patching the binary

The first way of achieving this would be statically patching the mach-o binary file. For this we would first write the assembly code of our mid function hook into a codecave, place a branch to it in the function we want to hook and branch back afterwards. This is common practice across various platforms so im not going to elaborate any further.

Advantages Disadvantages
Seems to be very straightforward The space that codecaves provide you with is limited
Pretty much no performance impact You are forced to write everything in assembly and don’t have any c interop
Unlimited amount of hooks possible as long the codecave space is big enough Calling libc functions that aren’t imported by the binary already is a pain in the ass

2. Abusing exceptions (via software breakpoints)

While the first way was probably known to most people, time to start with the more interesting stuff. The next way of achieving mid function hooks is by abusing exceptions via software breakpoints. The way this works is by placing a software breakpoint(BRK instruction on arm64) at the location where we want to enter our hook and catch the exception with a sigaction handler. From our sigaction handler we can then redirect code execution to our own code by editing the thread context.

Advantages Disadvantages
Unlimited amount of hooks Can need quite some effort to get it to work properly
C interop The performance can suffer depending on how often the function is called, as every call triggers an exception/hardware interrupt
Not needing to patch the binary manually every single time You can’t use it to hook functions in iOS system libraries as you cannot patch a breakpoint into them pre-runtime

Guide:

First we inject our own dynamic library into the iOS app bundle and add a dyld_load command to the main binary so our library gets loaded on app startup. This process is pretty common and there are a lot of resources out there on how to do this, so I’m not going to elaborate on this any further.

The next thing we need to do is register our sigaction handler.

#include <signal.h>
#define _XOPEN_SOURCE 600 //ucontext is deprecated, using this define we can continue using it
#include <ucontext.h>
void handler(int sig, siginfo_t *info, void *ucontext) {


}

__attribute((constructor)) static void setup() {
    struct sigaction sa;
    sa.sa_flags = SA_SIGINFO;
    sa.sa_sigaction = handler;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGTRAP, &sa, NULL);
}

Now our sigaction handler is registered, and once our software breakpoint gets hit and throws a SIGTRAP exception we will receive it in our handler. Next we need a way to differentiate our hooks. for this we can use the 16 bit immediate value from the BRK instruction and use it as an hook id. To extract it we can use a bitmask.

uint16_t extract_hook_id(uint32_t instruction) {
	uint32_t mask = 0x001FFFE0;
	uint16_t imm16 = (instruction & mask) >> 5;
	return imm16;
}
bool is_brk_instruction(uint32_t instruction) {
	uint32_t mask = 0xFFE0001F;
	uint32_t brk_pattern = 0xD4200000;
	return (instruction & mask) == brk_pattern;
}

Breakpoint placed in our function

So for example this hook would have the id 69. Next we will actually implement the logic for our sigaction handler to redirect code execution to the right function based on the hook id extracted from the BRK instruction.

#include <signal.h>
#define _XOPEN_SOURCE 600 //ucontext is deprecated, using this define we can continue using it
#include <ucontext.h>
struct hook {
	uintptr_t *original;
	uintptr_t func;
};
std::map<int, hook> hooks;

void handler(int sig, siginfo_t *info, void *ucontext) {
    NSLog(@"EINTIM: CALLED");
	ucontext_t *context = (ucontext_t *)ucontext;
	uint32_t instruction = *(uint32_t *)context->uc_mcontext->__ss.__pc; //extract the instruction by dereferencing the value of the program counter register.
	if (is_brk_instruction(instruction)) { 
		uint16_t hookid = extract_hook_id(instruction); // if its a break instruction extract our hook id
		if (hooks.find(hookid) == hooks.end()) { //if the hook doesn't have a corresponding handler, ignore. This is not one of our hooks
            NSLog(@"EINTIM: Handler for %d not found!\n", hookid);
            return;
        }

		hook h = hooks[hookid];

		if (h.original && *h.original == 0)
			*h.original = (uintptr_t)(context->uc_mcontext->__ss.__pc + 4); //Backup the return address and skip our placed 4 byte long breakpoint instruction while doing so
		context->uc_mcontext->__ss.__pc = h.func; //lastly set the program counter register to our hook address to actually redirect the execution
	}
}

uintptr_t originalAddressisSubscribed = 0;
__attribute__((naked)) uint64_t isSubscribedHook() {
    //run our mid function hook code
    __asm__("mov x0, 12");


    // execute original instruction we overwrote with our breakpoint instruction
    __asm__("svc	#0x80");

    // branch back to original function
    asm volatile(
        "ldr x1, %[addr]\n"
        "br x1\n"
        :
        : [addr] "m"(originalAddressisSubscribed)
        : "x1");
}

__attribute((constructor)) static void setup() {
    hooks = std::map<int, hook>();
    struct sigaction sa;
    sa.sa_flags = SA_SIGINFO;
    sa.sa_sigaction = handler;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGTRAP, &sa, NULL);

    //isSubscribed hook
    hook isSubscribed{};
    isSubscribed.func = (uintptr_t)isSubscribedHook;
    isSubscribed.original = &originalAddressisSubscribed;
    hooks[69] = isSubscribed;
}

Now we have a fully working implementation of this that registers our sigaction handler, registers our hook, finds the right hook handler based on the hook id, redirects code execution to it, then executes our code and the original instruction we overwrote with our BRK instruction and branches back to the programs code :). Easy!

Now you might think that this is quite annoying as you always need to patch the breakpoint into your target binary where you want the hook to run and write the actual hook registration and copy paste the original instruction. And while this is true i wrote a small python script to automate this process. It takes hook definitions from a json file, automatically searches the functions inside of the target binary, places the breakpoint and generates the hook handlers including the execution of the original function. Note that this is very dirty and was only meant for my personal use, you might still want to take some inspiration from it tho.

hooks.json

[
    {
        "pattern":"01 ? ? ? 01 ? ? ? 20",
        "funcDef": "uint64_t  %name%()",
        "description": "testhook description",
        "name": "testhook",
        "asm": true,
        "register": "x1"
    }
]

hooks.py

import json
import sys
import re
from capstone import *
from keystone import *

md = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
ks = Ks(KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN)

def match_pattern(data, pattern):
    pattern_bytes = pattern.split()
    pattern_length = len(pattern_bytes)
    
    for i in range(len(data) - pattern_length + 1):
        match = True
        for j in range(pattern_length):
            if pattern_bytes[j] != '?' and data[i + j] != int(pattern_bytes[j], 16):
                match = False
                break
        if match:
            return i
    return -1

def getFunctionParameters(functionDefinition):
    ret = []
    onlyArguments = functionDefinition.split("(")[1].split(")")[0]
    args = onlyArguments.split(",")
    for arg in args:
        splits = arg.split(" ")
        realName = splits[len(splits)-1]
        ret.append(realName)
    return ret


binaryFile = open(sys.argv[1], "rb")
binary = bytearray(binaryFile.read())
binaryFile.close()

configfile = open("hooks.json", "r")
config = json.loads(configfile.read())
configfile.close();
hookID = 0
hookFile = """
struct tim_hook {
	uintptr_t *original;
	uintptr_t func;
};
std::map<int, tim_hook> tim_hooks;

uint16_t extract_hook_id(uint32_t instruction) {
	uint32_t mask = 0x001FFFE0;
	uint16_t imm16 = (instruction & mask) >> 5;
	return imm16;
}
bool is_brk_instruction(uint32_t instruction) {
	uint32_t mask = 0xFFE0001F;
	uint32_t brk_pattern = 0xD4200000;
	return (instruction & mask) == brk_pattern;
}

void sigaction_handler(int sig, siginfo_t *info, void *ucontext) {
	ucontext_t *context = (ucontext_t *)ucontext;
	uint32_t instruction = *(uint32_t *)context->uc_mcontext->__ss.__pc;
	if (is_brk_instruction(instruction)) {
		uint16_t hookid = extract_hook_id(instruction);
		if (tim_hooks.find(hookid) == tim_hooks.end())
			return;

		tim_hook h = tim_hooks[hookid];
        NSLog(@"EINTIM: Found right hook handler with id %d", hookid);
		if (h.original && *h.original == 0)
			*h.original = (uintptr_t)(context->uc_mcontext->__ss.__pc + 4); //skip breakpoint instruction
		context->uc_mcontext->__ss.__pc = h.func;
	}
}

//hooks will be inserted here
"""
hookInitTemplate = """
void init_sideload_hooks() {
    tim_hooks = std::map<int, tim_hook>();
    NSLog(@"EINTIM: Initializing");
    // initialize handler
    struct sigaction sa;
    sa.sa_flags = SA_SIGINFO;
    sa.sa_sigaction = sigaction_handler;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGTRAP, &sa, NULL);
"""

for hook in config:
    hookID+=1
    pattern = hook["pattern"]
    description = hook["description"]
    name = hook["name"]
    definition = hook["funcDef"]
    origRegister = hook["register"]
    isASM = hook["asm"]

    offset = match_pattern(binary, pattern)
    if offset == -1:
        print(f"Couldnt find offset of \"{name}\"")
        continue
    print(f"Found offset of \"{name}\" at {hex(offset)}")
    instruction = binary[offset:offset+4]
    instructionString = ""
    for (address, size, mnemonic, op_str) in md.disasm_lite(instruction, 0):
        instructionString = mnemonic+"\t"+op_str

    newInstruction, count = ks.asm(f"BRK #{hookID}")
    for i in range(0, 4):
        binary[offset+i] = newInstruction[i]

    hookInitTemplate +=f"""
    //{description}
    tim_hook {name}{{}};
    {name}.func = (uintptr_t){name}Hook;
    {name}.original = &originalAddress{name};
    tim_hooks[{hookID}] = {name};
    """
    callOriginalDefinition = definition.replace("%name%", "callOriginal"+name)
    hookDefinition = definition.replace("%name%", name+"Hook")
    if isASM:
        callOriginalDefinition = hookDefinition

    argumentString = ""
    for arg in getFunctionParameters(hookDefinition):
        argumentString+=arg+","
    argumentString = argumentString[:-1]
    hookFile+=f"""
uintptr_t originalAddress{name} = 0;
__attribute__((naked)) {callOriginalDefinition} {{
    // execute original instruction we overwrote with our breakpoint instruction
    __asm__("{instructionString}");

    // run original
    asm volatile(
        "ldr {origRegister}, %[addr]\\n"
        "br {origRegister}\\n"
        :
        : [addr] "m"(originalAddress{name})
        : "{origRegister}");
}}
"""
    if not isASM:
        hookFile+=f"""
{hookDefinition} {{
    return callOriginal{name}({argumentString});
}}
"""

hookInitTemplate+="\n}"
hookFile+=hookInitTemplate

f = open("hooks.mm", "w")
f.write(hookFile)
f.close()

f = open(sys.argv[1]+".patched", "wb")
f.write(binary)
f.close()

Python script example use:

Real world project I use this hooking method on to spoof the Snapchat attestation payload parameters to bypass the server side sideload detection:

3. Abusing exceptions (via hardware breakpoints)

This method works exactly the same as the one above, with the difference that we will be using hardware breakpoints instead of software breakpoints.

Advantages Disadvantages
We can hook functions inside of iOS system libraries too Can need quite some effort to get it to work properly
C interop The performance can suffer depending on how often the function is called, as every call triggers an exception/hardware interrupt
Not needing to patch the binary manually every single time The amount of possible hooks is limited to 6, because thats how many hardware breakpoints most arm64 cpus can have
As no instruction inside the actual app will be modified we won’t trigger any integrity checks It can be pretty challenging to apply the hardware breakpoints to all threads, including newly created ones

Guide:

I’ve been writing on this for way too long, I’ll come back and add it at some point later or make a second part of this article.


This was my first blog post on here, I hope that you liked it and leave suggestions for improvement if you have any.