Sitemap

Mach IPC Security on macOS

Introduction to Mach IPC and System Services security

18 min readDec 17, 2024

--

Press enter or click to view image in full size

INTRO

It is one of the few articles on XNU internals. As the main article about XNU has become lengthy, I decided to publish the one about Mach Inter-Process Communication separately to not overwhelm readers (and myself).

Press enter or click to view image in full size

Enjoy!

Mach

Tasks and threads (in BSD layer mapped as processes and threads). Communication between tasks occurs via Mach IPC.

Press enter or click to view image in full size

Mach IPC has one-way communication channels.

Mach IPC

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

  • Ports: Kernel-protected communication channel (like pipe)
  • Port Rights: Define permissions to interact with ports (handle)
  • Messages: Structured data packets exchanged between ports
  • Service: Name registered in the Bootstrap Server for a given port
  • Bootstrap server: Service registration and discovery (launchd)

There is also MIG (Mach Interface Generator), which is unnecessary for communication, but it streamlines the coding process and makes it safer.

IPC part of the Mach: osfmk/ipc/, /osfmk/mach/ and osfmk/kern/.

Port

It lives in the kernel’s memory and maintains a message queue. It can be used to send/receive messages and has a globally unique identifier (name, for example 0x103) and specific security rights(send/receive/send_once).

/osfmk/ipc/ipc_port.h

Port Right

A port right is a handle to a port that grants the ability to either send (enqueue) or receive (dequeue) messages (similar to a file descriptor).

libsyscall/mach/mach_right.c

Message

The actual data packets that flow through ports. It contains a header and body (data to send). It can be used to send port rights (privilege delegation).

Press enter or click to view image in full size
mach_msg_header_t

/osfmk/mach/message.h

The header is standardized, while the body can contain any data structure — this is why we have, for example, MIG.

Mach Interface Generator

MIG is a tool that automates the creation of proper IPC code. Think of MIG as an automated translator (because writing IPC by hand is hard/error-prone).

osfmk/kern/ipc_mig.h

Why use MIG?

Developers without MIG would need to:

  • Manually pack their data structures into the message body
  • Ensure proper byte alignment and padding
  • Handle architecture differences (32-bit vs 64-bit, endianness)
  • Manage memory allocation and deallocation
  • Deal with type conversions
  • Handle all error cases
Press enter or click to view image in full size

MIG simplifies IPC development by hiding low-level mach_msg complexities. It is shown on the example in the Mach IPC Programming point of this article.

Task Special Ports

Some ports in XNU are privileged and provide access to critical system services and capabilities. Some are automatically assigned during task creation (task_create). SIP and entitlements protect most of them.

xnu/osfmk/mach/task_special_ports.h

These two ports are essential for communication.

TASK_BOOTSTRAP_PORT

A special port that every task gets when it is created. It provides a SEND right to communicate with launchd. Through this port, our process can:

  • Register its own services using bootstrap_check_in
  • Get SEND rights to other services’ ports using bootstrap_look_up
// How to get the bootstrap port for our program:
mach_port_t bootstrap;
task_get_special_port(mach_task_self(),
TASK_BOOTSTRAP_PORT,
&bootstrap);

It is inherited from the parent process, ultimately fromlaunchd which is PID 1.

TASK_KERNEL_PORT

The mach_task_self returns the TASK_KERNEL_PORT for the current task. It is a SEND right to our own task port. We can use it to interact with ourselves.

Every task has it. This is the port by which the kernel knows the new child task. If we have SEND rights to it, we can inject the task.

Mach IPC Flow

We have four phases (graph does not show phase 1, it is described later):

  1. When the server starts, it registers its port in the bootstrap server.
  2. When the client searches for service in the bootstrap server.
  3. When a client sends a message to a service.
  4. When a client receives a message from a server.
Press enter or click to view image in full size

Communication can be done without MIG, but it is commonly used.

Bootstrap Phase (Port Discovery)

Client needs to find the server’s port before communication starts. It is described in the Bootstrap Phase (Service Registration) point. After that:

Press enter or click to view image in full size
  • Client uses bootstrap_look_up to get a send right to the server's port
  • The kernel validates and returns the appropriate port right

Communication Phase (Message Sending)

