Mac IPC

Mach IPC It enables tasks (processes) to exchange information through ports asynchronously. Main components:

  • Ports: Kernel-managed communication channels, similar to pipes.

  • Port Rights: Permissions that control how processes can interact with ports (via handles).

  • Messages: Structured data units exchanged between ports.

  • Service: A named port registered with the bootstrap server.

  • Bootstrap Server: A service (typically launchd) responsible for service registration and discovery.

// Process A (Server/ Task with recieve right, attach send right to port for bootstrap) 
mach_port_t server_port;

// Create port with RECEIVE right
kern_return_t kr = mach_port_allocate(
    mach_task_self(),                  // our task
    MACH_PORT_RIGHT_RECEIVE,           // want receive right
    &server_port                       // port name to create
);

// Create SEND right
mach_port_insert_right(
    mach_task_self(),                  // our task
    server_port,                       // port
    server_port,                       // same port 
    MACH_MSG_TYPE_MAKE_SEND            // convert to send right
);

// Register with launchd
bootstrap_check_in(
    bootstrap_port,                    // launchd port
    "com.example.service",             // service name
    server_port                        // port with send right
);

// Process B (Client/ Task that obtains send right from bootstrap) 
mach_port_t client_port;
bootstrap_look_up(
    bootstrap_port,                    // launchd port
    "com.example.service",             // service name
    &client_port                       // receives send right
);

MAC Message Header

typedef struct {
  mach_msg_bits_t       msgh_bits;
  mach_msg_size_t       msgh_size;
  mach_port_t           msgh_remote_port;
  mach_port_t           msgh_local_port;
  mach_port_name_t      msgh_voucher_port;
  mach_msg_id_t         msgh_id;
} mach_msg_header_t;

A brief description of these fields is the following:

  • msgh_bits - options and message metadata, such as disposition of port rights in the message
  • msgh_size - total message size, including header
  • msgh_remote_port - remote Mach port, used as the destination when sending a message, or a reply port when receiving
  • msgh_local_port - local Mach port, the port the message was received on, or a reply port when sending a message
  • msgh_voucher_port - port identifying a Mach Voucher, that’s an optional field
  • msgh_id - user defined message identifier

📦 What is a Port Disposition? A port disposition defines how the port right is passed or interpreted in the message.

Examples of dispositions include:

Disposition Meaning
MACH_MSG_TYPE_MOVE_SEND Move the send right to the receiver (sender loses it)
MACH_MSG_TYPE_COPY_SEND Copy the send right to the receiver (sender keeps it too)
MACH_MSG_TYPE_MOVE_RECEIVE Move the receive right to the receiver
MACH_MSG_TYPE_MAKE_SEND Give a new send right based on a receive right the sender holds

Bidirectional Message

get service port

mach_port_t port;
if (bootstrap_look_up(bootstrapPort, <service name>", &port) !=
  KERN_SUCCESS) {
  return EXIT_FAILURE;
}

Create Local port (with receive right)

mach_port_t replyPort;
if (mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &replyPort) !=
    KERN_SUCCESS) {
  return EXIT_FAILURE;
}

Insert send right

if (mach_port_insert_right(
        task, replyPort, replyPort, MACH_MSG_TYPE_MAKE_SEND) !=
    KERN_SUCCESS) {
  return EXIT_FAILURE;
}

Prepare Message to send

Message message = {0};
message.header.msgh_remote_port = port;
message.header.msgh_local_port = replyPort;

Create Port Disposition

how the port rights are transferred

message.header.msgh_bits = MACH_MSGH_BITS_SET(
    /* remote */ MACH_MSG_TYPE_COPY_SEND,
    /* local */ MACH_MSG_TYPE_MAKE_SEND_ONCE,
    /* voucher */ 0,
    /* other */ 0);

MACH_MSG_TYPE_COPY_SEND tells the kernel:

“Use this send right to deliver the message, but do not remove it from my process.”

MACH_MSG_TYPE_MAKE_SEND_ONCE tells the kernel:

“allow the receiver to send only one reply message, after which the right is destroyed”.

Complex Message

complex Mach message:

typedef struct {
  mach_msg_header_t header;
  mach_msg_size_t msgh_descriptor_count;
  mach_msg_port_descriptor_t descriptor;
} PortMessage;

port descriptors

Unlike msgh_remote_port and msgh_local_port, which are part of the message header, port descriptors live inside the body of a message — and they allow you to send additional ports along with the message.

typedef struct{
  mach_port_t                   name;
  mach_msg_size_t               pad1;
  unsigned int                  pad2 : 16;
  mach_msg_type_name_t          disposition : 8;
  mach_msg_descriptor_type_t    type : 8;
} mach_msg_port_descriptor_t;
typedef struct {
    mach_msg_type_descriptor_t  type;
    mach_port_t                 name;
    mach_msg_type_name_t        disposition;
} mach_msg_port_descriptor_t;

