在Windows系统管理和安全审计工具的开发中,经常需要根据进程PID获取目标进程的主映像路径、命令行以及环境变量。这些信息看似简单,实际处理时却存在不少陷阱,尤其是面对32位(WOW64)与64位进程混跑的环境。本文从Win32/Native API调用的角度,梳理一条最小权限、按长度读取、区分位数的完整实现路径。
整体流程可以概括为:输入PID,以最小权限OpenProcess打开进程;通过IsWow64Process2确认目标机器类型;用QueryFullProcessImageNameW读取主映像路径;用NtQueryInformationProcess的ProcessCommandLineInformation按字节长度读取命令行;最后用GetEnvironmentStringsW读取当前进程环境块,并明确远程环境块的读取边界。这条路径涉及的关键结构是PEB(进程环境块)和RTL_USER_PROCESS_PARAMETERS。前者是进程初始化时在用户态建立的结构,后者保存命令行、环境块、当前目录、标准句柄等启动参数,由PEB中的指针引用。理解这些结构对跨位数读取尤其重要,因为32位和64位进程的指针宽度和结构偏移完全不同。
第一步:以最小权限打开进程
PID只是数字标识,后续API需要的是进程句柄。OpenProcess接受访问权限、继承标志和PID三个参数。读取映像路径和机器类型只需要PROCESS_QUERY_LIMITED_INFORMATION,而要读取远程命令行内容则必须额外请求PROCESS_VM_READ。正确做法是只请求必要的权限,关闭继承标志,并在同一作用域内释放句柄。目标进程已退出、受保护或安全描述符拒绝访问时,OpenProcess会失败,错误码应记录后停止后续操作。
- HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
- FALSE, targetPid);
- if (process == nullptr) {
- const DWORD error = GetLastError();
- // 记录 error,终止查询
- } else {
- // 执行读取操作
- CloseHandle(process);
- }
复制代码
第二步:确认机器类型并读取映像路径
IsWow64Process2返回两个USHORT值,分别表示目标进程的仿真机器类型和系统原生机器类型。若pProcessMachine为IMAGE_FILE_MACHINE_UNKNOWN,说明目标进程与原生架构一致;如果是IMAGE_FILE_MACHINE_I386等值,则说明目标进程运行在WOW64仿真层下。该函数只提供架构证据,不提供PEB偏移。
读取映像路径使用QueryFullProcessImageNameW。该API可以返回Win32路径(如C:\Windows\System32\notepad.exe)或原生NT路径(如\Device\HarddiskVolume...)。其长度参数以wchar_t字符数计算,成功返回的字符数不含结尾NUL。调用时需要先分配缓冲区,把容量写入lpdwSize,再调用。缓冲区不足时返回ERROR_INSUFFICIENT_BUFFER,此时应该扩大缓冲区重试,而不是直接放弃。
- std::vector<wchar_t> path(260, L'\0');
- for (;;) {
- DWORD chars = static_cast<DWORD>(path.size());
- if (QueryFullProcessImageNameW(process, 0, path.data(), &chars)) {
- std::wstring image(path.data(), chars);
- break;
- }
- const DWORD error = GetLastError();
- if (error != ERROR_INSUFFICIENT_BUFFER || path.size() >= 32768) {
- break;
- }
- path.resize(path.size() * 2, L'\0');
- }
复制代码
第三步:按字节长度读取命令行
命令行存储在RTL_USER_PROCESS_PARAMETERS中,由UNICODE_STRING结构描述。UNICODE_STRING的Length字段是字节数且不含NUL,MaximumLength是缓冲区总容量,Buffer指向第一个UTF-16代码单元。由于不同Windows版本中Buffer指向的位置可能不同——有的指向返回缓冲区内的数据,有的直接指向远程进程地址——必须分别验证。
NtQueryInformationProcess是ntdll导出Native API,返回NTSTATUS状态码。使用进程信息类ProcessCommandLineInformation(值为60)时,可以先以空缓冲区调用获取所需字节数,再分配缓冲区并读取。系统返回的UNICODE_STRING可能直接包含命令行数据,也可能需要进一步用ReadProcessMemory从远程地址复制。
- ULONG bytes = 0;
- NTSTATUS status = NtQueryInformationProcess(process, (PROCESSINFOCLASS)60,
- nullptr, 0, &bytes);
- if ((status == STATUS_INFO_LENGTH_MISMATCH || status == STATUS_BUFFER_TOO_SMALL) &&
- bytes >= sizeof(UNICODE_STRING) && bytes <= 1024 * 1024) {
- std::vector<BYTE> raw(bytes);
- status = NtQueryInformationProcess(process, (PROCESSINFOCLASS)60,
- raw.data(), (ULONG)raw.size(), &bytes);
- if (status == STATUS_SUCCESS && bytes >= sizeof(UNICODE_STRING)) {
- const auto* value = reinterpret_cast<const UNICODE_STRING*>(raw.data());
- // 按 value->Length 读取 value->Buffer,可能需 ReadProcessMemory
- }
- }
复制代码
使用ReadProcessMemory读取远程地址时,nSize是字节数,完成后必须验证lpNumberOfBytesRead等于请求的Length。部分复制或目标进程退出会产生不完整文本,绝不能使用。
第四步:读取当前进程环境块及远程边界
环境块是一串“名称=值\0”项,最后以额外NUL结束,例如Path=C:\Windows\0TEMP=C:\Temp\0\0。当前进程可以使用GetEnvironmentStringsW获取系统分配的本地块,完成后必须用FreeEnvironmentStringsW释放。遍历时,从块首开始,逐项移动到下一项首字符,直到遇到空项。
- LPWCH block = GetEnvironmentStringsW();
- if (block == nullptr) {
- const DWORD error = GetLastError();
- } else {
- for (LPWCH item = block; *item != L'\0';) {
- std::wstring oneItem(item);
- item += oneItem.size() + 1;
- }
- FreeEnvironmentStringsW(block);
- }
复制代码
这种遍历方式只适用于本地环境块。远程进程的环境块不能直接解引用指针,因为它位于目标地址空间。远程环境读取需要分层复制:先读目标PEB获取进程参数指针,再读匹配位数的RTL_USER_PROCESS_PARAMETERS前缀,取得环境地址和长度,然后分块复制到本地,在已复制范围内寻找连续两个NUL作为结束条件。关键约束是:32位目标用32位指针和结构偏移,64位目标用64位指针和结构偏移,WOW64目标可能同时涉及32位用户参数和64位宿主环境。把x64的PEB偏移直接套在32位目标上,会把命令行、环境地址和长度解释到错误位置,这是最常见的跨位数错误。
环境块读取时,注意单环境项可以包含如=C:=C:\...这样的驱动器当前目录形式,解析器不能因为等号前为空就丢弃它。环境块在目标进程运行期间可能被修改,分块读取中发现长度不一致时应记录部分读取并停止。
常见错误与边界
最容易混淆的单位问题是:UNICODE_STRING::Length和MaximumLength以字节为单位,QueryFullProcessImageNameW的容量参数以wchar_t字符数为单位,ReadProcessMemory的nSize以字节为单位。把字符数传给字节参数,或者反过来,会造成两倍分配、截断或越界读取。
另外,进程参数、令牌、会话和环境变量是不同层次的数据。进程参数是用户态启动文本,令牌是内核安全对象,会话来自进程对象或令牌信息。环境变量中的USERPROFILE等文本可能与令牌中的SID不一致,但两者可以相互印证,不能互相替代。
读取时序也是隐患。目标进程可能在长度查询和数据查询之间退出,命令行和环境数据可能在两次复制间变化。每次读取失败都应保存PID、进程创建时间、机器类型、远程地址、请求字节、实际字节和错误码。下一轮查询必须重新建立基线,不能复用上轮远程指针。
实践建议
对于仅需映像路径和位数判断的场景,只请求PROCESS_QUERY_LIMITED_INFORMATION就够了。只有在确定要读取远程命令行或环境块时,才增加PROCESS_VM_READ权限。这样做既符合最小权限原则,也能减少因权限不足导致的打开失败。写入代码时,善用RAII或defer保证句柄释放,避免错误路径泄漏。
映像路径、命令行和环境块三者来源不同,文本可以不同。命令行可由启动器构造,映像路径可能经过符号链接,环境变量可在进程运行后修改。调查记录应分别保存路径来源、路径形式、命令行原文、环境块解析状态、进程机器类型、会话、令牌查询状态和读取时间,保证证据链完整。 |