Press enter or click to view image in full size
  • Client’s MIG-generated code creates a message.
  • Client calls mach_msg_send() to send the message
  • Kernel copies message to server’s message queue
  • Server receives notification of pending message
  • Server’s MIG code unpacks the received message
  • Server processes the request

Communication Phase (Reply)

Press enter or click to view image in full size
  • After processing the request, the server’s MIG code creates a response
  • Server calls mach_msg_send() to send the response message
  • Kernel manages the message transfer
  • Client receives and unpacks response

Bootstrap Phase (Service Registration)

It is managed by launchd. It allows different processes to communicate with each other for the first time. It works like this:

  • Every process can talk to launchd (the bootstrap server)
  • Process A creates a communication channel (port) and registers it within launchd under a name (e.g. com.example.service)
  • Process B can then ask launchd for access to Process A using that name
Press enter or click to view image in full size
// Process A registering
kern_return_t kr;
kr = bootstrap_check_in(bootstrap_port,
"com.example.myservice", // service name
service_port); // port to register
// Process B looking up
mach_port_t service_port;
kr = bootstrap_look_up(bootstrap_port,
"com.example.myservice", // service name
&service_port); // will receive the port

Bootstrap on macOS uses launchd as a central coordinator that enables first-time communication between processes by letting them register and request (lookup) communication channels (ports) through a shared server.

Access Control — Granting Port Right

Processes can exchange port rights (SEND/RECEIVE) through a launchd.

  • Process A creates a port with RECEIVE rights, generates a SEND right, and registers it with launchd under a name (e.g. com.whatever.name).
  • Process B can lookup this name through launchd to obtain the SEND right, enabling direct Mach message communication with Process A.
Press enter or click to view image in full size
// Process A (Server)
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)
mach_port_t client_port;
bootstrap_look_up(
bootstrap_port, // launchd port
"com.example.service", // service name
&client_port // receives send right
);

Only the holder of a RECEIVE right for a port can generate SEND rights for that port. The receiver(*can be only one) controls who can send messages to it.

Tasks Security

By default, launchd does not restrict who registers the service. Moreover, any process can lookup and get SEND rights to their ports. This is why:

  • System services (in /System/Library/LaunchDaemons and /System/Library/LaunchAgents) are protected by SIP
  • Entitlements protect system service functions
  • User services can be impersonated by default

Process B can always get the SEND right through bootstrap_lookup. The security check should happen in Process A when it receives messages from Process B.

System Services Protection — Bootstrap & SIP

launchd protects system services in the bootstrap phase:

  1. It creates and holds the RECEIVE rights for their names
  2. When a service starts, it must register (bootstrap_check_in)
  3. launchd verifies the binary path matches with the list (SIP protected)
  4. launchd transfer the RECEIVE right, if path was valid
Press enter or click to view image in full size
// System Service checkin
mach_port_t service_port;
bootstrap_check_in( // same as bootstrap_register - which is deprecated
bootstrap_port,
"com.apple.systemservice", // must match plist
&service_port // receives the RECEIVE right
);

It means processes cannot register system service names (launchd already holds them), and only the legitimate binary can register and get the RECEIVE right. Users can only get SEND rights and cannot impersonate system services (SIP).

System Services Protection — Entitlements

SIP protects the service registration process, preventing impersonation. Still, there are functions in these services behind the Mach message.

Press enter or click to view image in full size

We can always get the SEND right and send messages, yet the service will reject unauthorized requests based on entitlements our process has.

Taskgated

When the kernel is asked for the task port of a process, it invokes this daemon (via launchd) to make the decision. It is task_for_pid access control daemon and every process can talk to it via TASK_ACCESS_PORT.

Press enter or click to view image in full size
# Daemon PLIST:
/System/Library/LaunchDaemons/com.apple.taskgated.plist

Bugs in its logic that allow for task_for_pid are critical for task security.

Task Injection Bypasses

The task_for_pid allows a process to get the task port of another process. Then, we can use mach_vm_allocate, mach_vm_write, mach_vm_protect and thread_create_running for injection. However, there are some rules:

  • As root, we can inject into any app that is not Apple platform binary (is_platform_binary) or is not compiled with hardened runtime.
  • The task port of the app with com.apple.security.get-task-allow can be accessed by any other process, but it must run at the same user level.
  • If we pwned the app with com.apple.system-task-ports, we could get the task port for any process except for the kernel.

