您正在尝试做的事情不能直接远程执行
当我在这里说“远程”时,我的意思是您不能从一个系统调用屏幕捕获到另一个系统。代码必须在本地上下文中执行。
最终,您需要从同样登录到桌面的用户运行$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 文档中的CreateProcessAsUser 和LogonUser。请注意,示例位于 C# 和 VB.NET 中,因此您必须自己将示例转换为 .NET。如果您可以让LogonUser 工作,您应该能够为目标用户调用您的 PowerShell 代码作为新进程,即使通过远程连接也是如此。
如果您尝试此路线,可能还有其他对您有用的AdvApi32函数,如果您决定使用P/Invoke其他方法,请注意您可以粘贴到函数中的C#代码定义中,确保更改对public 的任何访问修饰符(它们通常在C# 示例中定义为internal)。您还需要定义的任何其他数据结构都将在该站点上的任何 P/Invoke 页面上提及。
总结
User32 的GetDC(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."
}
}