Syscall 系统调用

本文为 Maldev Academy 中的 Module 64 小节的笔记,主要讲解 Syscall 的基本原理。作者在项目中通过代码实现了一个 Mini Edr,用于观察和分析 EDR 在 Syscall Hook 方面的行为。作者提到可以不用理解其代码具体实现,但当我跟着实现完该项目后,发现还是有很多地方值得注意的,能学到不少东西。所以,本文展开着重讲解该项目的实现。(完整学习内容请自行前往跳转链接查看)

1、Syscall 介绍

基于主机的安全解决方案通常会对 Syscall 执行 API Hook,以便在运行时对程序进行分析和监控。例如,通过 Hook NtProtectVirtualMemory 这一 Syscall,安全产品即使在该调用被刻意从二进制文件的导入地址表(IAT)中隐藏的情况下,也能够检测到诸如 VirtualProtect 这样的高层 WinAPI 调用。

此外,安全解决方案还可以访问任何被设置为可执行的内存区域,并对其进行扫描以查找特征码(signature)。Userland hook(用户态 Hook) 通常安装在 syscall 指令之前,因为这是用户模式下 Syscall 函数执行的最后一步。

某 EDR Hook 示例:

image-20251221152922689

Kernel mode hook(内核态 Hook) 可以在执行流程切换到内核之后进行实现。然而,Windows Patch Guard 以及其他防护机制使得第三方应用对内核内存进行修改变得非常困难,甚至几乎不可能。此外,在内核态放置 Hook 还可能引发系统稳定性问题,导致不可预期的行为,这也是该技术很少被实际采用的原因。

2、用户态 Hook 演示

本节使用了一个 DLL 文件。当该 DLL 被注入到进程中时,会利用 MinHook 库对 NtProtectVirtualMemory 安装 Hook,从而观察和分析 EDR 在 Syscall Hook 方面的行为。所安装的 Hook 具备在内存要被设置为可执行(RXRWX)时 dump(转储) 该内存内容的能力;此外,一旦检测到要被设置 RWX 类型的内存区域,进程将被直接终止。

该 DLL 的源代码可从官方课程页面,用于测试目的。目前不需要理解代码的具体实现,不过代码中包含了大量注释,以便于后续阅读和理解。

2-1、EDR Hooking 演示

本节演示了 EDR 如何通过 Syscall hooking 来阻止某个特定 payload 的执行。在本演示中,APC Injection 代码将作为恶意样本使用。

(1)在未对 NtProtectVirtualMemory 进行 Hook 的情况下运行程序。

image

(2)使用 Process Hacker 将 MalDevEdr.dll 注入到 ApcInjection.exe 进程中。

image

(3)DLL 成功注入,便立即检测到要被设置为 RX 的内存区域(该现象与 DLL 注入过程相关)。

image

(4)在 ApcInjection.exe 的控制台中按下 Enter 键,会触发对 NtProtectVirtualMemory 的调用,将地址 0x0000025041080000 设置为 RWX 内存;随后该地址的内存内容会被 DLL 转储并输出到屏幕上。被转储的内容正是 Msfvenom 的 calc payload。

image

2-2、Explanation

ApcInjection.exe 使用 VirtualProtect 并传入 PAGE_EXECUTE_READWRITE 参数时,该调用会被 MalDevEdr.dll 拦截。MalDevEdr.dll 会利用传递给 VirtualProtect 的基址,对对应的内存区域内容进行转储。由于该内存区域被修改为 RWXMalDevEdr.dll 会直接终止进程,从而阻止 payload 的执行————这一点是 Windows Defender Antivirus 所无法做到的。

该概念验证(PoC)展示了 API Hooking 在运行时检测和监控程序行为方面的强大能力。在真实环境中,EDR 通常会对更广泛的 Syscall 进行 Hook,从而进一步提升其对恶意行为的检测能力。

后面第 4 小节会对代码的实现进行详细的讲解。

3、绕过用户态 Syscall Hook

直接使用 Syscall 是绕过用户态 Hook 的一种方法。例如,在为 payload 分配内存时,使用 NtAllocateVirtualMemory,而不是 VirtualAlloc / VirtualAllocEx 这些 WinAPI。

此外,还有多种方式可以更加隐蔽地调用 Syscall,包括:

  • 使用 Direct Syscalls(直接系统调用)
  • 使用 Indirect Syscalls(间接系统调用)
  • 使用 Unhooking(解除 Hook)

