-
-
[原创] 进程模块枚举、映像身份与线程启动地址关联
-
发表于: 19小时前 41
-
进程模块枚举、映像身份与线程启动地址关联
43aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6%4j5h3&6Y4N6$3g2A6i4K6u0V1j5$3#2Q4x3V1k6C8M7%4N6G2M7X3b7`.

要完成对目标进程中已加载模块、PE 入口、磁盘签名和线程启动地址的关联检查,需要按以下四步处理。
- 打开目标进程并建立模块范围
- 读取 PE 入口并验证文件签名
- 枚举线程并读取启动地址
- 将线程启动地址关联到模块范围
处理关系是:PID -> 进程句柄 -> 模块快照 -> PE 入口和文件签名 -> 线程快照 -> 启动地址与模块范围的关联结果。
进程的模块信息来自多个层次。模块是被装入某个进程虚拟地址空间的一段映像,常见来源是 EXE、DLL、系统运行库或安全组件。每个已加载模块至少要记录进程实例、模块基址、映像大小、模块路径和读取时刻。显示名称只能帮助阅读,无法唯一标识一段加载映像。线程是进程中的执行单元,线程启动地址是系统保存的一项启动例程地址,它描述线程建立时从哪里开始,不表示线程当前正在执行哪条指令。
PE(Portable Executable,可移植可执行)格式保存 Windows 映像的头部和节表。DOS 头给出 NT 头偏移,NT 头中的可选头保存 AddressOfEntryPoint,这是相对虚拟地址 RVA(Relative Virtual Address)。RVA 加上当前模块基址得到入口虚拟地址。虚拟地址只在目标进程的地址空间中有意义,跨进程读取前必须通过进程句柄和 ReadProcessMemory 复制到本地缓冲区。
这个流程的核心是“范围优先”。线程地址关联时,查询器把每个模块表示为半开区间 [base, base + size):起点包含在范围内,终点排除在范围外。半开区间能让相邻映像的边界归属明确,也能避免把刚好等于末地址的指针写进前一个模块。任何基址加大小或基址加 RVA 的运算都要先检查整数回绕。
模块入口点、线程启动地址和当前指令地址代表三种不同事实。模块入口点来自 PE 头,是加载器用到的映像入口位置。线程启动地址来自线程对象创建时保存的启动例程。当前指令地址需要挂起线程、读取上下文或借助跟踪事件,不能由启动地址推导。主映像、DLL、运行库包装函数和 ntdll.dll 中的线程启动桩都可能出现在结果中。
一、第一步:打开目标进程并建立模块范围
PID(Process Identifier,进程标识符)是 Windows 分配给运行中进程的数值编号。进程结束后 PID 可以在未来复用,因此一条模块记录至少还应带上进程创建时间或进程句柄对应的观察周期。HANDLE(句柄)是当前进程持有的受访问控制对象引用。OpenProcess 成功后返回的进程句柄由调用方拥有,最后配对一次 CloseHandle。
模块路径表示 Toolhelp 快照记录的文件位置,签名验证针对的是该路径当前能打开的磁盘文件。加载后的内存页面可能已被重定位、修补、映射自已删除文件或来自手工映射区域,因此“路径上的签名验证通过”只证明验证策略在该文件上获得成功状态。模块基址、映像大小、PE 头读取得到的入口 RVA 和采样时刻应与签名状态一同保存,才能说明观察范围。
GetLastError 读取当前线程紧邻失败的 Win32 API 错误。后续 API、字符串处理和日志代码都可能覆盖这个线程局部状态,所以失败分支应先保存数值再做其它操作。GetCurrentProcessId 只返回调用进程自己的 PID,不创建句柄也没有释放责任。
// 作用:读取当前线程最近一次失败的 Win32 错误码。
// 返回:DWORD 错误编号。没有输入、输出缓冲区、权限要求和资源所有权。
DWORD GetLastError(void);
// 作用:返回调用进程的数值 PID。
// 返回:非零 PID。没有参数、错误码、资源所有权或关闭责任。
DWORD GetCurrentProcessId(void);
// 作用:按 PID 建立调用方拥有的进程 HANDLE。
// 返回:有效 HANDLE 成功。nullptr 失败,紧接着用 GetLastError 读取原因。成功句柄必须 CloseHandle。
HANDLE OpenProcess(
DWORD dwDesiredAccess, // 输入:访问掩码。模块 PE 头读取常用 PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ。
BOOL bInheritHandle, // 输入:FALSE 表示子进程不继承该 HANDLE。
DWORD dwProcessId // 输入:目标 PID。目标可在调用前退出。
);
// 作用:关闭调用方拥有的普通内核对象 HANDLE。
// 返回:非零成功。0 失败,立即读取 GetLastError。成功后 hObject 失效。
BOOL CloseHandle(HANDLE hObject); // 输入:OpenProcess、OpenThread 或快照 API 成功返回的真实 HANDLE,不能为 nullptr。
正确打开流程只申请本次需要的权限。PROCESS_QUERY_LIMITED_INFORMATION 支持多数受限的进程查询,PROCESS_VM_READ 允许把目标地址空间中的 PE 头复制到本地。受保护进程、完整性级别、进程退出和安全描述符会导致访问被拒绝。失败时当前轮没有有效进程句柄,不能继续枚举或读取。
const DWORD targetPid = suppliedPid == 0 ? GetCurrentProcessId() : suppliedPid;
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
FALSE, targetPid);
if (process == nullptr) {
const DWORD error = GetLastError(); // 记录 targetPid 和 error。不要把空句柄传给后续 API。
} else {
// 在这里创建快照、复制 PE 头和读取模块范围。
if (!CloseHandle(process)) {
const DWORD error = GetLastError(); // 当前作用域结束,不再使用该句柄值。
}
}
下面的写法跳过了句柄有效性检查。第二个调用只会制造新的参数错误,原始访问失败原因也失去上下文。
HANDLE process = OpenProcess(PROCESS_VM_READ, FALSE, targetPid);
ReadProcessMemory(process, remoteBase, local, size, &copied); // 错误:process 可能为 nullptr。
CloseHandle(process); // 错误:当前代码未拥有有效句柄。
模块快照、基址和映像范围
Toolhelp 是 Windows 提供的快照枚举机制。CreateToolhelp32Snapshot 创建一个枚举视图,返回的快照句柄归调用方拥有。用 TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32 请求模块视图时,MODULEENTRY32W 给出模块名称、模块文件路径、目标进程中的基址和映像大小。x64 查询器可以枚举 x86 目标的模块。x86 查询器观察 x64 目标时会受地址宽度和 ERROR_PARTIAL_COPY 等限制。
// 作用:创建指定时刻的 Toolhelp 快照。
// 返回:有效快照 HANDLE 成功。INVALID_HANDLE_VALUE 失败,立即读取 GetLastError。成功后 CloseHandle。
HANDLE CreateToolhelp32Snapshot(
DWORD dwFlags, // 输入:TH32CS_SNAPMODULE、TH32CS_SNAPMODULE32、TH32CS_SNAPTHREAD 等快照类别。
DWORD th32ProcessID // 输入:模块快照时为目标 PID。线程快照可传 0 后再按所有者 PID 过滤。
);
// 作用:读取模块快照中的第一项。
// 返回:非零成功。0 失败,读取 GetLastError。成功后 lpme 填入一项模块记录。
BOOL Module32FirstW(
HANDLE hSnapshot, // 输入:由 CreateToolhelp32Snapshot 成功返回的模块快照 HANDLE。
LPMODULEENTRY32W lpme // 输入/输出:调用方分配的 MODULEENTRY32W,不能为 nullptr。调用前 dwSize 必须为 sizeof(MODULEENTRY32W)。
);
// 作用:读取模块快照中的下一项。
// 返回:非零成功。0 时读取 GetLastError,ERROR_NO_MORE_FILES 表示正常结束。
BOOL Module32NextW(
HANDLE hSnapshot, // 输入:同一模块快照 HANDLE。
LPMODULEENTRY32W lpme // 输入/输出:同一调用方结构,不能为 nullptr。输出覆盖为下一项。
);
MODULEENTRY32W::modBaseAddr 是目标进程虚拟地址,modBaseSize 是映像范围长度,单位字节。szModule 是显示名,可能与文件名相同。szExePath 是宽字符路径缓冲区。dwSize 是结构版本与容量约定,调用 Module32FirstW 前必须写为 sizeof(MODULEENTRY32W)。未写入时 API 无法安全判断调用方提供的结构布局。
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, targetPid);
if (snapshot == INVALID_HANDLE_VALUE) {
const DWORD error = GetLastError();
} else {
MODULEENTRY32W module{};
module.dwSize = sizeof(module); // 输入容量与结构版本,单位字节。
if (Module32FirstW(snapshot, &module)) {
do {
const uintptr_t base = reinterpret_cast<uintptr_t>(module.modBaseAddr);
const DWORD sizeBytes = module.modBaseSize;
// 保存 base、sizeBytes、szModule、szExePath 和本轮采样时间。不要只保留显示名。
} while (Module32NextW(snapshot, &module));
const DWORD endError = GetLastError();
if (endError != ERROR_NO_MORE_FILES) {
// 枚举提前中断,已得到的模块属于部分结果。
}
} else {
const DWORD error = GetLastError();
}
CloseHandle(snapshot);
}
模块快照和线程快照互相独立。快照建立后,目标进程可以继续加载或卸载 DLL。模块路径还可能被重命名、删除或替换。报告应分别记录“快照返回的范围”“PE 头读取结果”“文件签名验证结果”,不要把其中一项成功扩展为整个生命周期的结论。
错误写法忽略 dwSize 和正常结束错误码。循环会把 ERROR_NO_MORE_FILES 误报成读取故障,未初始化的结构还可能直接使首项查询失败。
MODULEENTRY32W module; // 错误:未清零,也没有 module.dwSize = sizeof(module)。
while (Module32NextW(snapshot, &module)) {
// 错误:跳过第一项,且失败原因没有区分正常结束与实际故障。
}
完成第一步后,已经得到带有基址、映像大小、路径和采样时刻的模块范围。接下来需要从每段映像读取入口 RVA,并在文件路径上执行签名验证,才能为范围增加可解释的入口和身份信息。
二、第二步:读取 PE 入口并验证文件签名
远程模块基址只是一个数值地址,当前进程不能把它转换成普通指针后直接读取。ReadProcessMemory 通过目标进程句柄把指定字节复制到调用方分配的本地缓冲区。它的长度参数和实际读取长度都以字节为单位。读 PE 头时需要确认实际字节数等于请求字节数,部分复制不能继续解释为完整结构。
// 作用:把目标进程的虚拟地址范围复制到调用方本地缓冲区。
// 返回:非零表示读取成功。0 失败,立即读取 GetLastError。ERROR_PARTIAL_COPY 表示读取范围未完整可用。
BOOL ReadProcessMemory(
HANDLE hProcess, // 输入:具有 PROCESS_VM_READ 权限的进程 HANDLE,不能为 nullptr。
LPCVOID lpBaseAddress, // 输入:目标进程内起始地址,不能为 nullptr。地址归目标进程地址空间所有。
LPVOID lpBuffer, // 输出:调用方分配的本地可写缓冲区,不能为 nullptr,容量至少为 nSize 字节。
SIZE_T nSize, // 输入:请求复制长度,单位字节。
SIZE_T* lpNumberOfBytesRead // 可选输出:实际复制字节数。本流程提供有效地址以核验完整读取。
);
PE 头的读取顺序固定且有边界。先复制 IMAGE_DOS_HEADER,确认 e_magic == IMAGE_DOS_SIGNATURE。再检查 e_lfanew 为非负、落在可接受的头部偏移范围,并以不回绕的加法得到 NT 头地址。读取 NT 签名并确认 IMAGE_NT_SIGNATURE。跳过签名和 IMAGE_FILE_HEADER 后读取可选头魔数。最后读取 AddressOfEntryPoint。32 位和 64 位可选头的该字段位置相同,魔数仍要记录并校验。
bool ReadExact(HANDLE process, uintptr_t remoteAddress, void* local, SIZE_T bytes, DWORD& error) {
SIZE_T copied = 0;
if (!ReadProcessMemory(process, reinterpret_cast<LPCVOID>(remoteAddress), local, bytes, &copied)) {
error = GetLastError();
return false;
}
if (copied != bytes) { // 成功路径也要求完整长度,避免把截断的结构字段投入解析。
error = ERROR_PARTIAL_COPY;
return false;
}
return true;
}
IMAGE_DOS_HEADER dos{};
if (ReadExact(process, moduleBase, &dos, sizeof(dos), error) &&
dos.e_magic == IMAGE_DOS_SIGNATURE && dos.e_lfanew >= 0) {
const uintptr_t ntHeader = moduleBase + static_cast<size_t>(dos.e_lfanew); // 实现中先检查此加法是否回绕。
DWORD signature = 0;
if (ReadExact(process, ntHeader, &signature, sizeof(signature), error) &&
signature == IMAGE_NT_SIGNATURE) {
// 再从 OptionalHeader 的 AddressOfEntryPoint 读入 DWORD entryRva。
// entryRva 必须小于模块映像大小。entryAddress = moduleBase + entryRva 同样先检查回绕。
}
}
AddressOfEntryPoint 是 RVA,单位为字节,不能当作绝对地址输出。入口 RVA 为零可由有效映像表示,此时记录“没有可用入口地址”。非零 RVA 必须落在当前模块映像范围内。超出范围、DOS/NT 签名不匹配、可选头魔数无效和远程读取失败都应保存为各自状态。PE 头读取成功说明读取到的字节满足这些局部格式检查,不能替代文件签名或内存完整性验证。
以下错误直接解引用目标地址。该地址属于另一个进程,当前进程没有对应的本地映射或访问保证,访问可能异常或读到无关内存。
auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(moduleBase);
DWORD entryRva = reinterpret_cast<const IMAGE_NT_HEADERS*>(moduleBase + dos->e_lfanew)
->OptionalHeader.AddressOfEntryPoint; // 错误:两次都把远程地址当成本地指针。
签名验证是文件路径上的策略结果
嵌入签名通常保存在 PE 文件的 Authenticode 数据中。WinVerifyTrust 用指定策略检查一个文件的签名、证书链和信任条件。它返回 LONG 状态值:ERROR_SUCCESS 表示本次策略验证通过。其它值可能来自未签名文件、证书链不可用、签名损坏、策略限制、文件读取失败或缓存状态。这个接口以返回值表达结果,失败后不能用 GetLastError 替代该 LONG。
// 作用:按指定信任策略验证文件。
// 返回:LONG 状态。ERROR_SUCCESS 成功,其它值直接就是验证状态,调用方原样记录。
LONG WinVerifyTrust(
HWND hwnd, // 输入:可为 nullptr。本工具无父窗口并禁止 UI。
GUID* pgActionID,// 输入:策略 GUID,不能为 nullptr。此处为 WINTRUST_ACTION_GENERIC_VERIFY_V2 的可写 GUID 对象地址。
LPVOID pWVTData // 输入/输出:指向 WINTRUST_DATA,不能为 nullptr。其 dwUnionChoice 指定 pFile 等联合成员。
);
WINTRUST_FILE_INFO 保存路径字符串指针,字符串必须在验证调用期间保持有效。WINTRUST_DATA 指定文件联合、UI 选择、撤销检查、状态动作和提供者标志。使用 WTD_STATEACTION_VERIFY 后应以同一结构和 WTD_STATEACTION_CLOSE 再调用一次,释放验证器可能保留的状态。WTD_CACHE_ONLY_URL_RETRIEVAL 使验证只使用本地缓存,避免枚举程序因网络检索长时间阻塞。这也会让某些链状态在缓存缺失时无法通过。
WINTRUST_FILE_INFO fileInfo{};
fileInfo.cbStruct = sizeof(fileInfo);
fileInfo.pcwszFilePath = modulePath.c_str(); // modulePath 的 std::wstring 在两次调用结束前保持存在。
WINTRUST_DATA trustData{};
trustData.cbStruct = sizeof(trustData);
trustData.dwUIChoice = WTD_UI_NONE;
trustData.fdwRevocationChecks = WTD_REVOKE_NONE;
trustData.dwUnionChoice = WTD_CHOICE_FILE;
trustData.pFile = &fileInfo;
trustData.dwProvFlags = WTD_CACHE_ONLY_URL_RETRIEVAL;
GUID policy = WINTRUST_ACTION_GENERIC_VERIFY_V2;
trustData.dwStateAction = WTD_STATEACTION_VERIFY;
const LONG verifyStatus = WinVerifyTrust(nullptr, &policy, &trustData);
trustData.dwStateAction = WTD_STATEACTION_CLOSE;
WinVerifyTrust(nullptr, &policy, &trustData); // 关闭本次验证状态。其返回值另行记录即可。
正确报告将签名状态写为“该路径在本次策略、缓存和时间条件下的验证结果”。加载模块可能已经重定位,磁盘文件还可能在快照之后变动,因此这个结果不能单独证明内存中的每个字节保持原样。模块范围、路径、读取时间、验证状态和错误码共同构成可复核记录。
完成第二步后,模块记录已有内存中的入口地址和磁盘路径的签名状态。接下来需要取得同一采样窗口内的线程启动地址,才能把线程的建立位置放回这些模块范围中检查。
三、第三步:枚举线程并读取启动地址
线程快照返回全系统可见线程的 THREADENTRY32 项。每项的 th32OwnerProcessID 用来筛选目标 PID,th32ThreadID 用来打开具体线程。快照和 OpenThread 之间线程可能退出。打开失败应按 TID 记录,不能让一个短生命周期线程中断其它线程的查询。
// 作用:读取线程快照首项。
// 返回:非零成功。0 失败,读取 GetLastError。lpte 是调用方提供的 THREADENTRY32。
BOOL Thread32First(
HANDLE hSnapshot, // 输入:带 TH32CS_SNAPTHREAD 的快照 HANDLE。
LPTHREADENTRY32 lpte // 输入/输出:不能为 nullptr。调用前 dwSize 写为 sizeof(THREADENTRY32),成功后填充首项。
);
// 作用:读取线程快照的下一项。
// 返回:非零成功。0 时读取 GetLastError,ERROR_NO_MORE_FILES 表示正常结束。
BOOL Thread32Next(
HANDLE hSnapshot, // 输入:同一个线程快照 HANDLE。
LPTHREADENTRY32 lpte // 输入/输出:调用方结构,不能为 nullptr。成功时覆盖为下一项。
);
// 作用:按 TID 打开一个线程对象。
// 返回:有效 HANDLE 成功。nullptr 失败,立即读取 GetLastError。成功句柄必须 CloseHandle。
HANDLE OpenThread(
DWORD dwDesiredAccess, // 输入:THREAD_QUERY_INFORMATION 用于启动地址查询。
BOOL bInheritHandle, // 输入:FALSE 表示不继承。
DWORD dwThreadId // 输入:目标线程 TID。线程可能已退出或 TID 已复用。
);
Win32 公共 API 没有直接提供所有线程启动地址的稳定接口。NtQueryInformationThread 是 ntdll 导出的 Native API,返回 NTSTATUS,零值 STATUS_SUCCESS 表示成功。通过 GetModuleHandleW 取得当前进程已加载的 ntdll 借用模块句柄,再用 GetProcAddress 查询导出地址。借用模块句柄没有增加加载引用,调用方不得主动卸载模块。
// 作用:取得当前进程已经加载的模块句柄。
// 返回:有效 HMODULE 成功。nullptr 失败,读取 GetLastError。返回值是借用句柄,不转移模块加载引用。
HMODULE GetModuleHandleW(
LPCWSTR lpModuleName // 输入:UTF-16 模块名,可为 nullptr 以表示当前主映像。此处传入 L"ntdll.dll"。
);
// 作用:从已加载模块查询导出过程地址。
// 返回:FARPROC 成功。nullptr 失败,读取 GetLastError。地址借用自模块有效生命周期。
FARPROC GetProcAddress(
HMODULE hModule, // 输入:GetModuleHandleW 返回的有效模块句柄,不能为 nullptr。
LPCSTR lpProcName // 输入:ASCII 导出名或低位序号,不能为 nullptr。此处为 "NtQueryInformationThread"。
);
// 作用:查询线程的 Native 信息类。本流程选择信息类 9 取得 Win32 启动地址。
// 返回:NTSTATUS。0 成功,非零为 Native 状态码,不调用 GetLastError 替代。
NTSTATUS NTAPI NtQueryInformationThread(
HANDLE ThreadHandle, // 输入:带 THREAD_QUERY_INFORMATION 的线程 HANDLE。
THREADINFOCLASS ThreadInformationClass, // 输入:信息类 9(ThreadQuerySetWin32StartAddress)。
PVOID ThreadInformation, // 输出:调用方缓冲区,不能为 nullptr。本流程传入 PVOID 变量地址。
ULONG ThreadInformationLength, // 输入:缓冲区容量,单位字节。本流程为 sizeof(PVOID)。
PULONG ReturnLength // 可选输出:实际长度。固定大小读取可为 nullptr。
);
using NtQueryInformationThreadFn = NTSTATUS(NTAPI*)(
HANDLE ThreadHandle, THREADINFOCLASS ThreadInformationClass, PVOID ThreadInformation,
ULONG ThreadInformationLength, PULONG ReturnLength
); // 函数指针逐参语义与上方声明相同。它只借用 ntdll 中的导出代码地址。
正确调用先解析 ntdll 导出,再初始化 THREADENTRY32::dwSize,按所有者 PID 过滤,最后为每个 TID 打开线程。启动地址输出缓冲区是一个本地 PVOID 变量,其容量为 sizeof(PVOID) 字节。Native API 返回零后才把该地址转换为无符号整数,交给模块范围匹配。线程句柄在每一轮都关闭。
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
auto query = ntdll == nullptr ? nullptr : reinterpret_cast<NtQueryInformationThreadFn>(
GetProcAddress(ntdll, "NtQueryInformationThread"));
if (query != nullptr) {
HANDLE thread = OpenThread(THREAD_QUERY_INFORMATION, FALSE, tid);
if (thread != nullptr) {
PVOID startAddress = nullptr;
const NTSTATUS status = query(thread, static_cast<THREADINFOCLASS>(9),
&startAddress, sizeof(startAddress), nullptr);
if (status == 0 && startAddress != nullptr) {
const uintptr_t address = reinterpret_cast<uintptr_t>(startAddress);
// 对每个模块检查 base <= address && address < base + imageSize,且先检查 base + imageSize 回绕。
}
CloseHandle(thread);
}
}
线程启动地址落在模块范围外是可观察状态。JIT 生成代码、运行时分配的跳板、模块卸载与重新加载、地址查询与模块快照之间的竞态都会造成未命中。未命中不自动表示恶意代码,也不自动表示查询出错。记录地址、模块快照时间、模块范围和 Native 状态码,后续才能选择更合适的调查方法。
以下错误把范围末端包含进去。相邻模块时,地址刚好等于第一个模块的结束地址会被错误关联到前一个模块。
if (address >= moduleBase && address <= moduleBase + moduleSize) {
// 错误:使用 <=,并且没有检查 moduleBase + moduleSize 是否发生整数回绕。
}
完成第三步后,已经得到每个可查询线程的启动地址及其 Native 查询状态。下一步要使用第一步保存的半开模块范围匹配这些地址,并把入口点与签名结果作为独立证据一同解释。
四、第四步:将线程启动地址关联到模块范围
一个模块的 PE 入口地址适合描述映像声明的加载入口。主 EXE 的入口通常与运行库启动代码相关,DLL 的入口可用于加载通知,某些有效映像会把入口 RVA 置零。线程启动地址属于线程对象,它可能指向应用函数、运行库包装、系统线程入口或 ntdll 启动桩。把线程启动地址命中某模块写成“关联到模块范围”最准确。它不表示线程当前正在该模块执行,也不等价于调用栈归属。
签名验证则是文件级信任事实。ERROR_SUCCESS 说明给定路径在给定策略下通过。0x800B0100 一类状态可能表示当前链信息不足,也可能与文件来源、缓存和策略有关。模块名称相同、路径相同或签名状态相同都无法单独确认内存映像内容相同。需要更强身份时,可另行记录文件标识、文件大小、哈希、映像节范围、进程创建时间和加载事件,但这些字段仍要保留各自的时间与来源。
一次模块快照不提供系统暂停。加载器可在快照后加载或卸载模块,线程也可在线程快照与 OpenThread 之间退出。程序应把结果标注为“该采样窗口中的可见范围”,并按模块、线程和签名字段分别保存失败状态。短暂竞态、访问被拒绝、跨位数限制、远程头部读取失败和签名缓存缺失具有不同排查含义,不能合并为一个“模块异常”标签。
跨位数也是边界条件。x64 查询器观察 WOW64 目标时需要保留 32 位模块范围与指针宽度。x86 查询器观察 x64 目标会受到 API 地址宽度限制。PE 可选头的魔数、模块基址、映像大小、线程地址和读取器自身位数应一同记录。把 64 位地址截断到 DWORD 会使范围判断落到错误位置。
第四步完成后,结果应保留各字段的来源和采样时间。一个可复核的模块—线程关联记录至少包含:进程 PID 与创建时间、采样时间、模块基址和大小、模块路径与显示名、PE 魔数、入口 RVA 与入口地址、PE 读取错误、签名策略与返回状态、TID、线程启动地址、范围命中模块和 Native 查询状态。这样的记录能清楚地区分“没有模块快照”“地址未命中”“线程退出”“签名状态非成功”和“PE 头损坏”等不同情况。
完整可运行程序在附件

7c8K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4j5h3&6Y4N6$3g2A6j5$3#2Q4x3X3g2D9j5h3&6*7L8%4g2#2i4K6u0W2j5$3!0E0i4K6u0r3K9e0W2X3N6@1^5@1x3X3E0C8M7i4W2Z5
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。