Jun 5 2024 | Jared Atkinson

Part 14: Sub-Operations


On Detection: Tactical to Functional

When the Operation is not Enough


A while back, I was working on deconstructing a standard variation of Token Theft and stumbled into a couple of interesting edge cases that my model still needed to account for. Below is the operation chain for one of the most common Token Impersonation procedures. You’ll notice that the attacker must open a handle to the target process (Process Open). This process will typically run in the SYSTEM context (winlogon.exe, for example). They will then open a handle to the primary token assigned to the process (Token Open). Next, the application must make a copy of the token (Token Duplicate). Finally, the attacker can apply the token to the calling thread’s context (Thread Set). It is essential to understand that impersonation only applies at the thread level, so it will not affect the attacker’s entire process, only the calling (by default) or specified thread.

Operation chain for standard Token Impersonation

During this analysis, I noticed an anomaly with the Thread Set operation that may affect some of the prior axioms I’ve laid out in this series. For instance, one of the fundamental axioms is that while using modern EDR sensors, we perceive events at the operational level. The Thread Set operation breaks this axiom. In this post, I will explain why this is the case and update the model in a way that allows us to capture the details that are necessary to handle these edge cases.

Standard Function Call Stacks

I want to highlight a specific category of operations that does not conform to the current model that I have outlined. For those new to this discussion, I am developing a conceptual framework that enables us to articulate attacker tradecraft. This framework involves deconstructing attacker tools to identify the API functions that the sample calls. I’ve posited that API functions are the fundamental building blocks of tradecraft. Subsequently, we scrutinize Windows API functions to gain insight into their underlying operations. This scrutiny often yields the “function call stack,” which delineates the sequence of functions invoked implicitly behind the scenes after a high-level Win32 function call. For instance, in a previous installment, we analyzed the kernel32!OpenProcess function and found that the call stack consists of kernel32!OpenProcess -> api-ms-win-core-processthreads-l1–1–2!OpenProcess -> kernelbase!OpenProcess -> ntdll!NtOpenProcess -> syscall!OpenProcess as shown below.

A function call stack starting with the kernel32!OpenProcess function

We then identified that any functions in the same function call stack can be considered equivalent and abstracted as an operation, in this case, Process Open. This rule has remained true across many examples, but I recently discovered a class of functions that do not act this way. In this post, we will analyze the advapi32!SetThreadToken function. We’ve seen this function previously in our analysis of Compound Functions. At the time, I described ImpersonateLoggedOnUser as a “compound function” and SetThreadToken as a “simple function,” however there’s more than meets the eye with SetThreadToken. Let’s take a look at the function call stacks of kernel32!OpenProcess and advapi32!SetThreadToken to get an idea of how they compare.

A comparison of function call stacks starting with kernel32!OpenProcess and advapi32!SetThreadToken

In this case, we see that like kernel32!OpenProcess, advapi32!SetThreadToken eventually makes a syscall, this time to NtSetInformationThread. Therefore, we can label this function call stack as implementing the Thread Set operation. However, upon closer examination, it becomes evident that there’s something different about the Thread Set operation. This post seeks to understand this difference and extend the model to fill the gap.

More Than Meets the Eye


The primary difference between simple functions like OpenProcess and ReadProcessMemory and these new “special functions” like SetThreadToken is not noticeable until we get to the bottom of the function call stack. OpenProcess eventually makes a syscall to a function named NtOpenProcess, passes all of the necessary parameters, and everything appears normal.

Pseudocode for kernelbase!OpenProcess showing a call to ntdll!NtOpenProcess


SetThreadToken, on the other hand, calls NtSetInformationThread a syscall that acts slightly differently than all of the so-called “normal” functions we’ve seen previously. Remember that SetThreadToken takes two parameters: a handle to the target thread and a handle to the token to impersonate. However, we see that NtSetInformationThread takes four parameters. What do those parameters do?

Pseudocode for kernelbase!SetThreadToken showing a call to ntdll!NtSetInformationThread

To understand the parameters SetThreadToken passes to NtSetInformationThread, we can check out the function documentation for the ZwSetInformationThread function, which is just another name for NtSetInformationThread.

ZwSetInformationThread Function Documentation