3-1、Direct Syscalls

通过获取一份以汇编语言实现的 syscall 函数,并在汇编文件中直接调用该自定义 syscall,可以实现对用户态 Syscall Hook 的绕过。其难点在于确定 Syscall Service Number(SSN),因为该编号会因系统不同而变化。为了解决这一问题,可以选择将 SSN 硬编码 在汇编文件中,或者在 运行时动态计算。下面展示了一个在汇编文件(.asm)中手工构造的 Syscall 示例。

与本课程前面通过 GetProcAddressGetModuleHandle 调用 NtAllocateVirtualMemory 的方式不同,可以直接使用下面的汇编函数来实现同样的效果。这样做可以避免在安装了 Hook 的 NTDLL 地址空间 中调用 NtAllocateVirtualMemory,从而达到绕过 Hook 的目的。

NtAllocateVirtualMemory PROC
    mov r10, rcx
    mov eax, (ssn of NtAllocateVirtualMemory)
    syscall
    ret
NtAllocateVirtualMemory ENDP

NtProtectVirtualMemory PROC
    mov r10, rcx
    mov eax, (ssn of NtProtectVirtualMemory)
    syscall
    ret
NtProtectVirtualMemory ENDP

// other syscalls ...

这种方法被应用在诸如 SysWhispersHellsGate 等工具中,这两种工具都会在后续模块中进行讲解。

3-2、Indirect Syscalls

间接 Syscall 的实现方式与直接 Syscall 类似,同样需要首先手工编写汇编文件。两者的区别在于:在间接 Syscall 的汇编函数中并不直接包含 syscall 指令,而是通过跳转的方式去执行该指令。其可视化示意如下所示。

image

下面展示了 NtAllocateVirtualMemoryNtProtectVirtualMemory 的汇编函数实现。

NtAllocateVirtualMemory PROC
    mov r10, rcx
    mov eax, (ssn of NtAllocateVirtualMemory)
    jmp (address of a syscall instruction)
    ret
NtAllocateVirtualMemory ENDP

NtProtectVirtualMemory PROC
    mov r10, rcx
    mov eax, (ssn of NtProtectVirtualMemory)
    jmp (address of a syscall instruction)
    ret
NtProtectVirtualMemory ENDP

// other syscalls ...

间接 Syscall 的优势:

相比 直接 Syscall间接 Syscall 的主要优势在于:安全解决方案通常会检查 Syscall 是否是从 NTDLL 地址空间之外 发起的,并将此类行为视为可疑(即从非 NTDLL 区域执行 syscall 指令)。

而在 间接 Syscall 中,syscall 指令实际是在 NTDLL 的地址空间内 被执行的,这与正常的系统调用行为一致。因此,间接 Syscall 相比 直接 Syscall 更有可能绕过安全解决方案的检测。

关于间接 syscall 的具体实现,将在后续的高级模块中进行讲解。

3-3、Unhooking

Unhooking(解除 Hook) 是另一种绕过 Hook 的方式,其核心思路是用一个未被 Hook 的 NTDLL 库来替换当前进程内存中已被 Hook 的 NTDLL。未被 Hook 的版本可以通过多种方式获取,其中一种常见做法是直接从磁盘加载干净的 NTDLL。这样做可以移除 NTDLL 中被植入的所有 Hook。

image

关于 Unhooking 的具体实现细节,将在后续的高级模块中进行讲解。

4、项目代码

MaldevEdr 项目主要涉及 5 个关键文件:DllMain.cHook.cConsole.cCommon.hMinHook.h、。

DllMain.c 负责调度 → Hook.c 负责核心检测 → Console.c 负责可视化 → Common.h 负责粘合 → MinHook 提供底层能力,共同构成一个注入式、用户态、以内存行为监控为核心的 Maldev EDR DLL。

4-1、各文件的功能

(1)DllMain.c:DLL 的启动调度器

  • 在 DLL 被加载进进程时(DLL_PROCESS_ATTACH):

    • 创建一个新线程

    • 在新线程中启动整个 EDR / Hook 逻辑

  • 在 DLL 被卸载时(DLL_PROCESS_DETACH):

    • 负责调用清理函数,移除 Hook

(2)Hook.c:核心检测与拦截逻辑

  • 使用 MinHook:

    • Hook NtProtectVirtualMemory()
  • 在 Hook 函数中:

    • 监控内存权限变更
    • 识别可执行内存(RX / RWX)
    • Dump 可疑内存
    • 选择性阻断或终止进程
  • 提供安装与卸载 Hook 的完整生命周期逻辑

