如何使用PowerShell在远程桌面上进行屏幕截图

3

我写了一段代码,可以打开浏览器并截取屏幕。但是当我通过远程桌面运行时,它会截取一个空白的图片。

有人能否建议一下,如何通过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()
}

1
已经使用上述命令来执行脚本,脚本正在执行,但是我无法在远程桌面上捕获屏幕截图。 - Ganesh
你需要模拟想要捕获的用户帐户。 - Doug Maurer
4
Windows 安全边界限制了这一操作。在远程 PSSession 中,您无法运行涉及 GUI 操作的代码。PowerShell 只能在启动它的用户上下文中运行。如果没有用户登录,则不存在用户会话。您不是已登录用户,因此无法访问桌面。请将脚本发送到远程主机,仅当用户登录时作为计划任务运行。 - postanote
谢谢,但您能否建议其他实现相同场景的替代方案? - Ganesh
您在远程桌面上是否拥有管理员权限?您是否拥有想要截取屏幕截图的会话所属账户的凭证? - stackprotector
显示剩余5条评论
1个回答

5

您所尝试的操作无法在远程直接执行

在此处提到的“远程”,指的是您无法从一个系统调用屏幕捕获到另一个系统。代码必须在本地环境中执行。

最终,您需要从已登录桌面的用户运行$graphics.CopyFromScreen(),而不仅仅是通过PSRemoting会话。实际上,没有任何GUI组件可供捕获; 远程会话在另一侧没有加载GUI组件。简而言之,您不能指望使用PSRemoting来捕获远程屏幕截图。这是不可能的,没有要复制的屏幕,并且应该导致Win32Exception:“句柄无效”。每次尝试从PSRemoting会话中复制屏幕缓冲区(屏幕缓冲区与显示缓冲区不同)时都会发生这种情况,即使用户同时通过RDP登录也是如此。

注意:我的测试表明,屏幕截图应该仍然可以在RDP上工作,而无需修改您现有的代码。我不确定是否有GPO或其他设置可能会阻止这个功能的使用。
但是,如果登录会话存在但处于非活动状态(例如,RDP客户端断开连接),您将收到相同的“无效句柄”错误。此答案对行为进行了更详细的解释,但基本上您需要有一个活动的用户会话才能从中复制绘制上下文。没有这个,就没有绘制上下文,因此也没有有效的句柄来获取图形数据。
通过使用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

如果您的最终目标是通过PowerShell连接到RDP并启动网站,我可以分享以下功能来通过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会话,因此您现有的截屏代码将正常工作。只需记得在完成后关闭窗口即可。请注意,如果目标用户已经远程或本地登录,则会将其踢出。

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

我上面链接的答案还提供了一些额外的解决方法,包括可以P/Invoke 访问的Win32 API 的其他 User32 函数。然而,这些解决方法可能会强制重新架构您当前的自动化解决方案。请注意,这个答案是来自2012年,从服务捕获屏幕缓冲区的建议可能不再适用,因为在Windows Vista的生命周期内,授予服务访问用户桌面的能力已被悄然撤销。


附加信息

您可以从.NET Core源代码以及它使用的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(....)

为了正确使用这些函数的示例,您可以参考CreateProcessAsUserLogonUserP/Invoke文档。请注意,示例是用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 来获取类似的结果,而不是使用计划任务来绕过获取用户交互会话的限制? - stackprotector
不可以,因为你无法通过远程 PowerShell 访问本地登录会话。安全边界会阻止这种操作。 - codewario

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接