The second parameter, ThreadInformationClass, specifies to NtSetInformationThread which bit of the thread’s data to set; however, when we inspect the kernelbase!SetThreadToken function’s invocation of ntdll!NtSetInformationThread, we discover that it hardcodes the ThreadInformationClass parameter’s value to 0x05 or ThreadImpersonationToken.

Pseudocode for the kernelbase!SetThreadToken function highlighting the ThreadInformationClass parameter

At this point, you might wonder what other options you can pass to this parameter. According to the documentation, the THREADINFOCLASS enumeration is defined in ntddk.h and contains a list of possible values. With this in mind, I Googled ntddk.h and found a GitHub repo with that file. I searched the file for THREADINFOCLASS and found the enumeration definition. Within the enumeration, I found that the ThreadImpersonationToken value was associated with (0x05), and I discovered 39 alternative ThreadImpersonationClass values.

I immediately returned to IDA to look for cross-references to NtSetInformationThread. To do this, highlight the NtSetInformationThread function call and press the x key.

Cross references of NtSetInformationThread in kernelbase.dll


I selected the first function in the list, SetThreadPriority, and double-clicked it to see its implementation. You will notice that this function almost immediately calls NtSetInformationThread, but this time, it specifies ThreadBasePriority (0x03) as the second parameter.
Pseudocode for the kernelbase!SetThreadPriority function.

Pseudocode for the kernelbase!SetThreadPriority function

Operation Chain Implication

This observation told me that, unlike other operations such as Process Open, Process Read, Thread Create, etc., the mere observation of the Thread Set operation would not be enough to indicate impersonation. It also meant that our operation graph for “Standard Token Impersonation” did not tell the whole story. Here’s the operation graph below:

Note: You may have noticed that function call graphs typically end at the syscall. Meaning we are only mapping the user-mode portion of the call stack. In other words, we treat the kernel as if it were opaque. There’s a reason for this. We are modeling attacker tradecraft as if they are operating in user mode, so their ability to directly call kernel-mode functions is extraordinarily limited (primarily to the syscall interface). We know there are ways attackers can reach kernel mode, but at that point, many of the principles described in this series begin to crumble. This assumption is why Detection Engineers must understand and map how attackers can reach the kernel.

Ok, I said all of that to say that we typically do not find ourselves reversing the kernel, but in this particular case, it’s instructive. When an application makes the NtSetInformationThread syscall, execution is transferred (how this occurs is beyond the scope of this post) to a function in ntoskrnl.exe called NtSetInformationThread. With that in mind, we can open up ntoskrnl.exe in our disassembler and find the NtSetInformationThread function. In this example, I’m using IDA Pro’s decompiler feature to make it easier to follow the flow.

Here, we see that the function checks the value of the ThreadInformationClass parameter. If the ThreadInformationClass parameter is 0x05 (ThreadImpersonationToken), it will execute the code in the screenshot below. Of particular interest is the PsImpersonateClient function. As they say, this is the function where all the impersonation magic happens.

Parameter Types

The problem we see is that most functions don’t do much in user mode (this is generally true; however, as soon as you assume it is true, it will bite you). Therefore, most operations do their work in the kernel, which is often fine from the detection engineer’s point of view because the code will invoke the same kernel-mode routine regardless of the parameter values. For instance, if we analyze the three parameters for OpenProcess, we can see that dwDesiredAccess specifies the access rights the calling application wants the operating system to grant in the resulting handle. If set to true, bInheritHandle adds an extra routine but does not change the standard kernel-mode behavior. We see that dwProcessId will shift the target process, not the underlying behavior. These details may be relevant to a Detection Engineer, but it is the details about the behavior that are changing, not the behavior itself.

The ThreadInformationClass parameter in the NtSetInformationThread function determines the code path selection. If the parameter is not ThreadImpersonationToken, it does not signify token impersonation, as the PsImpersonateClient function in the kernel is not invoked. While there may be other significant ThreadInformationClass options, they do not signify our intended behavior, token impersonation.

With this example in mind, I’ve called this phenomenon “sub-operations,” where the operation is Thread Set as indicated by the syscall’s name (ntSETinformationTHREAD). However, the operation name itself does not convey sufficient information to discern whether a specific behavior occurred, so it is essential to include the sub-operation details in addition to the typical operation name. For now, I am using the name of the information class as the name of the sub-operation (e.g., ThreadImpersonationToken), but this may change as my understanding of this phenomenon develops. We can update the syscall and operation nodes in the function call stack to include the sub-operation in a property called “detail,” representing the information class.

Related Operations

At this point, we have increased the granularity with which we view the Thread Set operation, but I assumed this was not the only operation that would fit into this paradigm. I was curious which other functions might be in the same boat. For my first pass, I checked the export table in ntdll.dll for any functions that matched NtQueryInformation* or NtSetInformation*, as I have experience with both prefixes. Below are screenshots of what I found.

Ntdll.dll’s Exported Functions Matching NtQueryInformation*
Ntdll.dll’s Exported Functions Matching NtSetInformation*

You’ll notice many familiar objects such as Process, File, Key, Thread, Token, etc. However, if you’re like me, this list probably has a few objects you’ve never seen before. It is important to remember that this is not a comprehensive list, as additional operations certainly require this same sub-operation level of granularity. If you run into a function that you believe does not fit into the existing model, whether related to sub-operations or not, please comment below or message me privately so we can work together to understand how it fits.

Naming Convention

Since we are adding a new layer to the model, describing the naming convention I will use for these functions is helpful. For any function call stacks that result in an Nt(Query/Set)Information* syscall, I recommend and will be using the * as the object (where * is replaced by the specific object type) and either Query or Set as the action. For example, NtQueryInformationToken would become the Token Query operation, while NtSetInformationTransaction would become the Transaction Set operation. Of course, this blog post is about sub-operations, so we will also include the relevant *InformationClass value — for example, Token Query TokenPrivileges.

Hopefully, this will help you when you encounter these anomalies. In some (maybe many) cases, we expect to find similar behavior in kernelbase.dll, where a high-level Win32 API calls these underlying Native APIs while specifying a particular information class. This behavior is similar to what we saw with SetThreadToken and SetThreadPriority. To build a comprehensive list, I recommend selecting your native function of interest and checking its cross references. Who knows what you will find?


As I reviewed the list of the Nt(Query/Set)Information* functions in the previous section, some object types sounded unfamiliar and appeared to have little utility in a security context. One function that caught my eye was NtQueryInformationWorkerFactory, possibly because it was at the bottom of the list or because I didn’t know what a “Worker Factory” was. Honestly, I was slightly annoyed that Worker Factory being two words would mess up my nice Object Action naming convention.

I decided to Google NtQueryInformationWorkerFactory to learn more about this unfamiliar OS component and was very pleased with what I found. I immediately discovered a reference to a presentation given at Black Hat Europe 2023 (very recent!) called PoolParty by Alon Liviev from SafeBreach. PoolParty immediately struck a chord because my colleague Craig Wright had shown me the PoolParty GitHub repo just a couple of weeks prior, but I had yet to have a chance to dig into the approach. It turns out that PoolParty is a new Process Injection approach that “targets the user-mode thread pool,” which, coincidentally, includes the Worker Factory. The tool has eight variations that target various components. Still, the first variation is pretty straightforward and targets the Worker Factory itself, so that is where I will focus my discussion in this post.

PoolParty Variant 1 searches for a Worker Factory handle in the target process. It does this by calling the kernel32!OpenProcess function on the target process then passes the resulting Process handle to ntdll!NtQueryInformationProcess (interesting), specifically using the ProcessHandleInformation class. The ProcessHandleInformation class returns an array containing information about the target process’s handles. Next, it iterates through each handle, looking for a handle with the object type name TpWorkerFactory. To perform this search, PoolParty must create a copy of each returned handle using the kernel32!DuplicateHandle function before it validates the object type by calling the ntdll!NtQueryObject function specifying the ObjectTypeInformation information class.

Once PoolParty finds the handle, it calls the ntdll!NtQueryInformationWorkerFactory function using the WorkerFactoryBasicInformation information class. This call returns a WORKER_FACTORY_BASIC_INFORMATION structure shown below:

Notice that on line 1142, there is a field called StartRoutine. Alon describes the StartRoutine as “the entry point of the worker threads.” The idea behind PoolParty is to overwrite the StartRoutine code with malicious shellcode. Thanks to the NtQueryInformationWorkerFactory call, the attacker knows where the StartRoutine’s contents are.