(3)Console.c:运行时调试输出与可视化

  • 为 DLL 注入后的进程创建或获取控制台
  • 提供统一的输出通道(stdout)
  • 在 GUI / CLI 进程中适配不同控制台场景
  • 提供基础错误报告能力

(4)Common.h:公共接口与工具定义中心

  • 定义多个模块之间共享的:

    • 函数声明(Install / Detach / Console 等)

    • 宏(PRINT

    • 类型(fnNtProtectVirtualMemory

  • 提供统一的接口规范,解耦各 .c 文件

(5)MinHook.h(官方库)

  • 提供稳定、成熟的 API Hook 能力
  • 屏蔽底层指令修改、跳板构造等复杂细节
  • 让项目聚焦“检测逻辑”而非“Hook 技术细节”

4-2、链接器(Link)的作用

MaldevEdr 项目的最终产物为 MaldevEdr.dll。VS 会分别对每个 .c 文件进行独立编译:

源文件 编译结果
DllMain.c DllMain.obj
Hook.c Hook.obj
Console.c Console.obj
MinHook.h、Common.h 不参与编译

(1)把所有.obj文件合并

1
2
3
4
5
DllMain.obj
Hook.obj
Console.obj

MaldevEdr.dll

(2)解析“跨文件函数调用”

DllMain.c 调用 InstallTheHookviaMinHook(),该函数定义在 Hook.c。链接器会做:

1
2
3
4
5
DllMain.obj: InstallTheHookviaMinHook (未定义)

Hook.obj: InstallTheHookviaMinHook (已定义)

符号绑定成功

不是文件调用文件,而是 符号(函数)在链接阶段被解析。

(3)链接第三方库(MinHook)

1
#pragma comment(lib, "minhook.x64.lib")

告诉编译器:在最终 DLL 中,解析 MH_Initialize / MH_CreateHookApi / MH_EnableHook 等符号。

4-3、各文件详解

(4-3-1)DllMain.c

DllMain.c:DLL 的启动调度器。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
BOOL APIENTRY DllMain (HMODULE hModule, DWORD dwReason, LPVOID lpReserved){

    HANDLE hThread = NULL;

    switch (dwReason)
    {
        case DLL_PROCESS_ATTACH: {
           hThread = CreateThread(NULL, NULL, &InstallTheHookviaMinHook, NULL, NULL, NULL); //install the hook
           if (hThread)
               CloseHandle(hThread);
           break;
        };

        case DLL_PROCESS_DETACH: {
            ProcessDetachRoutine(); // remove the hooks
            break;
        };
    }

    return TRUE;
}

作为 DLL 的唯一入口点。

在 DLL 被加载进进程时(DLL_PROCESS_ATTACH):

  • 创建一个新线程
  • 在新线程中启动整个 EDR/Hook 逻辑

在 DLL 被卸载时(DLL_PROCESS_DETACH):

  • 负责调用清理函数,移除 Hook。

(4-3-2)Hook.c

Hook.c:核心检测与拦截逻辑。主要由 5 个部分组成:

  • MinHook 依赖与库链接
  • 全局变量(保存原函数与地址)
  • Detour 函数(Hooked_NtProtectVirtualMemory)
  • Hook 安装函数(InstallTheHookviaMinHook)
  • 辅助逻辑(BlockExecution / ProcessDetachRoutine)

(1)MinHook 依赖与库链接

根据编译目标架构,自动链接对应的 MinHook 静态库。

1
2
3
4
5
#ifdef _WIN64
#pragma comment(lib, "minhook.x64.lib")
#elif _WIN32
#pragma comment(lib, "minhook.x32.lib")
#endif

(2)全局变量

1
2
3
4
5
// original NtProtectVirtualMemory to call in the hook function
fnNtProtectVirtualMemory	g_NtProtectVirtualMemory	= NULL;	

// address of the NtProtectVirtualMemory function
PVOID						pNtProtectVirtualMemory		= NULL;	

g_NtProtectVirtualMemory 函数指针,指向原始的 NtProtectVirtualMemory,用于在 Hook 函数中“调用回原函数”。

pNtProtectVirtualMemory 指针,保存NtProtectVirtualMemory的真实地址,主要用于卸载 Hook 或调试

(3)Detour 函数(Hooked_NtProtectVirtualMemory)

这是 MinHook 安装后,用来“替代原 NtProtectVirtualMemory 执行”的函数。

一旦 Hook 成功,NtProtectVirtualMemory → Hooked_NtProtectVirtualMemory。

第一步,当被监测程序调用 NtProtectVirtualMemory 时,自动触发执行 Hooked_NtProtectVirtualMemory,此时我们先把原始调用 NtProtectVirtualMemory 要修改的内存地址(BaseAddress),及要修改的内存地址大小(NumberOfBytesToProtect)打印出来。

1
2
// 有人正在调用 NtProtectVirtualMemory,他正在修改一块从 `0xXXXXXXXX` 开始、大小为 `YYYY` 字节的内存区域的权限。
PRINT("[#] NtProtectVirtualMemory Effect At [ 0x%p ] Of Size [ %d ] \n", (PVOID)*BaseAddress, (unsigned int)*NumberOfBytesToProtect);

原系统调用:

1
NtProtectVirtualMemory(ProcessHandle, *BaseAddress, NumberOfBytesToProtect, NewAccessProtection, OldAccessProtection)

Hook 后,系统实际调用的是:

1
Hooked_NtProtectVirtualMemory(ProcessHandle, *BaseAddress, NumberOfBytesToProtect, NewAccessProtection, OldAccessProtection)

第二步,检测 RWX(高危行为)。是否有人把一块内存同时标记为:可读 + 可写 + 可执行。存在则执行 dump 内存 + 终止进程。

1
2
3
4
5
// if PAGE_EXECUTE_READWRITE = dump memory + terminate
	if ((NewAccessProtection & PAGE_EXECUTE_READWRITE) == PAGE_EXECUTE_READWRITE) {
		PRINT("\t\t\t<<<!>>> [DETECTED] PAGE_EXECUTE_READWRITE [DETECTED] <<<!>>> \n");
		BlockExecution((PBYTE)*BaseAddress, (SIZE_T)*NumberOfBytesToProtect, TRUE);
	}

第三步,检测 RX(中风险)。是否有人把一块内存同时标记为:可读 + 可执行。存在则执行 dump 内存 + 继续进程。

1
2
3
4
5
// if PAGE_EXECUTE_READ = dump memory + continue
	if ((NewAccessProtection & PAGE_EXECUTE_READ) == PAGE_EXECUTE_READ) {
		PRINT("\t\t\t<<<!>>> [DETECTED] PAGE_EXECUTE_READ [DETECTED] <<<!>>> \n");
		BlockExecution((PBYTE)*BaseAddress, (SIZE_T)*NumberOfBytesToProtect, FALSE);
	}

内存设置权限常见值,这些不是字符串,是位标志(bit flags)

含义
PAGE_READWRITE 可读 + 可写
PAGE_EXECUTE_READ 可执行 + 可读
PAGE_EXECUTE_READWRITE 可执行 + 可读 + 可写

为什么要用 &(按位与)?

原因是:内存权限不是“一个选一个”,而是“多个能力叠加在一个整数里”。比如:

1
PAGE_EXECUTE_READWRITE

在二进制上等价于:

1
EXECUTE | READ | WRITE

以检测 RWX 权限为例:

1
(NewAccessProtection & PAGE_EXECUTE_READWRITE) == PAGE_EXECUTE_READWRITE

这句话本质是在检测 NewAccessProtection 里,是否同时包含 EXECUTE + READ + WRITE 这三种权限。

第四步,返回调用原函数。

1
2
// return the expected output
	return  g_NtProtectVirtualMemory(ProcessHandle, BaseAddress, NumberOfBytesToProtect, NewAccessProtection, OldAccessProtection);

监控 ≠ 破坏系统行为(除非你决定终止),如果不调用原函数,可能会导致目标程序崩溃,行为异常等。

(4)Hook 安装函数(InstallTheHookviaMinHook)

第一步, 获取 NtProtectVirtualMemory 地址。明确 Hook 目标,不走 IAT,直接定位 NTDLL。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 用于后续“按地址启用/卸载 Hook”,后续使用 MH_ALL_HOOKS 关键字启用/卸载的话,则这里实际使用不到。
// 用于在 Terminate 进程前先卸载该 Hook,再 ExitProcess(1),也可直接退出进程。
pNtProtectVirtualMemory = GetProcAddress(GetModuleHandleW(TEXT("NTDLL.DLL")), "NtProtectVirtualMemory");

// MH_EnableHook(pNtProtectVirtualMemory);
// MH_EnableHook(MH_ALL_HOOKS);

// MH_RemoveHook(pNtProtectVirtualMemory);
// MH_RemoveHook(MH_ALL_HOOKS);

// if ((MinHookErr = MH_RemoveHook(pNtProtectVirtualMemory)) != MH_OK) {
//	ReportError("MH_RemoveHook", MinHookErr);
// }
// MessageBoxA(NULL, "Terminating The Process ... ", "Maldev Edr", MB_OKCANCEL | MB_ICONERROR);
// ExitProcess(1);

第二步,初始化控制台。保证后续 PRINT 可用。

1
CreateOutputConsole();

第三步,初始化 MinHook。为 Hook 做准备。

1
MH_Initialize();

第四步,创建 Hook。把 NTDLL!NtProtectVirtualMemory 重定向到 Hooked_NtProtectVirtualMemory,并把原函数地址保存到 g_NtProtectVirtualMemory。

1
MH_CreateHookApi(TEXT("NTDLL.DLL"), "NtProtectVirtualMemory", Hooked_NtProtectVirtualMemory, (LPVOID*)&g_NtProtectVirtualMemory)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 函数 MH_CreateHookApi 定义(MinHook.h)
// Creates a hook for the specified API function, in disabled state.
// Parameters:
//   pszModule   [in]  A pointer to the loaded module name which contains the
//                     target function.
//   pszProcName [in]  A pointer to the target function name, which will be
//                     overridden by the detour function.
//   pDetour     [in]  A pointer to the detour function, which will override
//                     the target function.
//   ppOriginal  [out] A pointer to the trampoline function, which will be
//                     used to call the original target function.
//                     This parameter can be NULL.
MH_STATUS WINAPI MH_CreateHookApi(
    LPCWSTR pszModule, LPCSTR pszProcName, LPVOID pDetour, LPVOID *ppOriginal);

第五步,启用 Hook。Hook 真正开始生效。

1
MH_EnableHook(MH_ALL_HOOKS);

(5)辅助逻辑(BlockExecution / ProcessDetachRoutine)

格式化输出:

1
PRINT(" %02X", pAddress[i]);

pAddress[i] 这个字节的值,以“两位十六进制”的形式打印出来,用于内存 Dump。

1
2
" %02X"
 ^

前面的空格" ",用来让输出更整齐,让每个字节之间有空格分隔。输入形如:

1
 90 90 90 CC E8 ...

%02X的精确含义:

部分 含义
% 格式开始
0 不足位数时,用 0 补齐
2 输出宽度为 2 个字符
X 大写十六进制 输出

(4-3-3)Console.c

Console.c:运行时调试输出与可视化。

(1)编译宏选项

通过编译期宏,区分 DLL 注入目标是「控制台进程(CLI)」还是「图形界面进程(GUI)」,从而决定是否需要创建/重绑定控制台。

1
2
3
4
5
6
7
// if injecting the dll into a cli process. if not, then comment it:

#define TARGET_CLI_PROCESSES	// 该行注释开关

#ifndef TARGET_CLI_PROCESSES
#define TARGET_GUI_PROCESSES
#endif // !TARGET_CLI_PROCESSES

这是一个人为选择的编译期开关

  • DLL 注入 命令行程序(cmd / powershell / console exe) → 打开 TARGET_CLI_PROCESSES
  • DLL 注入 GUI 程序(notepad / explorer / chrome) → 注释掉该宏

(2)代码整体逻辑

给这个 DLL 找到一个“可以往屏幕上打印文字的窗口(控制台)”,并把这个窗口记住,以后反复使用。

可以理解为:我想 printf,但我不知道现在有没有黑窗口(console);如果没有,就给我造一个;如果有,就别再造了,直接用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
HANDLE		g_hConsole		= NULL;

// create a console screen to write to
HANDLE CreateOutputConsole() {

	if (g_hConsole != NULL){
		return g_hConsole;
	}

#ifdef TARGET_GUI_PROCESSES
	
	if (!FreeConsole()) {
		return NULL;
	}
	if (!AllocConsole()) {
		return NULL;
	}

#endif // TARGET_GUI_PROCESSES

	if ((g_hConsole = GetStdHandle(STD_OUTPUT_HANDLE)) == NULL) {
		return NULL;
	}

	return g_hConsole;
}

VOID ReportError(LPCSTR lpFunctionName, DWORD dwError) {

	PRINT("[!] \"%s\" Failed With Error : %d \n", lpFunctionName, dwError);
	MessageBoxA(NULL, "", "", MB_OK);
}

(3)g_hConsole 变量

g_hConsole 为什要判断是否为空,一开始不就初始化为NULL了。还能有别的情况吗?

第一种情况:g_hConsole 一开始确实是 NULL,但这个函数很可能会被调用“不止一次”。第二次、第三次调用时,它就“可能不为空”了。

在 Hook.c 里会调用:

1
2
3
4
if (CreateOutputConsole() == NULL) {
    MessageBoxA(...);
    return FALSE;
}

而 PRINT 宏内部也会调用:

1
WriteConsoleA( CreateOutputConsole(), ... );

也就是说:

  • 第一次 PRINT → CreateOutputConsole
  • 第二次 PRINT → CreateOutputConsole
  • 第 N 次 PRINT → CreateOutputConsole

这个函数会被调用很多次。

那么,如果已经有控制台,就直接用。

1
2
3
if (g_hConsole != NULL){
    return g_hConsole;
}

第二种情况:别的代码提前创建了控制台。例如:

  • 目标进程本来是 CLI 程序
  • 或别的 DLL 已经 AllocConsole()

那在第一次调用时:

1
g_hConsole 仍然是 NULL

GetStdHandle() 会返回一个 有效的 HANDLE:

1
GetStdHandle(STD_OUTPUT_HANDLE)

于是:

1
g_hConsole = 有效值

但需要注意,下面代码的位置顺序是固定,一定不可以调整。

1
2
3
4
5
6
7
  ...
  
  // GUI Check PART
  
 	if ((g_hConsole = GetStdHandle(STD_OUTPUT_HANDLE)) == NULL) {
		return NULL;
	}

正常情况下的顺序:

  • 如果是 GUI 进程:

    • FreeConsole()

    • AllocConsole()(创建控制台)

  • 控制台已经存在

  • GetStdHandle(STD_OUTPUT_HANDLE)

    • 此时句柄是有效的

在 CLI 程序 场景下,本身就有控制台, 不走 FreeConsole()AllocConsole(),所以可能不会有问题。

在 GUI 程序(没有控制台)场景下,如果一上来就 GetStdHandle()g_hConsole = GetStdHandle(STD_OUTPUT_HANDLE); 很可能返回 NULL 或返回一个 不可用的句柄。然后再 FreeConsole()AllocConsole(),虽然新的控制台确实被创建了,但因为没有重新调用 GetStdHandle(),导致 g_hConsole 仍然指向旧的/无效的输出对象。

(4)为 GUI 程序创建控制台

GUI 程序(没有黑窗口的那种),例如:

  • notepad.exe
  • explorer.exe
  • chrome.exe

这些程序 默认是没有控制台的。

CLI 程序,例如:

  • cmd.exe
  • powershell.exe

这些一启动,就自带黑窗口(控制台),不用造。

第一步:FreeConsole() —— 先断开旧控制台

1
2
3
4
// 如果这个进程之前挂着一个控制台(哪怕是别人的),我先跟它断开关系
if (!FreeConsole()) {
    return NULL;
}

第二步:AllocConsole() —— 创建新的控制台

1
2
3
if (!AllocConsole()) {
    return NULL;
}

屏幕上弹出一个 cmd 风格的黑窗口,这个窗口 属于当前进程。

第三步:GetStdHandle() —— 向操作系统请求当前进程的“标准输出通道”的内核对象句柄。

1
g_hConsole = GetStdHandle(STD_OUTPUT_HANDLE);

STD_OUTPUT_HANDLE 指“标准输出”。

在 Windows 进程里,系统在启动时会为进程准备 3 个标准 I/O 通道

名称 常量 用途
标准输入 STD_INPUT_HANDLE 读键盘
标准输出 STD_OUTPUT_HANDLE 正常输出
标准错误 STD_ERROR_HANDLE 错误输出

上述语句返回值为一个内核对象的句柄(HANDLE),该对象当前被绑定到“标准输出”。

这个对象可以是:

  • 控制台屏幕缓冲区(console screen buffer)
  • 文件(如果 stdout 被重定向)
  • 管道(如果被父进程接管)

(4-3-4)Common.h

Common.h:公共接口与工具定义中心。

(1)HOOKS_H 宏的创建

目的是:防止 同一个头文件被重复包含(multiple inclusion),从而导致 重定义编译错误。

这段代码整体叫做 Header Guard(头文件保护宏)

1
2
3
4
5
6
#ifndef HOOKS_H
#define HOOKS_H

// ... Common.h 的全部内容 ...

#endif // !HOOKS_H

C/C++ 的 #include 是“纯文本展开”,编译器在预处理阶段只是把 Common.h 的内容原封不动拷贝进来,当多个文件中存在 #include "Common.h" 时,且没有保护时,编译器会识别到多行 #include "Common.h"

1
2
3
#include "Common.h"
#include "Common.h"
#include "Common.h"

等价于:

1
2
3
// Common.h 内容
// Common.h 内容   ← 第二次完整拷贝
// Common.h 内容   ← 第三次完整拷贝

Common.h 里包含大量 只能定义一次的内容

  • typedef
  • 函数声明
  • 结构 / 类型定义

例如:

1
typedef NTSTATUS(NTAPI* fnNtProtectVirtualMemory)(...);

如果被展开两次,则会报错。

1
error C2371: redefinition of 'fnNtProtectVirtualMemory'

本项目中 include 关系是这样的:

1
2
3
4
5
6
7
8
9
DllMain.c
 └─ #include "Common.h"

Hook.c
 ├─ #include "MinHook.h"
 └─ #include "Common.h"

Console.c
 └─ #include "Common.h"

Common.h 被多次 include 是必然的。如果没有 Header Guard:

  • 同一个 .c 文件中
  • 甚至间接 include
  • 都会导致 重复定义错误

HOOKS_H 这个名字本身有没有特殊含义,只是一个 宏名,这里用 HOOKS_H,更多是语义习惯,不是语法要求。

命名规则是:

  • 在整个工程中唯一
  • 不与其他头文件冲突

(2)PRINT() 就是 printf() ?

PRINT 函数不是 pritnf(),PRINT() 函数的目的不是“重复造一个 printf”,而是在 DLL/注入场景下,提供一个“可靠、可控的输出机制”。

PRINT() 与 pritnf() 区别:

项目 printf PRINT
输出目标 依赖 C 运行时 stdout 明确写到 Windows Console
是否依赖 CRT
DLL 注入 GUI 进程 基本不可用 可用
可控性

(3)为什么不直接用 printf() ?

printf() 并不是系统 API,而是 C 运行时库(msvcrt/ucrt)提供的高级封装

在 DLL 注入场景中,常见问题包括:

  • 目标进程 未初始化 CRT
  • stdout 未绑定
  • 输出被重定向到不存在的流
  • 输出成功但你永远看不到

结果就是 printf() 执行后,很可能“什么都没发生”。

在 GUI 进程中,printf 几乎一定失败。GUI 程序(如 notepad.exe):没有控制台、stdout = NULL、printf 没地方写;即使 AllocConsole() 了:CRT 也不一定重新绑定 stdout、行为不可预测。

PRINT 使用的是 WinAPI(不是 CRT),PRINT 核心实现如下:

1
HeapAlloc  wsprintfA  WriteConsoleA

这意味着:

  • 完全绕过 CRT
  • 直接使用 Windows 内核对象(Console Handle)
  • 行为确定、稳定

(4)为什么 PRINT 定义成宏,而不是函数?

关键原因只有一个:使用方便 + 可变参数

1
PRINT("Value = %d\n", x);

如果定义为函数,则需要:

1
void Print(const char* fmt, ...);

那就必须处理:va_listva_startva_end

对演示来说,宏更简单、直观、无额外代码复杂度

(5)为什么 PRINT 要放在 Common.h,而不是 Console.c?

核心原则:“谁要用,谁能看到”

Common.h 的定位是跨模块共享的公共接口层,所有 .c 文件都 include 它:DllMain.c、Hook.c、Console.c。

在项目中:

  • Hook.c 用 PRINT
  • Console.c 用 PRINT
  • 未来可能任何模块都用

如果把 PRINT 放在 Console.c,其他 .c 文件 根本看不到它。因为.c 文件不会被别的 .c include。

宏是 预处理阶段展开的,必须在 #include 时可见,.c 文件之间不会共享宏。

另外为什么不用一个 Print() 函数放在 Console.c?

原因这样定义的函数必须暴露函数声明,必须处理 va_list(更复杂),调用开销更高(虽小)。

(6)PRINT 函数实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Common.h 中关于 Console.c 的部分
HANDLE CreateOutputConsole();
VOID ReportError(LPCSTR lpFunctionName, DWORD dwError);
// print to screen (act as printf)
#define PRINT(STR, ...)                                                      \
	if (1) {                                                                   \
		LPSTR buf = (LPSTR)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 1024);  \
		if ( buf != NULL) {                                                      \
			int len = wsprintfA(buf, STR, __VA_ARGS__);                            \
			WriteConsoleA(CreateOutputConsole(), buf, len, NULL, NULL);            \
			HeapFree(GetProcessHeap(), 0, buf);                                    \
		}                                                                        \
	}

注意1:宏的多行定义,必须每一行以 \ 结尾,否则宏在第一行就结束,后面的代码会直接变成非法的全局语句。也就是说上面代码中的 \ 不要省略,不然直接语法错误。

注意2:PRINT 宏在展开时会用到 CreateOutputConsole(),而在宏展开后的 C 代码里,这个函数必须是“已声明的”。所以如果调整代码位置把 CreateOutputConsole()ReportError()函数的位置放在 PRINT 声明后也会报错。

PRINT 函数的整体目标是在任意被注入的进程中,把一段格式化字符串写入当前进程的控制台输出通道。

它完成的是一个完整的四阶段流程:

  • 准备一块临时缓冲区
  • 格式化字符串到缓冲区
  • 通过控制台句柄输出
  • 释放临时资源

当执行如下代码时:

1
PRINT("PID = %d\n", pid);

预处理后,编译器看到的代码等价于:

1
2
3
4
5
6
7
8
if (1) {
    LPSTR buf = (LPSTR)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 1024);
    if (buf != NULL) {
        int len = wsprintfA(buf, "PID = %d\n", pid);
        WriteConsoleA(CreateOutputConsole(), buf, len, NULL, NULL);
        HeapFree(GetProcessHeap(), 0, buf);
    }
}

