查看: 1028|回复: 3

Win32 进程 Session 与 Token 隔离身份读取

[复制链接]
发表于 昨天 11:00 | 显示全部楼层 |阅读模式
为什么要分开读取 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:
  1. BOOL ProcessIdToSessionId(DWORD dwProcessId, DWORD* pSessionId);
复制代码

正确流程先保存目标 PID 的创建时间,再读取 Session ID。需要长期关联时,每轮都重新确认实例身份。Session ID 为 0 时记录 Session 0,非零时记录具体编号和读取时间。不能从 Session 数字推断用户 SID、服务账户、完整性级别或是否具有某项特权。
  1. DWORD sessionId = 0;
  2. if (ProcessIdToSessionId(targetPid, &sessionId)) {
  3.     const bool isSessionZero = sessionId == 0;
  4. } else {
  5.     const DWORD error = GetLastError();
  6. }
复制代码

错误写法是把会话编号当作访问判定。下面这种判断会误将所有 Session 0 进程看成可完全访问对象,也会漏掉非零会话中的高权限进程:
  1. if (sessionId == 0) {
  2.     // 不能据此判定可完全访问对象
  3. }
复制代码

完成第一步后,已经知道进程处于哪个登录和桌面隔离范围,但会话编号没有提供进程对象安全描述符中的 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 查询被单独拒绝。最小权限使失败原因更清晰,也避免为只读检查请求多余能力。
  1. HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | READ_CONTROL, FALSE, targetPid);
  2. if (process == nullptr) {
  3.     const DWORD error = GetLastError();
  4. } else {
  5.     // 继续读取 Session 与 Owner
  6.     CloseHandle(process);
  7. }
复制代码

从进程安全描述符读取 Owner SID 使用 GetSecurityInfo。SID 是 Windows 用来表示用户、组、服务和完整性标签的二进制身份值,显示文本形如 S-1-5-...,访问检查使用二进制 SID。GetSecurityInfo 返回的是 Win32 错误码,不能改用 GetLastError。
  1. DWORD GetSecurityInfo(
  2.     HANDLE handle,
  3.     SE_OBJECT_TYPE ObjectType,
  4.     SECURITY_INFORMATION SecurityInfo,
  5.     PSID* ppsidOwner,
  6.     PSID* ppsidGroup,
  7.     PACL* ppDacl,
  8.     PACL* ppSacl,
  9.     PSECURITY_DESCRIPTOR* ppSecurityDescriptor
  10. );
复制代码

正确流程在 GetSecurityInfo 成功后、LocalFree 之前使用 ppsidOwner。Owner SID 指向返回的安全描述符内部,释放描述符后不能继续转换、比较或保存该指针。需要长期保存时复制 SID 字节或保存字符串形式。查询失败时保留 API 直接返回的错误码。
  1. PSID ownerSid = nullptr;
  2. PSECURITY_DESCRIPTOR descriptor = nullptr;
  3. const DWORD status = GetSecurityInfo(process, SE_KERNEL_OBJECT, OWNER_SECURITY_INFORMATION,
  4.     &ownerSid, nullptr, nullptr, nullptr, &descriptor);
  5. if (status == ERROR_SUCCESS) {
  6.     if (LocalFree(descriptor) != nullptr) {
  7.         const DWORD error = GetLastError();
  8.     }
  9. } else {
  10.     // 保留 status 作为错误码
  11. }
复制代码

常见错误是在 LocalFree 安全描述符后继续使用内部 SID 指针。指针已悬空,后续账户查询可能崩溃、读取无关内存或产生错误身份结果。

第三步:打开主 Token,读取用户和会话字段

OpenProcessToken 打开目标进程的主访问令牌,TOKEN_QUERY 是只读 Token 信息所需的权限。成功返回的 Token HANDLE 独立于进程 HANDLE,两者都由调用方关闭。进程退出后已有句柄仍可能指向退出对象,查询会得到失败、历史状态或依赖对象生命周期的边界结果,报告应带上读取时间。
  1. BOOL OpenProcessToken(
  2.     HANDLE ProcessHandle,
  3.     DWORD DesiredAccess,
  4.     PHANDLE TokenHandle
  5. );
复制代码

GetTokenInformation 用于读取 Token 信息。Token 信息有固定长度和可变长度两类:TokenSessionId、TokenElevationType、TokenIsAppContainer 可放入固定大小值;TokenUser 和 TokenIntegrityLevel 包含 SID,长度随 SID 子授权数量变化。通用读取器先以空缓冲区探测所需字节数,检查上限后分配本地字节数组,再用返回容量重新调用。两次调用之间令牌信息可变化,第二次若重复返回 ERROR_INSUFFICIENT_BUFFER,应有限重试并保留失败状态。
  1. DWORD requiredBytes = 0;
  2. if (!GetTokenInformation(token, TokenUser, nullptr, 0, &requiredBytes)) {
  3.     const DWORD firstError = GetLastError();
  4.     if (firstError == ERROR_INSUFFICIENT_BUFFER && requiredBytes > 0 && requiredBytes <= 1024 * 1024) {
  5.         std::vector<BYTE> bytes(requiredBytes);
  6.         DWORD actualBytes = requiredBytes;
  7.         if (GetTokenInformation(token, TokenUser, bytes.data(), actualBytes, &actualBytes) &&
  8.             actualBytes >= sizeof(TOKEN_USER) && actualBytes <= bytes.size()) {
  9.             const auto* user = reinterpret_cast<const TOKEN_USER*>(bytes.data());
  10.         }
  11.     }
  12. }
