首页
社区
课程
招聘
[原创]跨位数进程的映像、命令行与环境块读取
发表于: 7小时前 145

[原创]跨位数进程的映像、命令行与环境块读取

7小时前
145

跨位数进程的映像、命令行与环境块读取

要完成的工作与四步流程

本篇要完成的工作是:给定一个进程 PID,取得该进程的机器类型、主映像路径和命令行,同时列出当前进程的环境变量。这里的环境变量只来自当前进程。远程进程的环境块需要另一套按位数和长度读取的方案,文中会说明它的边界。

要完成这项工作,分为四步:

  1. 输入 PID,并以所需的最小权限打开目标进程。
  2. 确认目标进程的机器类型,读取主映像路径。
  3. 按字节长度取得并读取命令行。
  4. 读取当前进程的环境块,区分本地环境块和远程环境块。

PID(Process Identifier,进程标识符)是 Windows 为一个正在运行的进程分配的数值编号。内核进程对象按它定位进程并执行访问检查。HANDLE(句柄)是当前进程持有的一项受访问控制的对象引用,OpenProcess 成功后返回进程句柄。映像路径由进程映像对象提供。命令行和环境块由用户态 PEB(Process Environment Block,进程环境块)关联的 RTL_USER_PROCESS_PARAMETERS 保存。

完整路径为:PID -> OpenProcess -> IsWow64Process2 -> QueryFullProcessImageNameW -> NtQueryInformationProcess -> GetEnvironmentStringsW -> 输出 UTF-8 文本

PEB 是 Windows 进程初始化期间建立的用户态结构,进程退出时随地址空间销毁。RTL_USER_PROCESS_PARAMETERS 是 Windows 运行库内部使用的进程参数结构,PEB 中的指针会指向它。该结构保存命令行与环境等启动资料。WOW64(Windows 32-bit on Windows 64-bit)进程同时涉及 32 位用户结构和 64 位宿主环境。x64 查询器直接按 x64 PEB 偏移读取 32 位目标会错位。优先使用 ProcessCommandLineInformation 可以让系统处理命令行表示差异。

一、第一步:输入 PID 并以最小权限打开目标进程

第一步要建立一个受权限约束的进程引用。PID 只是一串数字,后续 API 需要的是由系统返回的进程句柄。读取映像路径和机器类型需要 PROCESS_QUERY_LIMITED_INFORMATION。读取命令行中的远程地址还需要 PROCESS_VM_READ。目标已经退出、受保护进程或安全描述符拒绝访问时,打开操作会失败,后续步骤应停止。

// 作用:按 PID 打开目标进程对象。
// 返回:有效 HANDLE 成功。nullptr 失败,读取 GetLastError。成功后调用 CloseHandle。
HANDLE OpenProcess(
    DWORD dwDesiredAccess, // 输入:PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ 等权限
    BOOL bInheritHandle,   // 输入:FALSE 表示不让子进程继承此 HANDLE
    DWORD dwProcessId      // 输入:目标 PID。查询期间目标可能退出或被 PID 复用
);

// 作用:释放调用方拥有的普通内核对象访问引用。
// 返回:非零成功。0 失败,读取 GetLastError。调用后 hObject 失效。
BOOL CloseHandle(HANDLE hObject);

正确流程只请求本次工作需要的权限,并在同一作用域释放成功取得的句柄。FALSE 阻止以后创建的子进程继承该句柄。权限不足时记录错误码,不能把空句柄传给后续查询函数。

HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
    FALSE, targetPid);
if (process == nullptr) {
    const DWORD error = GetLastError();
    // 记录 targetPid 与 error。没有进程句柄时,不能继续执行机器类型、路径或命令行查询。
} else {
    // 此处执行后续查询。每个查询仍须独立检查返回值。
    if (!CloseHandle(process)) {
        const DWORD error = GetLastError();
        // 记录 error。关闭后不再使用 process。
    }
}

完成第一步后,查询器已经持有目标进程的受控访问引用。接下来应确认目标的机器类型,再读取与主映像有关的系统记录,避免把跨位数结构当成本机结构解释。

