CVE-2017-2533 - The details behind

Intro

CVE-2017-2533 was part of a chain of vulnerabilities, used at pwn2own 2017 found by the phoenhex team. They wrote a blogpost about it here. This vulnerability led me to find CVE-2022-32780, which I detailed at Black Hat Asia 2022. Although the nature of CVE-2017-2533 was discussed by the authors, but the actual code part was never truly revealed, and I always wondered about the full details. Now I took the time to dig up the details, including how it was fixed, and why the fix solves the problem.

The initial info

CVE-2017-2533 is a race condition vulnerability impacting the Disk Arbitration service on macOS, allowing a user to escalate privileges to root. Disk Arbitration is open source, and the relevant code can be downloaded from Apple, link: https://opensource.apple.com/tarballs/DiskArbitration/DiskArbitration-288.1.1.tar.gz. As per the authors, the relevant part from DARequest.c is the following.

    /*
     * 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 authors say the following:

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.

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.

So there is a race condition issue between the check performed at if ( st.st_uid != DARequestGetUserUID( request ) ) and the execution of the mount operation. But why exactly? Other parts of the source code was not referenced. Also, Apple’s fix in Sierra 10.12.5, and Disk Arbitration 288.60.1 (download: https://github.com/apple-oss-distributions/DiskArbitration/archive/DiskArbitration-288.60.1.tar.gz) is essentially a one line modification: if ( DARequestGetUserUID( request ) != DADiskGetUserUID( disk ) ). So why DADiskGetUserUID( disk ) is better than st.st_uid? That was also never answered.

So let’s find out all the details, where the mountpoint path is important. Here I will use the original source code, version 288.1.1. It took me some time to put together the pieces, and I hope I got it right. So here you go, split into multiple parts.

One - The Disk

The DA service has an abstraction for a disk device, called DADisk which we can usually find as /dev/disk* in the file system, or list them with diskutil list. DA will maintain a list of disks internally. The API is public, we can also create those references for ourselves, like DADiskCreateFromVolumePath, more details can be found here: Apple Developer Documentation.

The full code of DADiskCreateFromVolumePath:

DADiskRef DADiskCreateFromVolumePath( CFAllocatorRef allocator, const struct statfs * fs )
{
    DADiskRef disk;

    disk = NULL;

    if ( fs )
    {
        CFURLRef path;

        path = CFURLCreateFromFileSystemRepresentation( kCFAllocatorDefault, ( void * ) fs->f_mntonname, strlen( fs->f_mntonname ), TRUE );

        if ( path )
        {
            CFStringRef kind;

            kind = CFStringCreateWithCString( kCFAllocatorDefault, fs->f_fstypename, kCFStringEncodingUTF8 );

            if ( kind )
            {
                char * id;

                id = _DAVolumeCopyID( fs );

                if ( id )
                {
                    CFTypeRef object;

                    disk = __DADiskCreate( allocator, id );

                    if ( disk )
                    {
                        struct passwd * user;

                        disk->_bypath = CFRetain( path );

                        CFDictionarySetValue( disk->_description, kDADiskDescriptionVolumePathKey, path );

                        CFDictionarySetValue( disk->_description, kDADiskDescriptionVolumeMountableKey, kCFBooleanTrue );

                        CFDictionarySetValue( disk->_description, kDADiskDescriptionVolumeKindKey, kind );

                        object = _DAFileSystemCopyName( NULL, path );

                        if ( object )
                        {
                            CFDictionarySetValue( disk->_description, kDADiskDescriptionVolumeNameKey, object );

                            CFRelease( object );
                        }

                        if ( ( fs->f_flags & MNT_LOCAL ) )
                        {
                            CFDictionarySetValue( disk->_description, kDADiskDescriptionVolumeNetworkKey, kCFBooleanFalse );
                        }
                        else
                        {
                            CFDictionarySetValue( disk->_description, kDADiskDescriptionVolumeNetworkKey, kCFBooleanTrue );
                        }

                        disk->_state |= _kDADiskStateMountAutomatic;
                        disk->_state |= _kDADiskStateMountAutomaticNoDefer;

                        disk->_state |= kDADiskStateStagedProbe;
                        disk->_state |= kDADiskStateStagedPeek;
                        disk->_state |= kDADiskStateStagedMount;

                        disk->_userUID = fs->f_owner;

                        user = getpwuid( fs->f_owner );

                        if ( user )
                        {
                            disk->_userGID = user->pw_gid;
                        }
                    }

                    free( id );
                }

                CFRelease( kind );
            }

            CFRelease( path );
        }
    }

    return disk;
}

Here the important part is the following. When the disk is created, we get the user ID and group ID based on the statfs structure, which also contains ownership information.

disk->_userUID = fs->f_owner;
disk->_userGID = user->pw_gid;

This is essentially the owner who created or mounted the /dev/ device. We can also check this in Terminal.

csaby@mantarey ~ % ls -l /dev/disk*
brw-r-----  1 root   operator  0x1000000 Aug 23 15:20 /dev/disk0
brw-r-----  1 root   operator  0x1000001 Aug 23 15:20 /dev/disk0s1
brw-r-----  1 root   operator  0x1000002 Aug 23 15:20 /dev/disk0s2
brw-r-----  1 root   operator  0x1000003 Aug 23 15:20 /dev/disk1
brw-r-----  1 root   operator  0x1000004 Aug 23 15:20 /dev/disk1s1
brw-r-----  1 root   operator  0x1000005 Aug 23 15:20 /dev/disk1s2
brw-r-----  1 root   operator  0x1000008 Aug 23 15:20 /dev/disk1s3
brw-r-----  1 root   operator  0x1000006 Aug 23 15:20 /dev/disk1s4
brw-r-----  1 root   operator  0x1000009 Aug 23 15:20 /dev/disk1s5
br--r-----  1 root   operator  0x100000a Aug 23 15:20 /dev/disk1s5s1
brw-r-----  1 root   operator  0x1000007 Aug 23 15:20 /dev/disk1s6
brw-r-----  1 csaby  staff     0x100000b Aug 29 19:33 /dev/disk2
brw-r-----  1 csaby  staff     0x100000c Aug 29 19:33 /dev/disk2s1
brw-r-----  1 csaby  staff     0x100000d Aug 29 19:33 /dev/disk3
brw-r-----  1 csaby  staff     0x100000e Aug 29 19:33 /dev/disk3s1

Later DADiskGetUserUID simply retrieves the userID (DADisk.c).

uid_t DADiskGetUserUID( DADiskRef disk )
{
    return disk->_userUID;
}

So we store the owner of the disk. Let’s keep this in mind for now.

Two - The mount call

When DA does the actual mounting (implemented in DAMount.c) it takes the user ID from the disk owner. This is done in DADiskGetUserUID( context->disk ).

static void __DAMountWithArgumentsCallbackStage1( int status, void * parameter )
...
           DAFileSystemMountWithArguments( DADiskGetFileSystem( context->disk ),
                                            DADiskGetDevice( context->disk ),
                                            context->mountpoint,
                                            DADiskGetUserUID( context->disk ),
                                            DADiskGetUserGID( context->disk ),
                                            __DAMountWithArgumentsCallbackStage2,
                                            context,
                                            context->options,
                                            NULL );

DAFileSystemMountWithArguments can be found in DAFileSystem.c. It will eventually call DACommandExecute with dynamically creating a mount command with arguments also passing along the UID and GID.

void DAFileSystemMountWithArguments( DAFileSystemRef      filesystem,
                                     CFURLRef             device,
                                     CFURLRef             mountpoint,
                                     uid_t                userUID,
                                     gid_t                userGID,
                                     DAFileSystemCallback callback,
                                     void *               callbackContext,
                                     ... )
{
    /*
     * Mount the specified volume.  A status of 0 indicates success.  All arguments in
     * the argument list shall be of type CFStringRef.  The argument list must be NULL
     * terminated.
     */

    CFStringRef             argument       = NULL;
    va_list                 arguments;
    CFURLRef                command        = NULL;
    __DAFileSystemContext * context        = NULL;
    CFStringRef             devicePath     = NULL;
    CFStringRef             mountpointPath = NULL;
    CFMutableStringRef      options        = NULL;
    int                     status         = 0;

    /*
     * Prepare to mount the volume.
     */

    command = CFURLCreateWithFileSystemPath( kCFAllocatorDefault, CFSTR( "/sbin/mount" ), kCFURLPOSIXPathStyle, FALSE );
    if ( command == NULL )  { status = ENOTSUP; goto DAFileSystemMountErr; }

    context = malloc( sizeof( __DAFileSystemContext ) );
    if ( context == NULL )  { status = ENOMEM; goto DAFileSystemMountErr; }

    devicePath = CFURLCopyFileSystemPath( device, kCFURLPOSIXPathStyle );
    if ( devicePath == NULL )  { status = EINVAL; goto DAFileSystemMountErr; }

    mountpointPath = CFURLCopyFileSystemPath( mountpoint, kCFURLPOSIXPathStyle );
    if ( mountpointPath == NULL )  { status = EINVAL; goto DAFileSystemMountErr; }

    options = CFStringCreateMutable( kCFAllocatorDefault, 0 );
    if ( options == NULL )  { status = ENOMEM; goto DAFileSystemMountErr; }

    /*
     * Prepare the mount options.
     */

    va_start( arguments, callbackContext );

    while ( ( argument = va_arg( arguments, CFStringRef ) ) )
    {
        CFStringAppend( options, argument );
        CFStringAppend( options, CFSTR( "," ) );
    }

    va_end( arguments );

    CFStringTrim( options, CFSTR( "," ) );

    /*
     * Execute the mount command.
     */

    context->callback        = callback;
    context->callbackContext = callbackContext;

    if ( CFStringGetLength( options ) )
    {
        DACommandExecute( command,
                          kDACommandExecuteOptionDefault,
                          userUID,
                          userGID,
                          __DAFileSystemCallback,
                          context,
                          CFSTR( "-t" ),
                          DAFileSystemGetKind( filesystem ),
                          CFSTR( "-o" ),
                          options,
                          devicePath,
                          mountpointPath,
                          NULL );
    }

