XNU IPC - Mach messages
Contents
This is the first part in a series of articles covering my learnings and experiments with the Interprocess Communication mechanisms in XNU.
I figured giving some structure to what I was learning would allow me to grasp the concepts better, so please keep in mind this is written from a learning person perspective. Providing a comprehensive guide on the topic is a non-goal of the series.
In this part, I’ll provide some background on XNU, the importance of Interprocess Communication, and introduce the core mechanism - Mach messages.
First up, I’d like to link some resources that I’ve been using to learn more about Mach IPC:
- *OS Internals by Jonathan Levin1
- Mach Overview from Apple2
- Mach documentation from the GNU Hurd project3
- Mach 3 Kernel Principles/Interfaces books
Prelude
XNU is the computer operating system kernel, and the name is an acronym for X is Not Unix. Why is it relevant at all? Well, XNU is the heart of all Apple consumer operating systems, from macOS, through iOS all the way to watchOS. XNU is a hybrid kernel combining the best of monolithic and microkernel designs. It is based on the Mach kernel and components from FreeBSD, leading to the kernel having two personalities. For example, similar to the concept of a process in UNIX there’s a task in Mach, and both are present in XNU.
Why IPC?
XNU is based on Mach, the microkernel. The principle of a microkernel design is providing only the bare minimum of the required functionality in the kernel space, such as threads, memory management and IPC. Additional features are moved into separate modules that can communicate using IPC and don’t require running in a privileged kernel mode.
The main advantage of the microkernel design is modularity and security. When subsystems don’t need to run in a privileged environment, their vulnerabilities are less of a risk for the entire system. On the other hand, the major challenge the design has faced historically was performance. Kernel subsystems that’d otherwise run in the same privileged space now had to cross the execution mode boundary to communicate with other parts of the kernel or user space modules. This is also why IPC in Mach is so important. For this design to be feasible, the communication has to be performant. While IPC in Mach is reportedly fast, the cost of crossing the memory/execution mode can be significant for the code powering the entire system.
Given the challenges of a microkernel design, XNU uses a hybrid design. Much of the functionality lives in the kernel space, but also many subsystems are backed by user space daemons. Apple is also actively deprecating third party kernel extensions, making them significantly harder to use and recommending developers to use user space alternatives4.
Mach messages
Mach messages was the only IPC mechanism used in the Mach kernel. Others used nowadays, such as sockets, pipes were added in XNU as a part of the BSD layer. Some of those are built on top Mach messages, so this is why I chose it to start with.
Before messages, there were ports
Mach messages being a message queue type of an IPC mechanism, require endpoints to send and retrieve messages through. In Mach, those are called ports. They are a kernel level abstraction and not directly exposed to the user space. Instead, ports can be accessed through port rights, such as a SEND or a RECEIVE right.
Each task has its namespace of port rights, and rights are named using 32-bit integers.
Therefore port APIs often take an explicit task argument to disambiguate the name, unless otherwise known from the usage context.
Ports are not only used to establish a communication channel for services or between processes, but they also serve as an endpoint for resources. In user space, all tasks, threads or even semaphore handles and more are really send rights to ports that represent the underlying resources. This is a truly powerful abstraction because the way to access and modify the state of the task of a running program is just the same as for other tasks. All that is needed is to obtain a send right to their port.
Here for example are some typedefs from mach/mach_types.h
showcasing the abstraction:
|
|
Communication principles
Ports are a unidirectional communication channel. There can only exist one holder of a receive right to a port, but there can be multiple send right owners. Rights can be transferred over messages. There can be only one receive right so it can be only moved, but owners of a send right can freely move/copy it and grant it to other tasks.
Bidirectional
Only unidirectional communication would be of limited usefulness, but given a unidirectional channel, a bidirectional communication can be established.
Given a priori that Alice, an owner of a receive right, and Bob, an owner of a send right to the same port, this is how bidirectional communication can be established between them:
- Bob allocated a Mach port with a receive right.
- He creates a send right to the port.
- He transfers the send right in a message sent over to Alice.
Now, both Alice and Bob own receive and send rights to ports they can communicate over. Syncing up the communication over different ports could be cumbersome, so each Mach message can optionally include a reply port — which can be used to send a message response over.
Establishing connection
Earlier, we assumed Alice and Bob both had rights to a common port, but how does that happen in the first place? Well, unlike with, for example, sockets, direct communication can’t be established between two tasks directly. Instead, there’s a centralised broker service, a bootstrap server, used to do precisely that. The role of the bootstrap server in XNU is nowadays fulfilled by launchd
5, but before its introduction, the init process and bootstrap server were separate. Communication with the bootstrap also happens using Mach messages, and this is where another property of ports comes in — each spawned process has a send right to the broker.
The bootstrap server is responsible for service registration and lookup, and allows to establish a connection between two parties. One way this could happen is as follows:
- Alice registers itself with the bootstrap server using some known name, e.g.,
com.example.alice
. - Registration in practice means that Alice grants the broker a send right to a port it holds a receive right to.
- Next, when Bob queries the bootstrap server for the
com.example.alice
name, it can copy its send right, grant it to Bob, and then the connection is established.
There’s one important security culprit to this mechanism.
Self-registration means that a malicious actor spawned before Alice could claim to provide the service.launchd
introduced an alternative check-in mechanism. It can spawn the service on demand and create a receive right on its behalf and then transfer it.
Messages
Whereas Mach ports serve as a communication channel, messages are used to exchange data over those channels, so let’s have a look now at how that works.
First up is the message format. Each message consists of a header and body. Header is defined as:
|
|
|
|
While each message consists of a header and body, this is not the complete story. When receiving a message, they additionally contain a trailer. Trailers contain information about the message and are appended by the kernel. All that’s necessary to know about trailers, for now, is that they exist, and that there’s a default/null type to begin with that contains only its type and size:
|
|
The last piece of the puzzle is how messages can be exchanged. Main userland API for communication over Mach ports is mach_msg
. Notably this single function is used to send as well as receive messages, the exact behaviour is specified using its arguments. The signature is:
|
|
|
|
Example
It’s now time to put all the theory into practice. The example demonstrates step by step how to create a simple server and a client program communicating using Mach messages.
First, the definition of the message for the client program:
|
|
Message structure consists of the required Mach message header and two body fields.
On the server side message has the same base contents, but it also includes the required default trailer data:
|
|
Server
The message structures are now defined, and we can move to write the service server program. This entails the following steps:
- Create a Mach port receive right
- Add a send right to the port for the bootstrap server to use
- Retrieving a Mach port to communicate with the broker, as it uses Mach messages for the service registration
- Registering our service
- Message receiving routine
- Main server loop to receive messages
#1 Create a receive right
mach_port_allocate
creates a port right in the task namespace, whereas
creating a receive right is equivalent to the creation of a Mach port, as there
can be only one owner of the right:
|
|
#2 Add a send right
Service registration requires a send right to the port, as that will allow the
bootstrap server to copy the right when clients request it.
Notably, send and receive rights use the same name in the task namespace.
Therefore instead of using mach_port_allocate
to allocate a new right as
in the previous step, there’s a mach_port_insert_right
API to add the
send right:
|
|
#3 Retrieve the bootstrap port
Port of the bootstrap server will be needed later to register the service, it can be retrieved using task_get_special_port
API:
|
|
#4 Service registration
|
|
#5 Message retrieval routing
Using the earlier allocated port and mach_msg
function messages can be
retrieved from the queue:
|
|
#6 Main program loop
Server will indefinitely retrieve messages, until stopped:
|
|
Client
Now onto the client side:
- Like in server, we need to retrieve the bootstrap server port to query it for the service port
- Query bootstrap for the service port
- Setup message header
- Fill message body
- Send the message
#1 Retrieve the bootstrap port
|
|
#2 Query bootstrap for the service port
|
|
#3 Setup message header
|
|
#4 Fill the message body
|
|
And then send the message:
|
|
The same message structure can be then reused to send an extra message:
|
|
Result
Now both, service server and client programs are ready and can be tested in action.
First, start the server program:
|
|
Then run the client in another shell session:
|
|
And in the server session, the messages will arrive:
|
|
So that’s a success! We were able to exchange messages between the client and server programs.
lsmp
macOS comes in with a tool called lsmp
, which can inspect ports actively used
by different processes.
Running lsmp
as a root allows it to look up right names in task local
namespaces and cross-reference them to show a neat overview. To see this in
action, add a sleep
call to the client program to keep it alive.
We can then find the PID of the server program and use lsmp
to find the active
ports:
|
|
Here the server process has a recv - receive and send rights to a port named 0xb03
.
Alike launchd
, serving as the bootstrap server, and bob_client
have send rights to the service port.
lsmp
is able to match corresponding port names between tasks to show this neat overview.
This concludes our unidirectional communication example using Mach messages over Mach ports.
Summary
To recap what we went over in this first part:
- A brief history of XNU
- What makes IPC so important in Mach/XNU
- Core principles of Mach IPC
- Overview of Mach ports and messaging APIs
- Example server and client program
Full implementation of the example can be found at GitHub.
Next up I’d like to look at how the reply port can be used to establish a bidirectional communication, and what other payload types are supported, so that will likely be the topic of the second part. Stay tuned!
Update 07-10
Added information about port rights task namespace and the usage of a right name in the context of the server program. Thanks to @janseredynski for bringing it up!
Thanks for reading! If you’ve got any questions, comments, or general feedback, you can find all my social links at the bottom of the page.