Today we have CVE-2017-2533 / ZDI-17-357 for you, a race condition in a macOS system service which could be used to escalate privileges from local admin to root. We used it in combination with other logic bugs to escape the Safari sandbox at this year’s Pwn2Own competition.

The bug was in the Disk Arbitration daemon, which is responsible for managing block devices on macOS. Its IPC interface can be accessed from inside the Safari sandbox, which made it an interesting target for us. By exploiting the bug, we could mount a disk partition over any non-SIP protected directory. Since there is a small FAT32 recovery partition in every recent MacBook which the default user is allowed to write to, this allowed us to put files with arbitrary contents into the file system and gain root privileges.

Bug Discovery

After achieving code execution in the Safari renderer, our goal was to laterally move to a more privileged system process, so we started off our audit by looking at the list of allowed mach lookups, i.e. services that we can talk to via mach IPC. The list can be found in the sandbox rule file /System/Library/Frameworks/WebKit.framework/Versions/A/Resources/com.apple.WebProcess.sb and starts as follows:

(allow mach-lookup
       (global-name "com.apple.DiskArbitration.diskarbitrationd")
       (global-name "com.apple.FileCoordination")
       (global-name "com.apple.FontObjectsServer")
       ...

The first one in the list immediately caught our attention. Disk Arbitration is the Apple framework responsible for managing (primarily mounting and unmounting) block devices, and diskarbitrationd is the service handling the corresponding IPC requests. Why would a browser renderer ever need to mount disks? That definitely warranted some further investigation.

$ ps aux | grep diskarbitrationd | grep -v grep
root                86   0.0  0.0  2494876   2132   ??  Ss   Wed10AM   0:00.37 /usr/libexec/diskarbitrationd
$ sudo launchctl procinfo $(pgrep diskarbitrationd) | grep sandboxed
sandboxed = no

So as expected, diskarbitrationd runs as root (it has to in order to use the mount syscall) and is not sandboxed. We also verified that we could reach it from the sandbox by writing a small client script in Swift and running it with the com.apple.WebProcess.sb sandbox file using the sandbox-exec tool. All this considered, it looked like diskarbitrationd could be a interesting target for exploitation, so we started auditing it. An old version of the server source code can be downloaded from the Apple web site.

I was looking for memory corruption bugs at first, but soon I came across the following code in DARequest.c, line 510:

    /*
     * Determine whether the mount point is accessible by the user.
     */

    if ( DADiskGetDescription( disk, kDADiskDescriptionVolumePathKey ) == NULL )
    {
        if ( DARequestGetUserUID( request ) )
        {
            CFTypeRef mountpoint;

            mountpoint = DARequestGetArgument2( request );
            // [...]
            if ( mountpoint )
            {
                char * path;

                path = ___CFURLCopyFileSystemRepresentation( mountpoint );

                if ( path )
                {
                    struct stat st;

                    if ( stat( path, &st ) == 0 )
                    {
                        if ( st.st_uid != DARequestGetUserUID( request ) )
                        {
                            // [[ 1 ]]
                            status = kDAReturnNotPermitted;
                        }
                    }

The mechanism implemented here is supposed to prevent a user with mount privileges to mount over a directory they do not own, such as /etc or /System. It works as follows: If the mount point exists, but is not owned by the user, the error code kDAReturnNotPermitted is produced at [[ 1 ]]. Otherwise, the mount proceeds. There are no more checks after this and the mount will succeed if the intended mount point exists and diskarbitrationd has sufficient permissions to perform the mount. This is the case for all directories that are not protected by the System Integrity Protection (SIP) security mechanism.

There is an old school time of check vs. time of use issue here: If the mount point is created after the check, but before the mount, the mount can succeed even if the caller does not own the mount point. An attacker can bypass the check by creating a symlink pointing to an arbitrary directory between the check and the mount.

Apple has patched the bug in macOS Sierra 10.12.5, but has not released the source code yet. We will update this post if the new source code becomes available, and outline the patch.

Building a local admin to root exploit

For example, we can use the bug to mount any disk over /etc, using the following pseudocode:

disk = "/dev/some-disk-device"

in the background:
  while true:
    create symlink "/tmp/foo" pointing to "/"
    remove symlink

while disk not mounted at "/etc":
  send IPC request to diskarbitrationd to mount disk to "/tmp/foo/etc"

Eventually, the check will be executed while the symlink is gone, but the mount will happen with the symlink present, so both will pass. At that point we have mounted over /etc, which should not be possible for the local admin user without at least typing the password.

Now from a privilege escalation standpoint, two problems remain:

  1. Is there a disk device that is usually unmounted but writable by the local admin user?
  2. What directory do we want to mount over that gives us code execution as root?

Problem 1 is easily solved by looking at all the physical devices:

$ ls -alih /dev/disk*
589 brw-r-----  1 root  operator    1,   0 Mar 15 10:27 /dev/disk0
594 brw-r-----  1 root  operator    1,   1 Mar 15 10:27 /dev/disk0s1
598 brw-r-----  1 root  operator    1,   3 Mar 15 10:27 /dev/disk0s2
596 brw-r-----  1 root  operator    1,   2 Mar 15 10:27 /dev/disk0s3
600 brw-r-----  1 root  operator    1,   4 Mar 15 10:27 /dev/disk1

One of them works for our purposes: /dev/disk0s1 is the EFI partition, a FAT32 volume used for BIOS updates on all MacBooks that we know of. It is usually not mounted, and since it contains a FAT file system, it does not support Unix permissions. So if we mount it as our local admin user, we can write to it.

It might also be possible to create a block device from a disk image using hdiutil and use it for the exploit, but I have not yet been able to get the race condition to work in that setting.

Problem 2 took me a while to figure out. Eventually I realized that the legacy cron daemon still exists on modern macOS operating systems. It is not running by default, but when a crontab file gets created in /var/at/tabs, launchd starts the cron daemon and it works as expected. So it suffices to just mount our disk device over /var/at/tabs and write our payload to /var/at/tabs/root:

* * * * * touch /tmp/pwned

The command will get executed every minute as root. At this point we have a fully working way of going from local admin to root without typing a password. The PoC code can be found in the file poc-mount.sh and gives you a root shell if you have clang installed:

$ ./poc-mount.sh
Just imagine having that root shell. It's gonna be legen...
wait for it...
dary!
./poc-mount.sh: line 77:  3179 Killed: 9               race_link
sh-3.2# id
uid=0(root) gid=0(wheel) groups=0(wheel),1(daemon),2(kmem),3(sys),4(tty),5(operator),8(procview),9(procmod),12(everyone),20(staff),29(certusers),61(localaccounts),80(admin),401(com.apple.sharepoint.group.1),33(_appstore),98(_lpadmin),100(_lpoperator),204(_developer),395(com.apple.access_ftp),398(com.apple.access_screensharing),399(com.apple.access_ssh)
sh-3.2#

This exploit by itself is comparable to a UAC bypass on Windows and turned out to be very useful when combined with some other bugs.

Towards a sandbox escape

Before we can use any of the Disk Arbitration APIs, we need to first provide an authorization token. When issuing a mount request, diskarbitrationd will try to obtain the right system.volume.*.mount using that token, where * depends on the type of volume we want to mount. For example, when we request to mount the internal disk partition /dev/disk0s1, diskarbitrationd will use the following logic to check for authorization inside the DAAuthorize function from DASupport.c:

DAReturn status;

status = kDAReturnNotPrivileged;

    // ...

    AuthorizationRef authorization;

    // [[ 1 ]]
    authorization = DASessionGetAuthorization( session );

    if ( authorization )
    {
        AuthorizationFlags  flags;
        AuthorizationItem   item;
        char *              name;
        AuthorizationRights rights;

        flags = kAuthorizationFlagExtendRights;

        // ...

                        if ( DADiskGetDescription( disk, kDADiskDescriptionDeviceInternalKey ) == kCFBooleanTrue )
                        {
                            // [[ 2 ]]
                            asprintf( &name, "system.volume.internal.%s", right );
                        }

        // ...

        if ( name )
        {
            item.flags       = 0;
            item.name        = name;
            item.value       = NULL;
            item.valueLength = 0;

            rights.count = 1;
            rights.items = &item;

            // [[ 3 ]]
            status = AuthorizationCopyRights( authorization, &rights, NULL, flags, NULL );

            if ( status )
            {
                status = kDAReturnNotPrivileged;
            }

            free( name );
        }
    }

At [[ 1 ]], the authorization token associated with the session is fetched, which was provided via an IPC call in advance. At [[ 2 ]], the string system.volume.internal.mount is produced. At [[ 3 ]], it is checked whether the token can be used to obtain this right via the AuthorizationCopyRights API. This API is implemented by the com.apple.authd service.

Triggering the bug from Safari

Let’s see what we need to trigger this bug and what we already have inside the sandbox:

Access to the IPC endpoint of diskarbitrationd - CHECK

Write access to any arbitrary directory - CHECK

It suffices to be able to write to just one specific directory, because while we need to write the root file to the mounted disk, we can mount the disk to that directory first, only to then umount it again and use the bug to mount it to /var/at/tabs.

(if (positive? (string-length (param "DARWIN_USER_CACHE_DIR")))
    (allow file* (subpath (param "DARWIN_USER_CACHE_DIR"))))

This sandbox rule from WebProcess.sb gives us full write access to

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

And all of its sub-directories.

Ability to obtain an authorization token for mounting - NOPE

While the local admin user can obtain this right in order to mount volumes, there is a mechanism in place that prevents sandboxed processes from creating authorization tokens with rights that the sandbox rules do not explicitly allow. WebProcess.sb contains no rule of the form (allow authorization-right-obtain ...), so we cannot obtain any rights.

The Safari sandbox explicitly disallows creating symlinks:

(if (defined? 'vnode-type)
        (deny file-write-create (vnode-type SYMLINK)))

In the next part of this series we will present three more bugs, one of which we used to create symlinks and two more to bypass the sandbox check in the authorization logic.