In this blog post, we’ll describe a design issue in the way XPC connections are authorised in Apple’s operating systems. This will start by describing how XPC works and is implemented on top of mach messages (based on our reverse engineering). Then, we’ll describe the vulnerability we found, which stems from implementing a (presumed to be) one-to-one communication channel on top of a communication channel that allows multiple concurrent senders. Next, we’ll describe this issue using an example for smd
and diagnosticd
on macOS. This instance was fixed in macOS 13.4 as CVE-2023-32405. As Apple did not apply a structural fix, but only fixed this instance, developers still need to keep this in mind when building XPC services and researchers may be able to find more instances of this issue.
XPC is an important inter-process communication technology in all of Apple’s operating systems. XPC connections are very fast and efficient and API is easy to use. XPC messages are dictionaries with typed values, removing the need for custom (de)serialization code in most situations, which is often an area where vulnerabilities might occur.
XPC is often used across different security boundaries. For example, to implement a highly privileged daemon that can perform operations requested by apps. In these scenarios, authorization checks are very important for the security of the system. These checks could be verifying that the app is not sandboxed, or signed by a specific developer, holding an entitlement, etc.
It is well documented that the process ID (PID) (for example by using the function xpc_connection_get_pid
) is not safe to use for such an authorization check: an application can send a message and immediately execute another process (in a way that keeps the same PID), hoping that the authorization check will check the new process instead. Instead, the function xpc_connection_get_audit_token
should be used (which, annoyingly, is not part of the public XPC API on macOS). An audit token is a structure that contains not just the PID but also an PID version, which increases when spawning a new process, making it possible to distinguish them and therefore obtain the right process.
From Audit tokens explained by Scott Knight:
audit_token.val[0] = my_cred->cr_audit.as_aia_p->ai_auid;
audit_token.val[1] = my_pcred->cr_uid;
audit_token.val[2] = my_pcred->cr_gid;
audit_token.val[3] = my_pcred->cr_ruid;
audit_token.val[4] = my_pcred->cr_rgid;
audit_token.val[5] = p->p_pid;
audit_token.val[6] = my_cred->cr_audit.as_aia_p->ai_asid;
audit_token.val[7] = p->p_idversion;
In this blog post, we’ll describe that xpc_connection_get_audit_token
may also use the wrong process in certain situations, and that xpc_dictionary_get_audit_token
is better to use in those cases. In order to explain why, we have to explain the way XPC implemented.
XPC is built on top of mach messages. While this part of the mach kernel is open source, XPC is not, so to figure out how to (for example) establish an XPC connection or serialize an XPC message, we have had to reverse engineer the libraries implementing this. Therefore, keep in mind that this contains some guesswork and Apple may change the implementation at any moment.
Mach messages are sent over a mach port, which is a single receiver, multiple sender communication channel built into the mach kernel. Multiple processes can send messages to a mach port, but at any point only a single process can read from it. Just like file descriptors and sockets, mach ports are allocated and managed by the kernel and processes only see an integer, which they can use to indicate to the kernel which of their mach ports they want to use.
Mach messages are sent or received using the mach_msg
function (which is essentially a syscall). When sending, the first argument for this call must be the message, which has to start with a mach_msg_header_t
followed by the actual payload:
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;
The process that can receive messages on a mach port is said to hold the receive right, while the senders hold a send or a send-once right. Send-once, as the name implies, can only be used to send a single message and then is invalidated.
The fact that mach ports only allow messages in a single direction may sound quite limited, but of course there are ways to deal with this. The main way bidirectional communication can be established is by transferring these rights to another process using a mach message. A receive or send-once right can be moved to another process and a send right can be moved or copied.
One place where this is used is for a field in the mach message header called the reply port (msgh_local_port
). A process can specify a mach port with this field where the receiver of the message can send a reply to this message. The bitflags in msgh_bits
can be used to indicate that a send-once right should be derived and transferred for this port (MACH_MSG_TYPE_MAKE_SEND_ONCE
).
The other fields of the message header are:
msgh_size
: the size of the entire packet.msgh_remote_port
: the port on which this message is sent.msgh_voucher_port
: mach vouchers.msgh_id
: the ID of this message, which is interpreted by the receiver.
To establish an XPC connection there are multiple options (mach services, embedded XPC services, using endpoints, etc.). We’ll focus on an app establishing an XPC connection to a mach service here. Mach services use a service name on which they are reachable. This name should be specified in the MachServices
key in the launch daemon configuration. For example, smd
, the service management daemon, specifies the name com.apple.xpc.smd
:
/System/Library/LaunchDaemons/com.apple.xpc.smd.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AuxiliaryBootstrapperAllowDemand</key>
<true/>
<key>EnablePressuredExit</key>
<true/>
<key>Label</key>
<string>com.apple.xpc.smd</string>
<key>LaunchEvents</key>
<dict>
<key>com.apple.fsevents.matching</key>
<dict>
<key>com.apple.xpc.smd.WatchPaths</key>
<dict>
<key>Path</key>
<string>/Library/LaunchDaemons/</string>
</dict>
</dict>
</dict>
<key>MachServices</key>
<dict>
<key>com.apple.xpc.smd</key>
<true/>
</dict>
<key>ProcessType</key>
<string>Adaptive</string>
<key>Program</key>
<string>/usr/libexec/smd</string>
</dict>
</plist>
When a launch agent or daemon launches, they generate a new mach port and send a send right to this port to the bootstrap service (part of launchd). We’ll refer to this as the service port.
To connect to a mach service, the client asks the bootstrap service for that name. If that name is registered, it duplicates the send right and sends it back to the application.
Once the app has the service port, it generates two new mach ports: the server port and the client port. Then, it sends a message to the service port (with an msgh_id
of 0x77303074
, or 'w00t'
) in which it moves the receive right for the server port and copies a send right for the client port. If the service accepts the connection, it starts listening for messages on the server port and it can use the client port to send messages to the app.
As you can see from this description, normal XPC mach messages don’t use the reply port field. But it is used for XPC messages that expect a reply (xpc_connection_send_message_with_reply
and xpc_connection_send_message_with_reply_sync
). Replies and normal XPC messages are therefore transferred over completely different mach ports. This way the implementation can keep track of multiple pending replies and differentiate them from normal messages automatically.
Now where do audit tokens come in? Well, when receiving a mach message, an application can add a flag that asks the kernel to append a certain trailers to the received message. The flag MACH_RCV_TRAILER_AUDIT
asks the kernel to append a trailer that contains the audit token of the sender of that message. libxpc sets this flag, so when a message comes in, the function _xpc_connection_set_creds
copies the audit token from the trailer to the XPC connection object.
We have just seen the following:
- Mach ports are single receiver, multiple sender.
- An XPC connection’s audit token is the audit token of copied from the most recently received message.
- Obtaining the audit token of an XPC connection is critical to many security checks.
This lead us to the research question: can we set up an XPC connection where multiple different processes are sending messages, leading to a message from one process being checked with the audit token of a different process?
XPC’s abstraction is a one-to-one connection, but it is based on top of a technology which can have multiple senders. As with many security issues, we are trying to break the abstraction and see what might be possible.
We established a few things that wouldn’t work:
- Audit tokens are often used for an authorization check to decide whether to accept a connection. As this happens using a message to the service port, there is no connection established yet. More messages on this port will just be handled as additional connection requests. So any checks before accepting a connection are not vulnerable (this also means that within
-listener:shouldAcceptNewConnection:
the audit token is safe). We are therefore looking for XPC connections that verify specific actions. - XPC event handlers are handled synchronously. This means that the event handler for one message must be completed before calling it for the next one, even on concurrent dispatch queues. So inside an XPC event handler the audit token can not be overwritten by other normal (non-reply!) messages.
This gave us the idea for two different methods this may be possible:
- A service that calls
xpc_connection_get_audit_token
while not inside the event handler for a connection. - A service that receives a reply concurrently with a normal message
Variant 1: calling xpc_connection_get_audit_token outside of an event handler
The first case we looked at is finding daemons that check an audit token asynchronously from the XPC event handler. To summarize the requirements, this requires:
- Two mach services A and B that we can both connect to (based on the sandbox profile and the authorization checks before accepting the connection).
- A must have an authorization check for a specific action that B can pass (but our app can’t).
- For this authorization check, A obtains the audit token asynchronously, for example by calling
xpc_connection_get_audit_token
fromdispatch_async
.
We found a hit for these requirements with A as smd
and B as diagnosticd
.
Exploiting smd
smd
handles features like login items and managing privileged helper tools. For example, the function SMJobBless
can be used to install a new privileged helper tool, which is a command line executable included in an application that gets installed to run as root, which can be used to perform the features an app needs that require root without having to run the entire app as root.
Normally to use SMJobBless
, an application would include the tool it wants to install inside Contents/Library/LaunchServices/
in its own app bundle and the key SMPrivilegedExecutables
in the Info.plist file. To install it, it must ask the user to authenticate, which results in an authorization reference if it succeeds. That authorization reference must then be passed to SMJobBless
. The goal of this exploit is to perform the installation of a privileged helper tool without obtaining an authorization reference first.
Internally, SMJobBless
works by communicating with smd
over XPC. Clients connection to smd
can perform multiple actions. The message must specify the key “routine” to indicate which operation to perform. Routine 1004 is the one eventually called by SMJobBless
. For this routine, dispatch_async
is used to execute a block on a different dispatch queue:
case 1004LL:
buf.i64[0] = (__int64)_NSConcreteStackBlock;
buf.i64[1] = 3254779904LL;
buf.i64[2] = (__int64)handle_bless;
buf.i64[3] = (__int64)&unk_100008180;
v49 = objc_retain(v4);
v50 = objc_retain(v5);
dispatch_async((dispatch_queue_t)queue, &buf);
goto LABEL_25;
__int64 __fastcall handle_bless([...])
{
[...]
err = 0;
pid = xpc_connection_get_pid(connection);
memset(&audit_token, 170, sizeof(audit_token));
xpc_dictionary_get_audit_token(message, &audit_token);
v129 = connection;
is_unauthorized = connection_is_unauthorized(connection, message, "com.apple.ServiceManagement.blesshelper", &err);
if ( is_unauthorized )
{
v15 = is_unauthorized;
send_error_reply(message, is_unauthorized, err);
LABEL_3:
v16 = 0LL;
goto LABEL_4;
}
[...]
The function named handle_bless
includes a call to connection_is_unauthorized
, which allows the operation to be performed if one of three checks passes:
- The requesting application is running as root.
- The requesting application has the entitlement
com.apple.private.xpc.unauthenticated-bless
. - The request contains authorization reference for the name
"com.apple.ServiceManagement.blesshelper"
(this is whatSMJobBless
obtains).
__int64 __fastcall connection_is_unauthorized(
void *connection,
void *message,
char *authorization_name,
OSStatus *error)
{
[...]
v22 = authorization_name;
v5 = objc_retain(connection);
v6 = objc_retain(message);
memset(audit_token, 170, sizeof(audit_token));
xpc_connection_get_audit_token(v5, audit_token);
v7 = 0;
// [1]: field 1 contains the UID, UID == 0 means root
if ( audit_token[1] )
{
v8 = error;
// [2]: Has a specific entitlement
v9 = (void *)xpc_connection_copy_entitlement_value(v5, "com.apple.private.xpc.unauthenticated-bless");
v10 = &_xpc_bool_true;
if ( v9 != &_xpc_bool_true )
{
v11 = v9;
length = 0LL;
// [3]: Passed in an authorization reference for the specified name
data = (const AuthorizationExternalForm *)xpc_dictionary_get_data(v6, "authref", &length);
v7 = 81;
if ( data && length == 32 )
{
authorization = 0LL;
v13 = AuthorizationCreateFromExternalForm(data, &authorization);
if ( v13 )
{
*v8 = v13;
v7 = 153;
}
else
{
v17 = 0LL;
v18 = 0LL;
v16 = v22;
*(_QWORD *)&rights.count = 0xAAAAAAAA00000001LL;
rights.items = (AuthorizationItem *)&v16;
v14 = AuthorizationCopyRights(authorization, &rights, 0LL, 3u, 0LL);
if ( v14 == -60005 )
{
v7 = 1;
}
else if ( v14 )
{
*v8 = v14;
v7 = 153;
}
else
{
v7 = 0;
}
AuthorizationFree(authorization, 0);
}
}
v10 = v11;
}
}
else
{
v10 = 0LL;
}
objc_release(v10);
objc_release(v6);
objc_release(v5);
return v7;
}
In order to perform our attack, we need a second service too. We picked diagnosticd
because it runs as root, but many other options likely exist. This daemon can be used to monitor a process. Once monitoring has started, it will send multiple messages per second about, for example, the memory use and CPU usage of the monitored process.
To perform our attack, we establish our connection to smd
by following the normal XPC protocol. Then, we establish a connection to diagnosticd
, but instead of generating two new mach ports and sending those, we replace the client port send right with a copy of the send right we have for the connection to smd
. What this means is that we can send XPC messages to diagnosticd
, but any messages diagnosticd
sends go to smd
. For smd
, both our and diagnosticd
’s messages appear arrive on the same connection.
Next, we ask diagnosticd
to start monitoring our (or any active) process and we spam routine 1004 messages to smd
.
This creates a race condition that needs to hit a very specific window in handle_bless
. We need the call to xpc_connection_get_pid
at [1] below to return the PID of our own process, as the privileged helper tool is in our app bundle. However, the call to xpc_connection_get_audit_token
inside the connection_is_authorized
function at [2] must use the audit token of diganosticd
.
__int64 __fastcall handle_bless([...])
{
[...]
err = 0;
pid = xpc_connection_get_pid(connection); // [1] Must be our process
memset(&audit_token, 170, sizeof(audit_token));
xpc_dictionary_get_audit_token(message, &audit_token);
v129 = connection;
// [2] Must use diagnosticd
is_unauthorized = connection_is_unauthorized(connection, message, "com.apple.ServiceManagement.blesshelper", &err);
if ( is_unauthorized )
{
v15 = is_unauthorized;
send_error_reply(message, is_unauthorized, err);
LABEL_3:
v16 = 0LL;
goto LABEL_4;
}
string = xpc_dictionary_get_string(message, "identifier");
if ( !string )
{
v15 = 22;
goto LABEL_3;
}
v135 = string;
path_of_pid = get_path_of_pid(pid);
if ( !path_of_pid )
{
v15 = 2;
goto LABEL_3;
}
v22 = (id)path_of_pid;
property = (const char *)xpc_bundle_get_property(path_of_pid, 9LL);
v15 = 107;
if ( !property )
goto LABEL_48;
v24 = sub_10000447A("%s/Library/LaunchServices/%s", property, v135);
While that looks difficult to hit, smd
doesn’t close the connection once it receives a malformed or unauthorized message so we can keep retrying.
Once our privileged helper tool is installed, we simply connect and send a message to get it to launch, and we have gained code execution as root!
We originally discovered this vulnerability on macOS Big Sur, in macOS Ventura it still worked, but Apple had added notifications about added launch agents, making it no longer stealthy. However, as these notifications are only showed afterwards, we have already succeeded at that point.
Sandbox escape?
Privilege escalation is fun, but it’s even more fun if we can escape the sandbox at the same time. Our smd
exploit kept working perfectly if we enabled the “App Sandbox” checkbox in Xcode, as both mach services can be reached by sandboxed apps.
However, the practical impact of this as a sandbox escape is very limited. Due to the requirement to embed the privileged helper tool in the app and set the Info.plist key, we can not escape from an arbitrary compromised application that has enabled the Mac Application Sandbox (and definitely not from a compromised browser renderer). We could attempt to submit an app like this to the Mac App Store, but static checks on the application will almost certainly find and reject our embedded helper tool (we didn’t test this, as testing against the Mac App Store review process tends to get one on Apple’s bad side).
This leaves just one scenario: we can construct an application that we offer as a download outside of the Mac App Store that is ostensibly sandboxed, but which turns out to escape its sandbox when launched and which even elevates its privileges to root. The number of users who will check if an application they have downloaded from the internet is sandboxed before running it will likely be extremely low.
Variant 2: reply forwarding
We also identified a second variant that can also modify the audit token. As mentioned before, the handler for events on an XPC connection is never executed multiple times concurrently. However, XPC reply messages are handled differently. Two functions exist for sending a message that expects a reply:
void xpc_connection_send_message_with_reply(xpc_connection_t connection, xpc_object_t message, dispatch_queue_t replyq, xpc_handler_t handler)
, in which case the XPC message is received and parsed on the specified queue.xpc_object_t xpc_connection_send_message_with_reply_sync(xpc_connection_t connection, xpc_object_t message)
, in which case the XPC message is received and parsed on the current dispatch queue.
Therefore, XPC reply packets may be parsed while an XPC event handler is executing. While _xpc_connection_set_creds
does use locking, this only prevents partial overwriting of the audit token, it does not lock the entire connection object, making it possible to replace the audit token in between the parsing of a packet and the execution of its event handler.
For this scenario we would need:
- As before, two mach services A and B that we can both connect to.
- Again, A must have an authorization check for a specific action that B can pass (but our app can’t).
- A sends us a message that expects a reply.
- We can send a message to B that it will reply to.
We wait for A to send us a message that expects a reply (1), instead of replying we take the reply port and use it for a message we send to B (2). Then, we send a message that uses the forbidden action and we hope that it arrives concurrently with the reply from B (3).
While we have confirmed this variant works using custom mach services, we did not find any practical examples with security impact.
We quickly found one instance of the first variant in smd
(which affects only macOS), but does that make it a design issue in XPC or an error in smd
? Arguing that it’s a design issue becomes a lot easier with more examples, preferably also on other platforms like iOS.
We spent a long time trying to find other instances, but the conditions made it difficult to search for either statically or dynamically. To search for asynchronous calls to xpc_connection_get_audit_token
, we used Frida to hook on this function to check if the backtrace includes _xpc_connection_mach_event
(which means it’s not called from an event handler). But this only finds calls in the process we have currently hooked and from the actions that are actively used. Analysing all reachable mach services in IDA/Ghidra was very time intensive, especially when calls involved the dyld shared cache. We tried scripting this to look for calls to xpc_connection_get_audit_token
reachable from a block submitted using dispatch_async
, but parsing blocks and calls passing into the dyld shared cache made this difficult too. After spending a while on this, we decided it would be better to submit what we had.
While this did not result in any further instances of this issue, the time we spent reverse engineering XPC services did lead us to discover CVE-2023-32437 in nsurlsessiond
, but that’s for another writeup.
In the end, we reported the general issue and the specific issue in smd
. Apple fixed it only in smd
by replacing the call to xpc_connection_get_audit_token
with xpc_dictionary_get_audit_token
.
The function xpc_dictionary_get_audit_token
copies the audit token from the mach message on which this XPC message was received, meaning it is not vulnerable. However, just like xpc_dictionary_get_audit_token
, this is not part of the public API. For the higher level NSXPCConnection
API, no clear method exists to get the audit token of the current message, as this abstracts away all messages into method calls.
It is unclear to us why Apple didn’t apply a more general fix, for example dropping messages that don’t match the saved audit token of the connection. There may be scenarios where the audit token of a process legitimately changes but the connection should stay open (for example, calling setuid
changes the UID field), but changes like a different PID or PID version are unlikely to be intended.
In any case, this issue still remains with iOS 17 and macOS 14, so if you want to go and look for it, good luck!