WorkflowKit: Race Vulnerability in Extraction/Generation (CVE-2024-27821)
I've been looking into WorkflowKit's shortcut extraction/signing process as of late and have found a race condition that a malicious app can potentially exploit to intercept shortcut files a user imports while running in the background.
The vulnerability also has WorkflowKit/Shortcuts check against the original signed shortcut imported, meaning we don't even have to sign our intercepted file; Shortcuts will import the unsigned shortcut file!
The vulnerability
The method responsible for extracting signed shortcut files, -[WFShortcutPackageFile preformShortcutDataExtractionWithCompletion:]
, contains a significant race condition that could be exploited by a malicious app. This method is designed to extract the Apple Encrypted Archive of the signed shortcut and extract the unsigned Shortcut.wflow
file from it.
-(void)preformShortcutDataExtractionWithCompletion:(void(^)(id, long long, NSString * _Nullable, NSError*))comp {
WFSecurityLog("Extracting Signed Shortcut Data");
if (![self signedShortcutData] && ![self signedShortcutFileURL]) {
comp(nil,0,nil,[NSError errorWithDomain:NSCocoaErrorDomain code:0x4 userInfo:nil]);
return;
}
AAByteStream byteStream;
if ([self signedShortcutData]) {
byteStream = AAMemoryInputStreamOpen([[self signedShortcutData]bytes], [[self signedShortcutData]length]);
} else {
byteStream = AAFileStreamOpenWithPath([[self signedShortcutFileURL]fileSystemRepresentation], 0, 420);
}
if (!byteStream) {
comp(nil,0,nil,WFShortcutPackageFileInvalidShortcutFileError());
return;
}
AEAContext context = AEAContextCreateWithEncryptedStream(byteStream);
if (!context) {
comp(nil,0,nil,WFShortcutPackageFileInvalidShortcutFileError());
return;
}
size_t buf_size = 0;
int errorCode = AEAContextGetFieldBlob(context, AEA_CONTEXT_FIELD_AUTH_DATA, 0, 0, 0, &buf_size);
if (errorCode) {
comp(nil,0,nil,WFShortcutPackageFileInvalidShortcutFileError());
return;
}
if (!buf_size) {
return;
}
uint8_t *buffer = (uint8_t *)malloc(buf_size);
if (AEAContextGetFieldBlob(context, AEA_CONTEXT_FIELD_AUTH_DATA, 0, buf_size, buffer, 0)) {
free(buffer);
comp(nil,0,nil,WFShortcutPackageFileInvalidShortcutFileError());
return;
}
NSData *authData = [NSData dataWithBytesNoCopy:buffer length:buf_size];
WFShortcutSigningContext *signingContext = [WFShortcutSigningContext contextWithAuthData:authData];
if (!signingContext) {
comp(nil,0,nil,WFShortcutPackageFileInvalidShortcutFileError());
return;
}
[signingContext validateWithCompletion:^(BOOL success, int options, NSString * _Nullable icId, NSError *validationError) {
if (!success) {
/* error? */
return;
}
SecKeyRef publicKey = [signingContext copyPublicKey];
if (!publicKey) {
comp(nil,0,nil,WFShortcutPackageFileInvalidShortcutFileError());
return;
}
NSData *externalRep = (__bridge NSData*)SecKeyCopyExternalRepresentation(publicKey, nil);
if (AEAContextSetFieldBlob(context, AEA_CONTEXT_FIELD_SIGNING_PUBLIC_KEY, AEA_CONTEXT_FIELD_REPRESENTATION_X963, [externalRep bytes], [externalRep length])) {
/* error? */
return;
}
NSURL *daURL = [[self temporaryWorkingDirectoryURL]URLByAppendingPathComponent:[self directoryName]];
if (![[self fileManager] fileExistsAtPath:[daURL path] isDirectory:nil]) {
[[self fileManager] createDirectoryAtURL:daURL withIntermediateDirectories:NO attributes:nil error:nil];
}
AAArchiveStream archiveStream = AAExtractArchiveOutputStreamOpen([daURL fileSystemRepresentation], nil, nil, 1, 0);
if (!archiveStream) {
comp(nil,0,nil,WFShortcutPackageFileFailedToExtractShortcutFileError());
return;
}
AAByteStream decryptionInputStream = AEADecryptionInputStreamOpen(byteStream, context, 0, 0);
AAArchiveStream decodeStream = AADecodeArchiveInputStreamOpen(decryptionInputStream, nil, nil, 0, 0);
/* Extracting Signed Shortcut Data */
WFSecurityInfo("Extracting Signed Shortcut Data");
ssize_t archiveEntries = AAArchiveStreamProcess(decodeStream, archiveStream, nil, nil, 0, 0);
/* archiveEntries will return a negative error code if failure */
if (archiveEntries < 0 || AAArchiveStreamClose(archiveStream) < 0) {
WFSecurityErrorF("Failed to extract signed shortcut data with %{public}zd entries",archiveEntries);
comp(nil,0,nil,WFShortcutPackageFileFailedToExtractShortcutFileError());
return;
}
WFFileRepresentation *fileRep = [WFFileRepresentation fileWithURL:[daURL URLByAppendingPathComponent:@"Shortcut.wflow"]; options:0x3 ofType:[WFFileType typeWithUTType:@"com.apple.shortcuts.workflow-file"] proposedFilename:[self fileName]];
if (!fileRep) {
/* Could not find the main shortcut Shortcut.wflow file in the archive */
WFSecurityError("Could not find the main shortcut Shortcut.wflow file in the archive");
comp(nil,0,nil,WFShortcutPackageFileInvalidShortcutFileError());
return;
}
WFSecurityInfoF("Signed Shortcut Data Extracted Successfully with %zd entries",archiveEntries);
/* Signed Shortcut Data Extracted Successfully */
comp(fileRep, options, icId, nil);
AAArchiveStreamClose(decodeStream);
AAByteStreamClose(decryptionInputStream);
}];
}
Note that, to my knowledge, Shortcuts never calls this method with signedShortcutData, the extraction wrapper methods if I remember correctly actually will just write the data to a temporary file to use the file URL.
The basic flow is:
- Open a Apple Archive file stream with the file path
- Check signing of AEA file
- Opens decryption streams and decode streams
- Extracts to a filepath
- Creates a WFFileRepresentation of that filepath
It does not account if the file is modified right after extraction and before WFFileRepresentation creation. The temporary Shortcuts directory is also able to be modified by any unsandboxed process, no permissions needed. This means that a malicious app is able to modify the directory/file in time and it imports.
A Fun Trick
This is exploitable without this trick, but the time between AAArchiveStreamClose and the WFFileRepresentation method is incredibly tight. Thankfully, we can do a trick to give us more time, and therefore greatly increase exploit reliability (which I do).
What if I told you that we actually only have to be between AAExtractArchiveOutputStreamOpen
and the WFFileRepresentation?
What? If we modify the shortcut while it's extracting, then the AAExtractArchiveOutputStream
will fail and it won't import the shortcut. There's no way we can do that, right?
You see the AAExtractArchiveOutputStreamOpen([daURL fileSystemRepresentation], nil, nil, 1, 0);
? What AAExtractArchiveOutputStreamOpen
does is it gets the realpath() of [daURL fileSystemRepresentation]
and uses it, meaning it resolves symlinks.
This means that before the AAExtractArchiveOutputStreamOpen
is ran, we can modify [daURL fileSystemRepresentation]
to be a symlink to the directory we want the extraction to actually go. Then, after AAExtractArchiveOutputStreamOpen
, we modify the symlink to point to a directory that will hold the unsigned Shortcut.wflow that will actually be imported.
This means that even though we changed where [daURL fileSystemRepresentation]
points to, AAExtractArchiveOutputStreamOpen
still is using the original directory that was previously symlinked.
This trick drastically increases exploit reliability for us, especially on bigger shortcuts that will take a while to extract!
On my MacBook Air (Retina, 13-inch 2020) Core i5, it's incredibly reliable; I currently haven't gotten the PoC exploit to fail yet.
Race Vulnerability in Contact Signing Generation
Immediately after finding a race vulnerability in extraction of signed shortcuts, I found yet another race condition, this time with generation of signed shortcuts. Both are with the same WorkflowKit class, WFShortcutPackageFile, albeit they are in different methods ex the extraction one is in preformShortcutDataExtractionWithCompletion:
and this one is in generateSignedShortcutFileRepresentationWithPrivateKey:signingContext:error:.
The other vulnerability
Before we go into the main method, I should notice the end wrapper method that the shortcuts CLI uses on Mac. Here it is:
@implementation WFP2PSignedShortcutFileExporter
-(void)exportWorkflowWithCompletion:(void(^)(NSURL *fileURL, NSError *err))comp {
WFFileRepresentation *fileRep = [self.workflowRecord fileRepresentation];
[fileRep setName:[self.workflowRecord name]];
NSError *err;
NSData *data = [fileRep fileDataWithError:&err];
if (data) {
WFShortcutPackageFile *package = [[WFShortcutPackageFile alloc]initWithShortcutData:data shortcutName:[self.workflowRecord name]];
SFAppleIDAccount *account = [[[SFAppleIDClient alloc]init]myAccountWithError:&err];
if (account) {
WFFileRepresentation *signedShortcutFile = [package generateSignedShortcutFileRepresentationWithAccount:account error:&err];
if (signedShortcutFile) {
[self setSignedShortcutFile:signedShortcutFile];
comp([signedShortcutFile fileURL], nil);
}
}
}
}
@end
(generateSignedShortcutFileRepresentationWithAccount:error:
generates a 256-bit ECDSA key used for signing and makes the signing context from it and the apple ID, then calls the main method, generateSignedShortcutFileRepresentationWithPrivateKey:signingContext:error:
).
initWithShortcutData: is where the random directory that will store the data is created. This knowledge really isn't needed for the race condition itself, but it is for the trick I apply since I modify this directory rather than the file itself for extra reliability.
The main method that handles generation of contact signed shortcut files is -[WFShortcutPackageFile generateSignedShortcutFileRepresentationWithPrivateKey:signingContext:error:]
. Here's the code:
-(NSURL *)generateDirectoryStructureInDirectory:(NSURL *)dir error:(NSError ** _Nullable)err {
if (![self shortcutData]) {
if (err) {
*err = WFShortcutPackageFileFailedToSignShortcutFileError();
}
return nil;
}
NSURL *url = [dir URLByAppendingPathComponent:[self directoryName]];
BOOL didSucceed = [[self fileManager] createDirectoryAtURL:url withIntermediateDirectories:NO attributes:nil error:err];
if (!didSucceed) {
return nil;
}
NSURL *writeToURL = [url URLByAppendingPathComponent:@"Shortcut.wflow"];
[[self shortcutData]writeToURL:writeToURL atomically:YES];
return url;
}
-(WFFileRepresentation *)generateSignedShortcutFileRepresentationWithPrivateKey:(SecKeyRef)daKey signingContext:(WFShortcutSigningContext *)signingContext error:(NSError**)err {
/*
* PROBABLY BUG(?):
*
* WorkflowKit opens a byte stream, encryption stream, and archive stream here.
* However, this method only closes the streams if signing was successful.
* This means that certain errors will cause an open stream to never be closed.
* This is present in iOS 15.2, not sure about past versions.
*/
/* The original implementation has the destroy functions for archives in blocks but eh */
NSData *authData = [signingContext generateAuthData];
if (!authData) {
if (err) {
*err = WFShortcutPackageFileInvalidShortcutFileError();
}
return nil;
}
NSURL *url = [self generateDirectoryStructureInDirectory:[self temporaryWorkingDirectoryURL] error:err];
if (!url) {
return nil;
}
AEAContext context = AEAContextCreateWithProfile(AEA_PROFILE__HKDF_SHA256_HMAC__NONE__ECDSA_P256);
if (!context) {
if (err) {
*err = WFShortcutPackageFileFailedToSignShortcutFileError();
}
return nil;
}
if (AEAContextSetFieldUInt(context, AEA_CONTEXT_FIELD_COMPRESSION_ALGORITHM, COMPRESSION_LZFSE)) {
if (err) {
*err = WFShortcutPackageFileFailedToSignShortcutFileError();
}
AEAContextDestroy(context);
return nil;
}
CFErrorRef cferr = 0;
NSData *key = (__bridge NSData *)SecKeyCopyExternalRepresentation(daKey, &cferr);
if (!key) {
if (err) {
/* For some reason WFShortcutPackageFileFailedToSignShortcutFileError is inlined here, and only here ??? */
*err = [NSError errorWithDomain:@"WFWorkflowErrorDomain" code:0x4 userInfo:@{
NSLocalizedDescriptionKey : WFLocalizedString(@"Failed to sign shortcut"),
}];
}
AEAContextDestroy(context);
return nil;
}
if (AEAContextSetFieldBlob(context, AEA_CONTEXT_FIELD_SIGNING_PRIVATE_KEY, AEA_CONTEXT_FIELD_REPRESENTATION_X963, [key bytes], [key length])) {
if (err) {
*err = WFShortcutPackageFileFailedToSignShortcutFileError();
}
AEAContextDestroy(context);
return nil;
}
AEAContextSetFieldBlob(context, AEA_CONTEXT_FIELD_AUTH_DATA, AEA_CONTEXT_FIELD_REPRESENTATION_RAW, [authData bytes], [authData length]);
NSURL *fileURL = [[self temporaryWorkingDirectoryURL]URLByAppendingPathComponent:[self fileName]];
const char *path = [fileURL fileSystemRepresentation];
AAByteStream byteStream = AAFileStreamOpenWithPath(path,O_CREAT | O_RDWR, 420);
AAByteStream encryptedStream = AEAEncryptionOutputStreamOpen(byteStream, context, 0, 0);
AAFieldKeySet fields = AAFieldKeySetCreateWithString("TYP,PAT,LNK,DEV,DAT,MOD,FLG,MTM,BTM,CTM,HLC,CLC");
if (!fields) {
/*
* PROBABLY BUG:(?)
*
* If the field key set couldn't be created,
* BUT the byte streams opened,
* They will not be closed.
*/
if (err) {
*err = WFShortcutPackageFileFailedToSignShortcutFileError();
}
AEAContextDestroy(context);
return nil;
}
const char *dir = [url fileSystemRepresentation];
AAPathList pathList = AAPathListCreateWithDirectoryContents(dir, 0, 0, 0, 0, 0);
if (!pathList) {
if (err) {
*err = WFShortcutPackageFileFailedToSignShortcutFileError();
}
AAFieldKeySetDestroy(fields);
AEAContextDestroy(context);
return nil;
}
AAArchiveStream archiveStream = AAEncodeArchiveOutputStreamOpen(encryptedStream, 0, 0, 0, 0);
if (!archiveStream) {
if (err) {
*err = WFShortcutPackageFileFailedToSignShortcutFileError();
}
AAPathListDestroy(pathList);
AAFieldKeySetDestroy(fields);
AEAContextDestroy(context);
return nil;
}
if (AAArchiveStreamWritePathList(archiveStream, pathList, fields, [url fileSystemRepresentation], 0, 0, 0, 0) == 0) {
AAArchiveStreamClose(archiveStream);
AAByteStreamClose(encryptedStream);
AAByteStreamClose(byteStream);
WFFileRepresentation *fileRep = [WFFileRepresentation fileWithURL:fileURL options:0x3 ofType:0x0 proposedFilename:[self sanitizedName]];
[[self fileManager]removeItemAtURL:fileURL error:nil];
AAPathListDestroy(pathList);
AAFieldKeySetDestroy(fields);
AEAContextDestroy(context);
return fileRep;
} else {
if (err) {
*err = WFShortcutPackageFileFailedToSignShortcutFileError();
}
AAPathListDestroy(pathList);
AAFieldKeySetDestroy(fields);
AEAContextDestroy(context);
return nil;
}
}
Pay attention to [generateDirectoryStructureInDirectory:error:]
. When a signed shortcut is generated, Shortcuts stores the unsigned shortcut in a Shortcut.wflow file.
HOWEVER, it does not account if the file is modified before generation is complete. This means that a malicious app is able to modify the directory/file in time and it imports.
This is how WFShortcutPackageFile creates the temporary directory:
-(void)commonInit {
NSURL *tempWorkingDir = [WFTemporaryFileManager createTemporaryDirectoryWithFilename:[[NSUUID UUID]UUIDString]];
_temporaryWorkingDirectoryURL = tempWorkingDir;
_fileManager = [NSFileManager defaultManager];
NSError* thisIsUnusedIThinkLol = nil;
[[self fileManager] createDirectoryAtURL:tempWorkingDir withIntermediateDirectories:NO attributes:nil error:&thisIsUnusedIThinkLol];
_executionQueue = dispatch_queue_create("com.apple.shortcuts.shorcut-package-file.execution-queue", 0);
}
(No, I did not make a typo with shorcut com.apple.shortcuts.shorcut-package-file.execution-queue, it's there in the original code :P)
In my PoC that exploits this, I check for when the temporary directory has been created. Then, before the generateDirectoryStructureInDirectory:error: call, I swap it out with a symlink to where we want the original unintercepted unsigned shortcut to be stored. Then, once writeToURL: in generateDirectoryStructureInDirectory:error: is called, I quickly change the symlink again to be a directory that has the unsigned shortcut that the user actually ends up signing!
Implications
This means that a malicious app can potentially run in the background, and whenever the user attempts to contact sign any shortcuts, ex to share with a contact, it instead without the user's knowledge intercepts it and potentially makes the user sign a different shortcut, ex injects malicious code in all Shortcuts the user tries to sign with innocent intentions. It can also use this to intercepts any shortcuts the user opens from a trusted user and inject them with malicious actions without them knowing.
Patch
Apple seems to have patched this by additional sandbox restrictions; at least I think, to be honest I haven't looked that far into the patch, but opendir() now fails on the directory so the bug does not work anymore.
Apple patched this in macOS 14.5 and assigned it CVE-2024-27821. I should note that the description at the time of writing this is a little different to the bug itself; stating "A shortcut may output sensitive user data without consent". I do actually remember from the time when there was a bug to arbitrary file write using the extract archive action as well as some action with GIF and video conversion that I learned shortcuts stores one of these in temporary files; perhaps maybe there was a bug resolving this in which those could be modified to be a symlink and point to sensitive data that a shortcut would output, and with the additional sandbox restrictions it also patched this as well, but I'm not too sure. Either way, I learned a lot about how Shortcuts extracts files from all of this.
Timeline
- 2024 27 March 18:55 - "WorkflowKit: Race Vulnerability in Signing/Extraction" is reported to Apple Product Security.
- 2024 29 March 22:05 - I make a comment that I have discovered another race vulnerability in the same WFShortcutPackageClass pertaining to generation, and submit my PoC to them.
- 2024 1 April 12:23 - Apple Product Security begins reviewing.
- 2024 12 April - WorkflowKit Race changes to Reproduced (From here on out these may get a couple days inaccurate, I just logged them when I noticed status changes).
- 2024 29 April - WorkflowKit Race changes to In Progress, with a patch intended for Fall 2024.
- 2024 07 May - Status changes back to Reproduced.
- 2024 09 May - Status changes back to In Progress.
- 2024 13 May - macOS Sonoma 14.5 Releases, patching CVE-2024-27821.
- 2024 02 Oct - Status changes back to Reproduced, a couple hours later they add a comment that they addressed this in macOS 14.5 and to test if I cannot reproduce it anymore. A day later I have time for a quick test and add a comment that the PoC no longer works.
- 2024 09 Oct - Apple says to be adding my credit to CVE-2024-27821 in a future security update.
- 2024 5 Nov - I ask them if it would be fine to release a public writeup with public PoCs.
- 2024 6 Nov - Product Security takes a look at it.
- 2024 11 Nov - Product Security gives OK for disclosure.
Overall, I had a pretty good time with the bug reporting process. It seemed that status report did change every 1-2 weeks up to In Progress which I am happy with. My only disappointment was this getting no bounty, but to be fair it is admittedly a lower impact vulnerability anyways so it's not like it was going to get a huge bounty if it did get one.
PoC links:
- main.m: (extraction race)
- SignedShortcutGenerationInterception-poc.m: (generation race)