0xd00's blog

Back

从 x64dbg 调试到红队免杀技术Blur image

Windows 架构#

现代操作系统设计的核心基石之一,便是对处理器特权级别的划分。在Windows中,这体现为两种截然不同的工作模式:用户模式 (User Mode)内核模式 (Kernel Mode)

  • 用户模式 (Ring 3):应用程序的运行环境。无论是浏览器、代码编辑器还是游戏,它们都受到严格的权限限制,无法直接访问物理硬件或干涉其他进程的核心数据,从而保证了系统的整体稳定性。
  • 内核模式 (Ring 0):操作系统的核心领域。它拥有CPU的最高特权级别,能够执行所有指令,直接管理CPU、内存、I/O设备等硬件资源,是整个系统安全与资源调度的仲裁者。

经典模型#

这种隔离设计是现代操作系统的基石。应用程序无法独立完成任何有意义的功能(如读写文件、创建网络连接、创建销毁进程),因为这些操作都涉及到底层硬件资源。应用程序必须“请求”内核来代为完成。整体调用链条如下:

用户进程 (Application) → 子系统DLL (Subsystem DLL) → Ntdll.dll (Native API) → [模式切换] → 内核 (Kernel)

这就像一家餐厅的运作流程:

  1. 你 (用户进程):想吃一份牛排。

  2. 服务员 (子系统DLL):过来为你点单,用餐厅的术语记下“T-Bone, medium rare”。

  3. 传菜员 (Ntdll.dll):将标准化的菜单指令传递给厨房。

  4. 厨房大门 (模式切换):这是普通人无法进入的地方。

  5. 厨师 (内核):在厨房里,用火、锅、食材等真正地做出牛排。

现代模型#

需要注意的是,从win7开始,微软为了实现一种叫做 “API Sets” 的技术(也为了后来的UWP应用架构),微软对Win32 API层进行了一次重构,将 kernel32.dll 中的大部分实现代码移动到了一个新的DLL中,那就是 KernelBase.dll。现在的 kernel32.dll 变成了一个**“转发层” (Forwarding Layer)**。它里面几乎没有真正的代码,只有一些“跳板”指令(JMP),把API调用直接转发给 KernelBase.dll 中的同名函数去执行。调用链条如下:

用户进程 (Application) → kernel32.dll (转发) → KernelBase.dll (实现) → ntdll.dll (原生API) → [模式切换] → 内核 (Kernel)

新建 Low-Level 二进制文件 - Win32 apps | Microsoft Learn

Windows API 集 - Win32 apps | Microsoft Learn

函数调用流程#

本文以CreateThread为例展示函数的调用流程。

#include <stdio.h>
#include <Windows.h>
DWORD WINAPI MyThreadFunction(LPVOID lpParam)
{
    // 1. 将 LPVOID 类型的参数强制转换回它本来的类型。
    //    我们知道我们传递的是一个字符串 (const char*)。
    const char* message = (const char*)lpParam;

    // 2. 在包装函数内部,安全地调用任何你需要的函数。
    printf_s("Message from the new thread: %s\n", message);

    // 3. 任务完成,返回一个 DWORD 值作为线程退出码。
    return 0;
}

int main()
{
    // 准备要传递给线程的参数
    const char* threadMessage = "Hello from main()!";

    // 创建线程
    HANDLE hThread = CreateThread(
        NULL,                   // 默认安全属性
        0,                      // 默认栈大小
        MyThreadFunction,       // * 传递我们自己包装函数的地址
        (LPVOID)threadMessage,  // * 将我们的消息作为 LPVOID 类型的参数传递
        0,                      // 默认创建标志
        NULL);                  // 不需要返回线程ID

    if (hThread == NULL)
    {
        printf_s("Failed to create thread. Error code: %d\n", GetLastError());
        return 1;
    }

    // 等待子线程执行完毕,否则 main 函数可能先结束,导致子线程来不及执行。
    WaitForSingleObject(hThread, INFINITE);

    // 关闭线程句柄,释放系统资源。
    CloseHandle(hThread);

    printf_s("Thread has finished execution. Main function is exiting.\n");

    return 0;
}
c

1.用户进程#

