Every Windows process has a command line — a string of characters that is supplied to the process when it first starts, and can be interpreted by the process to govern its behaviour.
It might be assumed that, once a process is started, the command line provides an immutable record of how the process was invoked. When we list the running processes in a system with their command lines, we expect that this reflects how the process was started. If we log process creations to the Windows Event Log, or to an EDR product, by using the recorded command line, we expect to be able to make inferences about what actions the process might have actually performed.
However, there is more to the story. One might naively assume that the command line for each process is stored within the kernel, but this is not the case. The process command line is actually stored within the user space of each process itself. Indeed, to determine the command line of any given process, an application such as Task Manager or Process Explorer must open a handle to that process, and read the command line directly from the memory of that process.
Because the command line is not protected in any meaningful way, it is possible for a process to both modify its own command line, or to modify the command lines of other processes.
How might this be of use to an adversary? EDR evasion is one such use. If the adversary creates a process with a benign-looking command line, and then quickly substitutes the command line with a malicious one, then from the EDR perspective, the process will appear innocent.
Depending on how the system is configured, there may be other uses. Certain security products may target or exclude certain behaviours based on command lines. In a subsequent post, we will explore a bypass for a Microsoft Defender Attack Surface Reduction rule that can be achieved by modifying a process command line.
In this post, we will present a general purpose tool that we have written that will launch a certain process, and then modify its command line such that it will behave in a different manner. Getting this to work generically has its difficulties, and we will describe what those difficulties are and how we managed to overcome them.
Command Line Basics
The process command line for any given process can be found in a memory structure which is reachable from a structure known as the Process Environment Block (or PEB).
To acquire the Process Environment Block for any given process, it is necessary to call the NtQueryInformationProcess function, with a Process Information Class of ProcessBasicInformation. This returns a structure of type PEB (scroll to the bottom of that page for details on the 64-bit version). The PEB stucture contains a pointer named ProcessParameters, which points to a structure of type RTL_USER_PROCESS_PARAMETERS. The ProcessParameters structure in turn contains a structure named CommandLine, which is of type UNICODE_STRING. The UNICODE_STRING structure is commonly used throughout the Windows kernel, and contains fields representing:
- a pointer to the buffer that contains the string
- the current length of the string
- the maximum length that the aforementioned buffer can currently accommodate.
As should be clear from the naming above, the command line itself is stored in the process data using Unicode (UTF-16) characters. However, as per the specification of UNICODE_STRING, the two “length” fields are specified in bytes.
A First Attempt
On our first attempt, we will assume that our new command line is equal to or less than in length than the original command line. This means that we will not need to allocate any new memory in the address space of the target process, but can reuse what is already allocated.
Launching the Process
To launch a process, we use the CreateProcess() Windows API call, as shown below:
HANDLE hThread, hProcess;
PROCESS_INFORMATION pi;
STARTUPINFO si;
ZeroMemory(&pi, sizeof(pi));
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
CreateProcess(nullptr, szOriginalCmdLine, nullptr, nullptr, FALSE, CREATE_SUSPENDED, nullptr, nullptr, &si, &pi);
hThread = pi.hThread;
hProc = pi.hProcess;
Note that we use the CREATE_SUSPENDED flag to ensure that the main process thread does not start immediately. CreateProcess() conveniently returns handles to the newly created process and to its main thread. As we will require both of these later, we save those in the hProc and hThread variables.
Reading the Process Environment Block
In order to locate the command line within the process memory, we must navigate through a set of structures beginning with the Process Environment Block. This is a relatively well-known technique, although not officially documented by Microsoft.
SIZE_T sLen;
PROCESS_BASIC_INFORMATION pInfo;
PEB64 peb;
RTL_USER_PROCESS_PARAMETERS64 params;
NtQueryInformationProcess(hProc, ProcessBasicInformation, &pInfo, sizeof(PROCESS_BASIC_INFORMATION), (ULONG*)&sLen);
ReadProcessMemory(hProc, pInfo.PebBaseAddress, &peb, sizeof(PEB64), &sLen);
ReadProcessMemory(hProc, (LPCVOID)peb.ProcessParameters, ¶ms, sizeof(RTL_USER_PROCESS_PARAMETERS64), &sLen);
As we will need the PEB and the Process Parameters later, we will pass those back to the caller.
Modifying the Command Line
Because we have assumed that the new command line is no longer than the original one, we can copy the new command line directly into the buffer that has already been initialised with the original one. We ensure that the end of the buffer contains a terminating ‘\0’ character.
SIZE_T nNewCmdLineBytes = min(wcslen(szNewCommandLine)*sizeof(WCHAR), pParams->CommandLine.u.MaximumLength - 2);
WriteProcessMemory(hProc, (LPVOID)params.CommandLine.Buffer, szNewCmdLine, nNewCmdLineBytes, &sLen);
WriteProcessMemory(hProc, (LPVOID)(pParams->CommandLine.Buffer + nNewCmdLineBytes), L"", sizeof(WCHAR), &sLen);
Running the Process
Finally, having set up the command line we want, we must allow the process to begin executing. We do this using the ResumeThread() call.
ResumeThread(hThread);
And that’s more or less it – for our first pass, at least. We can compile the code, execute it and verify that it does the job. Recalling our assumption that the new command line must be no longer than the original one, this may at first seem constraining. However, even that is not too bad, because we could always pad our “original” command line with as many spaces as would be required to accommodate the new command line. This may, of course appear suspicious under closer scrutiny, so it would still be nice to be able to inject a command line of arbitrary length.
Doing this will prove more difficult than one might expect, so we will devote Part II of this series to that topic.