二、第二步:确认机器类型并读取主映像路径

第二步先确认机器类型,再读取映像路径。IsWow64Process2 返回目标进程的仿真机器类型与操作系统原生机器类型。它帮助读取器判断目标是否经过 WOW64 仿真,却不提供 PEB 的固定偏移。映像路径标识实际映射为主模块的文件。命令行是创建进程时提交给应用的 UTF-16 文本。UTF-16 是 Windows 宽字符串常用的编码,每个 wchar_t 保存一个 16 位代码单元。少数 Unicode 字符由两个代码单元组成。命令行可能含引号、参数、环境变量展开后的路径或与映像路径不同的启动器语义。QueryFullProcessImageNameW 可返回 Win32 路径或原生 NT 路径。它的长度参数以 wchar_t 字符数计量,成功返回的字符数不含结尾 NUL。

// 作用:查询目标进程的 WOW64 机器类型和本机原生机器类型。
// 返回:非零成功。0 失败,读取 GetLastError。两个输出指针不能为 nullptr。
BOOL IsWow64Process2(
    HANDLE hProcess,            // 输入:可查询的目标进程 HANDLE
    USHORT* pProcessMachine,    // 输出:目标的仿真机器类型或 IMAGE_FILE_MACHINE_UNKNOWN
    USHORT* pNativeMachine       // 输出:操作系统原生机器类型
);
// 作用:取得当前线程上一次失败的 Win32 API 错误码。
// 返回:DWORD 错误编号。只应在刚刚失败的 API 之后读取,参数、缓冲区和所有权均不涉及。
DWORD GetLastError(void);
// 作用:读取目标进程主映像的完整路径。
// 返回:非零成功。0 失败,读取当前线程的 last-error code。调用者提供并拥有输出缓冲区。
BOOL QueryFullProcessImageNameW(
    HANDLE hProcess,      // 输入:具有 PROCESS_QUERY_LIMITED_INFORMATION 的进程 HANDLE
    DWORD dwFlags,        // 输入:0 得到 Win32 路径。PROCESS_NAME_NATIVE 得到 NT 路径
    LPWSTR lpExeName,     // 输出:UTF-16 缓冲区。不能为 nullptr
    PDWORD lpdwSize       // 输入容量、输出实际字符数。单位 wchar_t,不能为 nullptr
);

读取映像路径时,调用方先分配本地 UTF-16 缓冲区并把容量写入 lpdwSize,再调用查询函数。成功后只使用输出字符数构造字符串,调用方继续拥有缓冲区。缓冲区不足时函数返回零且 GetLastError()==ERROR_INSUFFICIENT_BUFFER,随后扩大字符缓冲区并重试。其它错误停止本次查询。失败后立即保存错误码,字符串转换和日志 API 都可能覆盖线程错误状态。

std::vector<wchar_t> path(260, L'\0');
for (;;) {
    DWORD chars = static_cast<DWORD>(path.size()); // 输入容量,单位是 wchar_t。
    if (QueryFullProcessImageNameW(process, 0, path.data(), &chars)) {
        std::wstring image(path.data(), chars);    // chars 不含 NUL,不能调用依赖 NUL 的构造方式。
        break;
    }
    const DWORD error = GetLastError();            // 紧接失败调用取得错误。
    if (error != ERROR_INSUFFICIENT_BUFFER || path.size() >= 32768) {
        // 记录 error。process 仍归调用方,离开作用域前由 CloseHandle 释放。
        break;
    }
    path.resize(path.size() * 2, L'\0');           // 扩容后重复把新容量传入 chars。
}

完成第二步后,查询器已经得到目标的机器类型证据和主映像路径。映像路径只能说明主模块来自哪里,不能保留创建进程时提交的全部参数。接下来按系统给出的字节长度读取命令行。

三、第三步:按字节长度读取命令行