使用x64dbg加载编译后的程序,通过符号表定位到main函数入口。代码执行到调用CreateThread之前,我们可以清晰地看到x64调用约定下,各参数被依次载入RCX, RDX,R8, R9寄存器。

x64dbg查看main函数汇编

2.转发层 - Kernel32.dll#

步进我们在代码中调用的 CreateThread 函数,可以发现我们来到了 kernel32.dll 这个动态链接库中。

kernel32汇编

分析其汇编代码可以发现,CreateThread并未包含复杂的逻辑,它更像是一个“前台接待”。它对参数进行了简单的重排,随即调用了一个功能更全面的API——CreateRemoteThreadEx。这印证了kernel32.dll作为转发层的角色。

CreateRemoteThreadEx是一个非常强大的函数,可以在有正确权限和句柄的情况下在任意进程中创建线程。

CreateThread则是仅仅在当前进程中创建一个线程。

3.实现层 - KernelBase.dll#

步进KernelBase.dll后跟进代码,找到了NtCreateThreadEx

它会调用 ntdll.dll 中的一个原生API (Native API)。这些API通常以 NtZw 开头,是用户模式与内核模式之间的“最后一道门”。对于创建线程而言,它最终会调用 NtCreateThreadEx

kernelBase汇编

4.原生 API - ntdll.dll#

00007FFC09D | 4C:8BD1           | mov r10,rcx                    | r10:"Actx "
00007FFC09D | B8 C9000000       | mov eax,C9                     |加载系统服务号(SSN),相当于告诉内核我要‘创建线程’
00007FFC09D | F60425 0803FE7F 0 | test byte ptr ds:[7FFE0308],1  |
00007FFC09D | 75 03             | jne ntdll.7FFC09D03415         |
00007FFC09D | 0F05              | syscall                        |执行系统调用!从这里,控制权将进入操作系统内核,我们的用户态跟踪到此结束。
00007FFC09D | C3                | ret                            |
plaintext

ntdll汇编

5.模式切换#

syscall调用内核

syscall处进行步进可以发现,光标停在了 C3(ret)处,这也印证了之前的描述,应用程序运行在用户模式下,用户态调试器没有权限进入内核空间。执行 syscall 后,CPU在内核中完成了所有创建线程的复杂工作,然后将结果(新线程的句柄)放入 rax 寄存器,最后返回到用户模式的 ntdll.dll 中,准备将结果一层层地传回给应用程序。

从免杀角度看API调用选择#

一个很自然的问题是:既然我们最终都要调用到ntdll.dll中的原生API,为什么不一开始就直接调用它,反而要绕道kernel32.dllKernelBase.dll呢?

从普通应用开发的角度看,答案是为了稳定和兼容。Win32 API是公开且稳定的接口,而原生API是未公开的,其函数签名甚至功能都可能在不同Windows版本间发生变化。

但从**恶意软件开发与免杀(Evasion)**的角度来看,这个问题就变得至关重要。API的选择直接决定了其行为能否绕过安全产品的监控。

调用高层API (kernel32.dll)#

这是最常规的编程方式,但对于恶意软件来说,也是最危险的方式。

  • 优点:
    • 简单稳定:代码编写简单,遵循官方文档,兼容性好。
  • 缺点:
    • 高频监控点 (Heavily Monitored)kernel32.dllKernelBase.dll 中的函数是所有终端安全产品(EDR/AV)重点监控的对象。它们会通过API Hooking技术,在CreateThread这类敏感函数的入口处插入自己的“探针”。一旦函数被调用,安全产品就能立刻捕获,并分析其行为(例如,是否用于代码注入),从而进行拦截。
    • 意图暴露 (Obvious Intent):恶意软件如果静态链接了kernel32.dll并导入了CreateThread函数,那么通过分析其导入地址表(IAT),就能轻易发现其意图。这是一种非常低级的、容易被静态查杀的特征。

简单来说,调用kernel32.dll就像从银行的正门进去,虽然路最简单,但到处都是摄像头和保安。

直接调用原生API (ntdll.dll)#