On top of that, we have the kernel task port (task_for_pid(0)), which is heavily restricted, but if compromised, it gives full control of the system.

Mach IPC Programming

Here, I provide a simple client-server Mach IPC communication, both with and without MIG, but also using Distributed Objects.

Distributed Objects, XPC, or NSNotification, are built on top of the Mach, providing a higher access level to a low-level IPC mechanism.

Example Client-Server without MIG

There are only client.c and server.c. We can compile it like every C:

gcc server.c -o server
gcc client.c -o client
Press enter or click to view image in full size

We can see that we are using mach_msg directly for receiving the message, and there are at least three places where a developer can mess up:

Press enter or click to view image in full size
server.c

In the case of the client, we are also using mach_msg directly for sending the message, and there are at least 4places where developers can mess up:

Press enter or click to view image in full size
client.c

There is also a lot of code to maintain in both cases, client and server. Every change in specification forces the developers to make changes in both files.

Example Client-Server with MIG

Instead of writing the IPC code directly in the client.c and server.c, we declare functions and structures in one definition file:

Press enter or click to view image in full size
message.defs

Then, we can use MIG to generate the server and client code that will be able to communicate with each other to call the send_message function:

Press enter or click to view image in full size
mig message.defs
# message.h - Header file with type declarations and function prototypes
# messageUser.c - Client-side IPC code to be used by client.c
# messageServer.c - Server-side IPC code to be used by server.c

Now, the developer only needs to declare the function prototypes and then use the generated by the MIG subsystem handler function message_server:

Press enter or click to view image in full size
server.c

The client only needs a line with an imported MIG-generated routine:

Press enter or click to view image in full size
client.c

The client and server work like before:

Press enter or click to view image in full size

MIG automates writing IPC glue code, letting focus on the server/client logic.

Example Client-Server with Distributed Object

The Objective-C runtime supports a distributed object's IPC.

Below is an example using deprecated API NSConnection:

Press enter or click to view image in full size
client.m
Press enter or click to view image in full size
server.m

There is also CFMessagePort, while not formally deprecated, is considered a legacy API just like NSConnection:

Press enter or click to view image in full size
server.m
Press enter or click to view image in full size
client.m

For more direct access to mach ports from Obj-C, we can use NSMachPort:

Press enter or click to view image in full size
server.m
Press enter or click to view image in full size
client.m

This point was not intended to teach how to program in distributed objects but to draw attention how many higher-level APIs are built on Mach IPC and there are more e.g., NSDistributedNotificationCenter or NSXPCConnection (XPC).

Swift — Kass

There is also a cool security research tool written in Swift. We can also use it to build a client-server and interact with Kernel in many other ways:

I encourage everyone interested in Mach to read the repository documentation, as it goes far beyond this article and is a great introduction to Mach with Swift.

IPC Table

Each process has a structure that consists of ipc_entry entries. Each entry maps port names (numerical values) to their corresponding Mach port objects in the kernel and associated port rights (like send/receive).

Press enter or click to view image in full size
// Example entry in a process's IPC table (WindowServer is just an example, it could be any kernel port object) 
struct ipc_entry {
ie_object = 0xfffff80123456789 // Points to WindowServer's port
ie_bits = 0x00010003 // Generation 1, SEND right, 3 references
ie_index = 42 // Port name base is 42
}

The table starts with 32 entries and can grow when a process wants to share a port. The kernel creates a new entry in the receiving process’s IPC table, granting specific rights. Entry 0 is always reserved (serves as the head of the free list).

ie_object

This is a pointer to the actual Mach port object in the kernel.

  • When we create a new port with mach_port_allocate, a new kernel port object is created and ie_object points to it.
  • In an entry for the WindowServer connection, ie_object would point to the Mach port that lets our app communicate with the WindowServer.

For a NULL entry (free/unused), ie_object would be set to IO_NULL.

ie_bits

This field packs several pieces of information together:

Press enter or click to view image in full size

If a malicious process tries to guess port names, it needs to guess both the index and the correct generation number, making the attack much harder.

ie_index

This is the port name/identifier number. For example, a port might have an index 42, making its name 0x2A. Combined with the generation number, a full port name might look like 0x1002A (generation 16, index 42)

When we see a port name like 0x1003 in debug output, the last digits (3) are the index, and the preceding digits (100) include the generation number.