OOL Messages

typedef struct {
  mach_msg_header_t header;
  mach_msg_size_t msgh_descriptor_count;
  mach_msg_ool_descriptor_t descriptor;
} OOLMachMessage;
typedef struct{
  void*                         address;
  mach_msg_size_t               size;
  boolean_t                     deallocate: 8;
  mach_msg_copy_options_t       copy: 8;
  unsigned int                  pad1: 8;
  mach_msg_descriptor_type_t    type: 8;
} mach_msg_ool_descriptor_t;

Controlling Another Task On MacOs/IOS

  1. The target process sends its task port to the other process : A task must send another task a send right (MACH_PORT_RIGHT_SEND) to its task port. With that, the other task can call mach_vm_write() on it.

  2. The other process obtains the task port by privilege (root / entitlement) : If the caller is root and System Integrity Protection (SIP) allows it, Or if the process has the com.apple.security.cs.debugger entitlement (task_for_pid-allow)

Exception Ports

  • Exception information is delivered as a Mach message via a Mach IPC port
  • Any process with the right Mach port access to a target process can register itself as its exception handler.
  • To build a Mach exception handler with raw IPC, you call mach_msg() on the port you set as the exception handler and wait for incoming exception messages.
#include <mach/mach.h>
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

mach_port_t exception_port;

void *exception_server_thread(void *arg) {
    mach_msg_header_t msg;
    kern_return_t kr;

    while (1) {
        memset(&msg, 0, sizeof(msg));

        // Receive an exception message
        kr = mach_msg(&msg,
                      MACH_RCV_MSG,
                      0,
                      sizeof(msg),
                      exception_port,
                      MACH_MSG_TIMEOUT_NONE,
                      MACH_PORT_NULL);

        if (kr != KERN_SUCCESS) {
            fprintf(stderr, "mach_msg failed: %s\n", mach_error_string(kr));
            continue;
        }

        // At this point, msg contains an exception notification
        // You’d normally cast it to `exception_raise_request_t`
        printf("[*] Exception message received!\n");

        // Here you’d reply using exception_raise_reply_t
        // For simplicity, skipping reply code
    }
    return NULL;
}

int main() {
    kern_return_t kr;
    pthread_t thread;

    // Allocate a Mach port for exceptions
    kr = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &exception_port);
    if (kr != KERN_SUCCESS) {
        fprintf(stderr, "mach_port_allocate: %s\n", mach_error_string(kr));
        return 1;
    }

    // Insert a send right
    kr = mach_port_insert_right(mach_task_self(),
                                exception_port,
                                exception_port,
                                MACH_MSG_TYPE_MAKE_SEND);
    if (kr != KERN_SUCCESS) {
        fprintf(stderr, "mach_port_insert_right: %s\n", mach_error_string(kr));
        return 1;
    }

    // Register exception port for this task
    kr = task_set_exception_ports(mach_task_self(),
                                  EXC_MASK_ALL,       // handle all exceptions
                                  exception_port,
                                  EXCEPTION_DEFAULT,  // behavior
                                  THREAD_STATE_NONE); // flavor
    if (kr != KERN_SUCCESS) {
        fprintf(stderr, "task_set_exception_ports: %s\n", mach_error_string(kr));
        return 1;
    }

    // Start a thread to handle exceptions
    pthread_create(&thread, NULL, exception_server_thread, NULL);

    // Cause a crash (EXC_BAD_ACCESS)
    int *p = NULL;
    *p = 42;

    pthread_join(thread, NULL);
    return 0;
}

IOKit

list 3rd Party Driver kex binary

ls -l /Library/Extensions/<>.kext/Contents/MacOS

read executable name from info.plist

defaults read /Library/Extensions/<>.kext/Contents/Info CFBundleExecutable

Connecting to Driver Kex

    io_service_t service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("<>"));
    
    // Connect to service
    io_connect_t connect;
    kern_return_t kr = IOServiceOpen(service, mach_task_self(), 1, &connect);
    IOObjectRelease(service);
    
    // Call external method (selector 0 == method 0)
    kr = IOConnectCallMethod(connect, 0, NULL, 0, NULL, 0, NULL, NULL, NULL, NULL);
    printf("Method call result: 0x%x\n", kr);
    
    // Cleanup
    IOServiceClose(connect);
    return 0;

Calling External Methods From UserSpace

IOConnectCallMethod
IOConnectCallScalarMethod
IOConnectCallStructMethod ...