这是更高级、更隐蔽的攻击者偏爱的方式。其核心思想是:在运行时动态地获取API地址,而不是在编译时静态链接。

  • 优点:
    • 绕过用户态钩子 (Bypass User-Mode Hooks):这是最大的优势。如果EDR的钩子设置在KernelBase!CreateThread上,而恶意软件直接调用ntdll!NtCreateThreadEx,就相当于**“跳过”**了EDR的用户态监控点,使其探针完全失效。
    • 隐藏意图 (Hiding Intent):通过动态解析API,恶意软件的导入表中不会出现任何敏感函数名,极大地增加了静态分析的难度。

常见调用原生API方式之一:手动解析导出表#

攻击者通常不会使用GetProcAddress,因为它本身也可能被监控。取而代之的是,他们会编写代码来手动遍历DLL的内存结构,直接从其**导出地址表(Export Address Table, EAT)**中找到所需函数的地址。

FARPROC MyGetProcAddress(HMODULE hModule, char* lpProcName) {
	IMAGE_DOS_HEADER* pDosHeader = (IMAGE_DOS_HEADER*)hModule;
	IMAGE_NT_HEADERS64* pNtHeaders = (IMAGE_NT_HEADERS64*)((char*)pDosHeader + pDosHeader->e_lfanew);

	//LPVOID exports1 = (LPVOID)&(pNtHeaders->OptionalHeader.DataDirectory[0]);
	//DWORD exports2 =  pNtHeaders->OptionalHeader.DataDirectory[0].VirtualAddress;

	IMAGE_EXPORT_DIRECTORY* pExportDir = (IMAGE_EXPORT_DIRECTORY*)((char*)pDosHeader + pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);

	DWORD* pAddressOfNames = (DWORD*)((char*)pDosHeader + pExportDir->AddressOfNames);
	WORD* pAddressOfOrdinals = (WORD*)((char*)pDosHeader + pExportDir->AddressOfNameOrdinals);
	DWORD* pAddressOfFunctions = (DWORD*)((char*)pDosHeader + pExportDir->AddressOfFunctions);

	for (DWORD i = 0; i < pExportDir->NumberOfNames; i++) {
		LPCSTR pProcName = (LPCSTR)((char*)pDosHeader + pAddressOfNames[i]);
		if (MyStrCmp((char*)pProcName, lpProcName) == 0) {
			WORD ordinal = pAddressOfOrdinals[i];
			DWORD functionRVA = pAddressOfFunctions[ordinal];
			FARPROC functionPtr = (FARPROC)((char*)hModule + functionRVA);
			return functionPtr;
		}
	}
	return NULL;
}
c

通过HMODULE ntdllHandle = GetModuleHandleA("ntdll.dll");和上述MyGetProcAddress(ntdllHandle, "NtCreateThreadEx"),恶意软件就能在不触碰任何敏感API的情况下,拿到原生函数的指针,从而执行隐蔽的操作。

直接系统调用#

最顶尖的攻击者甚至不调用ntdll.dll中的函数。他们会自己编写一小段汇编代码,手动将系统服务号(SSN)放入eax寄存器,然后执行syscall。这可以绕过对ntdll.dll本身的钩子,是目前最高级的用户态绕过技术之一。但这种方法也面临着SSN在不同系统版本间不一致的问题,增加了开发的复杂性。

总结#

特性调用高层API (kernel32.dll)直接调用原生API (ntdll.dll)
实现方式静态链接,直接调用动态解析地址,或直接执行syscall
优点简单、稳定、兼容性好隐蔽可绕过用户态Hook、隐藏API导入意图
缺点极易被EDR/AV监控和拦截,意图暴露实现复杂、不稳定(依赖系统版本)、仍可能被内核态监控捕获
攻击者画像初级攻击者,通用恶意软件高级持续性威胁(APT),注重免杀的攻击框架

因此,API调用方式的选择,是攻击与防御之间一场永恒的“猫鼠游戏”。防御方在更高层设防,攻击方就往更底层钻。理解这背后的完整调用链和技术细节,不仅有助于理解操作系统原理,更是理解现代网络安全攻防对抗的基石。

从 x64dbg 调试到红队免杀技术
https://blog.0xd00.com/blog/windows-createthread-evasion-techniques
Author 0xd00
Published at 2025年6月26日
Comment seems to stuck. Try to refresh?✨