Every bug class in the previous post ended in corrupted memory: a driver, a dispatch table, and a selector whose handler gets an argument it did not expect. The other local surface works differently. A sandboxed process can also send messages to a couple of dozen daemons, most of them running as root, and the interesting bugs there corrupt nothing at all. The daemon does exactly what it was written to do. It does it for the wrong caller.
That is a confused deputy, and on Apple platforms it has one mechanical cause. A service asks the IPC layer who is calling, and gets an answer that is true about the connection instead of true about the message it is holding. The rest is plumbing, and the plumbing is worth knowing, because you cannot recognise the mistake in a disassembly until you know which function returns which answer. Mach and MIG below go only as deep as XPC needs them to.
Everything here is public: Apple’s open-source XNU, Apple’s own security advisories, and published research. The bug walked in detail, CVE-2023-32405, was fixed in macOS 13.4 in May 2023 and has a public writeup by the researcher who found it. There is no exploit, private detail, or 0day here.
The message, and what rides in it
XNU under the hood mapped a Mach port onto a file descriptor: an integer that means nothing outside the process holding it, standing in for a kernel object userland never sees. Sending to one is where that mapping stops being a convenience, because a Mach message can move a right and a write() cannot.
Every message starts with the same six fields, from osfmk/mach/message.h:
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;
msgh_remote_port is the destination, msgh_local_port the reply port, msgh_size the whole packet, and msgh_id a number the receiver interprets however it likes.
msgh_bits is the field doing the security-relevant work. Its low five bits hold the disposition of the remote port and bits 8 to 12 the disposition of the local port. A disposition says what the kernel does with a right as the message crosses: move it out of the sender, copy it so both ends hold one, or manufacture a send right from a receive right. Each of those is a reference count adjusted in the kernel, which is why the kernel bugs here are overwhelmingly lifetime bugs.
The top bit of msgh_bits, MACH_MSGH_BITS_COMPLEX (0x80000000), sorts every message into one of two kinds. With the bit clear the message is simple: the header is followed by a flat block of inline bytes and nothing else, the Mach equivalent of a plain write(). With the bit set it is complex, and what follows the header is not raw bytes but a descriptor count and that many descriptors, each naming something the kernel must act on as the message crosses rather than merely copy. Complex messages are where port rights and out-of-line memory travel, so they are where the bugs are. Three descriptor kinds carry the interesting things:
| value | descriptor | carries |
|---|---|---|
| 0 | MACH_MSG_PORT_DESCRIPTOR |
one port right |
| 1 | MACH_MSG_OOL_DESCRIPTOR |
out-of-line memory, delivered as a fresh copy-on-write mapping rather than inline |
| 2 | MACH_MSG_OOL_PORTS_DESCRIPTOR |
an array of port rights |
Types 3 and 4 are a volatile flavour of out-of-line memory and a port right carrying a guard value. Two things matter to anyone reading a handler. The descriptors are not all the same size, so walking the array with a fixed stride breaks on the first out-of-line one, as XNU’s own header warns. And the descriptor count, like every number here, was written by the sender: a handler that trusts it without reconciling against msgh_size is the classic complex-message bug.
Who is actually calling
Nothing in the header identifies the sender, who wrote all of it. What a receiver can trust is appended by the kernel past the end of the message, and only if the receiver asks: the trailer. Ask with MACH_RCV_TRAILER_AUDIT and it ends in an audit_token_t, which is eight unsigned integers and no field names. What goes in them is decided in one place, proc_calc_audit_token() in bsd/kern/kern_prot.c:
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] = proc_getpid(p);
audit_token->val[6] = my_cred->cr_audit.as_aia_p->ai_asid;
audit_token->val[7] = proc_pidversion(p);
val[1] is the effective uid, so checking for root means checking that val[1] is zero. val[5] is the PID. val[7] is the PID version, which increases every time a proc slot goes to a new process. That last field is why a token beats a PID: a recycled PID comes back with a different version, so a token names a process and a PID names a slot.
The comment directly above that block says not to read it this way, and points at the BSM library instead. Everybody reads it this way, and further down you will see a decompiled daemon indexing val[1] by hand.
MIG, the stub factory
Hardly anyone interprets msgh_id themselves. MIG, the Mach Interface Generator, takes an interface description (a .defs file) and emits both halves: client stubs that pack arguments into a message, and a server demux that unpacks them and calls the function you wrote. The kernel is itself a MIG server, and so was every IOConnectCall* in the previous post.
A subsystem gets a base ID and routine N answers on msgh_id = base + N. The demux subtracts the base, bounds-checks the result, and indexes a table that records, per routine, how many argument words and how many descriptors to expect; a generated __MIG_check__Request__<routine>_t() rejects anything that does not match. That is the same arrangement as IOExternalMethodDispatch, with the same blind spot. It validates shape, never meaning. It has no opinion on whether the port you passed is the kind of port the routine believes it is, or whether the object ID in your message belongs to you, and in most services it is the only input validation there is.
The other half of MIG is a convention that lives in no single function: what a routine returns decides who owns what arrived in the message. XNU says it outright in ipc_kobject_server():
if (reply == IKM_NULL ||
ipc_kobject_reply_status(reply) == KERN_SUCCESS) {
/* The server function is responsible for the contents
* of the message. [...] */
ipc_kmsg_free(request);
} else {
/* The message contents of the request are intact. [...] */
ipc_kmsg_destroy(request, ...);
}
Return KERN_SUCCESS and MIG assumes the routine consumed every right and every out-of-line region, so it frees the buffer and nothing else. Return an error and MIG releases them for you. A routine that returns success on a path where it took no reference has the caller’s right dropped underneath it; one that consumed a right and then hit an error path has it released twice. That is the shape of voucher_swap, named in XNU under the hood. Shape checks cannot see it, and they cannot see a routine that looks an object up by an ID from the message and then uses it as the wrong class, which is CVE-2024-54529 in coreaudiod, published in January 2026.
XPC on top
Raw Mach messages are not what a modern service reads. libxpc puts a typed object model over them, dictionaries of typed values, and serializes one into a self-describing wire format inside a single complex message, with bulk data on out-of-line descriptors and file descriptors on port descriptors. That is why so few Apple services contain hand-written deserialization code.
A message is one of those dictionaries. The one the case study’s exploit sends to smd carries a routine number, the identifier of the helper to install, and an authorization blob. Build it and print its description:
// clang xpcdump.c -o xpcdump && ./xpcdump
#include <xpc/xpc.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
int main(void) {
xpc_object_t msg = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_int64(msg, "routine", 1004);
xpc_dictionary_set_string(msg, "identifier", "com.example.helper");
uint8_t authref[32] = {0};
xpc_dictionary_set_data(msg, "authref", authref, sizeof(authref));
char *desc = xpc_copy_description(msg);
printf("%s\n", desc);
free(desc);
return 0;
}
<dictionary: 0x1010c9a10> { count = 3, transaction: 0, voucher = 0x0, contents =
"identifier" => <string: 0x1010c9aa0> { length = 18, contents = "com.example.helper" }
"routine" => <int64: 0x9b0c85f110a87a87>: 1004
"authref" => <data: 0x1010ca020>: { length = 32 bytes, contents = 0x0000000000000000... }
}
That description is the object graph, not the bytes on the wire: libxpc packs it into the body of one complex Mach message before it leaves. But it is what a service receives and what its handler walks key by key, and it is where a value of a type the handler did not expect, or a length it trusts, turns into a bug.
How a connection is set up is not documented by Apple, and what follows is the reverse engineering published by Sector 7, Computest’s security-research team, whose sandbox escape this post walks below. A daemon declares its names under MachServices in its launchd plist, creates a port, keeps the receive right, and hands launchd a send right; a client that looks the name up gets a copy of that send right back. That port, the service port, is where connections are requested rather than where they live. The client makes two ports of its own and sends a message to the service port with msgh_id 0x77303074, which is 'w00t', moving the receive right for the first and copying a send right for the second. The daemon then reads this client on the first and answers on the second.
Read that from the daemon’s side. The port it receives a client’s messages on is a port that client created, gave the receive right away for, and kept a send right to. A Mach port has one receiver and any number of senders, so nothing stops the client handing a copy of that send right to a third process. The whole case study below is that sentence.
NSXPCConnection goes one level higher, turning messages into ObjC method calls whose arguments are NSKeyedArchiver-serialized and decoded against a per-argument list of allowed classes. Widen that list to a base class and arbitrary-class decoding is back.
What a sandboxed process can reach
The reachable set is an intersection. launchd publishes the names, one plist at a time:
$ plutil -p /System/Library/LaunchDaemons/com.apple.xpc.smd.plist
{
"AuxiliaryBootstrapperAllowDemand" => true
"EnablePressuredExit" => true
"Label" => "com.apple.xpc.smd"
"LaunchEvents" => { ... }
"MachServices" => {
"com.apple.xpc.smd" => true
}
"ProcessType" => "Adaptive"
"Program" => "/usr/libexec/smd"
}
The single name under MachServices, com.apple.xpc.smd, is what a client resolves to a send right. The sandbox profile decides which of these names a process may look up: The iOS sandbox pulled that list out of a compiled profile, and the mach-lookup allowances are the complete first-order IPC surface of a confined process. Anything past it is reached only through what one of those daemons will do on your behalf.
The confused deputy
A service that is going to refuse anything has to answer one question first, and there are three ways to ask it.
| what the service calls | what comes back | trustworthy |
|---|---|---|
xpc_connection_get_pid() |
the PID of the connection’s peer | no |
xpc_connection_get_audit_token() |
the token cached on the connection, from the most recent message received | only inside the event handler |
xpc_dictionary_get_audit_token() |
the token from the Mach trailer of this message | yes |
The first row has been public for years. A PID is a slot number, and an attacker can send a request and then replace its own process image while keeping that PID, so a service looking it up afterwards resolves it to a binary it trusts. Samuel Groß laid the pattern out at WarCon in 2018, and Csaba Fitzl walked a real one as CVE-2020-14977.
The second row is the interesting one. libxpc requests the audit trailer on every message, and _xpc_connection_set_creds copies that token onto the connection, overwriting what was there. So the connection’s token is not the caller’s. It is whoever sent the most recent message to arrive on that port. Inside an event handler that is invisible, because XPC runs a connection’s handlers strictly one at a time. Step outside the handler and it is not.
The third row reads the trailer of the message actually being handled. Both audit-token functions are private API on macOS. What Apple’s public XPC surface offers is the PID.
CVE-2023-32405, start to finish
Name the bug class first, because it is not the kind the last two posts dealt with. Nothing is corrupted here: no overflow, no double-free, no use-after-free, no heap spray. It is a logic bug, an authorization check that reads its answer off the wrong place, and it is made exploitable by a race condition. The value the check trusts is correct the instant it is written and wrong an instant later, and the exploit is the work of hitting that gap: a time-of-check-to-time-of-use race on a caller’s identity. smd is the confused deputy of the previous section, made to act as root for a caller that is not.
What the attacker needs first: local code execution as a normal user on macOS 13.3 or earlier, from an app bundle that already contains the helper tool it wants installed, and permission to reach two Mach services. No entitlement, no root, and the App Sandbox may be on. Sector 7 note this instance affects macOS only.
smd is the service management daemon behind SMJobBless, the API that installs a privileged helper tool: a small binary shipped inside an app bundle that then runs as root, so an app can do the few things that need root without running as root itself. Doing it legitimately requires an authorization reference, which means prompting the user for a password. The goal is to install a helper without ever asking.
Here is the whole bug in one breath. To decide whether the caller may do that, smd asks the connection for the caller’s audit token and checks that its uid is zero, that is, that the caller is root. But a connection’s audit token, from the previous section, is not fixed: it is whoever sent the most recent message on that port. So if the attacker can get a real root process to drop a message onto the attacker’s own connection to smd, at the instant smd runs that check, smd reads the root process’s uid and concludes the attacker is root. Nothing is corrupted. smd installs the helper because it was told, truthfully, that the last thing to speak on the line was root.
Everything else is engineering that instant, and it has two parts: get a root process to speak on the attacker’s connection, and make its message land inside the check.
Take the check first. SMJobBless ends up calling routine 1004, and smd runs that routine’s body through dispatch_async, on a queue that is not the XPC event handler. That is the precondition from the previous section: inside an event handler the connection’s token cannot change under you, because XPC delivers one message at a time; off the handler, on another queue, it can. The body calls connection_is_unauthorized, trimmed from Sector 7’s decompilation:
v5 = objc_retain(connection);
v6 = objc_retain(message);
xpc_connection_get_audit_token(v5, audit_token);
// [1]: field 1 contains the UID, UID == 0 means root
if ( audit_token[1] )
{
// [2]: Has a specific entitlement
v9 = xpc_connection_copy_entitlement_value(v5, "com.apple.private.xpc.unauthenticated-bless");
if ( v9 != &_xpc_bool_true )
{
// [3]: Passed in an authorization reference for the specified name
data = xpc_dictionary_get_data(v6, "authref", &length);
[...]
Three ways past it: be root, hold the entitlement, or produce the authorization reference. The first is audit_token[1], the uid field from earlier, indexed by hand and read off the connection (xpc_connection_get_audit_token) rather than off the message. That is the call the attacker turns.
Now the root process that has to speak on the connection. Recall the handshake: an XPC connection is a port the attacker created and handed to smd, keeping a send right, and one receive right can have many senders. When the attacker then opens a second connection, to a root-owned service, it chooses which port that service will answer on. diagnosticd is a convenient pick: it runs as root, and once asked to monitor a process it sends a status message several times a second. So the attacker connects to diagnosticd but, in place of the port diagnosticd would normally answer on, hands it a copy of the send right for the smd connection. From then on diagnosticd’s status messages flow into smd, arriving on the very connection smd already has with the attacker, and every one of them makes that connection’s cached token root’s.
Then it is a race. The attacker sets diagnosticd monitoring, so root messages are now streaming onto the connection, and spams routine 1004 at smd. smd reads xpc_connection_get_pid() first, and that has to still be the attacker’s PID, because that is how smd finds which app bundle to install the helper from. A few instructions later it reads the cached audit token, and that one has to be diagnosticd’s, so the uid check passes. Two reads of “who is calling” on one connection, needing two different answers a few instructions apart. Since smd leaves the connection open after refusing a message, the attacker just keeps firing until the timing lines up, and the helper installs as root.
Sector 7 are blunt about the limits. It survives the App Sandbox, since both services are reachable from inside it, but the helper has to be in the attacker’s own bundle, so none of it helps out of a compromised renderer or someone else’s app. The realistic scenario is an app distributed outside the Mac App Store that presents as sandboxed and is not. They confirmed a second variant, where a reply parsed on another queue replaces the token mid-handler, but found no first-party instance of it.
Apple fixed this in macOS 13.4 by changing one call in smd from xpc_connection_get_audit_token to xpc_dictionary_get_audit_token. The advisory files CVE-2023-32405 under libxpc, credits Thijs Alkemade of Computest Sector 7, and gives the impact as “An app may be able to gain root privileges”. What was fixed is smd. Any other service that asks a connection who its peer is, from outside an event handler, has the same bug.
Hands-on: sorting daemons by how they check you
Which of those three functions a daemon calls is visible without a disassembler, because all three come from libxpc and show up in the binary’s undefined symbols:
for b in /usr/libexec/* /usr/sbin/*; do
[ -f "$b" ] || continue
ids=$(nm -u "$b" 2>/dev/null |
grep -oE '_(xpc_connection_get_pid|xpc_connection_get_audit_token|xpc_dictionary_get_audit_token|audit_token_to_pid)$' |
sed 's/^_//' | sort -u | paste -sd, -)
[ -n "$ids" ] && printf '%-28s %s\n' "$(basename "$b")" "$ids"
done
airportd audit_token_to_pid
appleh16camerad xpc_connection_get_pid
applekeystored xpc_connection_get_audit_token,xpc_connection_get_pid
companiond xpc_connection_get_audit_token,xpc_connection_get_pid
configd audit_token_to_pid,xpc_connection_get_pid
cryptexd audit_token_to_pid,xpc_connection_get_audit_token,xpc_connection_get_pid,xpc_dictionary_get_audit_token
diagnosticd xpc_connection_get_pid
endpointsecurityd audit_token_to_pid,xpc_dictionary_get_audit_token
kernelmanagerd xpc_dictionary_get_audit_token
misagent xpc_connection_get_pid
nehelper xpc_connection_get_pid,xpc_dictionary_get_audit_token
opendirectoryd audit_token_to_pid,xpc_connection_get_audit_token,xpc_connection_get_pid,xpc_dictionary_get_audit_token
sandboxd audit_token_to_pid
secd xpc_connection_get_audit_token
smd xpc_dictionary_get_audit_token
syspolicyd audit_token_to_pid,xpc_connection_get_audit_token,xpc_connection_get_pid
taskgated audit_token_to_pid
trustd xpc_connection_get_audit_token
xpcproxy xpc_dictionary_get_audit_token
... 90 daemons in all
Read it as triage. diagnosticd, service B in the case study, imports xpc_connection_get_pid and nothing else: it authorizes by slot number, and daemons in that group go to the top of the list. trustd and secd import xpc_connection_get_audit_token, which is not a bug by itself since it is correct inside an event handler; telling those two cases apart is the part that needs the disassembler. A third group, smd and endpointsecurityd and kernelmanagerd, imports xpc_dictionary_get_audit_token, the per-message call. Watch also for audit_token_to_pid, which configd, sandboxd and taskgated carry: it takes a token and collapses it back to a PID, discarding the pidversion that made the token worth having, so if that PID drives an authorization decision the reuse gap is open again.
smd is the whole point of the exercise. Read the same way, on a current macOS it imports the per-message token call and not the connection variant:
$ nm -u /usr/libexec/smd | grep audit_token | sort -u
_sandbox_check_by_audit_token
_xpc_dictionary_get_audit_token
The xpc_dictionary_get_audit_token line is the fix from the case study, three years on, sitting in the symbol table; sandbox_check_by_audit_token shows smd also runs its sandbox check against the message’s token rather than the connection’s. Intersect the full list with the mach-lookup names your attacker position can resolve, and what is left is a few dozen binaries with one question each: is that call reachable from anywhere other than the event handler.
State in 2026
mach_msg2 (iOS 16, macOS 13). mach_msg() now goes through a split trap that passes the descriptor count as its own argument instead of reading it from the buffer, so impossible shapes die at the syscall boundary. The comment in osfmk/ipc/mach_msg.c names the reason: “Simple message cannot contain descriptors. This invalid config can only happen from mach_msg2_trap() since desc_count is passed as its own trap argument.”
kalloc_type (iOS 15, broadened from iOS 16). The zone allocator segregates by type, so the reallocate-across-types manoeuvre that turned MIG reference-count bugs into fake ports is far less reliable. The memory-mitigations post later in the series takes that apart.
Audit tokens, unevenly. Apple has been migrating first-party services onto per-message tokens since 2023, one service at a time. Third-party privileged helpers, where most of this code lives on macOS, were never migrated at all.
Launch and environment constraints (iOS 16, macOS 13). AMFI enforces constraints on what may launch a platform binary and in what context, which removes the re-launch trick several confused-deputy escalations depended on.
What has not changed: MIG validates shape and never meaning, allowlists get widened, and a service that answers “who is calling” by asking the connection is wrong in a way nothing on this list addresses.
Where this leaves us
The transport moves capabilities, so every question about privilege here becomes a question about which port a process holds and what a message did to a reference count. A caller’s identity is not in the message at all. It is in a trailer the kernel appends and the receiver has to ask for, and that trailer belongs to one message while the connection it arrived on belongs to everyone holding a send right. Services that keep those two apart are hard to fool. Services that do not are the shortest way out of a sandbox on either platform.
None of it reaches the kernel. A confused deputy gets you a more privileged userland context, and a MIG bug gets you a corrupted object and little else on its own. The next post picks up there: turning one memory-corruption primitive into stable kernel read and write, with heap layout, physical use-after-free, and the data-only strategies that survive kalloc_type.
Notes and sources
Everything here is drawn from open source, vendor documentation, published research, and binaries shipped on any Mac.
- Apple, XNU source:
osfmk/mach/message.hfor the header, dispositions, descriptors and trailers;bsd/kern/kern_prot.cforproc_calc_audit_token()and the audit-token layout quoted above;osfmk/kern/ipc_kobject.cfor the MIG ownership convention;osfmk/mach/mig.hfor the routine table;osfmk/ipc/mach_msg.cfor themach_msg2shape checks. - Sector 7 (Computest), “Don’t Talk All at Once! Elevating Privileges on macOS by Audit Token Spoofing” (13 October 2023), the source for the whole case study: the XPC handshake and its
'w00t'message ID,_xpc_connection_set_creds, thesmdanddiagnosticdrace, the decompilation above, and the fix. Linked through the Internet Archive because Sector 7 has since become DEFION and the article’s own URL now redirects to an index page. - Apple, “About the security content of macOS Ventura 13.4”, for CVE-2023-32405 itself: filed under libxpc, impact “An app may be able to gain root privileges”, credited to Thijs Alkemade (@xnyhps) of Computest Sector 7.
- Scott Knight, “Audit tokens explained” (2020), on how the audit token reaches a receiver through the Mach trailer.
- Samuel Groß, “Don’t Trust the PID! Stories of a simple logic bug and where to find it” (WarCon 2018), the talk that made PID-based authorization a known-bad pattern.
- Csaba Fitzl, “Secure coding XPC Services, part 5” (CVE-2020-14977), a worked PID-reuse attack against a real service.
- Brandon Azad, “voucher_swap: Exploiting MIG reference counting in iOS 12” (Project Zero, 2019), the canonical MIG ownership bug.
- Dillon Franke, “Breaking the Sound Barrier, Part II: Exploiting CVE-2024-54529” (Project Zero, 2026), for the object-by-ID type confusion in
coreaudiod. - Jonathan Levin, *OS Internals, Volume I: User Mode (newosxbook.com), the reference for Mach IPC, MIG internals, the launchd bootstrap namespace and XPC.
- Apple, Apple Platform Security, for the vendor-level account of XPC services, sandboxing and launch constraints.
