The previous part of this write-up was about a local privilege escalation on macOS 10.12.4. Two primitives are missing for it to be exploitable from the Safari sandbox: We need an authorization token with the system.volume.internal.mount right, as well as the ability to create symlinks in an arbitrary directory. Enter CVE-2017-2535 / ZDI-17-356, a logic issue in the Apple Security framework that allows us to bypass the authorization sandbox, and CVE-2017-2534, a quirky configuration of the Speech Synthesis service which allows us to easily execute arbitrary code in its context.

The resulting exploit chain does not rely on memory corruption issues and is able to elevate privileges to root. In fact, that’s only 95% true. We also used CVE-2017-6977, an entirely unspectacular NULL pointer dereference in an unsandboxed userland service, of which there were many in the same code base. It’s not exploitable by itself but we need it to crash the service and restart it. Stay tuned.

Recap

In order to exploit CVE-2017-2533, the TOCTOU issue in diskarbitrationd, we need the following abilities, some of which we already have:

  • Access to the IPC endpoint of diskarbitrationd
  • Write access to an arbitrary directory
  • Ability to obtain an authorization token for mounting
  • Ability to create symlinks

Authorization tokens and rights

An authorization token in macOS is created using the AuthorizationCreate API. It is provided by the service com.apple.authd, which manages the list of active tokens and captures what users and processes created them. Tokens can be copied and shared between processes by serializing and deserializing them via the AuthorizationMakeExternalForm / AuthorizationCreateFromExternalForm API. The external form is basically just a random 12-byte handle associated with a token inside the authd service. Interestingly, after a token has been exported and imported again by a different process, the process that initially created the token can exit without the token becoming invalid. authd simply keeps a token alive as long as there are references to if from connected processes.

A token can be associated with a number of rights – simple strings defined in the file /System/Library/Security/authorization.plist, which also specifies rules about who is allowed to obtain them (e.g. “is-admin”, specifying that any admin user is allowed to obtain this right). The API call to add rights to a token is AuthorizationCopyRights. Obviously, a token with a given right can act as evidence that the caller was allowed by authd to obtain that right. This is how authorization is handled by some of the macOS services and tools, such as the authopen utility.

The following shell snipped gives a short example of how the authorization framework works. It runs a small swift program to obtain a token and export it into a file. In this case authd will open a dialog asking the user for permission (“swift wants to make changes…”) which the user has to allow. Other rights (and in particular system.volume.internal.mount) can be obtained without user interaction as long as the user is in the admin group. Afterwards, authopen reads and internalizes the token again, checks whether the token contains the required rights (sys.openfile.readonly./tmp/cantread.txt in this case), then proceeds to open and read the file. Note that the authorize.swift process needs to stay alive at least until authopen has internalized the token again, and thus incremented it’s refcount inside authd.

> ls -l cantread.txt
-r--------  1 root  wheel  7 Jul  5 23:56 cantread.txt

> swift authorize.swift         # will open a authorizaton dialog which must be approved
External form written to ./token
Press enter to quit
^Z
[1]  + 3310 suspended  swift authorize.swift

> /usr/libexec/authopen -extauth cantread.txt < token
THE_FILE_CONTENT

Performing the right check on the wrong process

Apart from the rules that specify what rights can be obtained using a token, there are additional restrictions placed by the authd service regarding sandboxed tokens: Neither the process that created the token, nor the process that wants to add a right to it can be sandboxed, or if they are, the sandbox rules must contain an explicit "authorization-right-obtain" rule for the requested right, which could look like this:

(allow authorization-right-obtain (right-name "system.volume.internal.mount"))

An old version of the authd source code is available and shows the implementation of this check:

static bool _verify_sandbox(engine_t engine, const char * right)
{
    pid_t pid = process_get_pid(engine->proc);
    if (sandbox_check(pid, "authorization-right-obtain", SANDBOX_FILTER_RIGHT_NAME, right)) {
        LOGE("Sandbox denied authorizing right '%s' by client '%s' [%d]", right, process_get_code_url(engine->proc), pid);
        return false;
    }

    pid = auth_token_get_pid(engine->auth);
    if (auth_token_get_sandboxed(engine->auth) && sandbox_check(pid, "authorization-right-obtain", SANDBOX_FILTER_RIGHT_NAME, right)) {
        LOGE("Sandbox denied authorizing right '%s' for authorization created by '%s' [%d]", right, auth_token_get_code_url(engine->auth), pid);
        return false;
    }

    return true;
}

