【问题标题】:How to take screenshot on remote desktop using powershell如何使用powershell在远程桌面上截屏
【发布时间】:2021-09-03 19:02:37
【问题描述】:

我写了一段代码,打开浏览器并截屏。但是当我通过远程桌面运行它时,它正在拍摄空白图像。

任何人都可以建议,我如何通过PowerShell在远程桌面上截取浏览器的截图?

例如我需要在远程桌面上打开https://stackoverflow.com/ 并将屏幕截图保存在该远程服务器中。

代码

[Reflection.Assembly]::LoadWithPartialName("System.Drawing")
function screenshot([Drawing.Rectangle]$bounds, $path) {
   $bmp = New-Object Drawing.Bitmap $bounds.width, $bounds.height
   $graphics = [Drawing.Graphics]::FromImage($bmp)

   $graphics.CopyFromScreen($bounds.Location, [Drawing.Point]::Empty, $bounds.size)

   $bmp.Save($path)

   $graphics.Dispose()
   $bmp.Dispose()
}

【问题讨论】:

  • 我会尝试使用 Invoke-Command -ComputerName Server01 -FilePath c:\Scripts\script.ps1
  • 已经使用上面的命令来执行脚本并且脚本正在被执行,但是截图我无法在远程桌面上捕获它。
  • 您需要冒充您要捕获的用户帐户
  • Windows 安全边界阻止了这一点。您不能在远程 PSSession 中运行在远程主机上执行 GUI 操作的代码。 PowerShell 仅在启动它的用户的上下文中运行。没有用户登录,则没有用户会话。您不是登录用户,因此无权访问桌面。将您的脚本发送到远程主机,使其仅在用户登录时作为计划任务运行。
  • 谢谢,但您能否建议任何其他替代方案来实现相同的场景?

标签: windows powershell screenshot remote-desktop


【解决方案1】:

您正在尝试做的事情不能直接远程执行

当我在这里说“远程”时,我的意思是您不能从一个系统调用屏幕捕获到另一个系统。代码必须在本地上下文中执行。

最终,您需要从同样登录到桌面的用户运行$graphics.CopyFromScreen(),而不仅仅是通过PSRemoting 会话。从字面上看,没有什么可以捕获的 GUI。远程会话的另一端没有加载 GUI 组件。简而言之,你不能指望使用PSRemoting 来截取远程截图。它没有发生,没有可以复制的屏幕,并且应该导致Win32Exception:“句柄无效”。 每当您尝试从 PSRemoting 会话复制屏幕缓冲区(屏幕缓冲区不同于 显示 缓冲区)时,都会发生这种情况,即使用户同时通过 RDP 登录也是如此时间。

注意:我的测试表明屏幕截图仍然可以在 RDP 上运行,而无需修改您现有的代码。我不确定是否有 GPO 或其他设置可以阻止此操作。

但是,如果登录会话存在但处于非活动状态(例如 RDP 客户端已断开连接),您将收到相同的“无效句柄”错误。 This answer 更清楚地说明了这种行为,但基本上你需要有一个活动的用户会话才能复制绘制上下文。没有这个,就没有绘制上下文,因此没有有效的句柄来获取图形数据。

有一个可能的解决方案使用 Win32 API 以另一个用户身份执行交互式登录,然后执行您的 PowerShell 脚本(也使用 Win32 API 调用),我在这个答案中有更多关于此的信息。


解决方法,只要目标用户会话处于活动状态

要解决此问题,您必须使用活动会话模拟已登录的桌面主体,并在本地上下文中运行您的脚本。如果您使用PSRemoting 建立远程连接,请设置计划任务以运行脚本以作为目标登录用户捕获屏幕(您的脚本还应该将屏幕截图写入某个临时文件):

注意:如果您可能需要捕获多个用户的桌面,则您需要为每个用户执行一个任务,或者在运行之前为目标用户重新配置该任务。

$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-File C:\path\to\ScreenShotScript.ps1"
$trigger = New-ScheduledTaskTrigger -Once
$principal = "DomainOrComputerName\AccountName"
$settings = New-ScheduledTaskSettingsSet
$task = New-ScheduledTask -Action $action -Principal $principal -Trigger $trigger -Settings $settings
Register-ScheduledTask TakeScreenShot -InputObject $task

然后在你想运行它时调用它:

Start-ScheduledTask -TaskName TakeScreenShot
while( ( Get-ScheduledTask -TaskName TakeScreenShot ).State -ne 'Ready' ) {
  # Wait until the screencap task finishes
  Start-Sleep 1
}

然后从您的主机会话中远程处理(请注意,您的会话应存储为变量以使此副本起作用):

Copy-Item -FromSession $psRemotingSession C:\path\to\screenshot.png C:\path\to\save\screenshot\locally\to

如果您的最终目标是也连接到 RDP 并使用 PowerShell 启动站点,我可以共享以下功能以通过 PowerShell 启动远程 RDP 连接。然后,您可以使用上面的任务计划程序解决方法将浏览器启动到正确的站点并执行远程捕获,但您也可以在关闭 RDP 窗口之前对其进行截图:

注意:如果mstsc 已启动,此函数将返回$True,但不检查连接成功的结果。如果由于某种原因无法存储凭据,它将返回$False

function Connect-RDP {
  Param(
    [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
    [string]$Hostname,
    [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
    [ushort]$Port,
    [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
    [pscredential]$Credential
  )

  $cmdkeyArgs =
    "/generic:TERMSRV/${Hostname}",
    "/user:$($Credential.Username)",
    "/pass:$($Credential.GetNetworkCredential().Password)"
  cmdkey @cmdkeyArgs

  if ( $LASTEXITCODE -ne 0 ) {
    throw "cmdkey failed with exit code ${LASTEXITCODE}"
  }

  $mstscArgs = ,
    "/v:${Hostname}:${Port}"
  mstsc @mstscArgs
}

此功能还可用于启动与目标用户的目标计算机的活动 RDP 会话,因此您现有的屏幕截图代码将起作用。完成后请记住关闭窗口。请注意,如果目标用户已经远程或本地登录,这将踢出他们。

理论上,您也可以通过通过任务计划程序运行的代码来操作浏览器窗口,但您可能会更好地通过本地会话中的 RDP 窗口来操作浏览器窗口,除非您依赖于 Selenium 之类的东西。

The answer I linked to above 还提供了一些您可以尝试的其他解决方法,包括来自Win32 API 的其他User32 函数,您可以通过P/Invoke 访问这些函数。但是,这些变通办法可能会迫使您重新构建当前的自动化解决方案。请注意,此答案来自 2012 年,并且从服务捕获屏幕缓冲区的建议可能不再适用,因为在 Windows Vista 的生命周期中,授予服务访问用户桌面的能力已被悄悄撤销。


附加信息

您可以通过.NET Core source 以及它用来更好地理解此行为的User32.GetDC(IntPtr) 函数了解CopyFromScreen 在内部的工作原理。


如果您想试一试 Win32 API 中的 AdvApi.CreateProcessAsUser,我编写了一个函数,它将 P/Invoke 必要的 Win32 API 函数并在您当前的 PowerShell 会话中创建依赖数据结构。不幸的是,我在让LogonUser 工作时遇到了一些困难,所以我没有一个实际执行登录并以另一个用户身份启动进程的工作示例,但这个函数至少可以在你的PowerShell 会话。

以下是在会话中定义该函数后的使用方法:

# This function will only work once per session (assuming Add-Type doesn't errorout )
PInvoke-AdvApi32

# I don't have a working example but call both functions as static methods on `[AdvApi32]` like so
[AdvApi32]::LogonUser(....)
[AdvApi32]::CreateProcessAsUser(....)

有关如何使用这些函数的正确示例,您可以参考P/Invoke 文档中的CreateProcessAsUserLogonUser。请注意,示例位于 C# 和 VB.NET 中,因此您必须自己将示例转换为 .NET。如果您可以让LogonUser 工作,您应该能够为目标用户调用您的 PowerShell 代码作为新进程,即使通过远程连接也是如此。

如果您尝试此路线,可能还有其他对您有用的AdvApi32函数,如果您决定使用P/Invoke其他方法,请注意您可以粘贴到函数中的C#代码定义中,确保更改对public 的任何访问修饰符(它们通常在C# 示例中定义为internal)。您还需要定义的任何其他数据结构都将在该站点上的任何 P/Invoke 页面上提及。


总结

User32GetDC(IntPtr) 函数获取指定窗口句柄的绘图上下文,如果提供了0,则获取整个桌面。此函数行为对于Graphics.CopyFromScreen 正常运行至关重要。如上所述,主要问题是当您通过PSRemoting 连接时没有绘图上下文,如果您在登录会话不在时使用任务计划程序解决方法,也不会有绘图上下文。一个活跃的状态。因此,无论您使用 Graphics.CopyFromScreen 还是尝试重写您在上面链接的 .NET Core 源代码中找到的一些内容,如果出现以下情况,您都无法复制用户的屏幕缓冲区:

  • 用户已注销。
  • 用户已登录,但会话处于非活动状态(例如 RDP 客户端断开连接、屏幕锁定等)。
  • 用户会话处于活动状态,但您正试图通过PSRemoting 会话复制屏幕缓冲区,因为PSRemoting 不允许远程用户访问远程计算机上的图形上下文。
  • Win32 API 有一个潜在的解决方案,但它不适合胆小的人。

PInvoke-AdvApi32 函数

这是PInvoke-AdvApi32的来源:

Function PInvoke-AdvApi32 {
    Param(
        [string]$ExposedClassName = 'AdvApi32',
        [switch]$PassThru
    )
    
    #region CSDefinitions

    # Define the C# code we need to import
    # ... yes this function needs several definitions
    $pinvokeDefinitions = 
    @"
using System;
using System.Runtime.InteropServices;

[Flags]
public enum CreateProcessFlags
{
    CREATE_BREAKAWAY_FROM_JOB = 0x01000000,
    CREATE_DEFAULT_ERROR_MODE = 0x04000000,
    CREATE_NEW_CONSOLE = 0x00000010,
    CREATE_NEW_PROCESS_GROUP = 0x00000200,
    CREATE_NO_WINDOW = 0x08000000,
    CREATE_PROTECTED_PROCESS = 0x00040000,
    CREATE_PRESERVE_CODE_AUTHZ_LEVEL = 0x02000000,
    CREATE_SEPARATE_WOW_VDM = 0x00000800,
    CREATE_SHARED_WOW_VDM = 0x00001000,
    CREATE_SUSPENDED = 0x00000004,
    CREATE_UNICODE_ENVIRONMENT = 0x00000400,
    DEBUG_ONLY_THIS_PROCESS = 0x00000002,
    DEBUG_PROCESS = 0x00000001,
    DETACHED_PROCESS = 0x00000008,
    EXTENDED_STARTUPINFO_PRESENT = 0x00080000,
    INHERIT_PARENT_AFFINITY = 0x00010000
}

public enum LOGON_PROVIDER
{
     LOGON32_PROVIDER_DEFAULT,
     LOGON32_PROVIDER_WINNT35,
     LOGON32_PROVIDER_WINNT40,
     LOGON32_PROVIDER_WINNT50
}

public enum LOGON_TYPE
{
     LOGON32_LOGON_INTERACTIVE = 2,
     LOGON32_LOGON_NETWORK = 3,
     LOGON32_LOGON_BATCH = 4,
     LOGON32_LOGON_SERVICE = 5,
     LOGON32_LOGON_UNLOCK = 7,
     LOGON32_LOGON_NETWORK_CLEARTEXT = 8,
     LOGON32_LOGON_NEW_CREDENTIALS = 9
}

// This also works with CharSet.Ansi as long as the calling function uses the same character set.
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct STARTUPINFOEX
{
    public STARTUPINFO StartupInfo;
    public IntPtr lpAttributeList;
}

// If you are using this with [GetStartupInfo], this definition works without errors.
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
public struct STARTUPINFO
{
    public Int32 cb;
    public IntPtr lpReserved;
    public IntPtr lpDesktop;
    public IntPtr lpTitle;
    public Int32 dwX;
    public Int32 dwY;
    public Int32 dwXSize;
    public Int32 dwYSize;
    public Int32 dwXCountChars;
    public Int32 dwYCountChars;
    public Int32 dwFillAttribute;
    public Int32 dwFlags;
    public Int16 wShowWindow;
    public Int16 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;
}

[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
    public int nLength;
    public unsafe byte* lpSecurityDescriptor;
    public int bInheritHandle;
}

public enum LogonProvider
{
    /// <summary>
    /// Use the standard logon provider for the system.
    /// The default security provider is negotiate, unless you pass NULL for the domain name and the user name
    /// is not in UPN format. In this case, the default provider is NTLM.
    /// NOTE: Windows 2000/NT:   The default security provider is NTLM.
    /// </summary>
    LOGON32_PROVIDER_DEFAULT = 0,
    LOGON32_PROVIDER_WINNT35 = 1,
    LOGON32_PROVIDER_WINNT40 = 2,
    LOGON32_PROVIDER_WINNT50 = 3
}

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

    [DllImport("advapi32.dll", SetLastError = true, BestFitMapping = false, ThrowOnUnmappableChar = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    public static extern bool LogonUser(
        [MarshalAs(UnmanagedType.LPStr)] string pszUserName,
        [MarshalAs(UnmanagedType.LPStr)] string pszDomain,
        [MarshalAs(UnmanagedType.LPStr)] string pszPassword,
        int dwLogonType,
        int dwLogonProvider,
        ref IntPtr phToken);
}
"@
    #endregion CSDefinitions

    # Compile and load our heroic Win32 helper class and definitions
    if ( !( [System.Management.Automation.PSTypeName]$ExposedClassName ).Type ) {
        Write-Host "Adding type ""${ExposedClassName}"""
        $addTypeParams = @{
            TypeDefinition        = $pinvokeDefinitions
            CompilerParameters    = New-Object System.CodeDom.Compiler.CompilerParameters -Property @{
                CompilerOptions = '/unsafe'
            }
            PassThru = $PassThru
            ErrorAction = 'Stop'
        }
    
        Add-Type @addTypeParams
    } else {
        Write-Warning "AdvApi32 has already been P/Invoked. If you need to P/Invoke this class again function again, you must start a new PowerShell session."
        Write-Warning "Changing the -ExposedClassName will bypass this check but Add-Type will fail on dependent definitions without a unique name."
    }
}

【讨论】:

  • 不使用带有计划任务的解决方法来访问用户的交互式会话,使用runas.exe 不能得到类似的结果吗?
  • 否,因为您无法通过 psremoting 访问本地登录会话。安全边界防止这种情况发生
猜你喜欢
  • 1970-01-01
  • 2021-06-28
  • 1970-01-01
  • 1970-01-01
  • 2012-10-22
  • 2014-08-21
  • 1970-01-01
  • 2018-08-16
  • 2023-04-07
相关资源
最近更新 更多