Implementing Kext takes an argument from user-space.

    virtual bool initWithTask(task_t owningTask, void*, UInt32) override {
        fTask = owningTask;
        return super::init();
    }
  • It’s called by IOKit when userspace first opens a connection to your driver via IOServiceOpen.

  • IOServiceOpen is the userland call, and on the kernel side it maps to your IOUserClient::initWithTask.

  • Think of it as the constructor for the user client object, binding it to the task (process) that opened the connection.

Dispatch table

 static const IOExternalMethodDispatch sMethods[];

https://github.com/apple-oss-distributions/xnu/blob/e3723e1f17661b24996789d8afc084c0c3303b26/iokit/IOKit/IOUserClient.h#L160

struct IOExternalMethodDispatch {
	IOExternalMethodAction function;
	uint32_t               checkScalarInputCount;
	uint32_t               checkStructureInputSize;
	uint32_t               checkScalarOutputCount;
	uint32_t               checkStructureOutputSize;
};

    virtual IOReturn externalMethod(uint32_t selector,
                                    IOExternalMethodArguments* args,
                                    IOExternalMethodDispatch* dispatch,
                                    OSObject* target,
                                    void* ref) override {
        if (selector < 1) {
            *dispatch = sMethods[selector];
            target = this;
            return IOUserClient::externalMethod(selector, args, dispatch, target, ref);
        }
        return kIOReturnUnsupported;
    }
    // Our Selector Implementation
    IOReturn SayHi(void* argStruct, IOByteCount argStructSize) {
        if (argStructSize < sizeof(uint64_t)) {
            IOLog("SayHi: argument too small\n");
            return kIOReturnBadArgument;
        }
        uint64_t* val = (uint64_t*)argStruct;
        IOLog("SayHi: got argument 0x%llx\n", *val);
        return kIOReturnSuccess;
    }
};

// Dispatch table: selector 0 = SayHi
const IOExternalMethodDispatch IPwnKitUserClient::sMethods[] = {
    {
        // Function pointer
        (IOExternalMethodAction)&IPwnKitUserClient::SayHi,
        0,                      // no scalar inputs
        1,                      // one struct input (we’ll send sizeof(uint64_t))
        0,                      // no scalar outputs
        0                       // no struct outputs
    }
};


When Sending ( 1 struct input)

    uint64_t myArg = 0x1337BEEF;
    size_t inputSize = sizeof(myArg);

  kr = IOConnectCallMethod(
        connect,
        0,      // selector index for SayHi (adjust if different in sMethods[])
        NULL, 0,    // no scalar input
        &myArg, inputSize,    // 1 struct input
        NULL, NULL, // no scalar output
        NULL, NULL  // no struct output
    );

PAC (Pointer Authentication Codes)

PAC is a hardware feature on ARMv8.3-A+ that helps prevent pointer corruption (like ROP/JOP exploits). A cryptographic tag is prepended to pointers, and it’s checked on use. If the tag doesn’t match, the CPU faults when it is used. Address in X64 is 64bits wide , most systems use the lower 48 bits are the actual pointer address and the Bits above are reserved for PAC/metadata. XNU only uses the lower 39 bits.

PAC KEYS (A 128-bit key derived from a hardware root)

  • Instruction keys (IA, IB) → used for return addresses, code pointers.

  • Data keys (DA, DB) → used for data pointers.

  • Generic key (GA) → less common.

PAC keys are per process (task) but shared across threads. The kernel programs a new set of PAC keys when a process is created.

PAC Operations

  • PAC* : generates pac
  • XPAC* : strips pac
  • AUT* : authenticates pac

pac-ing

PACIA X8, X9 ; use the  IA key to add PAC to X8 with context X9
PACIZA X8 ; use the  IA key to add PAC to X8 with context 0
AUTIA X8, X9 ; Authenticate the PAC in X8 using the key APIAKey using X9 as the context, and write back into X8 original pointer if success, otherwise write invalid pointer
XPACD x1            ; remove PAC from data pointer
BLRAA X8, X9 : Authenticate X8 using the key APIAKey using X9 as the context, then branch to result
LDRAA X8, [X9] : Authenticate X9 using the key APDAkey using 0 as the context, then store the result in X8
RETAB : Authenticate LR using the key APIBKey using SP as the context, then branch to result

reference:

  • https://karol-mazurek.medium.com/mach-ipc-security-on-macos-63ee350cb59b
  • https://ulexec.github.io/post/2022-12-01-xnu_ipc/
  • https://dmcyk.xyz/post/xnu_ipc_ii_message_apis/xnu_ipc_ii_message_apis/
  • https://docs.google.com/presentation/d/1jo__tA8Wp146oBkxchhgM-6V7HPw1gc1/edit?slide=id.p12#slide=id.p12