第3章 原生应用程序(Native Applications)
# 第3章 原生应用程序(Native Applications)
前几章中我们看到的示例应用程序都是“标准”Windows应用程序,只是恰好使用了原生API(Native API)。它们依赖于NtDll.dll,但并非仅依赖NtDll.Dll。仅依赖NtDll.dll的可执行文件才是原生应用程序(Native Application)。
本章包含以下内容:
- 原生应用程序与标准应用程序的对比
- 构建原生应用程序
- Main函数
- 简单的原生应用程序
- 启动原生应用程序
- 调试原生应用程序
# 3.1 原生应用程序与标准应用程序的对比
查看普通可移植可执行文件(Portable Executable,PE)的头部(例如Explorer.exe、Notepad.exe以及前几章的示例),会发现它们属于Windows子系统(Windows Subsystem)。控制台应用程序的子系统值为3(图3-1),图形用户界面(GUI)应用程序的子系统值为2(图3-2)。

图3-1:控制台应用程序的子系统值
)
图3-2:图形用户界面(GUI)应用程序的子系统值
尽管这两个数值不同,但它们代表相同的含义:Windows子系统。这种等价性体现在:控制台应用程序可以创建图形用户界面,而图形用户界面(GUI)应用程序也可以创建控制台(通过Windows API AllocConsole)。
查看此类可执行文件的依赖项时,可能会在PE导入目录(Imports PE directory)中看到或看不到NtDll.dll,但这种依赖关系始终存在(尽管是间接依赖)。查看第2章中Kill.exe(Release版本)的导入目录,会发现它依赖于Kernel32.dll、NtDll.Dll和VCRuntime140.Dll。选择特定的动态链接库(DLL),会在底部显示导入的函数(图3-3)。

图3-3:Kill.exe的导入依赖
原生应用程序(Native Application)是仅依赖NtDll.Dll而不依赖其他任何动态链接库(DLL)的应用程序。典型示例是会话管理器(session manager)Smss.exe,它是系统中创建的第一个用户模式(user-mode)进程。图3-4显示它属于原生子系统(Native subsystem),子系统值为1,本质上相当于没有子系统。查看其导入依赖,NtDll.Dll是唯一的依赖项(图3-5)。

图3-4:Smss.exe的子系统信息

图3-5:Smss.exe的导入依赖
另一个原生应用程序(Native Application)的示例是AutoChk.exe,位于System32目录中。这是一个原生应用程序,当Windows未正常关闭且硬盘完整性可能受到影响时,它会执行相关操作,并在Windows启动时提示检查磁盘。
然而,AutoChk.exe还有一个名为ChkDsk.exe的版本,它是一个标准的控制台Windows应用程序,依赖于msvcrt.dll和其他动态链接库(DLL)(图3-6)。为什么会有两个功能基本相同的版本?

图3-6:Chkdsk.exe的导入依赖
首先,原生应用程序(Native Application)无法通过常规方式直接运行,例如在资源管理器(Explorer)中双击或从命令行窗口运行。尝试运行AutoChk.exe会得到以下提示:
C:\Windows\system32\autochk.exe 应用程序无法在 Win32 模式下运行。
换句话说,Windows API CreateProcess无法启动原生应用程序(Native Application)。其次,只有原生应用程序(Native Application)才能在Windows启动的早期阶段运行。事实上,Smss.exe的任务之一是根据注册表项HKLM\System\CurrentControlSet\Control\Session Manager中名为开机启动的多字符串值(multi-string value)运行原生应用程序。图3-7显示了注册表编辑器(RegEdit)中该值的样子。

图3-7:开机启动值
多字符串值中的每一行都包含可执行文件名称和命令行参数。AutoChk获得了特殊处理,并拥有一个友好名称用于标识。从图3-7中可以看到,“autocheck”是友好名称,“autochk *”是可执行文件名称和命令行参数。该可执行文件必须位于System32目录中——不支持完整路径。这是有意设计的,因为默认情况下,向System32目录写入文件需要管理员权限。注册表项本身也仅允许管理员账户进行写入操作,以进一步限制普通用户随意修改该键值。
你也可以通过Sysinternals的Autoruns工具以更友好的方式查看开机启动信息(图3-8)。

