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返回NULL,DLL根本没进去。所以说,如果是服务想注入用户进程,标准四步曲已经不够用了。你得先解决"让代码在目标会话上下文中运行"的问题,再考虑注入本身。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。第二个细节:枚举的时候最好记录那个State==WTSActive的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 = L"winsta0\\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不能省。如果留NULL,CreateProcessAsUser默认创建的进程窗口站是目标会话的默认Window Station。某些情况下(特别是服务处于Session 0),进程还是会落到服务自己的WinSta0。显式指定"winsta0\default"最稳。CreateEnvironmentBlock如果失败,一定不要整个流程失败。有的远程登录场景下用户配置没加载,环境块确实拉不到,pEnv=NULL还能跑,最多环境变量不全。当前目录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(L"kernel32.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返回FALSE,GetLastError是5。排查过程:一开始怀疑是权限不够,于是把服务调成LocalSystem跑,还是5。又怀疑是SessionState判断问题,打印出来确实是WTSActive。后来翻文档发现,WTSQueryUserToken在Vista之后要求进程必须运行在Session 0里。我用调试器以普通管理员身份附加进程跑的,调试器进程本身在Session 1,自然拿不到。把服务正常部署后在Session 0跑,问题就消失了。这个坑做开发时特别容易踩,因为调试模式下的Session归属和服务真实运行环境完全不同。6.2 坑二:CreateRemoteThread成功了,但DLL没加载成功现象:线程创建成功,WaitForSingleObject也等了,但目标进程没有加载出DLL,GetExitCodeThread返回的不是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\Default,MessageBox往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窗口重定向输出,才看到错误是"0xC0000142"(DLL初始化失败)。原因是CreateEnvironmentBlock失败时我用了pEnv=NULL,但CreateProcessAsUser要求CRF_UNICODE_ENVIRONMENT标志时,环境块必须是有效的。NULL在某些Windows版本里会导致进程初始化时找不到关键环境变量,直接初始化失败。解决:要么创建环境块,要么干脆不用CREATE_UNICODE_ENVIRONMENT,改用继承父进程环境。但继承父进程环境又会导致helper拿到的是服务进程的环境变量,对某些依赖用户配置的功能不友好。最后我选择了:CreateEnvironmentBlock失败时,用GetEnvironmentStrings手动复制一份最小环境块,虽然丑,但能跑。7. 从防御视角看这类注入:如何发现它研究突破SESSION 0隔离的远线程注入,不应该只站在攻击方的角度。实际上,我之所以把这些问题摸得这么透,很大程度是因为在做EDR类产品的检测规则时,需要从反方向理解攻击路径。Windows自己留了不少观测点,最常用的是Sysmon的Event ID 8(CreateRemoteThread检测)。它记录的是调用进程对目标进程创建远程线程的核心参数,包括SourceProcessId、TargetProcessId、StartModule等。如果StartModule指向的不是已知的系统模块,而是某个临时目录的DLL,那基本就是注入行为。另一个观测点是ETW(Event 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或者自己写的一个测试进程,绝对不在真实生产系统上直接验证。注入的技术难度不在于把代码写出来,而在于你计算过每一种失败模式下系统的反应。
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返回NULL,DLL根本没进去。所以说,如果是服务想注入用户进程,标准四步曲已经不够用了。你得先解决"让代码在目标会话上下文中运行"的问题,再考虑注入本身。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。第二个细节:枚举的时候最好记录那个State==WTSActive的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 = L"winsta0\\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不能省。如果留NULL,CreateProcessAsUser默认创建的进程窗口站是目标会话的默认Window Station。某些情况下(特别是服务处于Session 0),进程还是会落到服务自己的WinSta0。显式指定"winsta0\default"最稳。CreateEnvironmentBlock如果失败,一定不要整个流程失败。有的远程登录场景下用户配置没加载,环境块确实拉不到,pEnv=NULL还能跑,最多环境变量不全。当前目录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(L"kernel32.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返回FALSE,GetLastError是5。排查过程:一开始怀疑是权限不够,于是把服务调成LocalSystem跑,还是5。又怀疑是SessionState判断问题,打印出来确实是WTSActive。后来翻文档发现,WTSQueryUserToken在Vista之后要求进程必须运行在Session 0里。我用调试器以普通管理员身份附加进程跑的,调试器进程本身在Session 1,自然拿不到。把服务正常部署后在Session 0跑,问题就消失了。这个坑做开发时特别容易踩,因为调试模式下的Session归属和服务真实运行环境完全不同。6.2 坑二:CreateRemoteThread成功了,但DLL没加载成功现象:线程创建成功,WaitForSingleObject也等了,但目标进程没有加载出DLL,GetExitCodeThread返回的不是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\Default,MessageBox往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窗口重定向输出,才看到错误是"0xC0000142"(DLL初始化失败)。原因是CreateEnvironmentBlock失败时我用了pEnv=NULL,但CreateProcessAsUser要求CRF_UNICODE_ENVIRONMENT标志时,环境块必须是有效的。NULL在某些Windows版本里会导致进程初始化时找不到关键环境变量,直接初始化失败。解决:要么创建环境块,要么干脆不用CREATE_UNICODE_ENVIRONMENT,改用继承父进程环境。但继承父进程环境又会导致helper拿到的是服务进程的环境变量,对某些依赖用户配置的功能不友好。最后我选择了:CreateEnvironmentBlock失败时,用GetEnvironmentStrings手动复制一份最小环境块,虽然丑,但能跑。7. 从防御视角看这类注入:如何发现它研究突破SESSION 0隔离的远线程注入,不应该只站在攻击方的角度。实际上,我之所以把这些问题摸得这么透,很大程度是因为在做EDR类产品的检测规则时,需要从反方向理解攻击路径。Windows自己留了不少观测点,最常用的是Sysmon的Event ID 8(CreateRemoteThread检测)。它记录的是调用进程对目标进程创建远程线程的核心参数,包括SourceProcessId、TargetProcessId、StartModule等。如果StartModule指向的不是已知的系统模块,而是某个临时目录的DLL,那基本就是注入行为。另一个观测点是ETW(Event 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或者自己写的一个测试进程,绝对不在真实生产系统上直接验证。注入的技术难度不在于把代码写出来,而在于你计算过每一种失败模式下系统的反应。