Aug 9 2022 |
On Detection: Tactical to Functional
Part 3: Expanding the Function Call Graph
Introduction
In the previous post in this series, I introduced the concept of operations and demonstrated how each operation has a function call graph that undergirds it. In that post, I purposely presented incomplete, relative to my knowledge, function call graphs because I wanted only to show the extent that was obviously based on what we’ve observed via mimikatz (in the first post in this series). Another benefit of limiting the function call graphs to what we’ve actively discovered as part of this series is that we can show that a partial picture, when formally documented, is still useful even when we know it is incomplete. Below is the function call graph for the Process Enumerate operation, which will serve as the basis for this article:
This graph is relatively sparse. During our analysis of mimikatz, we saw that it called NtQuerySystemInformation to enumerate a list of processes and ultimately find the process identifier (pid) for the LSASS process. We then analyzed the function call stack to identify the syscall and the alternative Native API function names. Generally speaking, we’ve observed that Native API functions are not the layer that most application developers are expected to interact with, so a reasonable question would be, “are there any higher level functions that might ultimately call NtQuerySystemInformation or similar functions?” One researcher did just this. @modexpblog from MDSec wrote an excellent blog post exploring 14 alternative options for identifying the process identifier of the LSASS process. This is EXACTLY what we are concerned about! The first option is to call NtQuerySystemInformation, which we have already covered, but the second method offers a different approach that is worth investigating.
body[data-twttr-rendered=”true”] {background-color: transparent;}.twitter-tweet {margin: auto !important;}
function notifyResize(height) {height = height ? height : document.documentElement.offsetHeight; var resized = false; if (window.donkey && donkey.resize) {donkey.resize(height);resized = true;}if (parent && parent._resizeIframe) {var obj = {iframe: window.frameElement, height: height}; parent._resizeIframe(obj); resized = true;}if (window.location && window.location.hash === “#amp=1” && window.parent && window.parent.postMessage) {window.parent.postMessage({sentinel: “amp”, type: “embed-size”, height: height}, “*”);}if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.resize) {window.webkit.messageHandlers.resize.postMessage(height); resized = true;}return resized;}twttr.events.bind(‘rendered’, function (event) {notifyResize();}); twttr.events.bind(‘resize’, function (event) {notifyResize();});if (parent && parent._resizeIframe) {var maxWidth = parseInt(window.frameElement.getAttribute(“width”)); if ( 500 < maxWidth) {window.frameElement.setAttribute("width", "500");}}
The second method, described in the blog post, focuses on the Windows Terminal Service (WTS). It describes a Windows API function called WTSEnumerateProcesses which can be used to list processes and which then can result in finding the LSASS pid.
A cool feature of this blog post is that they include sample source code for each method, so we can also see how this function is used to get the LSASS pid.
Before we can start our analysis, we must determine which library implements WTSEnumerateProcessesA. To do this, we can browse the Microsoft documentation for the WTSEnumerateProcessesA function.
In the Requirements section, we find that the name of the implementing DLL is wtsapi32.dll.
wtsapi32.dll
Now that we know the wtsapi32.dll library implements the WTSEnumerateProcessesA function, we can open it in our disassembler. When investigating API Functions, my first step is checking the exports table. Especially in cases where the function in question ends in A or W, I like to search the export table more generically because there are probably alternative versions of the function. To do this, I searched for the word “process” and found that there are four WTSEnumerateProcesses* functions (WTSEnumerateProcessesA, WTSEnumerateProcessesExA, WTSEnumerateProcessesExW, and WTSEnumerateProcessesW).
Since this article is part of my On Detection series, we will create a graph to represent everything we learn due to this analysis visually. We know there are four independent functions at this point, but we don’t know much else. The image below reflects this information:
WTSEnumerateProcessesA
While designing this article, I struggled with the sequencing for analyzing the four functions. There were generally two options, analyze all four functions simultaneously or evaluate each function in sequence. I decided it is easier to follow if I follow the execution of a single function and then reanalyze the rest of them. As we encounter new ideas, I will explain them, and then, if we meet them later, I will refer you back to the section that covered that information while providing the output of the analysis.
The first function that we encounter is WTSEnumerateProcessesA. We can double-click on the function name and view its implementation. This function is pretty simple in that it calls one of the other functions we are interested in, which is WTSEnumerateProcessesW.
Our updated function call graph now shows WTSEnumerateProcessesA calls WTSEnumerateProcessesW.
WTSEnumerateProcessesW
Continuing our analysis, we can dive into the implementation of WTSEnumerateProcessesW. Analyzing this function reveals two possible calls. The first to an imported function called WinStationGetAllProcesses and the second to an imported function called WinStationEnumerateProcesses. A glance at the flow of function calls indicates that the WTSEnumerateProcessesW only calls WinStatonEnumerateProcesses if the call to WinStationGetAllProcesses fails somehow. This flow is shown by the call to GetLastError, specifically checking the error code 0x6D1.
In the previous image, we see that the calls to WinStationGetAllProcesses and WinStationEnumerateProcesses are prefixed with __imp_, which indicates that these are imported functions or said differently. These functions are imported from an external library because they do not exist in winsta32.dll. To determine which binary implements these functions, we can search for them in the Import table. The image below shows the Import table entries for these two functions and shows that they are both implemented in winsta.dll.
We can update our function call graph to indicate that WTSEnumerateProcessesW can call either WinStationEnumerateProcesses or WinStationGetAllProcesses.
winsta.dll
Now we can load winsta.dll into our disassembler, and we can again view the Export table to find the reference to our functions. Again, we can use a genericized search term to make sure that if alternatives to these functions exist, we’d see them. In this case, there don’t seem to be any valid alternatives.
winsta!WinStationGetAllProcesses
We can now jump into the code implementation of WinStationGetAllProcesses, and we immediately see a call to an internal function called GetSystemProcessInformation@CProcessUtils.
We can follow that call, and we see that GetSystemProcessInformation@CProcessUtils ultimately calls the NtQuerySystemInformation Native API function.
We’ve reached an inflection point in our analysis because we’ve discovered that the WTSEnumerateProcess* graph converges with our existing NtQuerySystemInformation graph. I’ve updated the graph to show these new functions and connect the starting and newly created graphs.
winsta!Legacy_WinStationGetAllProcesses
If we continue our analysis of WinStationGetAllProcesses, it appears that in certain situations, the code can call a function called Legacy_WinStationGetAllProcesses which we can also analyze.
Our analysis of the Legacy_WinStationGetAllProcesse function reveals a call to the NdrClientCall3 function, which handles RPC Procedure calls. As a result, we must analyze the parameters being passed into NdrClientCall3 to understand precisely which RPC Procedure is being called.
The first step is to identify the RPC Interface, which is passed in as a field within the first parameter (labeled as pProxyInfo) shown below:
The values labeled as Data1, Data2, Data3, and Data4 in the picture above can be parsed using PowerShell into the string form a Globally Unique Identifier (GUID), which represents the RPC Interface that NdrClientCall3 is calling.
We can search for the GUID string, 5ca4a760-ebb1–11cf-8611–00a0245420ed, using Google. This search will result in the discovery of the Microsoft documentation for the Terminal Services Terminal Server Runtime Interface Protocol, otherwise referred to as [MS-TSTS].
Section 1.9 Standards Assignment finds that the GUID is associated with the Legacy RPC Interface. This aligns with the name of the calling function, Legacy_WinStationGetAllProcesses.
The next bit of important information is the RPC Procedure Number (Opnum or Procnum) which is indicated by the second parameter passed to NdrClientCall3 labeled as pProcNum. We can see that the value being passed is 70.
Referring to the RPC Interface documentation, we see that Opnum 70 refers to a procedure called RpcWinStationGetAllProcesses_NT6 which seems to align well with what we know about the calling function.
We can now update the function call graph to include the alternative call to Legacy_WinStationGetAllProcesses which then makes an RPC call to an RPC Procedure called RpcWinStationGetAllProcesses_NT6.
termsrv!RpcWinStationGetAllProesses_NT6
As you might have guessed, the RPC Procedure call is not the end of the line of execution. In fact, to this point, nothing has happened in the context of actions taking place to change or enumerate the system. To continue following the execution path, we must investigate the code associated with the RpcWinStationGetAllProcesses_NT6 RPC Procedure, but because it isn’t an imported function like we saw previously, we have to use a different approach. RPC is a client/server interface where a client, in this case, Legacy_WinStationGetAllProcesses, makes a call to the server, which executes the code associated with the procedure. This means that there is likely a binary on the system that implements the RPC Interface and functions as the server, so we need to find it.
To find the server, we can use a function from James Forshaw’s NtObjectManager called Get-RpcServer, which parses binary files passed to it to determine if that binary has an RPC server implemented in its code. A brute force strategy is to list all DLL files in system32 and pass them all via the PowerShell pipeline into Get-RpcServer. We can filter the results by looking for the Interface GUID we identified from the NdrClientCall3 parameters. This process works, and as a result, we see that termsrv.dll implements the Legacy Interface of the [MS-TSTS] protocol.
Now that we know that termsrv.dll implements the RPC server, we can load it into our disassembler and find the relevant code. RpcWinStationGetAllProcesses_NT6 is an RPC Procedure, NOT an exported function, so instead of looking for it in the Exports table, we must find it in the general functions menu.
After navigating to the function’s implementation, the first relevant call is to an internal function called GetSessionProcessInformation@CProcessUtils.
The GetSessionProcessInformation@CProcessUtils function ends up calling the NtQuerySystemInformation Native API function. NtQuerySystemInformation already exists in our function call graph, so we’ve reached this code path’s end.
Below is the updated function call graph, which now enumerates the Legacy_WinStationGetAllProcesses function path and the resulting RpcWinStationGetAllProcesses_NT6 RPC Procedure call.
winsta!Legacy_WinStationGetAllProcesses
We can return to the Legacy_WinStationGetAllProcesses function in winsta.dll and continue following the code. We immediately see that a second RPC Procedure will be called if the call to RpcWinStationGetAllProcesses_NT6 fails with a 0x6D1 error code. We can see that the first argument to this second call points to the same structure (stru_18002E308) passed to the first NdrClientCall3. This means the same RPC Interface ([MS-TSTS] Legacy) is being called. However, we can see that the second parameter, which contains the RPC Procedure Number, is different. This second call refers to ProcNum 43.
By referring back to the RPC Protocol documentation, we find that Opnum 43 is called RpcWinStationGetAllProcesses.
termsrv!RpcWinStationGetAllProcesses
We already know that termsrv.dll implements the RPC Server, so we can search the termsrv.dll function table to find the function called RpcWinStationGetAllProcesses. When we find it, we can navigate to its code implementation. Once there, we find that RpcWinStationGetAllProcesses calls RpcWinStationGetAllProcesses_NT6, which we’ve already included in our graph.
We’ve updated our function call graph to include the RpcWinStationGetAllProcesses RPC Procedure.
winsta!Legacy_WinStationEnumerateProcesses
The WinStationGetAllProcesses call was analyzed in the previous few sections of this article. However, if the call to WinStationGetAllProcesses fails in a certain way (cmp eax, 6D1h) a second call to WinStationEnumerateProcesses will be made. Let’s take a look at this function’s implementation.
Jumping back into winsta.dll, we can open the WinStationEnumerateProcesses function. Ultimately WinStationEnumerateProcesses calls a function called Legacy_WinStationEnumerateProcesses. Let’s take a look at its implementation.
The Legacy_WinStationEnumerateProcesses function makes an RPC call. In this case, we can see that the first parameter (pProxyInfo) is pointing to the same structure we saw previously, so we know this part of the [MS-TSTS] LegacyApi. The only difference is that the nProcNum parameter is set to 36.
The documentation for [MS-TSTS] LegacyApi says that Opnum 36 is associated with the RpcWinStationEnumerateProcesses procedure, which seems to align well with the name of the calling function.
We can now update the graph to include the RPC call that is made by the WinStationEnumerateProcesses function, as shown below:
termsrv!RpcWinStationEnumerateProcesses
Remember that termsrv.dll is acting as the RPC Server for the LegacyApi Protocol, so there should be an internal function called RpcWinStationEnumerateProcesses to analyze. Upon first glance at the code, there aren’t any substantive call instructions. We see a call to RpcCallTrace and a second call to DbgPrintMessage, but based on the names of those functions, it appears they are simply helpers. Further analysis identified a Load Effective Address (lea) instruction directly after the RpcCallTrace call, and it loads a string into the RDX register. We can see that according to the command, this string says “!!!RpcWinStationEnumerateProcesses depr “… where “depr” stands for “deprecated.” This means that while this functional path appears to exist, it isn’t expected to be reached or do any process enumeration, at least on the version of the Operating System that we are using for this analysis.
While this function path is non-viable, on this version of the OS, I still think it is essential to document it as part of the function call graph. However, because these functions don’t seem to be a possible entry point into the function call graph to perform the Process Enumerate operation, I’ve changed the nodes on this path to black instead of red. This change indicates that these nodes are not valid entry points on modern operating systems.
OldRpcWinStationEnumerateProcesses
Even though we’ve seen three different RPC Procedures in our code that are relevant to this operation, we shouldn’t assume those are the only three RPC Procedures. One strategy I use is to figure out the naming convention used by the specific RPC Interface’s Procedures. For instance, in the case of the Legacy Interface, we see that all of the procedures follow the RpcWinStation* naming convention. We can use this convention to search for relevant functions we might have missed. In doing this, I discovered one additional function called OldRpcWinStationEnumerateProcesses. Let’s check it out.
We can verify that this is actually an RPC Procedure by looking for it in the docs; sure enough, we see that it is there.
We can then check the code implementation of OldRpcWinStationEnumerateProcesses, and we see that it simply calls RpcWinStationEnumerateProcesses, which we’ve already analyzed.
We can now add this RPC Procedure to our function call graph, but because it results in a deprecated call, we can give the node a black border since it isn’t a valid entry point into the graph on modern systems.
WTSEnumerateProcessesExA
At this point, we can go back and analyze WTSEnumerateProcessesExA. After navigating to the WTSEnumerateProcessesExA function’s code implementation, we see a call to WTSEnumerateProcessesExW, another API function on the list to explore.
We’ve updated the function call graph to indicate that WTSEnumerateProcessesExA calls WTSEnumerateProcessesExW. Next, we will investigate the inner workings of WTSEnumerateProcessesExW.
WTSEnumerateProcessesExW
The last Windows API function to analyze is WTSEnumerateProcessesExW. Once we navigate to the function’s code implementation, we see that it calls WinStationGetAllProcesses, an undocumented function we previously investigated.
We can now complete the function call graph relative to these four new WTSEnumerateProcesses functions.
API Set
Before we finish, we should talk about API Sets. You may have noticed at the very beginning, when we analyzed the Microsoft API documentation for WTSEnumerateProcessesA that in the Requirements section, there was a reference to an API Set called ext-ms-win-session-wtsapi32-l1–1–0. Using this information, we can derive four additional functions that can serve as entry points into our function call graph. This means that developers can refer to the API Set version of the WTSEnumerateProcesses* functions like ext-ms-win-session-wtsapi32-l1–1–0!WTSEnumerateProcessesA.
We can update the function graph to include the four API Set versions of the WTSEnumerateProcesses* functions as shown below:
Other Functions
While this article focuses on analyzing the WTSEnumerateProcesses* functions, attackers can use a few other functions to enumerate processes and, specifically, process identifiers. The graph shown below has added these additional functions for the sake of completeness. That being said, it is essential to emphasize that we should always work under the assumption that our function call graphs remain incomplete. They serve to document and represent our current understanding of the territory, but it would be foolish to assume that our knowledge is, in fact, complete.
Conclusion
We’re all probably familiar with the idea of known knowns, known unknowns, unknown knowns, and unknown unknowns. Donald Rumsfeld famously defined known knowns as things WE know that WE know. This is interesting because “WE” is a relative concept. The function call graph that I started this blog post with was relative to MY knowledge (WE being defined in this case as one person) of the different ways in which someone could enumerate processes. The cool thing is that the concept of “WE” can be expanded to include your friend group, your company, or even the entire industry. This blog post demonstrates how we can consume the knowledge of others to, at least conceptually, expand the scope of WE, allowing us to have a map that better represents the reality of the environment.
On Detection: Tactical to Functional Series
- Understanding the Function Call Graph
- Part 1: Discovering API Function Usage through Source Code Review
- Part 2: Operations
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.