XPC Attacks: When Privileged Helpers Forget to Ask Who's Calling
A huge amount of macOS privilege escalation comes down to one design pattern done badly. An application that needs to do something privileged (change system proxy settings, install an update, write to a protected directory) does not run as root itself. Instead it ships a tiny privileged helper tool running as root, and talks to it over XPC. The unprivileged app sends a request, the root helper performs the action. The entire security of that arrangement rests on the helper correctly answering one question before it acts: is the process talking to me actually allowed to ask for this?
Helpers get that question wrong constantly. This post covers how XPC works, the right and wrong ways to verify a client, and then four real CVEs (Proxyman, Microsoft AutoUpdate, Apple’s own EndpointSecurity framework, and Adobe Reader) that are all variations on the same omission. This is where the series shifts from “getting code into a process” to “abusing the trust between processes,” and XPC is the richest hunting ground for it.
What XPC Is and Why Helpers Exist
XPC is macOS’s high-level inter-process communication framework, built on the Mach IPC primitives
from the earlier post. It exists to enable privilege separation: split an application into
components that each hold only the rights they need, communicating over typed messages. The most
security-relevant use is the privileged helper tool: a root daemon installed alongside an app,
registered with launchd as a Mach service, that exists solely to perform the handful of actions
the app cannot do itself.
You find these everywhere. A look at an app’s launchd plists reveals its helper’s service name: Proxyman’s com.proxyman.NSProxy.HelperTool, Microsoft AutoUpdate’s helper, and so on. Each is a
small root process listening for XPC connections. If an unprivileged attacker can connect to one of
these and convince it to perform its privileged action, that is a local privilege escalation,
usually straight to root.
There are two API surfaces. The low-level C API uses xpc_connection_* functions:
// filename: xpc-c-client.c
xpc_connection_t conn = xpc_connection_create_mach_service("com.example.service", NULL, 0);
xpc_connection_set_event_handler(conn, ^(xpc_object_t event) { /* ... */ });
xpc_connection_resume(conn);
xpc_connection_send_message_with_reply(conn, msg, NULL, ^(xpc_object_t resp) { /* ... */ });
The higher-level Foundation API uses NSXPCConnection and defines the interface as an
Objective-C protocol. Either way, a client connects to a named service and sends a request. The
attacker’s first move is always the same: find the service name from the app’s bundle or launchd
plist, then just connect to it and see what happens.
The Whole Game: Verifying the Client
When a connection arrives, the helper has to decide whether to honor it. With NSXPCConnection the
decision point is the delegate method listener:shouldAcceptNewConnection:. Return YES and the
connection proceeds; return NO and it is rejected. An alarming number of helpers contain exactly
this:
// filename: insecure-accept.m
- (BOOL)listener:(NSXPCListener *)listener
shouldAcceptNewConnection:(NSXPCConnection *)newConnection {
// ... set up the exported interface ...
return YES; // accepts EVERY caller, no questions asked
}
That unconditional return YES is the bug behind half this post. The helper accepts any process on
the system, then performs root-level actions on its behalf.
So what does correct verification look like? The helper must prove the connecting process is the legitimate app: meaning it is signed by the expected team and matches an expected code requirement. The pieces:
Identify the caller: by audit token, not PID. NSXPCConnection exposes a public
processIdentifier (the PID) and a private auditToken. Using the PID is insecure because of
PID reuse: a PID is just a number the kernel recycles, and an attacker can race to have the PID
of a trusted process point at their own code by the time the helper checks. The audit token is a
kernel-maintained, non-reusable identity, which is why it is the right choice, and why Apple
keeping it private is a recurring frustration that forces developers to declare it themselves:
// filename: audit-token.m
@interface ExtendedNSXPCConnection : NSXPCConnection
@property (nonatomic, readonly) audit_token_t auditToken; // private, redeclared
@end
Turn the identity into a code object and check it against a requirement. Pass the PID or audit
token to SecCodeCopyGuestWithAttributes to get a SecCodeRef, build a code-signing requirement
string with SecRequirementCreateWithString, and validate with SecCodeCheckValidity:
// filename: verify.m
SecCodeRef code = NULL;
SecCodeCopyGuestWithAttributes(NULL,
(__bridge CFDictionaryRef)@{(__bridge NSString *)kSecGuestAttributePid :
@(newConnection.processIdentifier)}, kSecCSDefaultFlags, &code);
SecRequirementRef req = NULL;
NSString *reqStr = @"anchor apple generic and identifier \"com.example.bundle\" "
"and certificate leaf[subject.OU] = \"TEAMID\"";
SecRequirementCreateWithString((__bridge CFStringRef)reqStr, kSecCSDefaultFlags, &req);
OSStatus ok = SecCodeCheckValidity(code, kSecCSDefaultFlags, req); // 0 == valid
The requirement string is where the real check lives. anchor apple generic ties it to Apple’s
signing root; identifier pins the bundle ID; certificate leaf[subject.OU] pins the team ID.
Drop the team-ID clause and an attacker can sign their own binary with any Apple Developer
certificate and satisfy the requirement. Put bluntly: any single missing element
in that chain reopens the door.
The secure recipe is “audit token → SecCodeCopyGuestWithAttributes → SecCodeCheckValidity against a requirement that pins anchor, identifier, AND team ID.” Every CVE below is what happens when one of those words is missing. The exploit is rarely clever; it is the absence of a check.
CVE-2019-20057: Proxyman: No Verification At All
Proxyman is an HTTP debugging proxy. Changing the system proxy is a privileged action, so it ships
a helper tool to do it. The helper’s shouldAcceptNewConnection: does no client verification: it
accepts any connection. Reverse the helper to recover its protocol, connect to
com.proxyman.NSProxy.HelperTool from an unprivileged process, and invoke the
change-proxy method directly. The root helper happily reconfigures the system proxy for an attacker
who simply asked. Root-level system configuration controlled by anyone on the box, because the
helper never checked who was calling. This is the textbook case: the return YES bug in the wild.
CVE-2020-0984: Microsoft AutoUpdate: Same Omission, Bigger Blast Radius
Microsoft AutoUpdate (MAU) keeps Office current and ships a root helper exposing
MAUHelperToolProtocol. The exploitation path is mechanical and worth seeing because it is the
standard methodology:
- Recover the protocol with
class-dumpon the helper binary, yieldingMAUHelperToolProtocoland its methods. - Confirm reachability: connect to the helper’s Mach service and verify the connection is accepted. It is; the client is not verified.
- Build a matching
NSXPCInterfacefrom the dumped protocol and call the helper’s privileged methods directly.
Because the helper performs update-style file operations as root with no caller check, an
unprivileged attacker drives those operations to escalate. The lesson layered on top of Proxyman:
this is not one careless vendor. The same missing verification recurs across major, professionally
developed software, because the secure pattern is non-obvious and the insecure default, return YES, looks like it works.
CVE-2019-8805: Apple’s EndpointSecurity: Apple Gets It Wrong Too
The most pointed of the four. Apple ships EvenBetterAuthorizationSample as the reference implementation for privileged helpers, and the EndpointSecurity framework’s helper followed it. Yet the analysis finds the client-verification routine effectively always returns 1: it accepts the connection regardless of who is calling. The very component meant to demonstrate doing this correctly, and a security-relevant Apple framework built on it, shipped without a real check.
The takeaway is not schadenfreude. It is that verifying an XPC client correctly is genuinely hard enough that Apple’s own sample code got it wrong, which is exactly why the bug class is so durable. If the reference implementation accepts everyone, every helper that copied it inherits the vulnerability.
CVE-2020-9714: Adobe Reader: The Patch Was the Hint
Adobe Reader’s updater rounds out the set with a twist: this one had been patched once, and the analysis works from the patch. Comparing the original vulnerable installer logic against the fixed version reveals what Adobe considered dangerous, and where the fix was incomplete. The privileged installer action could be coerced by an unprivileged process, and studying the patch shows precisely which file operation the helper performed without adequately validating its caller or its inputs.
This is a methodology worth internalizing on its own: diffing a patch is a vulnerability-discovery technique. When a vendor fixes a privileged helper, the diff tells you what the privileged action was, what check was added, and frequently whether the check is sufficient. A half-fixed helper is often still exploitable, and the patch is a map to the soft spot.
Why This Matters
Four CVEs across a debugging proxy, Microsoft, Apple, and Adobe, and they are the same bug: a
privileged helper that performs a root action without proving the caller is authorized to request
it. The defensive recipe is known and not even long (audit token, SecCodeCheckValidity, a
requirement string that pins anchor, identifier, and team ID) yet it is gotten wrong constantly,
including in Apple’s own reference sample. That gap between “the secure pattern is documented” and
“the secure pattern is rarely implemented” is the entire opportunity.
For an attacker the methodology generalizes cleanly: enumerate installed apps for privileged helpers
via their launchd plists, class-dump the helper to recover its protocol, connect to its Mach
service, and check whether it verifies you. If it does not, and often it does not, call its
privileged methods and you are root. The XPC trust boundary is only as strong as the helper’s
weakest verification clause, and helpers are written by application developers, not security
specialists.
XPC is one inter-process boundary an attacker leans on. The next post turns to a boundary designed specifically to contain code rather than serve it: the macOS sandbox, and the art of escaping it.
References
- Apple. EvenBetterAuthorizationSample. developer.apple.com/library/archive.
- Apple. Code Signing Services: SecCodeCheckValidity, SecRequirementCreateWithString. developer.apple.com.
- MITRE. CVE-2019-20057, CVE-2020-0984, CVE-2019-8805, CVE-2020-9714.