图3-8:Autoruns工具中的开机启动值
添加到开机启动中的任何字符串都会在系统启动时由Smss.exe运行。然而,此类应用程序必须是原生应用程序(Native Application),因为此时Windows子系统进程(Csrss.exe)尚未加载。
会话管理器(Session Manager)负责为每个会话加载
Csrss.exe。在开机启动指定的可执行文件运行时,会话管理器是唯一活跃的用户模式(user-mode)进程。
# 3.2 构建原生应用程序
构建原生应用程序(Native Application)需要移除所有“标准”依赖项,例如Kernel32.dll、Visual C++运行时库等所有依赖,仅保留NtDll.dll。创建标准控制台C++应用程序后,需执行以下步骤:
- 打开项目属性,在顶部的组合框中选择“所有平台”和“所有配置”,这样就无需为每个平台/配置组合重复这些操作。
- 导航到**“链接器(Linker)”** -> **“输入(Input)”**节点,将“忽略所有默认库(Ignore all Default Libraries)”设置为“是(Yes)”。同时,添加
NtDll.lib作为依赖项(图3-9)。

图3-9:链接器输入选项
- 导航到**“链接器(Linker)”** -> **“系统(System)”**节点,将“子系统(Subsystem)”更改为“原生(Native)”(图3-10)。

图3-10:链接器系统选项
- 导航到**“C/C++”** -> **“代码生成(Code Generation)”**节点。将“基本运行时检查(Basic Runtime Checks)”设置为“默认(Default)”,这实际上会关闭运行时检查。这是必需的,因为这些检查由C运行时库(CRT)提供。
将“安全检查(Security Check)”设置为“禁用安全检查(Disable Security Check)”。见图3-11。

图3-11:编译器代码生成选项
- 导航到**“C/C++”** -> **“常规(General)”**节点。将“SDL检查(SDL Checks)”设置为“否(No)”。将“调试信息格式(Debug Information Format)”设置为“程序数据库(Program Database)”。见图3-12。