Finally __DACommandExecute will run the command and set the EUID and EGID based on the info passed. Recall, this comes from the owner of the /dev/ device.

static void __DACommandExecute( char * const *           argv,
                                UInt32                   options,
                                uid_t                    userUID,
                                gid_t                    userGID,
                                DACommandExecuteCallback callback,
                                void *                   callbackContext )
{
...
    executablePID = fork( );

    if ( executablePID == 0 )
    {
        int fd;

        /*
         * Prepare the post-fork execution environment.
         */

        setgid( userGID );
        setuid( userUID );

This means that the mount command will be executed with the same rights as the owner of the /dev device.

Three - Back to the Vulnerability and Exploit

Originally the vulnerability was exploited by the authors using the EFI partition, which is owned by root, thus the mount is executed as root. They couldn’t just mount an arbitrary DMG or image, an important part in the exploit was the ownership of the device. The actual mount and the st.st_uid != DARequestGetUserUID( request ) check could be raced with a symlink.

Essentially there are no checks between the mount command execution and the ownership check, thus the race condition.

Four - The Fix

Here is Apple’s fix again:

if ( mountpoint )
{
    if ( DARequestGetUserUID( request ) )
    {
        if ( DARequestGetUserUID( request ) != DADiskGetUserUID( disk ) )
        {
            status = kDAReturnNotPermitted;
        }
    }
}

This is what I found initially very disturbing. Instead of verifying the owner of the target path, they verify if the owner of the device to be mounted is the same as the caller. Honestly this looked pretty odd, as it doesn’t deal with the target location at all, but actually it’s pretty clever, and here is why it works.

Case 1: As a user we want to mount a device, where the owner is root. We have to be root for that in order to satisfy the criteria. No LPE possible here, we can’t mount the EFI anymore. Case 2: As a user, we want to mount a device owned by our user over a location owned by root. The criteria will be satisfied, and DA will call mount. However as the device owner is the user, the mount command will be executed as the user, and it will eventually fail at the kernel level, as it will verify permissions. The ownership of the device is recorded only once at the very beginning, so no race condition is possible.

This is the case even today.

We can actually test this. Here we create a directory as root, and we want to mount over it a device as root, but where the device owner is the user. It will fail.

csaby@mantarey /tmp % sudo mkdir mnt        

csaby@mantarey /tmp % ls -l /tmp/ | grep mnt
drwxr-xr-x  2 root   wheel  64 Aug 29 21:15 mnt

csaby@mantarey /tmp % ls -l /dev/disk*
brw-r-----  1 root   operator  0x1000000 Aug 23 15:20 /dev/disk0
brw-r-----  1 root   operator  0x1000001 Aug 23 15:20 /dev/disk0s1
brw-r-----  1 root   operator  0x1000002 Aug 23 15:20 /dev/disk0s2
brw-r-----  1 root   operator  0x1000003 Aug 23 15:20 /dev/disk1
brw-r-----  1 root   operator  0x1000004 Aug 23 15:20 /dev/disk1s1
brw-r-----  1 root   operator  0x1000005 Aug 23 15:20 /dev/disk1s2
brw-r-----  1 root   operator  0x1000008 Aug 23 15:20 /dev/disk1s3
brw-r-----  1 root   operator  0x1000006 Aug 23 15:20 /dev/disk1s4
brw-r-----  1 root   operator  0x1000009 Aug 23 15:20 /dev/disk1s5
br--r-----  1 root   operator  0x100000a Aug 23 15:20 /dev/disk1s5s1
brw-r-----  1 root   operator  0x1000007 Aug 23 15:20 /dev/disk1s6
brw-r-----  1 csaby  staff     0x100000b Aug 29 19:33 /dev/disk2
brw-r-----  1 csaby  staff     0x100000c Aug 29 19:33 /dev/disk2s1
brw-r-----  1 csaby  staff     0x100000d Aug 29 19:33 /dev/disk3
brw-r-----  1 csaby  staff     0x100000e Aug 29 19:33 /dev/disk3s1

csaby@mantarey /tmp % sudo hdiutil mount  -mountPoint /tmp/mnt /dev/disk3s1
hdiutil: mount failed - no mountable file systems

If we run ProcessMonitor in the background, we will find that the mount call is run as the user (UID: 501). Here the parent PID 97 is diskarbitrationd.

{"event":"ES_EVENT_TYPE_NOTIFY_EXIT","timestamp":"2022-08-29 19:16:10 +0000","process":{"pid":85347,"name":"mount","path":"/sbin/mount","uid":501,"architecture":"unknown","arguments":[],"ppid":97,"rpid":97,"ancestors":[97,1],"signing info (reported)":{"csFlags":570522385,"platformBinary":1,"signingID":"com.apple.mount","teamID":"","cdHash":"85427FC991D8D020C57B4D566A89A49E1ED287FF"},"signing info (computed)":{"signatureID":"com.apple.mount","signatureStatus":0,"signatureSigner":"Apple","signatureAuthorities":["Software Signing","Apple Code Signing Certification Authority","Apple Root CA"]},"exit code":19200}}
{"event":"ES_EVENT_TYPE_NOTIFY_EXEC","timestamp":"2022-08-29 19:16:10 +0000","process":{"pid":85352,"name":"mount","path":"/sbin/mount","uid":501,"architecture":"unknown","arguments":["/sbin/mount","-t","apfs","-o","nodev,noowners,nosuid","/dev/disk3s1","/private/tmp/mnt"],"ppid":97,"rpid":97,"ancestors":[97,1],"signing info (reported)":{"csFlags":570522385,"platformBinary":1,"signingID":"com.apple.mount","teamID":"","cdHash":"85427FC991D8D020C57B4D566A89A49E1ED287FF"},"signing info (computed)":{"signatureID":"com.apple.mount","signatureStatus":0,"signatureSigner":"Apple","signatureAuthorities":["Software Signing","Apple Code Signing Certification Authority","Apple Root CA"]}}}

This confirms my analysis. I think.

This all means that if the user owns the device, DA won’t be able to mount it as root, because the executor and owner will be different. Problem? Likely not, maybe in some corner cases it can fail some code.

Conclusion

Not much. :) I’m just glad that I finally understand what happened here. It was a pretty useful exercise.

If I made a mistake or wrong conclusion somewhere let me know.