Back to XPC: Mapping Tahoe's Service Surface
Coming back to macOS XPC research after a long detour — setting up a Tahoe lab, enumerating the service surface, recovering selectors with disarm, and calling them from a host harness
Disclaimer: This post is for educational purposes and authorized security research only. Everything described here runs against a macOS Tahoe guest in a local virtual machine that I own. Do not point this kind of probing at machines you do not control.
Table of Contents
- Why I Came Back to XPC
- The Setup
- XPC, Briefly
- What are Entitlements?
- The Entitlement Database, Then and Now
- Inventorying the Tahoe Guest
- Recovering Selectors with disarm
- Calling the Service from the Host
- Where That Leaves Us
- Closing Notes
- References
Why I Came Back to XPC
TL;DR — Years ago I took an interest in macOS and iOS internals and worked through a couple of classes — most notably from Jonathan Levin and Stefan Esser. My career path took a different turn and I drifted away from low-level Apple work, but I am back, starting with XPC on macOS Tahoe. The goal of this first pass is to inventory the packaged
.xpcservice bundles on a stock guest, recover their callable surfaces, and figure out which ones an unprivileged or improperly entitled caller can reach. Launchd-only Mach services and XPC endpoints that are not packaged as.xpcbundles are out of scope for this pass.
Two things drove the choice to start here. First, I was looking at some current training material and XPC came up as a recurring vector — enough that I wanted to see for myself how much had actually changed under the hood. Second, this is a class of bug I am familiar enough with to make rapid progress, but distant enough from my recent work that I would learn something either way.
The part I want to call out before anything else is this: AI has levelled the playing field for this kind of research. When I took those classes years back, the instructors would demo tools live but would not release source. A lot of what they showed me looked like pure magic — selector recovery, dispatcher reversing, runtime introspection. It is not magic anymore. With a capable model and the right prodding, you can reconstruct the same kind of tooling yourself in an afternoon, and you can do it in a way that fits your workflow rather than someone else’s. The intersection of offensive security and AI is the thing I find most exciting right now, and this project is one of the more concrete examples of why.
I should also say up front that I did not expect to find a real bug here. XPC has had a lot of eyes on it for a lot of years, and any obvious mistakes have long since been hammered out of Apple’s own daemons. But “I do not expect to find anything” is not a reason to skip the work — it is a reason to do the work, because the only way to know what the surface looks like in 2026 is to walk it.
The Setup
One macOS Tahoe (macOS 26) guest running under Tart on an Apple Silicon host, with SSH provisioned by a tiny install script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Install Homebrew if missing
if ! command -v brew >/dev/null 2>&1; then
/bin/bash -c "$(curl -fsSL \
https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
[[ -x /opt/homebrew/bin/brew ]] && eval "$(/opt/homebrew/bin/brew shellenv)"
fi
# Install Tart and Expect (Expect is used for the first-boot SSH provisioning)
command -v tart >/dev/null 2>&1 || brew install cirruslabs/cli/tart
command -v expect >/dev/null 2>&1 || brew install expect
# Pull the Tahoe base image and clone it into a working VM
if ! tart list --quiet 2>/dev/null | grep -qx "tahoe-base"; then
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-base
fi
# Run headless and wait for the guest to get an IP
nohup tart run --no-graphics tahoe-base >/tmp/tart-tahoe-base.log 2>&1 &
The rest of the script — the expect blocks for password SSH, the bootstrap-user provisioning, and a small helper around tart clone so each research session can run against a throwaway copy of the base VM — is mechanical glue around those four steps. Once it finishes there is a working SSH login into a clean Tahoe guest, plus a one-liner that produces a fresh disposable clone (mounted at a timestamped name) any time I want to do something I do not want to keep:
1
2
ssh macho-reverser@$(tart ip tahoe-base)
tart clone tahoe-base session-$(date +%s) # disposable clone, throw away when done
The reason this is a one-liner now is that Apple ships a native Virtualization framework that, on Apple Silicon, can host macOS guests directly without emulation. Tart wraps that framework so the workflow is closer to running a container than provisioning a VM. The first pull is the only slow part — the base image lands at around 31 GB on disk on this host — and after that, cloning a fresh disposable guest from the local copy is effectively instant because Tart uses APFS copy-on-write under the hood. That is a meaningful improvement over where things were when I last did this.
XPC, Briefly
An XPC service is a Bundle — a self-contained package with its own Info.plist, executable, optionally resources, and the required _CodeSignature. The single service I will use as a running example through the rest of this post is com.apple.hiservices-xpcservice. Here it is on the Tahoe guest:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ ls -la /System/Library/Frameworks/ApplicationServices.framework/Versions/A/\
Frameworks/HIServices.framework/Versions/A/XPCServices/\
com.apple.hiservices-xpcservice.xpc
drwxr-xr-x 3 root wheel 96 Mar 20 05:25 .
drwxr-xr-x 3 root wheel 96 Mar 20 05:25 ..
drwxr-xr-x 7 root wheel 224 Mar 20 05:25 Contents
$ ls -la .../com.apple.hiservices-xpcservice.xpc/Contents
-rw-r--r-- 1 root wheel 1636 Mar 20 05:25 Info.plist
drwxr-xr-x 3 root wheel 96 Mar 20 05:25 MacOS
-rw-r--r-- 4 root wheel 8 Mar 20 05:25 PkgInfo
-rw-r--r-- 1 root wheel 526 Mar 20 05:25 version.plist
drwxr-xr-x 3 root wheel 96 Mar 20 05:25 _CodeSignature
$ ls .../Contents/MacOS
com.apple.hiservices-xpcservice # the Mach-O executable, 213184 bytes
The Info.plist is small and tells you everything you need to know about how launchd will run it:
1
2
3
4
5
"CFBundleIdentifier" => "com.apple.hiservices-xpcservice"
"CFBundleExecutable" => "com.apple.hiservices-xpcservice"
"CFBundlePackageType" => "XPC!"
"LSMinimumSystemVersion" => "26.4"
"XPCService" => { "ServiceType" => "User" }
The bundle is the deployment unit launchd looks at when an XPC client asks for the service. Everything that defines how the service actually behaves — the dispatcher inside the binary, the message format, the entitlements that gate it — lives inside that bundle.
Apple exposes XPC at two public layers. The lower layer is the C XPC Services API: xpc_main, xpc_connection_create_mach_service, and xpc_dictionary_* message passing from <xpc/xpc.h> and <xpc/connection.h>. That layer dates back to macOS 10.7. Starting in Mac OS X 10.8 / iOS 6, Foundation added the higher-level NSXPCConnection API: NSXPCConnection, NSXPCListener, NSXPCInterface, and related objects. That wrapper gives Objective-C, and Swift through Foundation interop, a protocol-and-proxy model over the same IPC boundary.
The Tahoe capture showed both styles. Most selector-bearing services were NSXPC, where selectors fall out of Objective-C protocol/interface metadata. com.apple.hiservices-xpcservice is the useful raw-XPC counterexample: callers send an xpc_dictionary with keys such as HIS_XPC_SELECTOR and HIS_XPC_PARAMETERS.
What are Entitlements?
Entitlements are key-value pairs embedded in the code signature of a Mach-O binary. They claim capabilities — sandbox membership, private API access, TCC pre-grants, driver attachment, and a long tail of com.apple.private.* keys that gate access to Apple’s own services.
The relevant property for this kind of work is that you cannot arbitrarily assign yourself an entitlement. Some entitlements are restricted — AppleMobileFileIntegrity (AMFI) requires them to be attested by a certificate chain that an ad-hoc signature cannot produce. Adding a restricted entitlement and re-signing with an ad-hoc identity is exactly what AMFI is designed to reject. To make that concrete, here is the demo on the guest:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Write a trivial C program
$ cat > /tmp/ent_demo.c <<'EOF'
#include <stdio.h>
int main(void) { printf("hello, ent\n"); return 0; }
EOF
# Compile it with the system clang
$ clang -o /tmp/ent_demo /tmp/ent_demo.c
# Author an entitlements plist that claims a restricted Apple entitlement
$ cat > /tmp/ent_demo.entitlements <<'EOF'
<?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>com.apple.private.dmd.policy</key><true/>
</dict></plist>
EOF
# Sign ad-hoc and embed the entitlement into the code signature
$ codesign --force --sign - --entitlements /tmp/ent_demo.entitlements /tmp/ent_demo
# Confirm the entitlement made it into the signature
$ codesign -d --entitlements - /tmp/ent_demo
[Dict]
[Key] com.apple.private.dmd.policy
[Value]
[Bool] true
# Try to run it — AMFI rejects the load before main() runs
$ /tmp/ent_demo
Killed: 9
# 137 = 128 + 9 (SIGKILL), the kill came from AMFI
$ echo $?
137
The entitlement embeds fine — codesign will happily attach whatever plist you hand it under an ad-hoc identity. The kernel is what rejects the load. Pulling the AMFI log for the same window:
1
2
3
4
5
6
7
amfid: Restricted entitlements not validated, bailing out.
amfid: Adhoc signed app with restricted entitlements detected
amfid: /private/tmp/ent_demo not valid: "The file is adhoc signed but contains
restricted entitlements"
kernel(AppleMobileFileIntegrity): AMFI: code signature validation failed.
kernel(AppleMobileFileIntegrity): AMFI: bailing out because of restricted
entitlements.
Not every entitlement is restricted in this way. On macOS, many com.apple.security.* entitlements — including App Sandbox and Hardened Runtime entitlements — can be self-declared in the code signature and do not require provisioning-profile authorization.
Restricted entitlements, especially many capability-backed com.apple.developer.* entitlements, still require Apple or provisioning-profile authorization and may not be honored just because they appear in an ad-hoc signature. The restricted set is the one this research orbits around, because that is the set a daemon can actually trust to mean what it says.
The Entitlement Database, Then and Now
Back when I was first looking at this, the canonical resource was Jonathan Levin’s entitlement database at newosxbook.com/ent.php — a searchable index of which Apple binaries carried which entitlements, scraped across OS versions. It is still useful as a reference, and it is still up.
It is dated. To close that gap I have a small query layer of my own — a Flask + MySQL app whose schema mirrors the same per-binary entitlement shape — and the Tahoe capture produces JSONL data that the database tooling can ingest. The collection step on the guest is mechanical: walk each .xpc bundle, run codesign -d --entitlements - <binary> on the executable inside it, parse the embedded plist, and emit one JSON row per service into guest-xpc-entitlements.jsonl. Importing that JSONL on top of the database tooling gives me, on this exact build, a queryable view of which services hold which entitlements.
Local entitlement database (Flask + MySQL), browse view.
Detail view for a single binary. The Tahoe guest capture is ingested into the same schema, so the same queries work against the freshly captured build.
Inventorying the Tahoe Guest
With a guest up and SSH working, the first job is enumeration. Walk the system on the guest, find each .xpc bundle, parse its Info.plist, dump its entitlements, and record any surface signals that suggest NSXPC versus raw XPC. Note the inventory unit here is the .xpc bundle itself — .appex bundles can carry nested .xpc services and those nested bundles get walked, but .appex bundles are not the inventory unit and launchd-only Mach services that are not packaged this way are not enumerated by this pass.
I wrote a small Swift CLI called xpc-scout and a Python collector script. The run is driven from the host but both tools execute inside the guest — xpc-scout is installed on the guest at ~/.local/bin/xpc-scout and the collector runs there too. The collector walks the standard locations on the VM and emits one JSON record per .xpc bundle:
1
2
3
4
xpc-scout --json scan-app /Applications/Target.app
xpc-scout --json scan-launchd --system --user
xpc-scout --json snapshot-stock --include-user
xpc-scout --json rank-snapshot stock-snapshot.json --limit 100
For the Tahoe capture I cared about three numbers at the end of this phase:
| Metric | Count |
|---|---|
.xpc bundles checked | 463 |
| Apple / platform services captured | 462 |
| Services carrying any entitlement | 394 |
| Selector candidates recovered (all) | 82,098 |
| Selector candidates that look XPC-relevant | 39,296 |
I settled on the 394 entitlement-carrying .xpc bundles as the working set. I did not go through those 394 services by hand. I used Codex’s /goal command to keep the long-running task stateful, let it spin up sub-agents, and gave it a deliberately mechanical instruction: process every guest service, reverse it with disarm, pull the selectors, infer the arguments and constraints needed to call them, and make at most five attempts per selector before recording the observed reply, error, invalidation, or permission failure. That pass took close to 24 hours, including one accidental process kill along the way.
hiservices-xpcservice is a useful concrete example of where the pass lands a service on the candidate list. Its own entitlements look unremarkable (com.apple.hid.manager.user-access-device, com.apple.private.hid.client.event-monitor, com.apple.private.dmd.policy, and com.apple.private.tcc.allow scoped to kTCCServiceListenEvent), and the dispatcher inside the service has no handler-local audit-token or entitlement gate recovered before it converts the incoming HIS_XPC_SELECTOR string into an Objective-C selector and invokes it. That does not by itself prove an exploitable missing gate — several of the underlying operations carry their own internal validation (the HIS_XPC_CFPreferencesCopyValue: path gates reads through permitted domains and keys, the bounded CFPreferences write/sync probes used invalid domains and keys, and SendAppleEventToSystemProcess returned -50 for invalid event IDs) — but the dispatcher shape is exactly the kind of thing that puts a service on the list for a closer reverse.
Per-bundle, each record carries enough to make a triage decision without re-reversing:
- bundle path, executable path, signing identifiers and authority chain
- the full dumped entitlement set
- linkage signals (
NSXPCListener,NSXPCInterface, rawxpc_main, dictionary use, sandbox / audit-token checks) - Objective-C selector candidates from
__TEXT.__objc_methlistand__TEXT.__objc_methname - decoded type signatures and per-argument labels when type encodings were recoverable
- raw libxpc dictionary key candidates when the binary uses dictionary APIs
Selector candidates pulled from the binary are exactly that — candidates. Presence in __objc_methname does not mean the method is reachable from outside the process.
Recovering Selectors with disarm
For the actual reversing I leaned on Jonathan Levin’s disarm, a small Mach-O / ARM64 disassembler with first-class understanding of Objective-C metadata.
Walking the hiservices binary on the guest looks like this. The file is a fat Mach-O, so the architecture has to be picked explicitly:
1
2
3
4
5
6
7
8
$ ARCH=arm64e disarm -i .../com.apple.hiservices-xpcservice
File type: executable
Format: Mach-O
Target OS: macOS 26.4.0
Architecture: ARM64e (ARMv8.3)
File Size: 0x1c0c0 (114880) bytes
Entry: 0x18b8
Binary is digitally signed (use --signature to view)
The section listing is where the interesting bits live. __objc_methname and __objc_methlist are the Objective-C metadata that the dispatcher walks at runtime — the names and the name-to-implementation map:
1
2
3
4
5
6
7
8
9
$ ARCH=arm64e disarm -L .../com.apple.hiservices-xpcservice
__TEXT.__text 0x100000f10-0x1000057f8 (code)
__TEXT.__objc_stubs 0x100006020-0x100006700
__TEXT.__objc_methlist 0x100006700-0x100006984
__TEXT.__objc_methname 0x100007b8c-0x100008554 (C-String Literals)
__TEXT.__objc_methtype 0x1000088ea-0x100008aa4 (C-String Literals)
__DATA.__objc_const
__DATA.__objc_selrefs 0x1000102e0-0x100010550
__DATA.__objc_data 0x100010588-0x1000105d8
Pulling the names out is one more disarm command — -e extracts a section to a file, and the section’s contents are the null-separated C strings the loader reads at runtime:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ ARCH=arm64e disarm -e __TEXT.__objc_methname \
.../com.apple.hiservices-xpcservice
Extracted __TEXT.__objc_methname (0x7b8c-0x8554) to
/tmp/com.apple.hiservices-xpcservice.__TEXT.__objc_methname
$ tr '\0' '\n' \
< /tmp/com.apple.hiservices-xpcservice.__TEXT.__objc_methname \
| grep -E '^HIS_XPC_'
HIS_XPC_CFPreferencesCopyValue:
HIS_XPC_CFNotificationCenterPostNotification:
HIS_XPC_CFPreferencesSynchronize:
HIS_XPC_CFPreferencesSetValue:
HIS_XPC_CopyApplicationPolicyForURLs:
HIS_XPC_CopyMacManagerPrefs:
HIS_XPC_CopyPrimaryKeyboardLanguage:
HIS_XPC_GetCapsLockModifierState:
HIS_XPC_RevealFileInFinder:
HIS_XPC_SendAppleEventToSystemProcess:
HIS_XPC_SetCapsLockDelayOverride:
HIS_XPC_SetCapsLockLED:
HIS_XPC_SetCapsLockLEDInhibit:
HIS_XPC_SetCapsLockModifierState:
HIS_XPC_SetNetworkLocation:
... (rest elided)
Those are the methods the dispatcher will route to. Two more strings — HIS_XPC_SELECTOR and HIS_XPC_PARAMETERS — live in __TEXT.__cstring and are the keys the service expects in every incoming xpc_dictionary: a method name and an argument bag. The dispatcher itself is a short routine off the entry point at 0x18b8 that reads HIS_XPC_SELECTOR, constructs a selector with sel_registerName, and invokes it on the service object with HIS_XPC_PARAMETERS as the argument bag. No xpc_connection_get_audit_token lookup or entitlement query was recovered in that path; what each operation does once invoked still depends on whatever internal validation lives downstream (and several HIS_XPC_* operations do have such validation).
Recovering the names is mechanical. The reverse-engineering work is reading the dispatcher and confirming what is — or isn’t — between the boundary and the selector call.
Calling the Service from the Host
Because hiservices is raw XPC, calling it is just constructing the right xpc_dictionary and sending it to the registered Mach service name. No protocol declaration, no NSXPCInterface, no class allow-listing:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
xpc_connection_t conn = xpc_connection_create_mach_service(
"com.apple.hiservices-xpcservice", NULL, 0);
xpc_connection_set_event_handler(conn, ^(xpc_object_t event) {
/* log replies / errors as structured JSON */
});
xpc_connection_resume(conn);
xpc_object_t msg = xpc_dictionary_create(NULL, NULL, 0);
xpc_object_t params = xpc_dictionary_create(NULL, NULL, 0);
xpc_dictionary_set_bool (params, "capsLockLED", false);
xpc_dictionary_set_string(msg, "HIS_XPC_SELECTOR", "HIS_XPC_SetCapsLockLED:");
xpc_dictionary_set_value (msg, "HIS_XPC_PARAMETERS", params);
xpc_object_t reply =
xpc_connection_send_message_with_reply_sync(conn, msg);
That is the entire harness. The probe loop wraps it in two layers: a list of selectors and parameter shapes recovered from the disarm pass, and a small JSONL writer that records per-selector mode, payload, status (reply / timeout / proxy_error / invalidation), and any returned object. A real reply from the capture looked like this; this one is HIS_XPC_CopyMacManagerPrefs: over the working full Mach-service transport on attempt 2:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
selector=HIS_XPC_CopyMacManagerPrefs:
attempt=2
transport=mach
connection_name=com.apple.hiservices-xpcservice
event=reply
<dictionary: 0xad3024000> { count = 1, transaction: 0, voucher = 0x0, contents =
"REPLY" => <dictionary: 0xad30200c0> { count = 6, transaction: 0, voucher = 0x0, contents =
"SystemPrefsRestricted" => <bool: 0x20d857850>: false
"DisableScreenLockImmediate" => <bool: 0x20d857850>: false
"RemoveAppStoreMenu" => <bool: 0x20d857850>: false
"ShutDownDisabledWhileLoggedIn" => <bool: 0x20d857850>: false
"LogOutDisabledWhileLoggedIn" => <bool: 0x20d857850>: false
"RestartDisabledWhileLoggedIn" => <bool: 0x20d857850>: false
}
}
For the NSXPC services in the inventory the harness has a parallel path that builds an NSXPCConnection and a synthesised @protocol from the recovered selectors, but for hiservices the raw path is all that is needed. Info.plist declares the bundle as "XPCService": { "ServiceType": "User" }, so the service is launched per-user rather than as a system-wide daemon; in the probe runs the successful transport was mach:com.apple.hiservices-xpcservice resolved within the guest/session context. The dispatcher accepts the dictionary without a handler-local audit-token gate, the synchronous send returns the semantic reply, and connection invalidation events are logged separately by the connection handler.
The same JSONL feeds the abuse-potential review. Across the 394 entitlement-carrying .xpc bundles on this capture, the per-selector records sort the surface into roughly four buckets borrowed from the review taxonomy: confirmed reachable (replies or accepted one-way sends on real evidence), confirmed but bounded / no-op (reachable, but the probe payloads were invalid, no-op, or returned downstream errors), blocked by entitlement, launch context, hardware, or domain fixture, and metadata mismatch / not invocation-ready (the workqueue marked the row successful but the packet evidence is only selector recovery). hiservices lands in confirmed reachable — every recovered HIS_XPC_* operation replied on attempt 2 from the host harness, with bounded inputs — which is what put it on the candidate list.
Where That Leaves Us
The phase this post covers is the callable surface phase, scoped to the 462 captured .xpc bundles (394 with entitlements). It tells me which selectors of which packaged .xpc services replied to the host harness under the guest/session transport that was actually exercised. It does not cover launchd-only Mach services that ship without an .xpc bundle, and it does not tell me whether any of the reachable selectors are exploitable — that is the next phase, and it is a different kind of work.
In my mind this bug class is dead, but there are a handful of targets on the candidate list that warrant a closer look before I write it off entirely. The most interesting ones cluster around the usual suspects: authorization, file-access helpers, archive extraction, parser surfaces, and the older raw-XPC services whose handler-local dispatcher gates look thin in the disarm pass.
Closing Notes
The thing I keep coming back to is how different this work feels now compared to a few years ago. Not the targets — the targets are the same shape they have always been — but the loop. Stand up a guest in seconds, walk a stock OS’s service surface in an afternoon, recover selectors and dispatchers with disarm, generate a probe harness from that pass without writing every line of glue code myself, and end the day with structured data I can query. The parts that used to be friction are now background.
If you are coming back to low-level Apple work after a detour, my advice is the same advice I would give to anyone restarting in any deep area: pick one small surface, walk it end-to-end on hardware you control, and let the gaps in what you remember surface themselves. The gaps are useful. They tell you what has actually changed.
Next post will go after one of the candidate services from this capture and try to actually break something. We will see.
References
- disarm — Jonathan Levin’s Mach-O / ARM64 disassembler, newosxbook.com/tools/disarm.html.
- Entitlement database (Levin) — newosxbook.com/ent.php.
- macOS Vulnerability Research training — macosvuln.training/curriculum.html by @theevilbit.
- XPC internals — MacOS and iOS Internals, Volume I: User Mode, Jonathan Levin. Chapter 14 — X is not a Procedure Call: XPC Internals.