Mach IPC Recon on macOS

Every service must be registered (bootstrap) through launchd to work on macOS, and because of that, we can use launchtl tool for active recon:

Press enter or click to view image in full size
# Show all System Services running:
launchctl list
launchctl print system

# Inspect user services
launchctl print user/$UID

# Oneliner to list all services
launchctl list | awk 'NR>1{print $3}'; launchctl print user/$UID | grep -Eo '(com|org)\.[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+' | sort -u

# Find port by its service name
sudo launchctl dumpstate | grep com_example_service

# Show process info
sudo launchctl procinfo $PID

# Resolve System Service name of the port for the PID
sudo launchctl resolveport PID PORT

# Locations
/System/Library/LaunchDaemons
/System/Library/LaunchAgents
/Library/LaunchDaemons
/Library/LaunchAgents
$HOME/Library/LaunchAgents

There is also lsmp for listing mach ports:

# Show all ports for all tasks in the server
sudo lsmp -a

# Show all ports for the given process
sudo lsmp -p $(pgrep PROCESS_NAME)

With disabled SIP, we can also utilise dtrace (and ktrace):

Press enter or click to view image in full size
# Tracing mach_traps in crimson_server
sudo dtrace -n 'mach_trap::: /execname == "crimson_server"/ { printf("%s %d\n", execname, arg0); }'

# Show timestamps and process names whenever a Mach port is registered through the notification system
sudo dtrace -n 'notify*:::register_mach_port { printf("%Y: %s registered mach port\n", walltimestamp, execname); }'

See also taskinfo and taskpolicy commands.

Using IPC table

We can also use existing APIs mach_port_space_info, mach_task_self and task_for_pid for enumeration of all ports for the given PID:

Press enter or click to view image in full size
port_inspector

However, we cannot use task_for_pid against system services even with root, which makes this a little useless and it is better to use lsmp -p which is allowed to do that with com.apple.system-task-ports.read entitlement.

Forward Service Lookup

We can use bootstrap_look_up to check if there is any port registered under the given service name and also check our process rights to it:
(we should always get SEND rights to them, as it is by design)

Press enter or click to view image in full size
service_lookup

Unfortunately, there is no API to get a service name from a port number(name). The Mach IPC system and bootstrap server only provide forward lookup (service name -> port number) but not reverse lookup (port number -> service name).

Special Task Port Right

We can use task_get_special_port MIG routine. I created a tool that enumerates our task rights to these ports. Interesting is the first result:

Press enter or click to view image in full size
enum_special_port_rights_self.c

I also made a version for checking other PIDs permissions, which does not throw this security error:

Press enter or click to view image in full size
enum_special_port_rights_pid.c

So, the mach_task_self gives us access to our own TASK_KERNEL_PORT but trying to get it again via task_get_special_port is blocked by security policy.

Reverse Engineering Mach IPC

For structures, we can use osfmk/ directory as it is open source:

  • IPC (Inter-Process Communication): osfmk/ipc/
  • Thread and task management: osfmk/kern/
  • Virtual memory management: osfmk/vm/
  • Low-level hardware abstractions: osfmk/arm/

See also the Mach API collection and Mach Enumeration.

Debugging

Breakpoints of our interest are mach_msg and mach_msg_overwrite as they are used to sending and receiving messages:

Press enter or click to view image in full size
br set -n mach_msg 
br set -n mach_msg_overwrite # For MIG

For example, when debugging our simple client-server example, we can see the content on the stack just after hitting the breakpoint:

Press enter or click to view image in full size

However, we can do better than that by navigating the message memory layout based on proper Objective-C types rather than hardcoded offsets:

Press enter or click to view image in full size
# Import Foundation and use Objc++ 
expr -l objc++ -O -- @import Foundation
# Cast the x0 to a Mach message header
p *(mach_msg_header_t *)$x0
# Skip over the mach_msg_header_t (+1) and cast message content to char
p (char *)((mach_msg_header_t *)$x0 + 1)

Similarly, we can set a breakpoint for client-server example that use MIG, but this time we need to use mach_msg_overwrite as mach_msg is not hit:

Press enter or click to view image in full size

Using the previous trick, we can parse the Mach message header, but it will not work for content. We can also observe the MIG subsystem ID used:

Press enter or click to view image in full size

