资讯动态

突破Session 0隔离:远线程注入的令牌路由方案与避坑指南

发布时间:2026/10/20 7:18:08 来源:尧图企业网站定制
1. 从需求讲起服务进程为什么要跨过SESSION 0先说一个我早年踩过的真实场景。当时我在做一个终端管理工具的后台服务功能很简单捕获当前登录用户在某个业务系统里的操作行为做合规审计。服务跑在Windows服务里Session 0但是用户进程跑在Session 1甚至Session 2。最初方案是直接在服务里对用户进程做远线程注入把抓取DLL塞进目标进程。结果就是一连串的拒绝访问当时我还以为是权限不够狂调OpenProcess的参数后来才发现问题压根不在权限上而在会话隔离上。这个问题的本质要从Windows Vista说起。在XP时代服务进程和用户进程共享同一个Session 0所以服务可以创建窗口、弹消息框甚至直接给用户桌面的窗口发消息。好处是方便坏处是恶意软件也方便。比如一个以SYSTEM权限运行的服务可以直接操控用户桌面上的所有程序弹窗、截屏、注入什么都行。微软从Vista开始引入SESSION 0隔离所有服务统统治进Session 0第一个登录的用户进Session 1后续用户进Session 2、3……服务默认接触不到用户会话的任何UI元素。但业务需求不会因为隔离就消失。远程支持、屏幕共享、输入法注入、终端审计、EDR的进程行为监控全都需要服务与用户会话交互。于是突破SESSION 0隔离成了一个绕不开的话题。这篇文章我就详细拆一下远线程注入在跨会话场景下的原理、实现路线和实际坑位适合做Windows系统编程、安全研究或者终端软件开发的读者参考。2. 先搞清楚远线程注入的标准玩法2.1 注入的本质让目标进程执行我们的代码远线程注入的核心思路非常简单在目标进程的地址空间里分配一块内存把我们要加载的DLL路径写进去然后让目标进程执行LoadLibrary让系统在目标进程里加载这个DLL。原理上走的是这么四步OpenProcess拿到目标进程句柄要求有PROCESS_CREATE_THREAD、PROCESS_VM_OPERATION、PROCESS_VM_WRITE、PROCESS_VM_READ权限。VirtualAllocEx在目标进程里分配内存大小至少够存DLL的全路径。WriteProcessMemory把DLL路径字符串写进刚刚分配的内存。CreateRemoteThread在目标进程里创建一个线程线程入口指向LoadLibraryA参数指向刚写入的路径字符串。如果拿C/C写的经典模板大概长这样HANDLE hProcess OpenProcess( PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, FALSE, dwPid); LPVOID pRemoteBuf VirtualAllocEx(hProcess, NULL, MAX_PATH, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); WriteProcessMemory(hProcess, pRemoteBuf, (LPVOID)dllPath, strlen(dllPath) 1, NULL); LPTHREAD_START_ROUTINE pLoadLibrary (LPTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandleA(kernel32.dll), LoadLibraryA); HANDLE hThread CreateRemoteThread(hProcess, NULL, 0, pLoadLibrary, pRemoteBuf, 0, NULL); WaitForSingleObject(hThread, INFINITE);这套模板在任何一本Windows核心编程的书里都有执行起来也没什么玄学。但请注意这套模板默认了调用方和目标进程在同一个会话里。一旦跨会话事情就不对了。2.2 为什么标准模板在跨会话时会翻车我第一次跨会话跑这个模板OpenProcess用SYSTEM权限加上SeDebugPrivilege句柄拿到了VirtualAllocEx也成功了结果CreateRemoteThread直接返回失败错误码是ERROR_ACCESS_DENIED。后来反复测试总结出三个层面的问题第一句柄权限问题。跨会话打开对方进程仅仅有SYSTEM令牌是不够的。有时候进程的DACL会显式拒绝或允许某些SID访问特别是保护进程Protected Process Light级别以上的SYSTEM也打不开。这不是会话隔离本身但跨会话场景下很容易触发。第二线程的Session归属问题。CreateRemoteThread创建的线程是从哪来的它的Session归属并不取决于目标进程而是继承自调用线程所属的Session和Window Station。也就是说你在Session 0的服务里调用CreateRemoteThread即使目标进程是Session 1的新线程起来后它的Win32k内核对象归属仍然是Session 0的。如果这个线程想创建窗口、访问desktop它仍然会受Session隔离约束。第三DLL加载的上下文问题。虽然你WriteProcessMemory写进去了路径但LoadLibrary的DLL搜索路径在目标进程里是基于目标进程的环境变量。如果路径写的是相对路径或者依赖目标会话里的某个环境变量结果就是LoadLibrary返回NULLDLL根本没进去。所以说如果是服务想注入用户进程标准四步曲已经不够用了。你得先解决让代码在目标会话上下文中运行的问题再考虑注入本身。3. SESSION 0隔离真正的三个层次3.1 会话、窗口站、桌面从大到小的三重边界做Windows编程的尤其是碰UI或者注入的必须把Session、Window Station、Desktop这三层关系理顺。Windows的会话隔离不是一个单点机制而是层层嵌套的三层结构层级作用隔离内容跨边界访问方式Session独立的内核对象命名空间、独立的会话空间窗口消息、剪贴板、全局原子、内核对象子集需要Token或句柄复制Window Station包含剪贴板、原子的容器剪贴板、原子表、部分窗口句柄无法直接访问其他StationDesktop包含窗口、钩子窗口消息、鼠标键盘输入SetThreadDesktop可切换服务进程默认在Session 0的Service-0x0-3e7$窗口站非交互桌面NonInteractive Desktop。用户进程在Session 1的WinSta0窗口站Default桌面。大部分人只注意到Session这层忽略了Window Station和Desktop这导致跨会话注入即使把DLL加载进去了DLL内部如果碰了UI、钩子、剪贴板照样是瞎忙活。3.2 服务令牌与用户令牌的差距Session 0里的服务令牌类型通常是LocalSystem或者LocalService/NetworkService。LocalSystem令牌权限极高但它没有加载用户配置文件的用户令牌特征用户进程的令牌是用户登录后由Winlogon创建的包含用户SID、组成员身份以及该用户特有的安全上下文。所以跨会话执行时最干净的做法不是试图从服务侧够到用户侧而是拿到用户的令牌然后用这个令牌在目标会话里启动一个新的进程来完成注入动作。这有点像你想进一个门禁卡控制的大楼与其在外面想办法撬锁不如找到门口保安Winlogon的机制给你一张合法的门禁卡。3.3 SESSION 0这道墙到底挡了什么Vista之后的隔离挡的具体是这么几类事服务创建的窗口无法显示在用户桌面上。服务无法给用户桌面的窗口发送消息SendMessage、PostMessage。服务无法枚举或操作其他Session的进程窗口。服务直接远线程注入用户进程后新线程的UI操作默认落在Session 0的非交互桌面导致失败或静默无效。服务无法直接访问用户会话里创建的全局命名对象需要正确的SD。但需要注意的是这套隔离通过获取目标会话用户令牌 → 以该令牌在目标会话创建进程 → 注入这条路是可以绕开的——这正是很多远程支持工具和辅助软件的做法也是恶意软件盯上的路径。4. 突破隔离的主流路线三条路线的取舍4.1 路线A用户令牌路由最推荐这是我在实际项目里最终采用的方案核心逻辑是不强行跨Session注入而是让注入动作发生在目标会话内部。具体流程服务调用WTSEnumerateSessions枚举所有会话找到目标用户的活跃会话State WTSActive。调用WTSQueryUserToken拿到该会话的用户令牌。DuplicateTokenEx把令牌改成主令牌Primary Token并指定TokenPrimary。用CreateProcessAsUser以该令牌在目标会话中启动一个轻量helper进程。helper进程自身位于目标会话中再执行标准远线程注入。这个方案表面上多了一步但安全性、稳定性都很好。因为它从根上解决了上下文归属问题helper进程创建出来的线程天然属于目标Session后续DLL里的任何UI操作、钩子安装、消息发送都不会被隔离拦截。4.2 路线B服务内直接跨会话注入这种是我一开始考虑过、后来放弃的方案。流程上就是前面说的标准四步曲但需要额外处理几件事启用SeDebugPrivilege提权。手动处理目标进程的ACL有些进程需要调整DACL才能打开。注入成功后DLL内部要自己切换Window Station和Desktop调用SetProcessWindowStation和SetThreadDesktop切到目标会话的WinSta0\Default。如果DLL要访问用户配置相关的资源还得用WTSQueryUserToken获取用户令牌来模拟Impersonate。路B最大的坑是上下文割裂代码虽然跑在目标进程里但内核层面的Session归属还是从调用线程继承来的涉及窗口的操作都需要手动切换。如果只是做非UI的数据采集这条路够用一旦牵扯UI就等着踩坑吧。4.3 路线C替代注入载体APC注入、线程劫持还有一些变体方案QueueUserAPC注入、SetThreadContext劫持、手动映射Manual Map。APC注入需要目标线程处于可告警状态跨会话场景下还得解决同样的Token和权限问题手动映射不依赖LoadLibrary能绕过DLL加载的路径和依赖问题但工程量大还要自己搞定重定位和导入表。我做技术选型时给这几个方案排了个优先级需求推荐方案原因服务需要稳定注入用户进程令牌路由helper注入上下文正确成功率高DLL不需要UI操作服务内直接注入少一套进程管理逻辑目标进程不可创建远程线程APC注入更隐蔽不需要CreateRemoteThread权限极少数EDR查得严的场景手动映射不依赖LoadLibrary但要注意方案C不是银弹APC注入对线程状态有要求手动映射对64位下的重定位、异常处理、TLS都要自己处理代码量直接翻倍。5. 令牌路由方案的关键代码拆解5.1 服务端枚举会话并获取用户令牌这是整个方案的地基。这里我用WTS系列API注意需要链接Wtsapi32.lib并包含wtsapi32.h#include wtsapi32.h #include userenv.h BOOL GetActiveUserToken(HANDLE* phToken) { DWORD dwSessionId 0; PWTS_SESSION_INFO pSessionInfo NULL; DWORD dwCount 0; // 枚举所有会话 if (!WTSEnumerateSessionsW(WTS_CURRENT_SERVER_HANDLE, 0, 1, pSessionInfo, dwCount)) { return FALSE; } // 找到处于Active状态的会话 BOOL bFound FALSE; for (DWORD i 0; i dwCount; i) { if (pSessionInfo[i].State WTSActive) { dwSessionId pSessionInfo[i].SessionId; bFound TRUE; break; } } if (!bFound) { WTSFreeMemory(pSessionInfo); return FALSE; } WTSFreeMemory(pSessionInfo); // 获取该会话的用户令牌 HANDLE hUserToken NULL; if (!WTSQueryUserToken(dwSessionId, hUserToken)) { DWORD dwErr GetLastError(); // 常见错误5拒绝访问令牌权限不足、7025会话未登录 SetLastError(dwErr); return FALSE; } *phToken hUserToken; return TRUE; }这里有个细节我说一下WTSQueryUserToken要求调用者至少拥有SYSTEM权限或者进程中启用了该Token所需的特权。服务以LocalSystem运行就满足条件如果调试时用普通管理员身份跑经常返回错误码5。第二个细节枚举的时候最好记录那个StateWTSActive的SessionId。有时候会出现多个会话都Active比如用户同时有控制台会话和远程桌面会话需要根据业务逻辑决定选哪个。我当时是选了SessionId最小的非0会话处理物理控制台优先的场景。5.2 用用户令牌在目标会话中启动进程拿到了用户Token还不能直接用CreateProcessAsUser搞有几个坑要躲DuplicateTokenEx必须先做把SecurityImpersonation令牌改成Primary令牌环境块要用CreateEnvironmentBlock生成否则目标进程缺少用户环境变量比如%APPDATA%找不到。看代码BOOL LaunchHelperInUserSession(HANDLE hUserToken, LPCWSTR lpszHelperPath) { HANDLE hPrimaryToken NULL; // 提升Token为主令牌 if (!DuplicateTokenEx(hUserToken, MAXIMUM_ALLOWED, NULL, SecurityImpersonation, TokenPrimary, hPrimaryToken)) { return FALSE; } // 创建用户环境块 LPVOID pEnv NULL; if (!CreateEnvironmentBlock(pEnv, hPrimaryToken, FALSE)) { // 环境块创建失败不是致命错误可以用默认NULL pEnv NULL; } STARTUPINFOW si { 0 }; si.cb sizeof(si); si.lpDesktop Lwinsta0\\default; // 指定到目标会话默认桌面 PROCESS_INFORMATION pi { 0 }; BOOL bOk CreateProcessAsUserW( hPrimaryToken, lpszHelperPath, NULL, // 不要拼接命令行防止注入参数 NULL, NULL, FALSE, CREATE_UNICODE_ENVIRONMENT | CREATE_NEW_CONSOLE, pEnv, NULL, // 当前目录设为NULL避免盘符映射问题 si, pi ); if (pEnv) DestroyEnvironmentBlock(pEnv); CloseHandle(hPrimaryToken); if (!bOk) { return FALSE; } CloseHandle(pi.hThread); CloseHandle(pi.hProcess); return TRUE; }这一段的坑我当时踩得比较痛的是机上三个si.lpDesktop不能省。如果留NULLCreateProcessAsUser默认创建的进程窗口站是目标会话的默认Window Station。某些情况下特别是服务处于Session 0进程还是会落到服务自己的WinSta0。显式指定winsta0\default最稳。CreateEnvironmentBlock如果失败一定不要整个流程失败。有的远程登录场景下用户配置没加载环境块确实拉不到pEnvNULL还能跑最多环境变量不全。当前目录lpCurrentDirectory最好显式设为某个明确的目录。如果不设置helper继承的是服务进程的当前目录而服务的工作目录往往是C:\Windows\System32有时候helper要读取同目录下DLL就会出幺蛾子。5.3 helper进程注入DLL的标准流程helper在目标会话里跑起来了后面的注入动作其实就回到了第2节的经典模板。你可以让helper内部直接把自己所在的模块、或者某个DLL LoadLibrary进目标进程。还有一个小技巧如果helper注入的DLL是和helper.exe放在同一个目录的helper拿到自己的模块路径后用GetModuleFileNameW提取目录再拼上DLL文件名。这样路径天然正确不会踩DLL搜索顺序的坑。void InjectDllToProcess(DWORD dwTargetPid, const wchar_t* dllPath) { HANDLE hProcess OpenProcess( PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, FALSE, dwTargetPid); if (!hProcess) return; size_t pathLen (wcslen(dllPath) 1) * sizeof(wchar_t); LPVOID pRemoteBuf VirtualAllocEx(hProcess, NULL, pathLen, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); WriteProcessMemory(hProcess, pRemoteBuf, dllPath, pathLen, NULL); LPTHREAD_START_ROUTINE pLoadLibrary (LPTHREAD_START_ROUTINE)GetProcAddress( GetModuleHandleW(Lkernel32.dll), LoadLibraryW); HANDLE hThread CreateRemoteThread(hProcess, NULL, 0, pLoadLibrary, pRemoteBuf, 0, NULL); if (hThread) { WaitForSingleObject(hThread, 10000); CloseHandle(hThread); } VirtualFreeEx(hProcess, pRemoteBuf, 0, MEM_RELEASE); CloseHandle(hProcess); }注意这里我用的是LoadLibraryW而不是LoadLibraryA。主要原因是路径里如果有中文字符ANSI版本会挂。用宽字符版本配合wcslen计算大小路径越长越稳。6. 实测中踩过的坑和排查过程6.1 坑一WTSQueryUserToken返回错误5现象服务是LocalSystem运行的调用WTSEnumerateSessionsW成功循环也找到了Session 1是Active结果WTSQueryUserToken返回FALSEGetLastError是5。排查过程一开始怀疑是权限不够于是把服务调成LocalSystem跑还是5。又怀疑是SessionState判断问题打印出来确实是WTSActive。后来翻文档发现WTSQueryUserToken在Vista之后要求进程必须运行在Session 0里。我用调试器以普通管理员身份附加进程跑的调试器进程本身在Session 1自然拿不到。把服务正常部署后在Session 0跑问题就消失了。这个坑做开发时特别容易踩因为调试模式下的Session归属和服务真实运行环境完全不同。6.2 坑二CreateRemoteThread成功了但DLL没加载成功现象线程创建成功WaitForSingleObject也等了但目标进程没有加载出DLLGetExitCodeThread返回的不是0。排查过程我把DLL路径从相对路径改成了绝对路径结果还是失败。最后发现问题是helper进程和目标进程的位数不匹配。服务是64位的helper是64位的但目标进程是32位的——64位的LoadLibraryW地址传给32位进程的CreateRemoteThread线程入口直接错乱LoadLibrary根本没有正常执行。解决思路要么按目标进程位数编译两套helper和DLL要么加载前先判断目标进程位数。判断方法就是用IsWow64Process2如果目标是32位就注入32位helper用32位的LoadLibrary地址。这个坑纯属架构设计时没把位数兼容想清楚属于低级但致命的问题。6.3 坑三注入成功、DLL也加载了但UI完全没有反应现象DLL加载成功了里面调用了MessageBoxW结果什么也没弹。排查过程这就要回到第3节说的Session、Window Station和Desktop三层结构。DLL虽然加载在Session 1的进程里但创建远程线程的动作是从Session 0发起的新线程的Window Station必须是调用方所属Window Station的子树或者兄弟。事实上线程创建时没有自动切换到目标Session的WinSta0\DefaultMessageBox往Session 0的非交互桌面上弹用户当然看不见。解决在DLL里入口函数第一时间调用SetProcessWindowStation和SetThreadDesktop。但这不是万能的SetProcessWindowStation只能设置当前进程的window station而且需要传入正确的HWINSTA。更省事的方案是DLL入口判断自己所在进程是否在目标Session的Interactive Window Station如果不是就调用WTSQueryUserToken去拿用户Token然后ImpersonateLoggedOnUser再SetThreadDesktop。这一套动作非常容易出错这也是我为什么强烈推荐路线A的原因——让helper进程在目标会话里创建就不会存在这个问题。让我说得更直白一点如果你在服务里直接CreateRemoteThread就算你DLL加载成功、就算你代码全对你依然面临一个很难绕过的线程上下文归属问题。与其和它搏斗不如让注入动作发生在目标会话内。这是我这几个项目下来最深的体会。6.4 坑四CreateProcessAsUser启动的helper一闪而逝现象helper进程起来了但立即退出看不到任何弹窗日志也没写。排查过程最初怀疑是权限问题后来在helper启动路径上加cmd窗口重定向输出才看到错误是0xC0000142DLL初始化失败。原因是CreateEnvironmentBlock失败时我用了pEnvNULL但CreateProcessAsUser要求CRF_UNICODE_ENVIRONMENT标志时环境块必须是有效的。NULL在某些Windows版本里会导致进程初始化时找不到关键环境变量直接初始化失败。解决要么创建环境块要么干脆不用CREATE_UNICODE_ENVIRONMENT改用继承父进程环境。但继承父进程环境又会导致helper拿到的是服务进程的环境变量对某些依赖用户配置的功能不友好。最后我选择了CreateEnvironmentBlock失败时用GetEnvironmentStrings手动复制一份最小环境块虽然丑但能跑。7. 从防御视角看这类注入如何发现它研究突破SESSION 0隔离的远线程注入不应该只站在攻击方的角度。实际上我之所以把这些问题摸得这么透很大程度是因为在做EDR类产品的检测规则时需要从反方向理解攻击路径。Windows自己留了不少观测点最常用的是Sysmon的Event ID 8CreateRemoteThread检测。它记录的是调用进程对目标进程创建远程线程的核心参数包括SourceProcessId、TargetProcessId、StartModule等。如果StartModule指向的不是已知的系统模块而是某个临时目录的DLL那基本就是注入行为。另一个观测点是ETWEvent Tracing for Windows的Microsoft-Windows-Kernel-Process可以记录VirtualAllocEx、WriteProcessMemory、CreateRemoteThread的内核级调用。ETW的优点在于很难被用户态代码篡改缺点是配置复杂对系统性能有一定影响。如果是检测跨会话注入这种特定行为核心特征是调用进程的Session和TargetProcess的Session不一致但CreateRemoteThread事件发生了。正常业务几乎没有理由跨会话创建远程线程一旦出现就很可疑。我们可以把这条作为高置信度告警规则。防御侧的建议是不要轻易给服务进程授予SeDebugPrivilege这是多数跨会话访问的关键前提。对SESSION 0里的常驻服务进程做行为监控特别注意WTSQueryUserToken和CreateProcessAsUser的调用链。关键进程开启Protected Process Light保护能拦住大多数非特权注入。如果业务确实需要跨会话支持优先选官方支持的机制比如CreateProcessAsUser启动助手进程而不是直接注入。8. 这项技术的边界与我的最终建议写到这里我得把话说透DLL注入本身是Windows系统编程里很常规的技术SDK文档里就有对CreateRemoteThread的详细描述。而SESSION 0隔离本质上是系统为了安全和稳定性引进的一道防线。突破隔离的技术从正面用途来说是合法的远程协助、屏幕共享、无障碍工具等产品的必备能力从反面用途来说也是恶意软件常用的手段。这篇分析的目的是帮助读者理解背后的机制进而无论是做正常的产品功能还是做攻击检测都能有清晰的思路。我的建议是如果你的业务真需要跨会话交互优先选令牌路由方案让辅助进程在目标会话里做注入。它虽然多了一套进程管理的复杂度但收益是后面的麻烦不超过20%。而如果你做的是安全产品我强烈建议去实测一遍WTSQueryUserToken、CreateProcessAsUser、CreateRemoteThread这套链路理解每一种异常表现才能在写检测规则时做出精准判断。我个人在实际开发中还有一个习惯所有注入实验先丢到虚拟机里跑目标进程选notepad或者自己写的一个测试进程绝对不在真实生产系统上直接验证。注入的技术难度不在于把代码写出来而在于你计算过每一种失败模式下系统的反应。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