Our scenario is as follows: We can create a token inside the Safari renderer process (WebContent), and pass it to diskarbitrationd. When it tries to obtain the right system.volume.internal.mount, the first check (regarding the user of the token, in this case diskarbitrationd) will pass, but the second one, regarding its creator will fail.

Note that in both sandbox_checks the corresponding process is identified by it’s PID. However, as we have seen before, the creator process can exit without the token being invalidated. Moreover, PIDs on macOS are restricted to the range 0 to 99999 and are subject to reuse. As such the sandbox check could be executed on the wrong process! This is why we want to crash an unsandboxed service: If we can get it to take up the same PID as the sandboxed process who created our token, both checks will pass and the right can be added.

Coincidentally, this bug is very similar to CVE-2017-7004, which was reported only a few days after Pwn2Own by Ian Beer from Google Project Zero and can be exploited in the same manner to gain entitlements on iOS.

Of course this bug is only realistically exploitable if we can create almost 100k processes in a short amount of time (there was a time limit of 5 minutes per attempt at Pwn2Own). Our initial idea was to just crash some system service repeatedly using NULL pointer derefs or other trivial bugs and have the launchd service restart them. However, it seems to implement some form of rate limiting, since after two crashes in a row it takes 10 seconds to restart a service. The second option is to use the exec(), fork() or vfork() syscalls. They are not ideal because they are typically not allowed by application sandboxes, but there are exceptions.

Code execution in speechsynthesisd – bug or feature?

There are in fact only two services which

  1. are reachable from the Safari sandbox;
  2. do have a sandbox profile and hence have likely undergone little scrutiny, but
  3. still support both forking and creating symlinks and hence are powerful enough to implement our exploit.

Namely, those two are the notoriously unsafe com.apple.fontd as well as com.apple.speechsynthesisd, the service implementing Apple’s Speech Synthesis API.

Gladly in the case of the latter, code execution is not a bug… it’s a feature! The SpeechSynthesisRegisterModuleURL literally takes a user-controlled file path and treats it as a CFBundle, in order to load a dynamic library from it and use it as a speech recognition plugin. There is no signature verification, so arbitrary code can be run during the library load via initializers. This by itself could be put off as not so bad since the service is sandboxed, but it also contained the following sandbox rule until macOS 10.12.4:

(allow file-read* file-write* (regex #"^(/private)?/var/folders/.+/com\.apple\.speech\.speechsynthesisd.*"))

Remember that the Safari renderer has full read and write access to the directory

/private/var/folders/<some-random-string>/C/com.apple.WebKit.WebContent+com.apple.Safari

In particular, both processes can read and write from/to

/private/var/folders/<some-random-string>/C/com.apple.WebKit.WebContent+com.apple.Safari/com.apple.speech.speechsynthesisd

So it is no problem to fake a CFBundle inside that directory and load it into speechsynthesisd from the renderer. There is a bug somewhere here, because the sandbox for speechsynthesisd is less strict than the one for the renderer. Apple decided the bug is in the regex, it was changed as follows in the macOS 10.12.5 update:

(allow file-read* file-write* (regex #"^(/private)?/var/folders/[^/]+/[^/]+/[^/]+/com\.apple\.speech\.speechsynthesisd.*"))

Putting the pieces together

At this point we have all the ingredients for a complete sandbox escape chain:

  • CVE-2017-2533: Local privesc given the ability to create symlinks and obtain the system.volume.internal.mount right
  • CVE-2017-2535: Ability to obtain the above-mentioned right, given the ability to fork processes and spawn an unsandboxed process on-demand
  • CVE-2017-2534: Ability to create symlinks and use the vfork syscall
  • CVE-2017-6977: NULL pointer reference in nsurlstoraged (unsandboxed), causing it to get respawned

The sequence of the final exploits can be crudely summarized as follows:

The full exploit code for the sandbox escape can be found on our GitHub.