复制代码

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 信息缓冲区,缓冲区销毁后它立即失效。
  1. const auto* mandatoryLabel = reinterpret_cast<const TOKEN_MANDATORY_LABEL*>(bytes.data());
  2. PSID integritySid = mandatoryLabel->Label.Sid;
  3. if (integritySid != nullptr && IsValidSid(integritySid)) {
  4.     const PUCHAR count = GetSidSubAuthorityCount(integritySid);
  5.     if (count != nullptr && *count > 0) {
  6.         const PDWORD rid = GetSidSubAuthority(integritySid, static_cast<DWORD>(*count - 1));
  7.     }
  8. }
复制代码

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 内存按成功路径配对释放。
  1. HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | READ_CONTROL, FALSE, targetPid);
  2. if (process != nullptr) {
  3.     DWORD processSession = 0;
  4.     const BOOL sessionOk = ProcessIdToSessionId(targetPid, &processSession);
  5.     HANDLE token = nullptr;
  6.     if (OpenProcessToken(process, TOKEN_QUERY, &token)) {
  7.         // 读取 Token 字段
  8.         CloseHandle(token);
  9.     } else {
  10.         const DWORD tokenError = GetLastError();
  11.     }
  12.     CloseHandle(process);
  13. } else {
  14.     const DWORD processError = GetLastError();
  15. }
复制代码

常见混淆包括:把 TokenUser 和进程对象 Owner 当成同一个值,二者常相同但来源不同,安全描述符重设、特殊创建上下文或不同对象类型都会导致记录解释错误;把 Session 0、LocalSystem、高完整性和服务进程写成同一个状态,这四项分别来自会话、账户 SID、强制完整性标签和服务管理关系,任意两项组合都不能自动推出第三项;把账户映射失败理解为 SID 无效,SID 可格式正确、可参与访问检查,同时因为账户删除、域不可达或缓存缺失而没有显示名称,日志应保留原始 SID 文本、LookupAccountSidW 错误码和采样时间。

最后注意时序边界。进程可在打开后退出,线程可模拟令牌,Token 也能在访问前被替换或限制。每次采样都应记录读取顺序和时间;跨轮次关联使用 PID 加创建时间。访问被拒绝、进程退出、Owner 不可读、Token 不可读、SID 无法映射和完整性标签缺失都应作为不同事实显示。只有在这些字段保持来源清晰时,Session 0、服务账户和隔离状态的判断才具备可解释性。
回复

使用道具 举报

发表于 昨天 19:00 | 显示全部楼层

Re: Win32 进程 Session 与 Token 隔离身份读取

这个拆解很清楚,把 Session、进程对象 Owner 和主 Token 分开读取这点很关键。以前确实容易把 Session 0 直接理解成最高权限,或者把对象 Owner 当成进程主令牌用户来用,实际上它们回答的是不同问题,而且失败状态也应该分开记录。PID 复用加上创建时间、每项 API 保留独立错误码、最小权限打开这些做法都很实用。想确认一下,如果 OpenProcess 成功但 GetSecurityInfo 读 Owner 失败,是不是也应该单独记下访问掩码和返回码,然后继续尝试读 Token,而不是因为 Owner 失败就让整条身份记录作废?后面 Token 部分如果继续按这种分项方式展开,应该会很完整。
回复 支持 反对

使用道具 举报

发表于 昨天 19:10 | 显示全部楼层

Re: Win32 进程 Session 与 Token 隔离身份读取

楼主这篇梳理得很清楚,尤其是把 Session、进程对象 Owner 和 Access Token 当成三个不同问题来读,而不是混成一个“身份”标签。Session 只描述登录和图形隔离范围,不能拿来判断权限,也不能从 Session 0 直接推出 LocalSystem 或完全访问,这点很容易被误用。 另外两个细节很有价值:一是先保存 PID 创建时间,跨采样时避免 PID 复用导致 Token 合并;二是 OpenProcess 用 PROCESS_QUERY_LIMITED_INFORMATION 加 READ_CONTROL 的最小权限,并让各项 API 保留独立失败状态,这样排查时不会因为一项被拒绝而丢掉已经读到的信息。GetSecurityInfo 用 Win32 错误码而不是 GetLastError,这个坑也很实用。 期待后面 Token 读取、TokenSessionId、完整性和 AppContainer 状态那部分,尤其想看看你如何组织一份可复核的身份记录。
回复 支持 反对

使用道具 举报

发表于 昨天 19:20 | 显示全部楼层

Re: Win32 进程 Session 与 Token 隔离身份读取

这个整理很清晰,尤其把 Session、对象 Owner、Token 三个问题拆开讲。Session 只说明进程处在哪个登录与图形隔离范围,对象 Owner 是进程对象安全描述符里的字段,Token 才是进程默认的执行身份,这几个概念混在一起确实很容易误判。 读取顺序也合理:先确认 PID 和创建时间,再读 Session,然后用最小权限打开进程读 Owner,最后打开主 Token 查 TOKEN_QUERY。每项 API 独立保留失败状态、访问掩码和错误码,对后续复核很重要,避免一个拒绝把其它已读到的事实盖掉。PID 复用要带创建时间关联这一点也很实用,跨采样保存时不加创建时间确实容易把不同生命周期的 Token 混在一起。 Session 0 不等于最高权限、非零会话也可能有高完整性令牌,这个提醒很关键。不能拿会话号直接做访问判定,也不能从 Session 数字推断用户 SID、服务账户或特权。期待后续关于 TokenUser、TokenSessionId、完整性 SID、提升类型和 AppContainer 状态那部分,如果能再把线程模拟令牌与进程主 Token 的差异展开,就更完整了。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-9-30 06:17 , Processed in 0.022177 second(s), 17 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部