NtQueryInformationProcess 是 ntdll 的 Native API。Native API 是 Windows 用户态基础 DLL 向内核系统服务传递请求所使用的接口,返回值为 NTSTATUS 状态码。零表示 STATUS_SUCCESSPROCESSINFOCLASS 是选择所需数据类别的枚举,ProcessCommandLineInformation 的值为 60。它返回 UNICODE_STRING 和命令行数据,先用空缓冲区取得字节数,再按返回容量读取。UNICODE_STRING 是“指针加明确长度”的 UTF-16 描述。其 Length 是字节数且不含 NUL,MaximumLength 是缓冲区总容量字节数,Buffer 指向第一个 UTF-16 代码单元。Buffer 有的系统版本指向返回缓冲区内数据,有的路径可指向远程进程地址,读取器必须分别验证。

typedef struct _UNICODE_STRING {
    USHORT Length;        // 有效 UTF-16 数据长度,单位字节,不含结尾 NUL。
    USHORT MaximumLength; // Buffer 容量,单位字节。读取时也要防止 Length 超过它。
    PWSTR Buffer;         // 指针本身不拥有字符串。远程目标的地址不能直接解引用。
} UNICODE_STRING, *PUNICODE_STRING;
// 作用:查询一个进程的信息类。本篇使用 ProcessCommandLineInformation(60)。
// 返回:NTSTATUS。0 成功,STATUS_INFO_LENGTH_MISMATCH/STATUS_BUFFER_TOO_SMALL 表示缓冲区不足。
NTSTATUS NTAPI NtQueryInformationProcess(
    HANDLE ProcessHandle,              // 输入:目标进程 HANDLE。需要查询权限
    PROCESSINFOCLASS ProcessInformationClass, // 输入:信息类 60
    PVOID ProcessInformation,          // 输出:调用方缓冲区。测量阶段可为 nullptr
    ULONG ProcessInformationLength,    // 输入:缓冲区容量,单位字节
    PULONG ReturnLength                // 可选输出:实际或所需字节数。本流程提供有效指针
);
ULONG bytes = 0;
NTSTATUS status = NtQueryInformationProcess(
    process, static_cast<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);                  // 调用方拥有并管理这一段本地字节。
    const ULONG capacity = static_cast<ULONG>(raw.size());
    status = NtQueryInformationProcess(
        process, static_cast<PROCESSINFOCLASS>(60), raw.data(), capacity, &bytes);
    if (status == STATUS_SUCCESS && bytes >= sizeof(UNICODE_STRING)) {
        const auto* value = reinterpret_cast<const UNICODE_STRING*>(raw.data());
        // 先验证 Length 为偶数字节、Length <= MaximumLength、Buffer 非空,
        // 再检查 Buffer 在 raw 内。远程地址须用 ReadProcessMemory 复制精确 Length 字节。
    }
}

ReadProcessMemory 读取远程地址时,nSize 是字节数,lpNumberOfBytesRead 是实际读取字节数。调用成功也应要求实际字节数等于 UNICODE_STRING::Length。部分复制和进程退出会留下不完整文本,不能使用。

完成第三步后,查询器已经用明确的字节长度取得命令行,且区分了本地返回缓冲区与目标进程地址。最后处理环境变量时,要保留同样的长度和所有权意识,因为本地环境块与远程环境块的取得方式不同。

四、第四步:读取当前进程环境块并划清远程读取边界

环境块是一串 名称=值\0 项,最后以额外 NUL 结束,例如 Path=C:\\Windows\0TEMP=C:\\Temp\0\0。当前进程可用 GetEnvironmentStringsW 取得由系统分配的本地块,完成后以 FreeEnvironmentStringsW 释放。远程环境块必须先读远程 PEB 和参数结构,再分块读取并在返回长度范围内寻找双 NUL。目标位数决定指针字段宽度与结构偏移。

// 作用:取得当前进程环境的系统分配 UTF-16 双 NUL 字符串块。
// 返回:成功为 LPWCH。nullptr 失败,读取 GetLastError。成功块必须 FreeEnvironmentStringsW。
LPWCH GetEnvironmentStringsW(void);

