多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

进程模块枚举、映像身份与线程启动地址关联

进程模块枚举、映像身份与线程启动地址关联 进程模块枚举、映像身份与线程启动地址关联开源项目实现https://github.com/wangwei-cm/ksword要完成对目标进程中已加载模块、PE 入口、磁盘签名和线程启动地址的关联检查需要按以下四步处理。打开目标进程并建立模块范围读取 PE 入口并验证文件签名枚举线程并读取启动地址将线程启动地址关联到模块范围处理关系是PID - 进程句柄 - 模块快照 - PE 入口和文件签名 - 线程快照 - 启动地址与模块范围的关联结果。进程的模块信息来自多个层次。模块是被装入某个进程虚拟地址空间的一段映像常见来源是 EXE、DLL、系统运行库或安全组件。每个已加载模块至少要记录进程实例、模块基址、映像大小、模块路径和读取时刻。显示名称只能帮助阅读无法唯一标识一段加载映像。线程是进程中的执行单元线程启动地址是系统保存的一项启动例程地址它描述线程建立时从哪里开始不表示线程当前正在执行哪条指令。PEPortable Executable可移植可执行格式保存 Windows 映像的头部和节表。DOS 头给出 NT 头偏移NT 头中的可选头保存AddressOfEntryPoint这是相对虚拟地址 RVARelative Virtual Address。RVA 加上当前模块基址得到入口虚拟地址。虚拟地址只在目标进程的地址空间中有意义跨进程读取前必须通过进程句柄和ReadProcessMemory复制到本地缓冲区。这个流程的核心是“范围优先”。线程地址关联时查询器把每个模块表示为半开区间[base, base size)起点包含在范围内终点排除在范围外。半开区间能让相邻映像的边界归属明确也能避免把刚好等于末地址的指针写进前一个模块。任何基址加大小或基址加 RVA 的运算都要先检查整数回绕。模块入口点、线程启动地址和当前指令地址代表三种不同事实。模块入口点来自 PE 头是加载器用到的映像入口位置。线程启动地址来自线程对象创建时保存的启动例程。当前指令地址需要挂起线程、读取上下文或借助跟踪事件不能由启动地址推导。主映像、DLL、运行库包装函数和ntdll.dll中的线程启动桩都可能出现在结果中。一、第一步打开目标进程并建立模块范围PIDProcess Identifier进程标识符是 Windows 分配给运行中进程的数值编号。进程结束后 PID 可以在未来复用因此一条模块记录至少还应带上进程创建时间或进程句柄对应的观察周期。HANDLE句柄是当前进程持有的受访问控制对象引用。OpenProcess成功后返回的进程句柄由调用方拥有最后配对一次CloseHandle。模块路径表示 Toolhelp 快照记录的文件位置签名验证针对的是该路径当前能打开的磁盘文件。加载后的内存页面可能已被重定位、修补、映射自已删除文件或来自手工映射区域因此“路径上的签名验证通过”只证明验证策略在该文件上获得成功状态。模块基址、映像大小、PE 头读取得到的入口 RVA 和采样时刻应与签名状态一同保存才能说明观察范围。GetLastError读取当前线程紧邻失败的 Win32 API 错误。后续 API、字符串处理和日志代码都可能覆盖这个线程局部状态所以失败分支应先保存数值再做其它操作。GetCurrentProcessId只返回调用进程自己的 PID不创建句柄也没有释放责任。// 作用读取当前线程最近一次失败的 Win32 错误码。// 返回DWORD 错误编号。没有输入、输出缓冲区、权限要求和资源所有权。DWORDGetLastError(void);// 作用返回调用进程的数值 PID。// 返回非零 PID。没有参数、错误码、资源所有权或关闭责任。DWORDGetCurrentProcessId(void);// 作用按 PID 建立调用方拥有的进程 HANDLE。// 返回有效 HANDLE 成功。nullptr 失败紧接着用 GetLastError 读取原因。成功句柄必须 CloseHandle。HANDLEOpenProcess(DWORD dwDesiredAccess,// 输入访问掩码。模块 PE 头读取常用 PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ。BOOL bInheritHandle,// 输入FALSE 表示子进程不继承该 HANDLE。DWORD dwProcessId// 输入目标 PID。目标可在调用前退出。);// 作用关闭调用方拥有的普通内核对象 HANDLE。// 返回非零成功。0 失败立即读取 GetLastError。成功后 hObject 失效。BOOLCloseHandle(HANDLE hObject);// 输入OpenProcess、OpenThread 或快照 API 成功返回的真实 HANDLE不能为 nullptr。正确打开流程只申请本次需要的权限。PROCESS_QUERY_LIMITED_INFORMATION支持多数受限的进程查询PROCESS_VM_READ允许把目标地址空间中的 PE 头复制到本地。受保护进程、完整性级别、进程退出和安全描述符会导致访问被拒绝。失败时当前轮没有有效进程句柄不能继续枚举或读取。constDWORD targetPidsuppliedPid0?GetCurrentProcessId():suppliedPid;HANDLE processOpenProcess(PROCESS_QUERY_LIMITED_INFORMATION|PROCESS_VM_READ,FALSE,targetPid);if(processnullptr){constDWORD errorGetLastError();// 记录 targetPid 和 error。不要把空句柄传给后续 API。}else{// 在这里创建快照、复制 PE 头和读取模块范围。if(!CloseHandle(process)){constDWORD errorGetLastError();// 当前作用域结束不再使用该句柄值。}}下面的写法跳过了句柄有效性检查。第二个调用只会制造新的参数错误原始访问失败原因也失去上下文。HANDLE processOpenProcess(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。HANDLECreateToolhelp32Snapshot(DWORD dwFlags,// 输入TH32CS_SNAPMODULE、TH32CS_SNAPMODULE32、TH32CS_SNAPTHREAD 等快照类别。DWORD th32ProcessID// 输入模块快照时为目标 PID。线程快照可传 0 后再按所有者 PID 过滤。);// 作用读取模块快照中的第一项。// 返回非零成功。0 失败读取 GetLastError。成功后 lpme 填入一项模块记录。BOOLModule32FirstW(HANDLE hSnapshot,// 输入由 CreateToolhelp32Snapshot 成功返回的模块快照 HANDLE。LPMODULEENTRY32W lpme// 输入/输出调用方分配的 MODULEENTRY32W不能为 nullptr。调用前 dwSize 必须为 sizeof(MODULEENTRY32W)。);// 作用读取模块快照中的下一项。// 返回非零成功。0 时读取 GetLastErrorERROR_NO_MORE_FILES 表示正常结束。BOOLModule32NextW(HANDLE hSnapshot,// 输入同一模块快照 HANDLE。LPMODULEENTRY32W lpme// 输入/输出同一调用方结构不能为 nullptr。输出覆盖为下一项。);MODULEENTRY32W::modBaseAddr是目标进程虚拟地址modBaseSize是映像范围长度单位字节。szModule是显示名可能与文件名相同。szExePath是宽字符路径缓冲区。dwSize是结构版本与容量约定调用Module32FirstW前必须写为sizeof(MODULEENTRY32W)。未写入时 API 无法安全判断调用方提供的结构布局。HANDLE snapshotCreateToolhelp32Snapshot(TH32CS_SNAPMODULE|TH32CS_SNAPMODULE32,targetPid);if(snapshotINVALID_HANDLE_VALUE){constDWORD errorGetLastError();}else{MODULEENTRY32Wmodule{};module.dwSizesizeof(module);// 输入容量与结构版本单位字节。if(Module32FirstW(snapshot,module)){do{constuintptr_t basereinterpret_castuintptr_t(module.modBaseAddr);constDWORD sizeBytesmodule.modBaseSize;// 保存 base、sizeBytes、szModule、szExePath 和本轮采样时间。不要只保留显示名。}while(Module32NextW(snapshot,module));constDWORD endErrorGetLastError();if(endError!ERROR_NO_MORE_FILES){// 枚举提前中断已得到的模块属于部分结果。}}else{constDWORD errorGetLastError();}CloseHandle(snapshot);}模块快照和线程快照互相独立。快照建立后目标进程可以继续加载或卸载 DLL。模块路径还可能被重命名、删除或替换。报告应分别记录“快照返回的范围”“PE 头读取结果”“文件签名验证结果”不要把其中一项成功扩展为整个生命周期的结论。错误写法忽略dwSize和正常结束错误码。循环会把ERROR_NO_MORE_FILES误报成读取故障未初始化的结构还可能直接使首项查询失败。MODULEENTRY32Wmodule;// 错误未清零也没有 module.dwSize sizeof(module)。while(Module32NextW(snapshot,module)){// 错误跳过第一项且失败原因没有区分正常结束与实际故障。}完成第一步后已经得到带有基址、映像大小、路径和采样时刻的模块范围。接下来需要从每段映像读取入口 RVA并在文件路径上执行签名验证才能为范围增加可解释的入口和身份信息。二、第二步读取 PE 入口并验证文件签名远程模块基址只是一个数值地址当前进程不能把它转换成普通指针后直接读取。ReadProcessMemory通过目标进程句柄把指定字节复制到调用方分配的本地缓冲区。它的长度参数和实际读取长度都以字节为单位。读 PE 头时需要确认实际字节数等于请求字节数部分复制不能继续解释为完整结构。// 作用把目标进程的虚拟地址范围复制到调用方本地缓冲区。// 返回非零表示读取成功。0 失败立即读取 GetLastError。ERROR_PARTIAL_COPY 表示读取范围未完整可用。BOOLReadProcessMemory(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 位可选头的该字段位置相同魔数仍要记录并校验。boolReadExact(HANDLE process,uintptr_t remoteAddress,void*local,SIZE_T bytes,DWORDerror){SIZE_T copied0;if(!ReadProcessMemory(process,reinterpret_castLPCVOID(remoteAddress),local,bytes,copied)){errorGetLastError();returnfalse;}if(copied!bytes){// 成功路径也要求完整长度避免把截断的结构字段投入解析。errorERROR_PARTIAL_COPY;returnfalse;}returntrue;}IMAGE_DOS_HEADER dos{};if(ReadExact(process,moduleBase,dos,sizeof(dos),error)dos.e_magicIMAGE_DOS_SIGNATUREdos.e_lfanew0){constuintptr_t ntHeadermoduleBasestatic_castsize_t(dos.e_lfanew);// 实现中先检查此加法是否回绕。DWORD signature0;if(ReadExact(process,ntHeader,signature,sizeof(signature),error)signatureIMAGE_NT_SIGNATURE){// 再从 OptionalHeader 的 AddressOfEntryPoint 读入 DWORD entryRva。// entryRva 必须小于模块映像大小。entryAddress moduleBase entryRva 同样先检查回绕。}}AddressOfEntryPoint是 RVA单位为字节不能当作绝对地址输出。入口 RVA 为零可由有效映像表示此时记录“没有可用入口地址”。非零 RVA 必须落在当前模块映像范围内。超出范围、DOS/NT 签名不匹配、可选头魔数无效和远程读取失败都应保存为各自状态。PE 头读取成功说明读取到的字节满足这些局部格式检查不能替代文件签名或内存完整性验证。以下错误直接解引用目标地址。该地址属于另一个进程当前进程没有对应的本地映射或访问保证访问可能异常或读到无关内存。auto*dosreinterpret_castconstIMAGE_DOS_HEADER*(moduleBase);DWORD entryRvareinterpret_castconstIMAGE_NT_HEADERS*(moduleBasedos-e_lfanew)-OptionalHeader.AddressOfEntryPoint;// 错误两次都把远程地址当成本地指针。签名验证是文件路径上的策略结果嵌入签名通常保存在 PE 文件的 Authenticode 数据中。WinVerifyTrust用指定策略检查一个文件的签名、证书链和信任条件。它返回LONG状态值ERROR_SUCCESS表示本次策略验证通过。其它值可能来自未签名文件、证书链不可用、签名损坏、策略限制、文件读取失败或缓存状态。这个接口以返回值表达结果失败后不能用GetLastError替代该LONG。// 作用按指定信任策略验证文件。// 返回LONG 状态。ERROR_SUCCESS 成功其它值直接就是验证状态调用方原样记录。LONGWinVerifyTrust(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.cbStructsizeof(fileInfo);fileInfo.pcwszFilePathmodulePath.c_str();// modulePath 的 std::wstring 在两次调用结束前保持存在。WINTRUST_DATA trustData{};trustData.cbStructsizeof(trustData);trustData.dwUIChoiceWTD_UI_NONE;trustData.fdwRevocationChecksWTD_REVOKE_NONE;trustData.dwUnionChoiceWTD_CHOICE_FILE;trustData.pFilefileInfo;trustData.dwProvFlagsWTD_CACHE_ONLY_URL_RETRIEVAL;GUID policyWINTRUST_ACTION_GENERIC_VERIFY_V2;trustData.dwStateActionWTD_STATEACTION_VERIFY;constLONG verifyStatusWinVerifyTrust(nullptr,policy,trustData);trustData.dwStateActionWTD_STATEACTION_CLOSE;WinVerifyTrust(nullptr,policy,trustData);// 关闭本次验证状态。其返回值另行记录即可。正确报告将签名状态写为“该路径在本次策略、缓存和时间条件下的验证结果”。加载模块可能已经重定位磁盘文件还可能在快照之后变动因此这个结果不能单独证明内存中的每个字节保持原样。模块范围、路径、读取时间、验证状态和错误码共同构成可复核记录。完成第二步后模块记录已有内存中的入口地址和磁盘路径的签名状态。接下来需要取得同一采样窗口内的线程启动地址才能把线程的建立位置放回这些模块范围中检查。三、第三步枚举线程并读取启动地址线程快照返回全系统可见线程的THREADENTRY32项。每项的th32OwnerProcessID用来筛选目标 PIDth32ThreadID用来打开具体线程。快照和OpenThread之间线程可能退出。打开失败应按 TID 记录不能让一个短生命周期线程中断其它线程的查询。// 作用读取线程快照首项。// 返回非零成功。0 失败读取 GetLastError。lpte 是调用方提供的 THREADENTRY32。BOOLThread32First(HANDLE hSnapshot,// 输入带 TH32CS_SNAPTHREAD 的快照 HANDLE。LPTHREADENTRY32 lpte// 输入/输出不能为 nullptr。调用前 dwSize 写为 sizeof(THREADENTRY32)成功后填充首项。);// 作用读取线程快照的下一项。// 返回非零成功。0 时读取 GetLastErrorERROR_NO_MORE_FILES 表示正常结束。BOOLThread32Next(HANDLE hSnapshot,// 输入同一个线程快照 HANDLE。LPTHREADENTRY32 lpte// 输入/输出调用方结构不能为 nullptr。成功时覆盖为下一项。);// 作用按 TID 打开一个线程对象。// 返回有效 HANDLE 成功。nullptr 失败立即读取 GetLastError。成功句柄必须 CloseHandle。HANDLEOpenThread(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。返回值是借用句柄不转移模块加载引用。HMODULEGetModuleHandleW(LPCWSTR lpModuleName// 输入UTF-16 模块名可为 nullptr 以表示当前主映像。此处传入 Lntdll.dll。);// 作用从已加载模块查询导出过程地址。// 返回FARPROC 成功。nullptr 失败读取 GetLastError。地址借用自模块有效生命周期。FARPROCGetProcAddress(HMODULE hModule,// 输入GetModuleHandleW 返回的有效模块句柄不能为 nullptr。LPCSTR lpProcName// 输入ASCII 导出名或低位序号不能为 nullptr。此处为 NtQueryInformationThread。);// 作用查询线程的 Native 信息类。本流程选择信息类 9 取得 Win32 启动地址。// 返回NTSTATUS。0 成功非零为 Native 状态码不调用 GetLastError 替代。NTSTATUS NTAPINtQueryInformationThread(HANDLE ThreadHandle,// 输入带 THREAD_QUERY_INFORMATION 的线程 HANDLE。THREADINFOCLASS ThreadInformationClass,// 输入信息类 9ThreadQuerySetWin32StartAddress。PVOID ThreadInformation,// 输出调用方缓冲区不能为 nullptr。本流程传入 PVOID 变量地址。ULONG ThreadInformationLength,// 输入缓冲区容量单位字节。本流程为 sizeof(PVOID)。PULONG ReturnLength// 可选输出实际长度。固定大小读取可为 nullptr。);usingNtQueryInformationThreadFnNTSTATUS(NTAPI*)(HANDLE ThreadHandle,THREADINFOCLASS ThreadInformationClass,PVOID ThreadInformation,ULONG ThreadInformationLength,PULONG ReturnLength);// 函数指针逐参语义与上方声明相同。它只借用 ntdll 中的导出代码地址。正确调用先解析 ntdll 导出再初始化THREADENTRY32::dwSize按所有者 PID 过滤最后为每个 TID 打开线程。启动地址输出缓冲区是一个本地PVOID变量其容量为sizeof(PVOID)字节。Native API 返回零后才把该地址转换为无符号整数交给模块范围匹配。线程句柄在每一轮都关闭。HMODULE ntdllGetModuleHandleW(Lntdll.dll);autoqueryntdllnullptr?nullptr:reinterpret_castNtQueryInformationThreadFn(GetProcAddress(ntdll,NtQueryInformationThread));if(query!nullptr){HANDLE threadOpenThread(THREAD_QUERY_INFORMATION,FALSE,tid);if(thread!nullptr){PVOID startAddressnullptr;constNTSTATUS statusquery(thread,static_castTHREADINFOCLASS(9),startAddress,sizeof(startAddress),nullptr);if(status0startAddress!nullptr){constuintptr_t addressreinterpret_castuintptr_t(startAddress);// 对每个模块检查 base address address base imageSize且先检查 base imageSize 回绕。}CloseHandle(thread);}}线程启动地址落在模块范围外是可观察状态。JIT 生成代码、运行时分配的跳板、模块卸载与重新加载、地址查询与模块快照之间的竞态都会造成未命中。未命中不自动表示恶意代码也不自动表示查询出错。记录地址、模块快照时间、模块范围和 Native 状态码后续才能选择更合适的调查方法。以下错误把范围末端包含进去。相邻模块时地址刚好等于第一个模块的结束地址会被错误关联到前一个模块。if(addressmoduleBaseaddressmoduleBasemoduleSize){// 错误使用 并且没有检查 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 头损坏”等不同情况。完整可运行程序在附件https://wangweicm.lanzouu.com/i9fwN42kkqyh
返回列表