您所尝试的操作无法在远程直接执行
在此处提到的“远程”,指的是您无法从一个系统调用屏幕捕获到另一个系统。代码必须在本地环境中执行。
最终,您需要从已登录桌面的用户运行$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' ) {
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(....)
为了正确使用这些函数的示例,您可以参考
CreateProcessAsUser和
LogonUser的
P/Invoke
文档。请注意,示例是用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
)
$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);
}
"@
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."
}
}