// 作用:释放 GetEnvironmentStringsW 返回的环境块。
// 返回:非零成功。0 失败,读取 GetLastError。lpEnvString 不能为 nullptr。
BOOL FreeEnvironmentStringsW(LPWCH penv);

本地环境块的正确流程只适用于 GetEnvironmentStringsW 返回的本地系统分配块。系统承诺以双 NUL 结束,因此可以逐项移动到下一项的首字符。打印或复制完成后,无论项数多少都释放一次。这个遍历方法不能直接用于远程地址,因为远程地址没有本地可解引用性,也没有已知长度。

LPWCH block = GetEnvironmentStringsW();
if (block == nullptr) {
    const DWORD error = GetLastError();
    // 记录 error。此时没有获得任何块,也不能调用 FreeEnvironmentStringsW。
} else {
    for (LPWCH item = block; *item != L'\0';) {
        std::wstring oneItem(item);                // 仅对系统返回的本地、NUL 终止项使用此构造。
        item += oneItem.size() + 1;                // 跳过当前项和它的单个结尾 NUL。
    }
    if (!FreeEnvironmentStringsW(block)) {
        const DWORD error = GetLastError();        // 释放失败也需要留下错误状态。
    }
}

本地环境块已经有系统给出的双 NUL 终点和释放函数。远程环境块没有这些条件,读取器需要先复制指定的字节范围,再在本地副本内寻找结束位置。下一个调用只用于这种远程复制。

// 作用:复制目标进程的一段虚拟地址空间到调用方提供的本地缓冲区。
// 返回:非零表示请求范围已读取。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      // 可选输出:实际复制字节数。可为 nullptr,本流程提供有效指针核验完整性
);

远程字符串的正确读取先把 UNICODE_STRING::Length 当作字节数,分配同样大小的本地缓冲区,再要求实际读取量完全相等。本地 text 在读取前已经验证为非空、偶数字节且不超过合理上限。process 是此前以 PROCESS_VM_READ 打开的句柄。

std::vector<wchar_t> local(text->Length / sizeof(wchar_t));
SIZE_T copied = 0;
const BOOL ok = ReadProcessMemory(process, text->Buffer, local.data(),
    text->Length, &copied);                       // nSize 与 Length 的单位都是字节。
if (!ok || copied != text->Length) {
    const DWORD error = GetLastError();           // 保存失败或部分读取的证据。
    // 不使用 local 解析命令行。其内容可能只覆盖了远程字符串的前半段。
} else {
    std::wstring command(local.data(), local.size()); // 指定字符数,不依赖远程 NUL。
}

以下错误把字符数量传给了字节参数,也忽略了部分复制结果:

std::vector<wchar_t> local(text->Length); // 错把字节数当成字符数,多分配两倍空间。
ReadProcessMemory(process, text->Buffer, local.data(), text->Length / 2, nullptr);
// 错误:只请求了一半字节,还失去了 actual bytes 的核验机会。

远程环境读取的正确停止条件是连续两个 NUL,且两者都位于已经从远程进程复制到本地的字节范围内。单个环境项可包含特殊 =C:=C:\\... 驱动器当前目录形式,解析器不能以第一个等号前为空就丢弃它。环境块可能在目标进程修改环境时变化,分块读取中发现长度不一致时应记录部分读取并停止。

完成第四步后,当前进程的环境块已经按双 NUL 边界遍历并释放。远程环境块仍属于目标地址空间,读取它需要额外复制 PEB 与进程参数,不能沿用本地指针遍历方式。以下内容说明这些读取边界和常见错误。

远程读取的错误边界:不能按本机 PEB 偏移解释所有目标

auto* params = reinterpret_cast<RTL_USER_PROCESS_PARAMETERS*>(remotePeb + fixedOffset);
// 错误:remotePeb 是远程地址。结构位数、地址可读性和偏移都未验证。

