3

I have written a code, which is opening a browser and taking a screenshot. But when I running it through remote desktop, it's taking blank image.

Can anyone suggest, how can I take the screenshot of browser on the remote desktop through PowerShell ?

for e.g. I need to open https://stackoverflow.com/ on remote desktop and save screenshot in that remote server.

Code:

[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()
}
codewario
  • 19,553
  • 20
  • 90
  • 159
Ganesh
  • 486
  • 3
  • 8
  • 18
  • i would try with Invoke-Command -ComputerName Server01 -FilePath c:\Scripts\script.ps1 – Bonneau21 Sep 03 '21 at 19:33
  • 1
    Already using above command toexecute script and the script is getting executed, but screenshot i am not able to capture it on remote desktop. – Ganesh Sep 03 '21 at 19:36
  • You need to impersonate the user account you want to capture – Doug Maurer Sep 03 '21 at 19:45
  • 4
    Windows security boundary prevents this. You cannot run code that does GUI stuff on a remote host in a Remote PSSession. PowerShell only runs in the context of the user who started it. No user logged on, then there is no user session. You are not the logged-on user and thus have no access to the desktop. Send your script to the remote host, have it run as a scheduled task only when a user is logged on. – postanote Sep 03 '21 at 19:50
  • Thanks, but could you suggest any other alternative to achieve the same scenario? – Ganesh Sep 04 '21 at 19:09
  • Do you have administrative privileges on the remote desktop? Do you have the credentials of the account of whose session you want to take a screenshot of? – stackprotector Sep 06 '21 at 11:42
  • @Ganesh This is the best you can do is copy the screen buffer for a currently logged-in user. Technically you could obtain raw screen buffer from the display adapter but I'm not sure whether that can be done with managed .NET code, and there are still cases where such a technique would not work. – codewario Sep 06 '21 at 14:10
  • @stackprotector, yes I have credentials of account, – Ganesh Sep 06 '21 at 18:57
  • @Ganesh, are you trying to run this code in a PowerShell session initiated through an RDP session `mstsc.exe`? In other words, did you connect to the remote server using `mstsc.exe`, open a PowerShell terminal, and then run your code which resulted in an empty `bmp` file? Or are you trying to run this through a `PSRemoting` session? Your question is a bit unclear as this should work without modification over RDP, but attempting over a `PSRemoting` session should result in an error. – codewario Sep 07 '21 at 16:07
  • I've updated my answer once more with some new information about the Win32 API. I was unable to get it working today, but I did provide code you can use to `P/Invoke` the required functions into your PowerShell session, should you wish to attempt that route. – codewario Sep 13 '21 at 04:21

1 Answers1

5

What you are trying to do can't be directly performed remotely

When I say "remotely" here, I mean you can't invoke the screen capture from one system to another. The code has to be executed in a local context.

Ultimately, you'll need to run $graphics.CopyFromScreen() from a user who is also logged into the Desktop, not just over a PSRemoting session. There's literally nothing GUI-wise to capture; the remoting session has no GUI components loaded on the other side. In short, you can't expect to use PSRemoting to capture a remote screenshot. It's not happening, there is no screen to copy from, and should result in a Win32Exception: "The handle is invalid". This occurs any time you attempt to copy the screen buffer (screen buffer is different than the display buffer) from a PSRemoting session, even if the user is logged in via RDP at the same time.

Note: My testing indicates the screen capture should still work over RDP without modification to your existing code. I am not sure whether there are GPOs or other settings which can prevent this from working.

However, if the logon session exists but is inactive (e.g. RDP client disconnected), you will get the same "Invalid handle" error. This answer sheds some more light on the behavior, but basically you need to have an active user session to have the draw context copied from. Without this, there is no draw context and thus there is no valid handle to obtain the graphics data from.

There is a possible solution using the Win32 API to perform an interactive logon as another user and then execute your PowerShell script (also with a Win32 API call), I have more information about that further in this answer.


Workaround as long as the target user session is in an active state

