Pwn2Own: Safari sandbox part 2 – Wrap your way around to root
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_check
s 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
- are reachable from the Safari sandbox;
- do have a sandbox profile and hence have likely undergone little scrutiny, but
- 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.