正确流程先记录 IsWow64Process2ProcessMachineNativeMachine,选择匹配的 32/64 位远程结构定义,所有远程指针先检查可读取范围,字符串按明确 Length 复制,环境按双 NUL 结束。映像路径、命令行和环境块来自不同层,任一读取成功都不能替代另外两项。

第二步的补充:位数判定

IsWow64Process2 用两个 USHORT 输出目标进程机器类型与宿主机器类型。pProcessMachineIMAGE_FILE_MACHINE_UNKNOWN 时,目标与原生机器位数一致。其它值可为 IMAGE_FILE_MACHINE_I386 等。该函数只提供架构证据,不能说明远程 PEB 结构在所有 Windows 版本的精确偏移。

进程参数、令牌、会话和远程地址的对象关系

进程参数对象 RTL_USER_PROCESS_PARAMETERS 由用户态加载器在进程初始化时建立,并由 PEB 指向。它保存当前目录、DLL 搜索路径、映像路径、命令行、环境块、窗口标题和标准句柄等启动参数。PEB 保存加载器、堆、进程参数和会话相关入口。两者位于目标进程用户地址空间,进程退出后地址空间消失。远程读取得到的是查询时刻的字节副本,任何指针只在目标地址空间和对应位数布局中有意义。

访问令牌与进程参数服务于不同层。令牌是内核安全对象,保存用户 SID、组、权限、完整性级别和会话。进程参数保存用户态启动文本和环境。Session ID 来自进程对象或令牌信息,环境块中的 USERPROFILETEMP 等变量是启动时或运行时的用户态文本,二者可以相互印证,却不能互相替代。

远程读取前先以 OpenProcess 请求查询权限和虚拟内存读取权限。PROCESS_QUERY_LIMITED_INFORMATION 支持基础映像和位数查询。PROCESS_VM_READ 支持 ReadProcessMemory。受保护进程、已退出进程和安全描述符限制会使请求失败,失败状态需要保留为该 PID 的查询结果。

打开进程的正确流程把权限限制在本次读取所需范围。FALSE 阻止以后创建的子进程继承句柄。成功路径和失败路径都把错误码或释放责任留在同一作用域,避免句柄泄漏。映像路径和位数查询可先只申请 PROCESS_QUERY_LIMITED_INFORMATION,确实读取远程内存时再加上 PROCESS_VM_READ

HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
    FALSE, targetPid);
if (process == nullptr) {
    const DWORD error = GetLastError();
    // 记录 targetPid 与 error。没有句柄时不能继续调用任何进程查询 API。
} else {
    USHORT processMachine = 0;
    USHORT nativeMachine = 0;
    const BOOL machineOk = IsWow64Process2(process, &processMachine, &nativeMachine);
    if (!machineOk) {
        const DWORD error = GetLastError();
        // 位数证据缺失时不按固定 PEB 偏移解析远程结构。
    }
    if (!CloseHandle(process)) {
        const DWORD error = GetLastError();
        // 记录 error。当前流程不再使用这个句柄值,避免在关闭状态不明时继续访问对象。
    }
}

远程 PEB 和环境块的有界读取步骤

远程环境读取需要分层复制。第一层读取目标 PEB 中的进程参数指针。第二层读取匹配位数的 RTL_USER_PROCESS_PARAMETERS 前缀。第三层取得环境地址和可控的读取块大小。最后在本地副本中寻找连续两个 NUL。每层开始前确认远程地址为非空、长度落在上限内,ReadProcessMemory 成功后确认 lpNumberOfBytesRead 等于请求字节数。

32 位目标使用 32 位指针和 32 位结构偏移,64 位目标使用 64 位指针和 64 位结构偏移。WOW64 目标可能同时具有 32 位用户参数和 64 位宿主信息。IsWow64Process2 提供机器类型证据,调用方据此选择相应结构定义。把 x64 PEB 偏移用于 32 位目标会把命令行、环境地址和长度解释到错误位置。