MIG messages are not simple text like in our previous example — they are RPC data. Before we can cast it to the message structure, we need to understand it.

Debugging MIG

The structure is generated by MIG, but most of the time, we do not have the source code. However, we can use msgh_id to find routine it implements:

Press enter or click to view image in full size

Then, we can find it in decompiled code and find the function that handles the request. As we can see below, the function takes 3 parameters:

Press enter or click to view image in full size
# I hided casts on the image
SERVER_send_message(
*(unsigned int *)(a1 + 12), # First parameter at offset 12
*(_QWORD *)(a1 + 28), # Second parameter at offset 28
*(unsigned int *)(a1 + 40) # Third parameter at offset 40
);

When debugging and reading the message content, we need to look at these specific offsets from our x0 register. Let’s try reading them:

Press enter or click to view image in full size
# MIG message structure contains:
p/x *(unsigned int *)($x0 + 12) # offset 12: Port/ID (0xd03)
p/x *(unsigned long *)($x0 + 28) # offset 28: Message pointer (0x100014000)
p/x *(unsigned int *)($x0 + 40) # offset 40: Length (0x13)

We could also get an idea of where is our message content by looking at the implementation of the MIG subsystem 400 ID routine(function):

Press enter or click to view image in full size

The point is, every implementation on top of Mach will have some nuances, but we can always start RE from message header (mach_msg_header_t) structure.

Static Analysis

The below functions can be considered as Mach artifacts when we want to quickly identify if a given program implements Mach IPC in its code:

//===============================
// 1. Message Handling Functions
//===============================

// Core message sending/receiving
mach_msg // Primary function for sending/receiving Mach messages
mach_msg_overwrite // Variant that can reuse buffer space for efficiency
mach_msg_server // Framework for handling MIG-generated message servers
_NDR_record // Network Data Representation for MIG
// Message structure manipulation
mach_msg_type_number_t // Used for managing message size and buffer lengths
mach_msg_header_t // Basic message header structure
mach_msg_trailer_t // Message trailer for security/audit information

//===============================
// 2. Service Management
//===============================

// Service registration
bootstrap_check_in // Register a service with launchd
bootstrap_register // Legacy registration method
bootstrap_look_up // Find a registered service by name

// Port management
mach_port_insert_right // Add a port right to a task
mach_port_allocate // Create new port rights
mach_port_deallocate // Clean up port rights

//===============================
// 3. Task Operations
//===============================

// Task port access
mach_task_self // Get the task port for current task
task_set_special_port // Configure special ports for a task
task_get_special_port // Retrieve special ports from a task

// Task manipulation
task_for_pid // Get task port for a process ID
pid_for_task // Get process ID for a task port

I also described in detail how to identify MIG subsystems in this article:

# Look for _NDR_record
CrimsonUroboros -p PATH_TO_BIN --mig

Working with MIG is easier than it looks. We just look for subsystems (msgh_id) and follow the cross-references to the function implementation behind it.

Decompilation

For the server, most of the time, we should check for bootstrap_check_in or bootstrap_register and analyze the mach_msg logic that follows:

Press enter or click to view image in full size

Similarly, for the client, but we check the bootstrap_look_up:

Press enter or click to view image in full size

This general RE flow, but it depends on the implementation and the developer's imagination. In general, there is always mach_msg or mach_msg_overwrite

FINAL WORDS

The article is not the shortest. Still, many things have been omitted, such as privileged ports or an example task injection with task_for_pid.

Although I uploaded it to the repository with comments, the shellcoding and process injection techniques deserve a separate article.

Moreover, I did not write about the XPC which is also built on top of Mach.

All these topics will appear in future articles about process injections and XPC.

References

I did not focus on the low-level vulnerabilities in Mach IPC. If this is a point of interest, I think a good start would be Ian Beer's presentations:

Source

While working on this article, I read some very good shorter and longer pieces about Mach IPC from many cool guys in security. Here they are:

For vulnerability research, these presentations are extraordinary:

I also used some of Apple’s official documentation and various other manuals that should be referenced here:

The best book about Mach: *OS Internals Volume II by Jonathan Levin:

Press enter or click to view image in full size

Also, self-reference, if you are interested in MIG, read Snake&Apple VI — AMFI

Sssssssssstay tuned.

--

No responses yet