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:

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

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: