Aug 18 2022 |
On Detection: Tactical to Functional
Part 5: Expanding the Operation Graph
Welcome back to the On Detection: Tactical to Functional blog series. Previously we discussed operations and sequences of operations that I call operation paths. This article will explore the idea that there must be one operation path for any given technique or sub-technique, but there can be many. When there are many operation paths, these paths can be combined to form an operation graph representing the different sequences of operations that attackers can use to perform a technique or sub-technique. This post will explore this concept and look at real-world examples of attackers implementing alternative operation paths to evade certain detective or preventative controls. If we understand the operational options and the reasons why an attacker might prefer one path over another, we can make better predictions about the types of variations we can expect to see. I hope you enjoy this article, and as always, I would love feedback and/or any discussion about the ideas I have presented in this series.
Introduction
The second article in this series introduced the concept of Operations. Operations work as a container for teleologically equivalent functions. In other words, if two functions provide the same output (say, generating a handle to a process), they accomplish the same operation (Process Access in this case).
The article compared two tools, Mimikatz and Dumpert, to demonstrate how operations are useful. Mimikatz, specifically the sekurlsa::logonPasswords command, makes three function calls to accomplish the OS Credential Dumping technique. Those function calls are ntdll!NtQuerySystemInformation, kernel32!OpenProcess, and kernel32!ReadProcessMemory. This sequence of function calls is shown in Figure 1 below:
On the other hand, Dumpert accomplishes the same outcome, reading credentials from LSASS memory, while using three different function calls than Mimikatz. The function calls used by Dumpert are syscall!NtQuerySystemInformation, syscall!NtOpenProcess, and dbghelp!MiniDumpWriteDump. The sequence of function calls made by Dumpert is shown in Figure 2 below:
The article continued to demonstrate how to use abstract operations in place of specific functions to describe the functional combinations possible to accomplish OS Credential Dumping: LSASS Memory. The sequence of operations shown in Figure 3 applies to Mimikatz and Dumpert’s implementations.
Each operation acts as an abstraction layer for a function call graph. Therefore, a function call graph exists for the Process Enumerate operation, a function call graph exists for the Process Access operation, a function call graph exists for the Process Read operation, and many more function call graphs exist for operations we have not yet considered.
In the original post that introduced operations, we used our current understanding of the relevant function call graphs (Process Enumerate, Process Access, and Process Read) to calculate the number of functional combinations possible for this sequence of operations. We calculated that this single sequence of operations contained 192 possible variations of functions (4 Process Enumerate functions, 6 Process Access functions, and 8 Process Read functions) that could be combined to accomplish this task.
However, in the third article, we discussed integrating new information into existing function call graphs to expand our understanding of the territory of each operation. This integration increased the number of functions represented in the Process Enumerate function call graph from 4 to 21 functions. Using the function call graphs shown below in Figure 4 (Process Enumerate), Figure 5 (Process Access), and Figure 6 (Process Read), we can recalculate the number of functional variations contained within this single operational sequence as 1,008 (21 Process Enumerate, 6 Process Access, and 8 Process Read).
Through the power of abstraction, we can represent 1,008 unique variations (at the functional level) as one variation at a higher level of abstraction (the operational level).
As I work through different examples in this post, I will explain my interpretation of why the authors made certain “tradecraft” decisions — starting with Dumpert as an example.
It is worth reiterating that one of the primary reasons why the team at Outflank decided to use different function calls, specifically replacing kernel32!OpenProcess with syscall!NtOpenProcess because they noticed that many EDR vendors focused on detecting the Process Access operation of this attack. However, they also saw that many sensors only monitored the more superficial layers, OpenProcess the Win32 API Function instead of NtOpenProcess the syscall, even though these functions are functionally equivalent.
In the most recent post in this series, we introduced the concept of compound functions. Unlike simple functions, like kernel32!OpenProcess or kernel32!ReadProcessMemory, which executes one and only one operation; compound functions are individual functions that perform multiple operations. In the case of kernel32!Toolhelp32ReadProcessMemory, which we explored, performs Process Access and Process Read. Since it executes two of the three operations necessary to perform OS Credential Dumping: LSASS Memory, we can imagine a NEW pseudo operational path consisting of Process Enumerate -> Toolhelp32ReadProcessMemory as shown below in Figure 7.
Twenty-one additional variations are possible as a result of the Process Enumerate (21) -> [Process Access + Process Read] (1) pseudo-operational path. The addition of this pseudo-operational path brings the count to 1,029 possible functional variations.
It is essential to realize that because Toolhelp32ReadProcessMemory contains two operations, it is only a valid option for any operational sequence that includes this subsequence (Process Access -> Process Read). So although the function call graphs for both the Process Access AND Process Read operations include the Toolhelp32ReadProcessMemory function, it is not a valid choice for all operational paths that use one of the other. For instance, there may be future operational paths that we discover that have a subsequence of Process Access -> Process Write (something like Process Injection, maybe). Therefore, Toolhelp32ReadProcessMemory would not be a valid option for this operational path.
What if a technique or sub-technique (like OS Credential Dumping: LSASS Memory) contained not one but potentially many valid operational paths? We have seen with Dumpert that attackers can change their choices at the functional level of resolution to evade detection, but what if they could change their tradecraft at the operational level as well? This blog post explores some examples of operational variations. It explains how we can integrate that into our map and what that means for us as we strive to increase our understanding of this particular technique and tradecraft.
Alternative Operational Paths
In Part 2, we discussed how attackers, like the team from Outflank, could make functional changes to evade detection, but I had never considered that changes were possible at the operational layer. Considering that the concept of the operational layer did not exist at the time, who could blame me? I did, however, know of a blog post that presented a slightly modified way to dump credentials that I knew I had to integrate into the model. Still, I was having a tough time with it until it dawned on me that maybe this approach was presenting an alternate operational path. This next section will introduce that approach from James Forshaw and a couple of other alternatives I have discovered in the aftermath of figuring this out.
James Forshaw’s Handle Duplication Method
As mentioned in the previous paragraph, it felt terrific to identify the 1,029 functional variations of OS Credential Dumping: LSASS Memory. Still, I knew that James Forshaw had written a blog post (shared below) about an approach that allowed him to bypass the SACL that Microsoft added to LSASS in Windows 10.
Bypassing SACL Auditing on LSASS
The SACL specifically targeted the Process Access operation. Still, James found that it only reported instances where the PROCESS_VM_READ access right was included in the request (he has much more detail in his post, so please check it out). As a result, James found that he could open a handle to LSASS (Process Access). However, he could open it with only the PROCESS_DUP_HANDLE access right, which would not trigger the SACL. James could then use a trick with NtDuplicateObject where an application can derive a full access handle to the process, which, in this case, is LSASS. Then he could read from this new “duplicate” handle without triggering the SACL. The series of functions James used in his POC is below in Figure 8:
Note: James’ example in the blog post did not specify which function he used to enumerate the process identifier of LSASS or to read from the resulting handle, so I took a few liberties in creating Figure 8. They should be representative of functions he could or likely would have used.
This sequence of function calls presented a dilemma. How could there be concordance between a sequence of four functions and three operations? At first, I chose to ignore it, but it kept bothering me. Eventually, I realized that this was an alternative operation path. James’ approach followed the Process Enumerate -> Process Access -> Handle Copy -> Process Read operation path. Notice that this is VERY similar to the Process Enumerate -> Process Access -> Process Read operation path, but with a Handle Copy operation inserted to bypass the SACL. This small change could be the difference between visibility and invisibility. This approach evades any detection strategy that relies on the LSASS SACL. Figure 9 shows the operation path that James’ method introduced.
Bill Demirkapi’s Process Forking
After realizing that multiple operation paths are possible, we should attempt to find as many alternative operation paths as possible. One interesting article by Bill Demirkapi describes a different evasion approach. In this case, Bill was worried about a subset of anti-virus products that would filter access rights from process handle requests, especially to sensitive processes like LSASS. He mentioned that 9 of the 13 possible access rights are often filtered and therefore unreliable when facing these products. This constraint set him off on a journey to discover whether any of the remaining four access rights could be helpful towards achieving some end, such as dumping credentials from the LSASS process. A link to his blog post describing his approach is below:
Abusing Windows' Implementation of Fork() for Stealthy Memory Operations
Bill found that most products would leave the PROCESS_CREATE_PROCESS access right unfiltered. This access right allows the caller to create a process using the resulting handle. This access right might sound familiar to any developers that have used parent process spoofing, as Bill details in his post. Bill ultimately found that this access right allows for the creation of a child process called a fork. Specifically, when a process is forked, the fork inherits handles to private memory regions of the forked (parent) process, LSASS.
Forking the LSASS process allows Bill to, among other exciting techniques, dump credentials from LSASS. The cool thing is that even if the target has anti-virus or EDR products that filter handle requests to sensitive processes if they allow the handle to have the PROCESS_CREATE_PROCESS access right, the attacker can create a fork of LSASS and ultimately read the credentials via the fork process without ever getting a “read handle” to LSASS.
This approach presents a third operation path. This path includes the following operation sequence of Process Enumerate -> Process Access -> Process Create -> Process Read, which is shown below in Figure 11:
Matteo Malvica’s LSASS Snapshotting
The third and final alternate operation path to explore in this post was discovered and written about by Matteo Malvica and
b4rtik. In his blog post, shared below, Matteo mentions that he and a colleague were having issues dumping credentials from LSASS without triggering a built-in Windows Defender Advanced Threat Protection alert. Even tools like Dumpert did not seem to bypass the alert, so they were stumped for a solution. Interestingly, it appeared that this alert was targeting the Process Read operation instead of the Process Access operation that many other products target (there is some good discussion that we will save for a future article about which operation we should use as a foundation for detective or preventative controls). So what did they do?
Evading WinDefender ATP credential-theft: a hit after a hit-and-miss start
They eventually discovered a function called PssCaptureSnapShot, which allowed them to create a snapshot of the process and then read from the snapshot instead of the process itself. This way, detective controls focused on the Process Read of LSASS miss the behavior because this approach performs the Process Read operation on the snapshot of LSASS instead of the LSASS process. To do this, the sample code project ATPMiniDump calls ntdll!ZwQuerySystemInformation, ntdll!ZwOpenProcess, kernel32!PssCaptureSnapshot, and dbghelp!MiniDumpWriteDump. This sequence of function calls is shown in Figure 12 below:
We can then abstract these functions into operations where the new operation path is Process Enumerate -> Process Access -> Snapshot Create -> Process Read. ATPMiniDump demonstrates the fourth and final operation path we will introduce in this blog post, shown in Figure 13.
The Operation Graph
We now have four known operation paths developers can use to perform the OS Credential Dumping: LSASS Memory sub-technique. It is crucial that we do not assume that these four paths represent ALL possible operation paths. Instead, it represents known operation paths. That said, we can now combine the four individual operation paths to form an operation graph, shown in Figure 14:
If one operation path, Process Enumerate -> Process Access -> Process Read, had 1,029 functional variations, imagine how many functional variations are represented by the four operation paths in this graph. Remember that each operation is an abstraction of a function call graph. To perform this calculation, we will first need to create the function call graphs for the new operations (Handle Copy, Process Create, and Snapshot Create), shown below in Figure 15, Figure 16, and Figure 17, respectively.
Note: Notice the addition of a “partial” label to the Snapshot Create function call graph. That is because PssCreateSnapshot is a compound function that will require further analysis; however, this analysis could distract from the point of this article (the existence of multiple operation paths which can be combined to form an operation graph), so the article treats it as a simple function.
Using these new function call graphs, we can now calculate the number of functional variations for each operation path in our operation graph. To demonstrate this, we will use shorthand abbreviations for operation names. Below is a list of operations included in our graph and their associated abbreviation:
- Handle Copy (Figure 15): HC has six (6) functional variations
- Process Access (Figure 5): PA has six (6) functional variations
- Process Create (Figure 16) : PC has twenty-eight (28) functional variations
- Process Enumerate (Figure 4): PE has twenty-one (21) function variations
- Process Read (Figure 6): PR has eight (8) functional variations
- Snapshot Create (Figure 17): SsC has four (4) functional variations
We can then calculate the number of functional variations represented by each operational path.
- Direct Memory Access PE (21) x PA (6) x PR (8) = 1,008
- Toolhelp32ReadProcessMemory PE (21) x [PA -> PR] (1) = 21
- Duplicate Token PE (21) x PA (6) x HC (6) x PR (8) = 6,048
- Process Forking PE (21) x PA (6) x PC (28) x PR (8) = 28,224
- Process Snapshotting PE (21) x PA (6) x SsC (4) x PR (8) = 4,032
By adding the total number of variations for each operation path, we can find 39,333 total functional variations across the four operational variations for the OS Credential Dumping: LSASS Memory sub-technique.
Note: This is, of course, based on our current knowledge shown in the operational graph and function call graphs shared in this article. We should assume that this is merely a subset of the total, but at least we have something tangible to start.
Conclusion
This article expanded on the “operations” idea presented in Part 2. The starting hypothesis was that each technique or sub-technique has one and only one sequence of operations. In Part 2, we saw that the sequence used by Mimikatz sekurlsa::logonPasswords was PE -> PA -> PR. Still, in this post, we explored three alternative approaches/tools that did not fit into our preconceived notion of there being only one valid operational path. Specifically, we found that James Forshaw was able to insert an additional operation into the sequence to evade a specific detection approach. We repeated the process for two additional examples.
The key takeaway is that at the Operational level of analysis, a single technique or sub-technique can have many valid operation paths, which we can graph to form an operation graph. We then know that each operation is an abstraction of an underlying function call graph which serves as our map of the different functional choices to accomplish the operation. We postulate that we can calculate the number of functional variations for a given operation path by multiplying the number of entry points in each function call graph. For example, Mimikatz and Dumpert follow the Direct Memory Access operation path, which uses the Process Enumerate, Process Access, and Process Read operations. If Process Enumerate has 21 function entry points, Process Access has six, and Process Read has 8, then the total number of functional variations for this operation path is 21x6x8 or 1,008. We can sum the number of functional variations from each operation path to calculate the total number of functional variations for the sub-technique. The exciting thing is that based on this calculation, there are 39,333 total variations of the OS Credential Dumping: LSASS Memory sub-technique at the functional level. However, these variations can be represented abstractly as only four unique variations at the operational level.
We will close with this observation. When we talk about an instance of an attack, maybe we are analyzing a breach report; we can think about it at the tactic level, “ we observed that the attacker used the Credential Access tactic.” We can think about it at the technique level, “we observed that the attacker used the OS Credential Dumping technique.” We can think about it at the sub-technique level, “we observed that the attacker used the LSASS Memory sub-technique.” We can think about it at the operational level, “we observed that the attacker used Direct Memory Access (PE -> PA -> PR).” Alternatively, we can think about it at the functional level, “we observed that the attacker called syscall!NtQuerySystemInformation, syscall!NtOpenProcess, and dbghelp!MiniDumpWriteDump”. The question is, “which level of analysis is the MOST appropriate level for the task at hand?” Let me know what you think in the comments or on Twitter.
On Detection: Tactical to Functional Series
- Understanding the Function Call Graph
- Part 1: Discovering API Function Usage through Source Code Review
- Part 2: Operations
- Part 3: Expanding the Function Call Graph
- Part 4: Compound Functions
On Detection: Tactical to Functional was originally published in Posts By SpecterOps Team Members on Medium, where people are continuing the conversation by highlighting and responding to this story.