图3-12:编译器常规选项
至此,配置完成。此时,假设项目中只有一个空的main函数,它将能够编译但链接失败,提示找不到名为NtProcessStartup的未解析外部符号。正如人们常说的——我们进入了一个全新的领域。
# 3.3:Main函数
标准控制台应用程序(console application)具有一个主函数(main function),该函数可以接收命令行参数的数量、参数数组,甚至环境变量数组:
int main(int argc, const char* argv[], const char* envp[])
{
2
另一种受支持的变体是使用 Unicode 字符串:
int wmain(int argc, const wchar_t* argv[], const wchar_t* envp[])
{
2
一个很自然的问题是:谁会传入正确的参数来调用这些函数?答案是 CRT(C 运行时库,C Runtime Library)。这也是 CRT 为进程提供启动代码(startup code)的原因之一,这些启动代码的函数名类似 mainCRTStartup;如果在主函数中设置断点,查看调用堆栈(call stack),你就能清楚地看到这一点。实际上,Visual Studio 提供了 CRT 的源代码,这意味着你可以跟踪(并调试)这些调用过程。
CRT 会按照 C/C++ 标准的预期语义调用用户提供的 main/wmain 函数。CRT 还有其他职责——例如,它负责调用全局对象的构造函数(甚至在 main 函数被调用之前)。这是为了遵守 C++ 标准规则所必需的。
现在,由于我们移除了对 CRT 的任何依赖(这是原生应用程序(native application)必须做到的),就不再有任何组件来调用标准的 main 函数,也无法调用全局对象的构造函数了。Windows 用户模式进程(user-mode process)的基础“主函数”如下所示:
NTSTATUS NtProcessStartup(PPEB peb);
该函数的名称本身可以通过链接器选项(linker option)进行修改,但参数和返回类型是固定的。返回值在概念上与普通的 main 函数相同——即进程的退出代码(exit code)。然而,输入参数则完全不同——它是一个指向进程环境块(Process Environment Block,PEB)的指针。
我们预期命令行参数等信息会存在于 PEB 中,事实也确实如此。具体来说,这些信息可以在 ProcessParameters 成员中找到,该成员是一个类型为 RTL_USER_PROCESS_PARAMETERS 的大型结构体。
当前进程的 PEB 始终可以通过 NtCurrentPeb 函数获取。
# 3.4:简单的原生应用程序
让我们构建一个简单的原生应用程序,并将其注册到 开机启动 注册表值中。该应用程序名为 SimpleNative,我们会按照上一节中的步骤进行配置,使其成为一个标准的原生应用程序。
这个应用程序将在启动屏幕(boot screen)上显示 Windows 版本信息,并暂停几秒钟以便我们能够看到它。主函数会调用 RtlGetVersion 函数来获取一些版本信息:
NTSTATUS NtProcessStartup(PPEB peb)
{
RTL_OSVERSIONINFOEXW osvi = { sizeof(osvi) };
RtlGetVersion(&osvi);
2
3
4
RtlGetVersion 是一个简单的 API,其定义如下:
NTSTATUS RtlGetVersion(_Out_ PRTL_OSVERSIONINFOW VersionInformation);
它需要接收两种结构体中的一种:PRTL_OSVERSIONINFOW 或扩展的 PRTL_OSVERSIONINFOEXW,这两种结构体都在 <WinNt.h> 头文件中定义。然而,该函数本身并未在官方文档化的头文件中声明。这两种结构体的第一个成员都是“大小”(size),必须正确初始化,以便 API 知道需要提供多少信息:
typedef struct _OSVERSIONINFOW {
DWORD dwOSVersionInfoSize;
DWORD dwMajorVersion;
DWORD dwMinorVersion;
DWORD dwBuildNumber;
DWORD dwPlatformId;
WCHAR szCSDVersion[128];
} OSVERSIONINFOW, *POSVERSIONINFOW, RTL_OSVERSIONINFOW, *PRTL_OSVERSIONINFOW;
typedef struct _OSVERSIONINFOEXW {
DWORD dwOSVersionInfoSize;
DWORD dwMajorVersion;
DWORD dwMinorVersion;
DWORD dwBuildNumber;
DWORD dwPlatformId;
WCHAR szCSDVersion[128];
WORD wServicePackMajor;
WORD wServicePackMinor;
WORD wSuiteMask;
BYTE wProductType;
BYTE wReserved;
} OSVERSIONINFOEXW, *POSVERSIONINFOW, RTL_OSVERSIONINFOEXW, *PRTL_OSVERSIONINFOEXW;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
获取版本信息后,我们需要构建一个字符串,包含返回的部分成员信息。我们可以使用 NtDll.dll 中导出的 swprintf_s 函数,该函数是可用的。遗憾的是,phnt 头文件没有提供这些类 CRT 函数(CRT-like functions)的原型,因此我们需要自行定义。幸运的是,这很简单,因为该函数的定义是已知的,并且在标准 CRT 的使用场景中已有文档说明:
extern "C" int swprintf_s(
wchar_t* _Buffer, USHORT size, wchar_t const* _Format, ...);
2
你可能会想直接通过 #include <stdio.h> 来获取该定义。但遗憾的是,这样做无法编译通过,因为该头文件包含了其他 C++ 依赖项,而在没有 CRT 的情况下,编译会被编译器拒绝。不过,你可以从官方头文件中复制该定义。
现在,我们可以构建字符串,并将其传递给 NtDrawText 函数,使其显示在启动屏幕上:
WCHAR text[256];
swprintf_s(text, ARRAYSIZE(text), L"Windows version: %d.%d.%d\n", osvi.dwMajorVersion, osvi.dwMinorVersion, osvi.dwBuildNumber);
UNICODE_STRING str;
RtlInitUnicodeString(&str, text);
NtDrawText(&str);
2
3
4
5
6
该字符串使用了操作系统的主版本号(major version)、次版本号(minor version)以及内部版本号(build number)。你也可以根据需要使用该结构体的其他成员。
NtDrawText 函数需要一个 UNICODE_STRING 类型的参数,因此我们使用 RtlInitUnicodeString 函数对其进行初始化(因为我们最初拥有的是一个以 NULL 结尾的字符串)。
最后要做的是,在进程关闭之前让线程延迟(delay)几秒钟:
LARGE_INTEGER li;
li.QuadPart = -10000000 * 10;
NtDelayExecution(FALSE, &li);
2
3
4
NtDelayExecution 函数大致相当于 Windows API 中的 SleepEx 函数。我们将在后续章节中详细讨论其完整语义,目前上述代码会导致线程休眠 10 秒钟。以下是该示例的完整代码:
#include <phnt_windows.h>
#include <phnt.h>
extern "C" int swprintf_s(
wchar_t* _Buffer, USHORT size, wchar_t const* _Format, ...);
NTSTATUS NtProcessStartup(PPEB peb)
{
RTL_OSVERSIONINFOEXW osvi = { sizeof(osvi) };
RtlGetVersion(&osvi);
WCHAR text[256];
swprintf_s(text, ARRAYSIZE(text), L"Windows version: %d.%d.%d\n", osvi.dwMajorVersion, osvi.dwMinorVersion, osvi.dwBuildNumber);
UNICODE_STRING str;
RtlInitUnicodeString(&str, text);
NtDrawText(&str);
LARGE_INTEGER li;
li.QuadPart = -10000000 * 10;
NtDelayExecution(FALSE, &li);
return STATUS_SUCCESS;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
遗憾的是,项目构建会失败,链接器会报错找不到 swprintf_s 函数(“未解析的外部符号”,unresolved external)。这是为什么呢?
事实证明,Windows SDK 提供的导入库(import library)NtDll.Lib 并未列出 NtDll.Dll 中实现的类 CRT 函数,因此链接器会报错。
解决这个问题的一种方法是如第 2 章所讨论的那样,动态绑定(bind dynamically)到该函数。这当然是可行的,但显然我们不能使用 GetModuleHandle 和 GetProcAddress 函数,因为这些函数在原生环境中不可用。我们需要使用的函数是 LdrGetDllHandle 和 LdrGetProcedureAddress。我们将在第 5 章中详细介绍这两个函数。目前,我们可以尝试另一种方法。
我们可以构建自己的 NtDll.lib。要知道,导入库包含两部分信息:DLL 名称(不包含任何路径信息)和导出符号(exported symbols)列表。我们无需真正实现任何功能,因为这些功能已经由 DLL 本身实现了。构建导入库的目的只是为了让链接器满意,从而将函数绑定延迟到运行时(runtime)。
实际上,已经有人完成了这项工作。Github 上的一个项目(地址:https://github.com/Fyyre/ntdll (opens new window))提供了这样一个“扩展版”的 NtDll.lib。要获取这个扩展库,请克隆该代码仓库(或下载代码到某个文件夹),打开 Visual Studio 命令窗口,导航到你系统中的项目目录,然后输入以下命令:
C:\Github\ntdll>nmake msvc
执行该命令后,会生成两个文件:NtDll64.lib(适用于 64 位系统)和 NtDll86.lib(适用于 32 位系统)。只需复制对应的文件(在我们的案例中是 NtDll64.lib,因为在 64 位 Windows 系统上,只有 64 位可执行文件才能作为原生应用程序运行),并使用该文件替代“标准”的 NtDll.lib 即可。我已经将这个 LIB 文件包含在了 SimpleNative 示例项目中。
现在,该可执行文件应该能够成功编译和链接了。
如果我们希望该可执行文件在系统启动时运行,下一步需要做的是:将 SimpleNative.exe 文件复制到 System32 目录,然后修改 开机启动 注册表值,将我们的可执行文件添加进去。图 3-13 展示了对该注册表值的修改。

(图 3-13:包含 SimpleNative 的 开机启动 注册表值)
现在重启计算机(你也可以选择在虚拟机(Virtual Machine)中进行测试),你应该能看到 Windows 版本字符串会显示几秒钟(如图 3-14 所示)。

(图 3-14:SimpleNative 应用程序运行中)
# 3.5:启动原生应用程
在某些情况下,我们可能希望直接运行原生应用程序,而不需要通过 开机启动。正如我们已经看到的,尝试从命令行窗口(command window)、文件资源管理器(Explorer)或类似工具直接运行此类应用程序会失败。在这些情况下,系统会调用 CreateProcess 函数(或其变体,如 CreateProcessAsUser),但这些 API 无法运行原生应用程序。
我们将创建一个标准的 Windows 应用程序,该应用程序能够运行原生应用程序,并接收原生应用程序的可执行文件路径和可选的命令行参数。
创建一个新的 C++ 控制台应用程序,命名为 nativerun。由于我们需要使用原生 API 来实现启动原生应用程序的功能,因此我们会添加常用的 phnt 头文件!
首先,我们使用标准的 wmain 函数来获取原生应用程序的路径和任何可选参数:
int wmain(int argc, const wchar_t* argv[])
{
if (argc < 2)
{
printf("Usage: nativerun <executable> [arguments ...]\n");
return 0;
}
2
3
4
5
6
7
接下来,我们需要将传递给原生应用程序的命令行参数构建为一个 UNICODE_STRING 类型。为了方便字符串拼接,我们将使用标准库(standard library)中的 std::wstring C++ 类:
std::wstring args;
for (int i = 2; i < argc; i++)
{
args += argv[i];
args += L" ";
}
UNICODE_STRING cmdline;
RtlInitUnicodeString(&cmdline, args.c_str());
2
3
4
5
6
7
8
我们还需要将可执行文件路径本身转换为 UNICODE_STRING 类型:
UNICODE_STRING name;
RtlInitUnicodeString(&name, argv[1]);
2
创建进程需要两个步骤:第一步是通过调用 RtlCreateProcessParameters 函数创建一个辅助结构体 RTL_USER_PROCESS_PARAMETERS,如下所示:
PRTL_USER_PROCESS_PARAMETERS params;
auto status = RtlCreateProcessParameters(¶ms, &name, nullptr, nullptr, &cmdline,
nullptr, nullptr, nullptr, nullptr, nullptr);
2
3
该函数有多个参数,我们将在第 5 章中详细介绍这些参数。目前,只需注意传递给该函数的可执行文件名称和命令行参数即可。
接下来,我们通过调用 RtlCreateUserProcess 函数来创建进程,该函数需要传入上述参数结构体以及其他相关细节,如下所示:
RTL_USER_PROCESS_INFORMATION info;
status = RtlCreateUserProcess(&name, 0, params,
nullptr, nullptr, nullptr, 0, nullptr, nullptr, &info);
RtlDestroyProcessParameters(params);
2
3
4
关于RtlCreateUserProcess这个API 的详细讨论将推迟到第5章。最后一个参数是一个输出结构,如果创建进程成功,它将包含所创建进程的一些详细信息。其中部分信息与CreateProcess调用中PROCESS_INFORMATION结构提供的信息一致。
创建的进程其第一个线程已准备就绪,但初始状态为挂起。我们需要恢复该线程以启动进程:
auto pid = HandleToULong(info.ClientId.UniqueProcess);
printf("Process 0x%X(%u) created successfully.\n ", pid, pid);
ResumeThread(info.ThreadHandle);
2
3
最后,我们可以关闭收到的两个句柄(handle),尽管在这种情况下这并不是什么大问题,因为我们的NativeRun进程很快就会退出:
CloseHandle(info.ThreadHandle);
CloseHandle(info.ProcessHandle);
2
CloseHandle或NtClose都可以完成此操作。
以下是包含错误处理的完整代码:
#include <phnt_windows.h>
#include <phnt.h>
#include <stdio.h>
#include <string>
#pragma comment(lib, "ntdll")
int Error(NTSTATUS status)
{
printf("Error (status=0x%08X)\n ", status);
return 1;
}
int wmain(int argc, const wchar_t* argv[])
{
if (argc < 2)
{
printf("Usage: nativerun <executable> [arguments . . .]\n ");
return 0;
}
//
// build command line arguments
//
std::wstring args;
for (int i = 2; i < argc; i++)
{
args += argv[i];
args += L" ";
}
UNICODE_STRING cmdline;
RtlInitUnicodeString(&cmdline, args.c_str());
UNICODE_STRING name;
RtlInitUnicodeString(&name, argv[1]);
PRTL_USER_PROCESS_PARAMETERS params;
auto status = RtlCreateProcessParameters(¶ms, &name,
nullptr, nullptr, &cmdline,
nullptr, nullptr, nullptr, nullptr, nullptr);
if (!NT_SUCCESS(status))
return Error(status);
RTL_USER_PROCESS_INFORMATION info;
status = RtlCreateUserProcess(&name, 0, params, nullptr,
nullptr, nullptr, 0, nullptr, nullptr, &info);
if (!NT_SUCCESS(status))
return Error(status);
RtlDestroyProcessParameters(params);
auto pid = HandleToULong(info.ClientId.UniqueProcess);
printf("Process 0x%X (%u) created successfully.\n ", pid, pid);
ResumeThread(info.ThreadHandle);
CloseHandle(info.ThreadHandle);
CloseHandle(info.ProcessHandle);
return 0;
}
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
让我们通过调用上一节中的原生应用程序(如SimpleNative)来测试这个应用程序。打开命令窗口,导航到生成的NativeRun.exe文件所在位置,然后输入以下命令:
C:\Chapter03\x64\Debug>nativerun.exe SimpleNative.exe
Error (status=0xC000003B)
2
我们得到一个错误,该错误值表示“对象路径组件不是目录对象(Object Path Component was not a directory object)”。或许我们需要提供完整路径:
C:\Chapter03\x64\Debug>nativerun.exe C:\Chapter03\x64\Debug\SimpleNative.exe
Error (status=0xC000003B)
2
结果相同。问题在于原生应用程序编程接口(API)期望路径采用“NT”风格,而非Win32风格。这意味着路径必须基于对象管理器(Object Manager)的命名空间(namespace)。看起来像“C:”这样的路径非常基础,但实际上并非如此。驱动器号(Drive letters)由标准Windows应用程序编程接口(API)进行特殊处理。
解决此问题的一种方法是在常规路径前添加“??”,如下所示:
C:\Chapter03\x64\Debug>nativerun.exe \??\C:\Chapter03\x64\Debug\SimpleNative.exe
Process 0xAC48 (44104) created successfully .
2
这个奇怪的“??\”是什么意思?它是对象管理器(Object Manager)命名空间中的一个目录,用于存储符号链接(symbolic links),其中之一就是“C:”。你可以使用Sysinternals的WinObj工具或我自己的Object Explorer来查看所有这些内容。图3-15显示WinObj正在显示“\Global??\”目录(“??\”的另一个名称)。它还显示了这个符号链接的目标:“Device\Harddiskvolume3”(在你的系统上可能有所不同)。

图3-15:WinObj显示符号链接(symbolic links)
你也可以将“??\c:”替换为“\Device\Harddiskvolume3”,这样同样可以正常工作:
C:\Chapter03\x64\Debug>nativerun.exe \Device\harddiskvolume3\Chapter03\x64\Debug\SimpleNative.exe
Process 0x93B0 (37808) created successfully .
2
# 3.6:调试原生应用程序
调试原生应用程序面临一些挑战。由于CreateProcess及其变体无法启动原生应用程序,因此Visual Studio或WinDbg等标准调试器(debuggers)无法启动此类可执行文件(executables)。
然而,一旦此类可执行文件启动,这些调试器完全能够附加(attach)到正在运行的进程。一个简单的调整是在NtProcessStartup函数的开头添加对NtDelayExecution的调用,以便有几秒钟的时间让你请求Visual Studio附加到该进程。
不过,我们可以做得更好。创建原生应用程序进程时,主线程不会开始运行,因此我们可以利用这一点,在此刻附加调试器,然后让线程恢复执行。在NativeRun中添加对此的支持非常简单,如下所示:
int wmain(int argc, const wchar_t* argv[])
{
if (argc < 2)
{
printf("Usage: nativerun [-d] <executable> [arguments ...]\n");
return 0;
}
int start = 1;
bool debug = _wcsicmp(argv[1], L"-d") == 0;
if (debug)
{
start = 2;
}
std::wstring args;
for (int i = start + 1; i < argc; i++)
{
args += argv[i];
args += L" ";
}
UNICODE_STRING cmdline;
RtlInitUnicodeString(&cmdline, args.c_str());
UNICODE_STRING name;
RtlInitUnicodeString(&name, argv[start]);
PRTL_USER_PROCESS_PARAMETERS params;
NTSTATUS status = RtlCreateProcessParameters(
¶ms,
&name,
nullptr,
nullptr,
&cmdline,
nullptr,
nullptr,
nullptr,
nullptr,
nullptr
);
if (!NT_SUCCESS(status))
{
return Error(status);
}
RTL_USER_PROCESS_INFORMATION info;
status = RtlCreateUserProcess(
&name,
0,
params,
nullptr,
nullptr,
nullptr,
0,
nullptr,
nullptr,
&info
);
if (!NT_SUCCESS(status))
{
return Error(status);
}
RtlDestroyProcessParameters(params);
auto pid = HandleToULong(info.ClientId.UniqueProcess);
printf("Process 0x%X (%u) created successfully.\n", pid, pid);
if (debug)
{
printf("Attach with a debugger. Press ENTER to resume thread ...\n");
char dummy[3];
gets_s(dummy);
}
ResumeThread(info.ThreadHandle);
CloseHandle(info.ThreadHandle);
CloseHandle(info.ProcessHandle);
return 0;
}
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# 3.6.1:调试开机启动原生应用程序
那么如何调试开机启动应用程序呢?这些应用程序运行得太早,无法使用任何用户模式(user-mode)调试器。对于这类应用程序,我们必须使用内核模式(kernel-mode)调试器,例如WinDbg。调试器和目标机(将运行原生应用程序的机器)的配置方式与其他任何内核调试设置相同。
如果你不熟悉内核模式调试,请查阅WinDbg文档或在线查找相关资料。此外,我所著的《Windows内核编程(Windows Kernel Programming)》一书的第5章对WinDbg的用户模式和内核模式调试有很好的介绍。
提醒一下,请将可执行文件的程序数据库(PDB,Program Database)文件(符号文件,symbols file)复制到目标机的System32目录(可执行文件所在位置),以便调试器能够轻松找到符号。
一旦目标机重启且调试器的初始断点(breakpoint)命中,你可以设置一个事件,当原生应用程序可执行文件加载时触发断点:
!gflag +ksl
sxe ld:simplenative .exe
2
第一条命令指示调试器自动加载内核符号(kernel symbols),而不是延迟加载。它提高了调试器执行第二条命令的能力,第二条命令表示当指定的镜像名称(image name)首次加载时,调试器应触发断点。
在断点处,进程(几乎)已创建。实际上,!process 0 0命令不会显示该进程。不过,你可以使用!process @$proc或!process -1查看其基本信息,因为它是当前进程:

遗憾的是,调试器对该进程的支持不够完善,因此设置的任何断点都将失败或被忽略,例如:
bp simplenative!NtProcessStartup
bp /p ffff820384609140 simplenative!NtProcessStartup
2
在NtDll.dll中设置断点同样会失败。我发现的最佳解决方法是在NtProcessStartup的开头人为添加NtDelayExecution代码,然后在主线程休眠时强制中断到调试器。之后,我们就可以使用命令正常设置断点,或者打开源文件并在想要命中断点的位置按F9。
# 3.7:总结(Summary)
在本章中,我们介绍了仅依赖NtDll.Dll的原生应用程序。这些应用程序主要用于在系统启动时由Smss.exe运行。一旦我们了解了更多关于原生应用程序编程接口(API)的知识,你可能会想到在Windows启动过程的这个早期阶段可以执行的一些操作。
在下一章中,我们将探讨可以通过原生API获取和/或修改的系统信息。