Next, the attacker overwrites the StartRoutine with malicious shellcode using kernel32!WriteProcessMemory. The attack finishes with a call to ntdll!NtSetInformationWorkerFactory, specifying the WorkerFactoryThreadMinimum information class. Instead of waiting for a new work item for the Thread Pool to queue a new thread, PoolParty tells the WorkerFactory that the minimum number of threads should be one more than how many are currently running. Increasing the minimum number of threads forces adding a new thread to the pool, which executes the shellcode.

I’ve included the PoolParty Variant 1 tool graph below:

Tool Graph for PoolParty Variant 1 (WorkerFactoryStartRoutineOverwrite)

Notice that the sub-operation concept appears four times in this sample as it calls NtQueryInformationProcess, NtQueryObject, NtQueryInformationWorkerFactory, and NtSetInformationWorkerFactory. You’ll notice that I’ve included the specific information class on both the syscall and operation nodes for the relevant function call stacks. The information class helps to differentiate between similar but different or incompatible operation executions. For instance, PoolParty uses NtQueryInformationProcess to obtain a list of handles opened by the target process by specifying the ProcessHandleInformation information class. Any application that calls NtQueryInformationProcess but specifies one of the other 112 information classes would be irrelevant in the context of a PoolParty Variant 1 detection rule.

A Note on Write vs. Set

You may have noticed that I chose to use the word Set as the action for this operations class and wondered why I used Set instead of Write. After all, you are “writing” the token to the thread object. Is it just because the word Set is in the function name for NtSetInformationWorkerFactory? No, not quite. To understand why there needs to be a distinction between the Set and Write actions, we can reference the NtSetInformationFile function. This function “writes” metadata to a file. While the File Write operation “writes” the file’s contents, the File Set operation writes, or “modifies,” the file’s metadata.

An excellent example of file metadata would be the file’s timestamps. We all know that each file on NTFS has several timestamps associated with it. In forensics, we call these MACB times (Modified, Accessed, Changed, and Born). The NtSetInformationFile function can take an information class called FileBasicInformation that would allow the caller to modify these timestamps (for the NTFS forensic nerds out there, this is specific to the $STANDARD_INFORMATION timestamps). Instead, when it comes to writing content to the file, we use the WriteFile or WriteFileEx functions, which eventually make the NtWriteFile syscall.

SetEndOfFile (File Set) vs. WriteFile (File Write)

I found a Win32 function called kernel32!SetEndOfFile that demonstrates the difference between File Set and File Write. The image below shows SetEndOfFile queries (File Query) and sets (File Set) metadata attributes for a specified file. It first uses the File Query (FilePositionInformation) operation to identify the current file pointer’s byte offset (within the file). Then, it uses a File Set (FileEndOfFileInformation) operation to set the new byte offset for the end of the file. Finally, if the specified offset is outside the bounds of the allocated file, the allocation is changed to the specified size in bytes using another File Set (FileAllocationInformation) operation. Each of these steps is querying or setting file metadata details. Compare this to WriteFile, which makes the NtWriteFile syscall and changes the file’s actual data (contents). Hopefully, this demonstrates the difference between Set and Write (the same distinction applies to Query vs. Read).


This post focuses more on the semantics of the model we are building, but the distinction becomes quite important when we put the ideas into practice. The basis for this model is to demonstrate that we can describe attacker tradecraft using operation chains. The idea is that modern EDR sensors perceive operations because the subroutines responsible for generating telemetry have, by and large, been pushed down the stack (often to the kernel, as demonstrated by Jonathan Johnson in his TelemetrySource project). A problem arises, however, with a particular subset of functions where much of the functional distinction exists in the kernel. The juxtaposition between our treatment of the kernel as an opaque box and the existence of meaningful differences in the kernel means that the operation alone is too generic to be useful for detection engineering tasks. As we saw with the token impersonation example, it is not enough to observe a Thread Set operation because several information classes belong to this operation that are irrelevant from the point of view of token impersonation. In fact, every Thread Set operation that occurs that does not specify the ThreadImpersonationToken information class should be considered a false positive. One way to deal with this is to push the telemetry generation even further down into the kernel after a branching decision is made based on the information class (this is something we are seeing some EDR sensors do). However, this is more than just a technical problem; it is also a conceptual one, which I hope will be solved by introducing Sub-Operations.

On Detection: Tactical to Functional Series

Part 14: Sub-Operations was originally published in Posts By SpecterOps Team Members on Medium, where people are continuing the conversation by highlighting and responding to this story.