首先,关于if (1) { ... }部分。

这是一个宏结构保护,不是逻辑判断。确保宏展开后在语法上是一个完整的语句块,允许 PRINT 像普通语句一样被使用。条件恒为真,内部代码必然执行一次。

接着,申请输出缓冲区。

1
LPSTR buf = (LPSTR)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 1024);
  • 输入:当前进程的默认堆、申请 1024 字节、要求内存清零
  • 输出:buf指向一块可写的用户态内存,若失败,buf == NULL
  • 目的:提供一个临时字符串缓冲区,避免使用静态 / 全局缓冲区(重入安全)

接着,检查分配结果。

1
if (buf != NULL) 
  • 防止在分配失败时继续执行
  • 避免对 NULL 指针写入

接着,格式化字符串。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int len = wsprintfA(buf, STR, __VA_ARGS__);

// 假设调用
PRINT("PID = %d\n", 1234);
// 格式化结果(buf中,字符数组)
"PID = 1234\n\0"

buf 
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
 'P' 'I' 'D' ' ' '=' ' ' '1' '2' '3' '4''\n''\0' 00  00  00  ...
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
  0    1    2    3    4    5    6    7    8    9    10    11   12   13   14
  • 输入:buf 指向目标缓冲区、STR为格式字符串(如"PID = %d\n")、__VA_ARGS__为可变参数(如pid
  • 输出:buf 中写入格式化后的 ASCII 字符串、len = 写入的字符数(不含 \0
  • 关键点:wsprintfA 的返回值不是字符串地址,而是“写入的字符数量”,所以必须用 int(或类似整型)来接收。
  • 输入参数 buf 其原有内容会被从头开始覆盖重写,最终变成一段新的、格式化后的字符串作为输出参数 buf。

接着,获取控制台输出句柄。

1
CreateOutputConsole()
  • 目的:确保当前进程有控制台,返回 STD_OUTPUT_HANDLE 对应的 HANDLE;若失败,行为由 WriteConsoleA 决定。

接着,写入控制台。

1
WriteConsoleA(CreateOutputConsole(), buf, len, NULL, NULL);
  • 输入:控制台输出句柄、要写的 buf 数据地址、写入长度(字符数)
  • 效果:把 buf 中的内容显示到控制台窗口

最后,释放缓冲区。

1
 HeapFree(GetProcessHeap(), 0, buf);
  • 作用:释放刚才申请的堆内存、防止内存泄漏
  • 效果:buf 不再有效、本次 PRINT 调用结束

(4-3-5)MinHook.h

MinHook.h:官方库

updatedupdated2025-12-262025-12-26