You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Windows服务中切换用户桌面以录制RDP会话?

解决Windows服务Session 0隔离下的RDP桌面录制问题

问题背景

你基于Windows服务的OnSessionChange事件实现RDP登录监控,但受Session 0隔离限制,服务无法直接访问用户的交互式会话桌面,导致录制功能崩溃。核心原因是Windows服务默认运行在Session 0,而用户RDP登录属于独立的交互式会话(通常是Session 1及以上),两者的桌面环境完全隔离。

核心解决方案

要实现用户桌面录制,必须将录制进程切换到目标用户的交互式会话中运行,而不是在服务的Session 0中执行录制逻辑。具体通过以下Windows API实现:

  • WTSQueryUserToken:获取目标会话的用户令牌
  • CreateEnvironmentBlock:创建用户会话的环境变量块
  • CreateProcessAsUser:使用用户令牌在目标会话中启动录制进程

具体实现步骤

1. 添加Windows API声明

在你的服务项目中添加以下API声明(需引用System.Runtime.InteropServices):

using System.Runtime.InteropServices;

public static class Win32Api
{
    [DllImport("wtsapi32.dll", SetLastError = true)]
    public static extern bool WTSQueryUserToken(int sessionId, out IntPtr tokenHandle);

    [DllImport("userenv.dll", SetLastError = true)]
    public static extern bool CreateEnvironmentBlock(out IntPtr lpEnvironment, IntPtr hToken, bool bInherit);

    [DllImport("userenv.dll", SetLastError = true)]
    public static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);

    [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
    public static extern bool CreateProcessAsUser(
        IntPtr hToken,
        string lpApplicationName,
        string lpCommandLine,
        IntPtr lpProcessAttributes,
        IntPtr lpThreadAttributes,
        bool bInheritHandles,
        uint dwCreationFlags,
        IntPtr lpEnvironment,
        string lpCurrentDirectory,
        ref STARTUPINFO lpStartupInfo,
        out PROCESS_INFORMATION lpProcessInformation);

    [StructLayout(LayoutKind.Sequential)]
    public struct STARTUPINFO
    {
        public int cb;
        public string lpReserved;
        public string lpDesktop;
        public string lpTitle;
        public int dwX;
        public int dwY;
        public int dwXSize;
        public int dwYSize;
        public int dwXCountChars;
        public int dwYCountChars;
        public int dwFillAttribute;
        public int dwFlags;
        public short wShowWindow;
        public short cbReserved2;
        public IntPtr lpReserved2;
        public IntPtr hStdInput;
        public IntPtr hStdOutput;
        public IntPtr hStdError;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct PROCESS_INFORMATION
    {
        public IntPtr hProcess;
        public IntPtr hThread;
        public int dwProcessId;
        public int dwThreadId;
    }

    public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
    public const uint CREATE_NO_WINDOW = 0x08000000;
}

[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr hObject);

2. 修改服务的OnSessionChange逻辑

在用户登录/连接/解锁时,启动独立的录制程序到目标会话;在用户注销/断开/锁定时停止录制:

// 先给RdpSession结构体添加进程ID字段
struct RdpSession
{
    // ... 原有字段
    public int sessionRecorderProcessId;

    // ... 原有方法
}

protected override void OnSessionChange(SessionChangeDescription sessionChangeDescription)
{
    try
    {
        var userInfo = TermServicesManager.GetSessionInfo(Dns.GetHostEntry("").HostName, sessionChangeDescription.SessionId);
        IPAddress ipAddress = new IPAddress(userInfo.ClientAddress.Address.Skip(2).Take(4).ToArray());

        if (!rdpSessionList.ContainsKey(sessionChangeDescription.SessionId))
        {
            onRdpSession.sessionId = sessionChangeDescription.SessionId;
            onRdpSession.ipAddress = ipAddress.ToString();
            onRdpSession.userName = userInfo.UserName;
            onRdpSession.sessionDateTime = DateTime.Now.ToString("yyyy'-'MM'-'dd'T'HH'-'mm'-'ss");
            onRdpSession.recordFileName = onRdpSession.generateRecordFileName();
            rdpSessionList.Add(sessionChangeDescription.SessionId, onRdpSession);
        }
        else
        {
            onRdpSession = rdpSessionList[sessionChangeDescription.SessionId];
        }

        WriteToFile("SessionChange event");

        switch (sessionChangeDescription.Reason)
        {
            case SessionChangeReason.SessionLogon:
            case SessionChangeReason.RemoteConnect:
            case SessionChangeReason.SessionUnlock:
                WriteToFile($"SessionChange{DateTime.Now.ToLongTimeString()}, SessionUnlock|RemoteConnect|SessionLogon [{sessionChangeDescription.SessionId}], User: {userInfo.UserName}, Connect state: {userInfo.ConnectState}, Client address: {ipAddress}, WinStationName: {userInfo.WinStationName}");
                
                // 启动录制进程到目标会话
                StartRecorderInUserSession(sessionChangeDescription.SessionId, onRdpSession.recordFileName);
                break;

            case SessionChangeReason.SessionLock:
            case SessionChangeReason.SessionLogoff:
            case SessionChangeReason.RemoteDisconnect:
                WriteToFile($"SessionChange: {onRdpSession}, {DateTime.Now.ToLongTimeString()} RemoteDisconnect|SessionLogoff|SessionLock [{sessionChangeDescription.SessionId}], User: {userInfo.UserName}, Connect state: {userInfo.ConnectState}, Client address: {ipAddress}, WinStationName: {userInfo.WinStationName}");
                
                // 停止对应会话的录制进程
                StopRecorderForSession(sessionChangeDescription.SessionId);
                break;

            default:
                break;
        }
    }
    catch (Exception ex)
    {
        WriteToFile($"SessionChange exception: {ex.Message} || {sessionChangeDescription.SessionId} || {onRdpSession.sessionId}");
    }
}

private void StartRecorderInUserSession(int sessionId, string recordFilePath)
{
    IntPtr userToken = IntPtr.Zero;
    IntPtr environmentBlock = IntPtr.Zero;
    Win32Api.PROCESS_INFORMATION processInfo = new Win32Api.PROCESS_INFORMATION();
    Win32Api.STARTUPINFO startupInfo = new Win32Api.STARTUPINFO();
    startupInfo.cb = Marshal.SizeOf(startupInfo);
    // 指定用户会话的桌面格式
    startupInfo.lpDesktop = $"WinSta0\\Default";

    try
    {
        // 获取用户会话令牌
        if (!Win32Api.WTSQueryUserToken(sessionId, out userToken))
        {
            WriteToFile($"WTSQueryUserToken failed, error: {Marshal.GetLastWin32Error()}");
            return;
        }

        // 创建环境变量块
        if (!Win32Api.CreateEnvironmentBlock(out environmentBlock, userToken, false))
        {
            WriteToFile($"CreateEnvironmentBlock failed, error: {Marshal.GetLastWin32Error()}");
            return;
        }

        // 录制程序路径,替换为你实际的程序路径
        string recorderPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "DesktopRecorder.exe");
        // 传递录制文件路径和会话ID作为参数
        string commandLine = $"\"{recorderPath}\" \"{recordFilePath}\" {sessionId}";

        // 在用户会话中启动录制进程
        if (!Win32Api.CreateProcessAsUser(
            userToken,
            null,
            commandLine,
            IntPtr.Zero,
            IntPtr.Zero,
            false,
            Win32Api.CREATE_UNICODE_ENVIRONMENT | Win32Api.CREATE_NO_WINDOW,
            environmentBlock,
            null,
            ref startupInfo,
            out processInfo))
        {
            WriteToFile($"CreateProcessAsUser failed, error: {Marshal.GetLastWin32Error()}");
            return;
        }

        // 保存进程ID用于后续停止
        onRdpSession.sessionRecorderProcessId = processInfo.dwProcessId;
        rdpSessionList[sessionId] = onRdpSession;
        WriteToFile($"Recorder started for session {sessionId}, process ID: {processInfo.dwProcessId}");
    }
    finally
    {
        // 释放资源
        if (userToken != IntPtr.Zero)
            CloseHandle(userToken);
        if (environmentBlock != IntPtr.Zero)
            Win32Api.DestroyEnvironmentBlock(environmentBlock);
        if (processInfo.hProcess != IntPtr.Zero)
            CloseHandle(processInfo.hProcess);
        if (processInfo.hThread != IntPtr.Zero)
            CloseHandle(processInfo.hThread);
    }
}

private void StopRecorderForSession(int sessionId)
{
    if (rdpSessionList.TryGetValue(sessionId, out var session))
    {
        try
        {
            Process recorderProcess = Process.GetProcessById(session.sessionRecorderProcessId);
            recorderProcess.Kill();
            recorderProcess.WaitForExit();
            WriteToFile($"Recorder stopped for session {sessionId}");
        }
        catch (Exception ex)
        {
            WriteToFile($"Failed to stop recorder: {ex.Message}");
        }
    }
}

3. 独立录制程序实现

创建一个单独的控制台应用程序(比如DesktopRecorder.exe),负责实际的桌面录制逻辑(示例使用SharpAvi库):

using SharpAvi;
using SharpAvi.Codecs;
using SharpAvi.Output;
using System.Drawing;
using System.Drawing.Imaging;
using System.Threading;

namespace DesktopRecorder
{
    class Program
    {
        static void Main(string[] args)
        {
            if (args.Length < 2)
            {
                return;
            }

            string outputPath = args[0];
            int sessionId = int.Parse(args[1]);
            var screenBounds = Screen.PrimaryScreen.Bounds;
            int width = screenBounds.Width;
            int height = screenBounds.Height;

            using (var writer = new AviWriter(outputPath)
            {
                FramesPerSecond = 15,
                EmitIndex1 = true
            })
            {
                var videoStream = writer.AddVideoStream(width, height, CodecIds.MotionJpeg);
                videoStream.Quality = 70;

                // 持续录制直到进程被终止
                while (!Console.KeyAvailable)
                {
                    using (var bitmap = new Bitmap(width, height))
                    {
                        using (var g = Graphics.FromImage(bitmap))
                        {
                            g.CopyFromScreen(screenBounds.X, screenBounds.Y, 0, 0, screenBounds.Size);
                        }

                        var bitmapData = bitmap.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb);
                        videoStream.WriteFrame(true, bitmapData.Scan0, bitmapData.Stride * height);
                        bitmap.UnlockBits(bitmapData);
                    }

                    Thread.Sleep(1000 / (int)writer.FramesPerSecond);
                }
            }
        }
    }
}

注意事项

  • 服务权限:服务必须以LocalSystem账户运行,并且需要分配替换进程级令牌和作为批处理作业登录的权限(可通过本地安全策略配置)。
  • 依赖管理:确保录制程序和服务在同一目录,或指定完整路径,且包含所有依赖库(比如SharpAvi的DLL文件)。
  • 资源清理:用户注销/断开时必须停止录制进程,避免资源泄漏。
  • 错误处理:所有API调用需检查返回值,处理Win32错误码,防止服务崩溃。

内容的提问来源于stack exchange,提问作者H Aßdöµ

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.20 10:31:20