为什么要分开读取 Session、对象 Owner 与 Token
Session 把登录桌面、窗口站和相关进程划分为相互隔离的运行空间。进程对象是内核对象管理器维护的可访问对象,带有自己的安全描述符和 Owner。Access Token 保存进程以哪个用户、组、特权、完整性级别和隔离状态运行。它们关联同一个进程,却回答不同问题:进程在哪个登录环境运行、谁拥有进程对象、进程以什么身份运行。
正确读取顺序是先确认进程实例和 Session,再读取进程对象 Owner,随后打开主 Token 读取用户、会话和隔离字段。进程对象 Owner 查询需要 READ_CONTROL,令牌查询需要 TOKEN_QUERY。每项 API 应保留独立失败状态,避免一项访问被拒绝时掩盖其它已读到的事实。
一份可复核的身份记录至少包含 PID、进程创建时间、进程 Session ID、对象 Owner SID、TokenUser SID、TokenSessionId、完整性 SID 与 RID、提升类型、AppContainer 状态、账户映射状态、使用的访问掩码和错误码。PID 可能复用,跨采样保存时还必须加入创建时间,同一个数值 PID 在不同生命周期中得到的 Token 不能合并。
第一步:确定目标进程的会话位置
Session 是 Windows 为登录环境划分的编号空间。控制台登录、远程桌面或快速用户切换都会分配 Session ID,该会话包含窗口站、桌面、剪贴板、输入路由和会话内进程等资源。Session ID 主要描述进程所处的登录与图形隔离范围,不能直接说明进程拥有管理员权限,也不能直接说明它属于某个服务。
Session 0 是系统服务使用的会话。Windows Vista 以后,交互用户通常登录到非零会话,服务进程留在 Session 0,使服务 UI 不会直接出现在用户桌面,这就是 Session 0 隔离。服务尝试显示对话框时,交互用户桌面通常看不到;服务应通过服务控制机制、日志、命名管道或经过授权的 IPC 与用户程序通信。
Session 0 只说明会话归属,不说明最高权限。一个 Session 0 进程可使用 LocalSystem、LocalService、NetworkService 或指定用户账户的令牌;非零会话中的进程也可能拥有高完整性令牌。Session 只是一层隔离边界,Token 权限、完整性标签、对象 DACL 和保护进程策略共同决定实际访问结果。
读取会话使用 ProcessIdToSessionId:
- BOOL ProcessIdToSessionId(DWORD dwProcessId, DWORD* pSessionId);
复制代码
正确流程先保存目标 PID 的创建时间,再读取 Session ID。需要长期关联时,每轮都重新确认实例身份。Session ID 为 0 时记录 Session 0,非零时记录具体编号和读取时间。不能从 Session 数字推断用户 SID、服务账户、完整性级别或是否具有某项特权。
- DWORD sessionId = 0;
- if (ProcessIdToSessionId(targetPid, &sessionId)) {
- const bool isSessionZero = sessionId == 0;
- } else {
- const DWORD error = GetLastError();
- }
复制代码
错误写法是把会话编号当作访问判定。下面这种判断会误将所有 Session 0 进程看成可完全访问对象,也会漏掉非零会话中的高权限进程:
- if (sessionId == 0) {
- // 不能据此判定可完全访问对象
- }
复制代码
完成第一步后,已经知道进程处于哪个登录和桌面隔离范围,但会话编号没有提供进程对象安全描述符中的 Owner,也没有提供主 Token 的运行身份。
第二步:读取进程对象 Owner
进程对象由内核对象管理器维护,有对象类型、句柄引用、访问掩码和安全描述符。安全描述符中的 Owner SID 是该进程对象的所有者,参与对象权限管理,例如对象 Owner 在通常规则下能调整对象 DACL。这个 Owner 是进程对象安全描述符字段,不能直接替代进程主令牌的用户。
主 Token 是进程默认执行身份,保存 TokenUser SID、组 SID、特权、默认 DACL、完整性标签、会话号、AppContainer 与能力等。线程还可能临时模拟另一个令牌。OpenProcessToken 读取的是进程主令牌,不读取某个线程当下的模拟令牌,因此线程正在模拟时,线程访问结果可以与进程主 Token 显示的信息不同。
对象 Owner 与 TokenUser 经常相同,因为创建进程对象时系统会使用相关安全上下文设置安全描述符。它们仍然来自不同位置,允许出现差异。调查记录应分别显示进程对象 Owner 和 TokenUser,而不是只输出一个进程所有者标签。
打开进程时使用最小权限:PROCESS_QUERY_LIMITED_INFORMATION 支持受限进程查询,READ_CONTROL 请求读取安全描述符所需的控制权限。访问受保护进程或安全描述符限制更严的进程时,OpenProcess 可能失败;也可能打开成功而 Owner 或 Token 查询被单独拒绝。最小权限使失败原因更清晰,也避免为只读检查请求多余能力。
- HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | READ_CONTROL, FALSE, targetPid);
- if (process == nullptr) {
- const DWORD error = GetLastError();
- } else {
- // 继续读取 Session 与 Owner
- CloseHandle(process);
- }
复制代码
从进程安全描述符读取 Owner SID 使用 GetSecurityInfo。SID 是 Windows 用来表示用户、组、服务和完整性标签的二进制身份值,显示文本形如 S-1-5-...,访问检查使用二进制 SID。GetSecurityInfo 返回的是 Win32 错误码,不能改用 GetLastError。
- DWORD GetSecurityInfo(
- HANDLE handle,
- SE_OBJECT_TYPE ObjectType,
- SECURITY_INFORMATION SecurityInfo,
- PSID* ppsidOwner,
- PSID* ppsidGroup,
- PACL* ppDacl,
- PACL* ppSacl,
- PSECURITY_DESCRIPTOR* ppSecurityDescriptor
- );
复制代码
正确流程在 GetSecurityInfo 成功后、LocalFree 之前使用 ppsidOwner。Owner SID 指向返回的安全描述符内部,释放描述符后不能继续转换、比较或保存该指针。需要长期保存时复制 SID 字节或保存字符串形式。查询失败时保留 API 直接返回的错误码。
- PSID ownerSid = nullptr;
- PSECURITY_DESCRIPTOR descriptor = nullptr;
- const DWORD status = GetSecurityInfo(process, SE_KERNEL_OBJECT, OWNER_SECURITY_INFORMATION,
- &ownerSid, nullptr, nullptr, nullptr, &descriptor);
- if (status == ERROR_SUCCESS) {
- if (LocalFree(descriptor) != nullptr) {
- const DWORD error = GetLastError();
- }
- } else {
- // 保留 status 作为错误码
- }
复制代码
常见错误是在 LocalFree 安全描述符后继续使用内部 SID 指针。指针已悬空,后续账户查询可能崩溃、读取无关内存或产生错误身份结果。
第三步:打开主 Token,读取用户和会话字段
OpenProcessToken 打开目标进程的主访问令牌,TOKEN_QUERY 是只读 Token 信息所需的权限。成功返回的 Token HANDLE 独立于进程 HANDLE,两者都由调用方关闭。进程退出后已有句柄仍可能指向退出对象,查询会得到失败、历史状态或依赖对象生命周期的边界结果,报告应带上读取时间。
- BOOL OpenProcessToken(
- HANDLE ProcessHandle,
- DWORD DesiredAccess,
- PHANDLE TokenHandle
- );
复制代码
GetTokenInformation 用于读取 Token 信息。Token 信息有固定长度和可变长度两类:TokenSessionId、TokenElevationType、TokenIsAppContainer 可放入固定大小值;TokenUser 和 TokenIntegrityLevel 包含 SID,长度随 SID 子授权数量变化。通用读取器先以空缓冲区探测所需字节数,检查上限后分配本地字节数组,再用返回容量重新调用。两次调用之间令牌信息可变化,第二次若重复返回 ERROR_INSUFFICIENT_BUFFER,应有限重试并保留失败状态。
- DWORD requiredBytes = 0;
- if (!GetTokenInformation(token, TokenUser, nullptr, 0, &requiredBytes)) {
- const DWORD firstError = GetLastError();
- if (firstError == ERROR_INSUFFICIENT_BUFFER && requiredBytes > 0 && requiredBytes <= 1024 * 1024) {
- std::vector<BYTE> bytes(requiredBytes);
- DWORD actualBytes = requiredBytes;
- if (GetTokenInformation(token, TokenUser, bytes.data(), actualBytes, &actualBytes) &&
- actualBytes >= sizeof(TOKEN_USER) && actualBytes <= bytes.size()) {
- const auto* user = reinterpret_cast<const TOKEN_USER*>(bytes.data());
- }
- }
- }
复制代码
TOKEN_USER::User.Sid 是令牌用户 SID,描述进程主令牌以哪个用户身份执行。若线程模拟其它令牌,当前线程实际访问使用的身份可能不同。TokenUser 映射到 LocalSystem、LocalService 或 NetworkService 等内置 SID 时,可以把它标为服务账户 SID;但这个分类不能单独证明进程由服务控制管理器启动,计划任务或其它系统组件也可以使用同类账户。
将 SID 转为可读身份信息时,账户映射把 SID 解析为本机或域中的域名称和账户用途。映射依赖本机账户数据库、域控制器和可用缓存,因此同一有效 SID 可能在离线、删除账户或跨设备环境中没有可解析名称。记录应始终保留字符串 SID,账户名作为可失败的辅助展示。
LookupAccountSidW 的正确流程先以空缓冲区探测名称和域的字符数,再按 wchar_t 字符数分配缓冲区。名称、域和 SID 字符串都使用 UTF-16,LookupAccountSidW 的容量单位是 wchar_t 字符数。返回的 SID_NAME_USE 可帮助区分用户、组、域和别名,却不授予访问权限。服务账户与服务程序要分开表达:LocalSystem 拥有系统本地账户的 Token,LocalService 与 NetworkService 使用受限的内置服务身份,SCM 可以用它们创建服务进程,也可以配置服务使用指定的本地或域账户。一个 TokenUser 命中内置服务 SID,记录应写为服务账户 SID 分类命中;是否真是 SCM 管理的服务,需要结合服务数据库、父进程和启动记录另行确认。
第四步:读取完整性、UAC 与 AppContainer 隔离字段
MIC 通过完整性标签限制跨完整性边界的访问。TokenIntegrityLevel 返回 TOKEN_MANDATORY_LABEL,其中 SID 的最后一个子授权值 RID 对应不受信任、低、中、中等加、高、系统或受保护进程等等级。常见中等完整性 RID 为 0x2000,高完整性 RID 为 0x3000,系统完整性 RID 为 0x4000。
完整性级别不能替代 DACL。访问检查同时考虑对象 DACL、令牌用户和组、特权、完整性标签、保护进程政策、AppContainer 能力与调用 API 的访问掩码。高完整性令牌不自动获得所有对象访问权,低完整性令牌也可能读到对象允许的内容。报告中应把完整性标签、请求访问掩码和实际错误码并列保存。
正确读取完整性标签时,先用变长 Token 查询得到本地缓冲区,再确认结构长度、SID 非空和 SID 格式有效,随后读取最后一个子授权 RID。SID 指针借自 Token 信息缓冲区,缓冲区销毁后它立即失效。
- const auto* mandatoryLabel = reinterpret_cast<const TOKEN_MANDATORY_LABEL*>(bytes.data());
- PSID integritySid = mandatoryLabel->Label.Sid;
- if (integritySid != nullptr && IsValidSid(integritySid)) {
- const PUCHAR count = GetSidSubAuthorityCount(integritySid);
- if (count != nullptr && *count > 0) {
- const PDWORD rid = GetSidSubAuthority(integritySid, static_cast<DWORD>(*count - 1));
- }
- }
复制代码
UAC 使用完整令牌和受限令牌帮助管理管理员账户的日常运行权限。TokenElevationTypeDefault 表示没有这种 UAC 成对令牌关系,TokenElevationTypeFull 表示当前使用完整令牌,TokenElevationTypeLimited 表示当前使用过滤令牌。提升类型描述 UAC 令牌关系,完整性级别描述 MIC 标签,两者相关却不能互相替换。
AppContainer 是应用隔离模型。TokenIsAppContainer 为真时,令牌带有 AppContainer 隔离身份,访问还会受包 SID、能力 SID、资源 ACL 和容器策略影响。它与 Session 隔离、完整性级别、桌面隔离并列工作;一个非 AppContainer 令牌也可能低完整性,一个 AppContainer 令牌也仍有 Session ID。
把字段、错误和资源放回同一份记录
整体读取应分别记录独立字段:先获取进程句柄,读取 PID Session 与对象 Owner;再打开主 Token,读取 TokenUser、TokenSessionId、TokenIntegrityLevel、TokenElevationType 和 TokenIsAppContainer;最后将 SID 映射为文本和账户显示名。每个字段使用自己的状态和错误码,资源句柄与 LocalAlloc 内存按成功路径配对释放。
- HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | READ_CONTROL, FALSE, targetPid);
- if (process != nullptr) {
- DWORD processSession = 0;
- const BOOL sessionOk = ProcessIdToSessionId(targetPid, &processSession);
- HANDLE token = nullptr;
- if (OpenProcessToken(process, TOKEN_QUERY, &token)) {
- // 读取 Token 字段
- CloseHandle(token);
- } else {
- const DWORD tokenError = GetLastError();
- }
- CloseHandle(process);
- } else {
- const DWORD processError = GetLastError();
- }
复制代码
常见混淆包括:把 TokenUser 和进程对象 Owner 当成同一个值,二者常相同但来源不同,安全描述符重设、特殊创建上下文或不同对象类型都会导致记录解释错误;把 Session 0、LocalSystem、高完整性和服务进程写成同一个状态,这四项分别来自会话、账户 SID、强制完整性标签和服务管理关系,任意两项组合都不能自动推出第三项;把账户映射失败理解为 SID 无效,SID 可格式正确、可参与访问检查,同时因为账户删除、域不可达或缓存缺失而没有显示名称,日志应保留原始 SID 文本、LookupAccountSidW 错误码和采样时间。
最后注意时序边界。进程可在打开后退出,线程可模拟令牌,Token 也能在访问前被替换或限制。每次采样都应记录读取顺序和时间;跨轮次关联使用 PID 加创建时间。访问被拒绝、进程退出、Owner 不可读、Token 不可读、SID 无法映射和完整性标签缺失都应作为不同事实显示。只有在这些字段保持来源清晰时,Session 0、服务账户和隔离状态的判断才具备可解释性。 |