To work around this issue, you will have to impersonate a logged on Desktop principal with an active session and run your script in a local context. If you use PSRemoting to establish a remote connection, set up a scheduled task to run a script to capture the screen as a targeted logged-in user (your script should also write the screenshot to a temp file somewhere):

Note: If there are multiple users you might need to capture the desktop of, you will need a task per user or reconfigure the task for the target user prior to running it.

$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

then just invoke the task when you want to run it:

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

Then from your host session you are remoting from (note that your session should be stored as a variable for this copy to work):

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

If your end goal is to also connect to RDP and launch the site with PowerShell, I have the following function I can share to initiate a remote RDP connection via PowerShell. You can then use the Task Scheduler workaround above to launch a browser to the correct site as well as perform your remote capture, though you could also just screenshot the RDP window as well before closing it:

Note: This function returns $True if mstsc was launched but does not check the result of a successful connection. It will return $False if the credential was unable to be stored for some reason.

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
}

This function can also be used to start an active RDP session to the target machine with the target user, so your existing screenshot code would work. Just remember to close the window when you are done. Note this would kick out the target user if they are already logged on remotely or locally.

You could also theoretically manipulate the browser window via code run through Task Scheduler, but you would probably do better to manipulate the browser window through the RDP window from the local session unless you are relying on something like Selenium.

The answer I linked to above also offers some additional workarounds you can try, including additional User32 functions from the Win32 API which you can P/Invoke to access. However, these workarounds may force a re-architecture of your current automation solution. Note that this answer is from 2012 and the suggestion to capture the screen buffer from a service may no longer apply as the ability to grant services access to a user's desktop was quietly revoked during the lifetime of Windows Vista.


Additional information

You can see how CopyFromScreen works internally from the .NET Core source as well as the User32.GetDC(IntPtr) function it uses to understand this behavior better.


If you want to give AdvApi.CreateProcessAsUser from the Win32 API a shot, I have written a function that will P/Invoke the necessary Win32 API functions and create the dependency data structures within your current PowerShell session. Unfortunately I've had some difficulty getting LogonUser to work so I don't have a working example of actually performing the logon and starting a process as another user, but this function at least does the work in getting the functions available in your PowerShell session.

Here's how to use the function once it's defined in your session:

# 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(....)

For proper examples of how to use these functions, you can reference the P/Invoke documentation for CreateProcessAsUser and LogonUser. Note that the samples are in C# and VB.NET so you will have to transpose the examples to .NET yourself. If you can get LogonUser working, you should be able to invoke your PowerShell code as a new process for the target user even when going through a remoting connection.

There may be other AdvApi32 functions that are of use to you if you attempt this route, if you decide to P/Invoke additional methods note that you can paste into the C# code definition in the function, making sure to change any access modifiers to public (they are often defined as internal in the C# samples). Any additional data structures you also need to define will be mentioned on any of the P/Invoke pages on that site.


In Summarium

User32's GetDC(IntPtr) function gets the drawing context of the specified window handle, or the whole desktop if 0 is provided. This function behavior is critical for Graphics.CopyFromScreen to function correctly. The main issue, as mentioned further above, is that there is no drawing context when you connect via PSRemoting, nor will there be one if you use the Task Scheduler workaround when the logon session is not in an active state. So whether you use Graphics.CopyFromScreen or attempt to rewrite some of what you find in the .NET Core source linked above, you cannot copy the screen buffer of a user if:

  • The user is logged out.
  • The user is logged in but the session is inactive (e.g. disconnected RDP client, locked screen, etc).
  • The user session is active but you are attempting to copy the screen buffer over a PSRemoting session as PSRemoting does not give the remote user access to graphical contexts on the remote machine.
  • There is a potential solution with the Win32 API, but it is not for the faint of heart.

PInvoke-AdvApi32 Function

Here is the source for 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."
    }
}
codewario
  • 19,553
  • 20
  • 90
  • 159
  • Instead of using the workaround with a scheduled task to get access to the interactive session of a user, can you not get a similar result by using `runas.exe`? – stackprotector Sep 13 '21 at 05:11
  • No, because you don't get access to the local logon session over psremoting. The security boundary prevents this – codewario Sep 13 '21 at 05:14