环境块的读取可以按固定大小分块。每次把新块追加到本地 std::vector<wchar_t>,仅在已复制范围内检查双 NUL。达到合理上限、出现部分读取、进程退出或地址改变时停止并标记不完整。读取器还应接受以 =C: 开头的驱动器当前目录项,它的第一个等号位于名称开头,仍是 Windows 环境块中的有效条目。

SIZE_T copied = 0;
BOOL ok = ReadProcessMemory(process, remoteAddress, local.data(), requestedBytes, &copied);
if (!ok || copied != requestedBytes) {
    DWORD error = GetLastError();
    // 记录 error、copied、remoteAddress 与请求长度。当前块不能继续按完整结构解析。
    return;
}
// 仅在 local 已复制的 wchar_t 范围中扫描 \0\0,绝不对 remoteAddress 调用 wcslen。

命令行、映像路径与启动事实的边界

映像路径来自内核记录的主映像,命令行来自用户态进程参数,环境块来自用户态环境区域。三者的文本可以不同:命令行可由启动器构造,映像可经符号链接或设备路径表达,环境变量可在进程运行后修改。调查记录应分别保存路径来源、路径形式、命令行原文、环境块解析状态、进程机器类型、会话、令牌查询状态和读取时间。

以下错误写法把远程指针当成本地 C 字符串:

std::wstring command(remoteUnicodeString.Buffer);
// 错误:Buffer 属于目标进程地址空间,当前进程没有本地字符串所有权,也没有可信 NUL 边界。

正确流程先用 UNICODE_STRING::Length 确定字节数,再验证返回缓冲区内地址,或用 ReadProcessMemory 复制精确字节数。映像路径成功、命令行成功或环境块成功都只证明对应层的读取结果,进程完整身份仍需与 PID、创建时间和会话共同记录。

UTF-16 长度、路径形式和查询时序

UTF-16 使用 16 位代码单元保存 Windows 宽字符文本。UNICODE_STRING::LengthMaximumLength 的单位是字节,QueryFullProcessImageNameW 的容量参数单位是 wchar_t 字符,ReadProcessMemory 的长度参数单位是字节。混淆这三种单位会造成两倍分配、截断或越界读取。命令行中的代理对由两个 UTF-16 代码单元表示,长度除以 sizeof(wchar_t) 后得到的是代码单元数,显示层再负责 Unicode 文本呈现。

路径形式也有来源差异。Win32 路径常以盘符开头,例如 C:\\Windows\\System32\\notepad.exe。原生 NT 路径可使用 \\Device\\HarddiskVolume...。环境文本可能仍保留 %SystemRoot% 变量。命令行还可能引用启动器、脚本宿主或相对路径。记录应保存 API 使用的 flags、原始文本、字符长度和读取时间,再进行路径规范化或文件验证。把显示名、命令行第一个空格前的片段或环境变量文本当作确定映像会丢失启动语义。

IsWow64Process2 返回的两个机器编号还需要和查询器自身位数一起记录。ProcessMachineIMAGE_FILE_MACHINE_UNKNOWN 表示目标没有 WOW64 仿真层,NativeMachine 表示系统原生机器。x64 查询器读取 64 位目标时使用 64 位用户结构。32 位查询器读取 64 位目标需要额外的跨位数方案。读取 WOW64 目标时需要选择其 32 位进程参数布局。机器编号帮助选择解析结构,无法免除远程地址、长度和读取结果检查。

远程读取还受到时序影响。目标进程可在长度查询和数据查询之间退出,命令行和环境可在两次复制间变化,内存区域可被释放或保护属性改变。每次失败应保存 PID、进程创建时间、机器类型、远程地址、请求字节、实际字节和错误码。下一轮查询重新建立基线,不能复用上轮远程指针。

令牌和会话查询与 PEB 查询可以并行保存。令牌会话标识、进程对象提供的会话编号和环境中的用户目录文本可能不同步,差异需要按各自采样时间解释。只读查询应遵循最小权限:只有在确实需要远程命令行或环境时才请求 PROCESS_VM_READ,仅需映像路径时使用查询权限即可。

完整可运行程序在附件

图片描述


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

上传的附件:
收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回