Windows Remote Management

WinRM (Windows Remote Management) is both a Windows service and a network protocol that enables remote interaction with Windows systems. It serves as the transport layer for tools like remote PowerShell and certain WMI/CIM operations, particularly those using the WSMan protocol.

WinRM is used for legitimate administration but is also one of the most common techniques abused by attackers for Remote Code Execution (RCE) and lateral movement across a network.
https://attack.mitre.org/techniques/T1021/006

Common WinRM indicators and PowerShell cmdlets:

Invoke-Command
Executes a script block remotely on one or more computers. Most common for remote code execution without opening an interactive shell.

New-PSSession
Creates a persistent remote PowerShell session that can be reused.

Enter-PSSession
Opens an existing interactive remote PowerShell session.

Import-PSSession
Imports commands from another session into the current session. Used to run remote modules as if they were local

Get-CimInstance
Retrieves management data from local or remote systems using WSMan by default. Remote enumeration of system details without triggering DCOM-based WMI logs.

New-CimSession
Establishes a persistent WSMan-based session to perform CIM/WMI queries. Maintains a reusable channel for repeated remote reconnaissance or lateral movement.

Invoke-CimMethod
Executes methods on local or remote CIM/WMI classes using the WSMan (WinRM) protocol. Commonly used to remotely spawn processes or trigger system actions, functioning like Invoke-WmiMethod but via WinRM instead of DCOM.

WinRM
Configures and manages the Windows Remote Management (WinRM) service. Used to enable, disable, or modify WinRM listener settings. Common in staging or pre-execution phases to prepare a system for remote access.

WinRS
Windows Remote Shell through CLI, this is its own executable.

HUNTING

Open the door with the cmdlet Enable-PSRemoting. Admin privileges required.

DeviceEvents
| where ActionType contains "PowerShellCommand"
| where InitiatingProcessAccountDomain !~ 'nt authority'
| extend Parsed = parse_json(AdditionalFields)
| extend Command = tostring(Parsed.Command)
| where Command contains "Enable-PSRemoting"
| project Timestamp, DeviceName, InitiatingProcessAccountName, Command, InitiatingProcessCommandLine
| sort by Timestamp desc

If it’s enabled indirectly by the likes of a script review the registry.

DeviceRegistryEvents
| where RegistryKey has @'SOFTWARE\Microsoft\Windows\CurrentVersion\WSMAN'
| where ActionType in ("RegistryKeyCreated", "RegistryValueSet")
| project Timestamp, DeviceName, InitiatingProcessAccountName, InitiatingProcessCommandLine, RegistryKey, RegistryValueName, RegistryValueData

Using the indicator list query across process and PowerShell command execution.

let PowerShell = dynamic(["powershell.exe", "pwsh.exe", "powershell_ise.exe"]);
let RemoteCmdlets = dynamic(["Invoke-Command", "New-PSSession", "Enter-PSSession", "Import-PSSession", "Get-CimInstance", "New-CimSession", "Invoke-CimMethod"]);
let RemoteArguments = dynamic(["-ComputerName", "-HostName", "-Uri", "-ConnectionUri", "-SSHConnection", "-ResourceUri"]); 
DeviceProcessEvents
| where (FileName in~ (PowerShell) and ((ProcessCommandLine has_any (RemoteCmdlets) and ProcessCommandLine has_any (RemoteArguments)) 
or ProcessCommandLine has "winrm " or FileName =~ "winrs.exe"))
| project Timestamp, DeviceName, AccountName, FolderPath, ProcessCommandLine, InitiatingProcessCommandLine, InitiatingProcessParentFileName
| sort by Timestamp desc 

PowerShell can use variables to disguise parameters or flags. As a result, events may not show explicit flags like -ComputerName. Searching for the $ token can surface variable based execution. Pivot on specific devices and earlier commands to reveal what was stored inside the variable.

Speaking from the actor or originating host perspective, The command evidence within the session will not be logged.

New-PSSession - ComputerName ⟵ Logged

Enter-PSSession ⟵ Logged

Session Commands ⟵ Not Logged

Remove-PSSession ⟵ Logged

let PowerShell = dynamic(["powershell.exe", "pwsh.exe", "powershell_ise.exe"]);
let RemoteCmdlets = dynamic(["Invoke-Command", "New-PSSession", "Enter-PSSession", "Import-PSSession", "Get-CimInstance", "New-CimSession", "Invoke-CimMethod"]);
let RemoteArguments = dynamic(["-ComputerName", "-HostName", "-Uri", "-ConnectionUri", "-SSHConnection", "-ResourceUri"]); 
DeviceEvents
| where ActionType contains "PowerShellCommand"
| where InitiatingProcessAccountDomain !~ 'nt authority'
| extend Parsed = parse_json(AdditionalFields)
| extend Command = tostring(Parsed.Command) 
| where (InitiatingProcessFileName in~ (PowerShell) and ((Command has_any (RemoteCmdlets) and Command has_any (RemoteArguments)) or InitiatingProcessCommandLine has "winrm "))
| project Timestamp, DeviceName, InitiatingProcessAccountName, InitiatingProcessFolderPath, Command, InitiatingProcessCommandLine, InitiatingProcessParentFileName
| sort by Timestamp desc

WinRM connects over the ports 5985 and 5986 by default, on Windows 7 and later versions, WinRM HTTP uses port 5985 and WinRM HTTPS uses port 5986. On earlier versions of Windows, WinRM HTTP uses port 80 and WinRM HTTPS uses port 443. Kinda weak as a stand-alone query.

let PS = dynamic(['powershell.exe', 'pwsh.exe', 'powershell_ise.exe', 'winrs.exe']);
DeviceNetworkEvents
| where RemoteIP !in ('::1', '127.0.0.1')
| where LocalIP != RemoteIP
| where RemotePort in (5985, 5986)
| where InitiatingProcessFileName in~ (PS)
| where InitiatingProcessAccountDomain !~ 'nt authority'
| project TimeGenerated, ActionType, DeviceName, InitiatingProcessAccountName, InitiatingProcessCommandLine, LocalIP, LocalPort, RemoteIP, RemotePort, RemoteUrl
| sort by TimeGenerated desc

WS-Management (WSMan) is a SOAP-over-HTTP(S) protocol. WinRM is Microsoft’s implementation that exposes /wsman on ports 5985/5986 and is the default transport for PowerShell Remoting, Windows PowerShell DSC, and CIM sessions. Defender does offer some form of packet inspection, in this case through the ActionType HttpConnectionInspected, so no process information.

DeviceNetworkEvents 
| where ActionType == 'HttpConnectionInspected'
| extend Parsed = parse_json(AdditionalFields)
| extend Uri = tostring(Parsed.uri) 
| extend Method = tostring(Parsed.method) 
| extend StatusCode = tostring(Parsed.status_code) 
| extend Direction = tostring(Parsed.direction) 
| extend UserAgent = tostring(Parsed.user_agent) 
| where Uri contains "/wsman" or UserAgent =~ "Microsoft WinRM Client"
| project TimeGenerated, DeviceName, LocalIP, LocalPort, RemoteIP, RemotePort, Uri, Method, StatusCode, Direction, UserAgent
| sort by TimeGenerated desc

So, using the above we can cook something up. Use network connections to spot true remote PowerShell. It is more reliable than command due to variable and obfuscation. Identify the origin host from outbound traffic to 5985 or 5986, pivot on the initiating PID, then summarize every command recorded for that PID.

let Remote = dynamic(["powershell.exe", "pwsh.exe", "powershell_ise.exe", "winrs.exe"]);
let CommandInsight =
DeviceEvents
| where ActionType == "PowerShellCommand"
| where InitiatingProcessFileName in~ (Remote) or ProcessCommandLine has "winrm "
| extend Parsed = parse_json(AdditionalFields)
| extend Command = tostring(Parsed.Command)
| project 
  DeviceName,
  CommandTime = Timestamp,
  CommandProcessID = InitiatingProcessId,
  CommandAccountName=InitiatingProcessAccountName,
  CommandInitiatingCommandLine=InitiatingProcessCommandLine,
  Command;
let NetActivity =
DeviceNetworkEvents
| where InitiatingProcessFileName in~ (Remote) or InitiatingProcessCommandLine has "winrm "
| where RemoteIP !in ("::1", "127.0.0.1")
| where LocalIP != RemoteIP
| where RemotePort in (5985, 5986)
| project 
  NetConTime = Timestamp,
  DeviceName,
  NetProcessID = InitiatingProcessId,
  NetAccountName=InitiatingProcessAccountName,
  NetCommandLine = InitiatingProcessCommandLine,
  RemoteIP,
  RemoteUrl;
NetActivity
| join kind=leftouter (
  CommandInsight
) on DeviceName, $left.NetProcessID == $right.CommandProcessID
| where isnull(CommandTime) or (CommandTime between (NetConTime - 5m .. NetConTime + 5m))
| summarize
  CommandsInWindow = make_set(Command, 1000),
  CommandTimes = make_set(CommandTime, 1000)
by
  NetConTime,
  DeviceName,
  NetProcessID,
  CommandProcessID,
  CommandAccountName,
  CommandInitiatingCommandLine,
  NetAccountName,
  NetCommandLine,
  RemoteIP,
  RemoteUrl
| sort by NetConTime desc

When a WinRM remote session starts, the target host spawns wsmprovhost.exe for PowerShell remoting, or winrshost.exe for WinRS sessions.

DeviceProcessEvents
| where InitiatingProcessFileName in~ ('wsmprovhost.exe', 'winrshost.exe')
| project Timestamp, DeviceName, AccountName, FolderPath, ProcessCommandLine, InitiatingProcessCommandLine, InitiatingProcessParentFileName
| sort by Timestamp desc 

Check historic data for identified devices and impacted users. Join IdentiyInfo to provide additional context.

let winrm_processes = 
DeviceProcessEvents
| where InitiatingProcessFileName =~ "wsmprovhost.exe"
| project TimeGenerated, DeviceName, AccountName, FolderPath, ProcessCommandLine, InitiatingProcessCommandLine,
InitiatingProcessParentFileName;
winrm_processes
| where TimeGenerated >= ago(30d)
| join kind=leftouter (
IdentityInfo
| summarize arg_max(TimeGenerated, *) by AccountUPN
) on AccountName
| project TimeGenerated, DeviceName, AccountName, JobTitle, AssignedRoles, ProcessCommandLine, InitiatingProcessCommandLine, InitiatingProcessParentFileName

So we should be able to identify source to target execution as long as both endpoints are onboarded.

We identify true remote PowerShell activity by detecting PowerShell usage over the default ports 5985/5986.

We use the remote IP in that event and correlate it with the closest preceding event in DeviceNetworkInfo to pull the device name of the target.

Next, we search for remote PowerShell execution on the target host indicated by the initiating processes of wsmprovhost.exe.

Finally, we provide context using PowerShellCommand from DeviceEvents.

let RemoteInitiating = dynamic(["powershell.exe", "pwsh.exe", "powershell_ise.exe", "winrs.exe"]);
let RemoteTarget = dynamic(["wsmprovhost.exe", "winrshost.exe"]);
// Step 1: Capture target-side WinRM execution 
let TargetExecution = 
DeviceProcessEvents
| where InitiatingProcessFileName in~ (RemoteTarget)
| project TargetTime=Timestamp, TargetDevice=DeviceName, TargetAccount=AccountName,
TargetCommandLine=ProcessCommandLine, TargetInitiatingProcessCommandLine=InitiatingProcessCommandLine;
// Step 2: Used for mapping RemoteIP to DeviceName
let GetHostName = 
DeviceNetworkInfo
| extend Parsed = parse_json(IPAddresses)
| mv-expand IPObject = Parsed
| extend IP = tostring(IPObject.IPAddress)
| project HostTime = Timestamp, HostDevice = DeviceName, IP;
// Step 3: Capture WinRM network connections and ensure its remote
let NetActivity = 
DeviceNetworkEvents
| where InitiatingProcessFileName in~ (RemoteInitiating) or InitiatingProcessCommandLine has "winrm "
| where RemoteIP !in ("::1", "127.0.0.1")
//| where LocalIP != RemoteIP // Uncomment this, I tested this using the one host
| where RemotePort in (5985, 5986)
| project NetConTime=Timestamp, SourceDevice=DeviceName, NetProcessID=InitiatingProcessId, NetAccountName=InitiatingProcessAccountName,
NetCommandLine=InitiatingProcessCommandLine, RemoteIP;
// Step 4: Join NetActivity to closest pre network connection event for accurate IP to DeviceName mapping 
let ResolvedNet = 
NetActivity
| join kind=inner (GetHostName) on $left.RemoteIP == $right.IP
| where HostTime <= NetConTime // only use mappings before or at connection time
| extend DeltaMinutes = datetime_diff("minute", NetConTime, HostTime)
| summarize arg_max(HostTime, *) by SourceDevice, NetConTime, RemoteIP // closest prior mapping per RemoteIP
| project NetConTime, SourceDevice, NetProcessID, NetAccountName, NetCommandLine, RemoteIP, TargetDevice = HostDevice;
// Step 5: Join to target-side process execution (RemoteTarget must have been triggered on matched device)
let WinRMConfirmed = 
ResolvedNet
| join kind=inner (
 TargetExecution
) on TargetDevice
| where abs(datetime_diff("minute", NetConTime, TargetTime)) <= 10
| project SourceDevice, NetAccountName, NetCommandLine, NetConTime, TargetDevice, TargetAccount, TargetTime, TargetCommandLine, NetProcessID,
TargetInitiatingProcessCommandLine;
// Step 6: Provide PowerShell context from initiator
let CommandInsight =
DeviceEvents
| where ActionType == "PowerShellCommand"
| where InitiatingProcessFileName in~ (RemoteInitiating) or InitiatingProcessCommandLine has "winrm "
| extend Parsed = parse_json(AdditionalFields)
| extend Command = tostring(Parsed.Command)
| project SourceDevice=DeviceName, CommandTime=Timestamp, CommandProcessID=InitiatingProcessId, Command,
InitiatingProcessTime=InitiatingProcessCreationTime, InitiatingProcessCommandLine;
// Pull command context near the WinRM session time - 5min pre and post
WinRMConfirmed
| join kind=leftouter (
 CommandInsight
) on SourceDevice, $left.NetProcessID == $right.CommandProcessID
| where isnull(CommandTime) or (CommandTime between (NetConTime - 5m .. NetConTime + 5m))
| summarize
 Commands = make_set(Command, 1000),
 CommandTimes = make_set(CommandTime, 1000)
by
 InitiatingProcessTime,
 InitiatingProcessCommandLine,
 InitiatingDevice = SourceDevice,
 InitiatingNetConTime = NetConTime,
 InitiatingNetAccountName = NetAccountName,
 InitiatingNetCommandLine = NetCommandLine,
 TargetDevice,
 TargetAccount,
 TargetExecutionTime = TargetTime,
 TargetInitiatingProcessCommandLine,
 TargetExecutionCommandLine = TargetCommandLine
Screenshot of a Windows Event Log showing PowerShell commands executed on a remote machine with details of commands, timestamps, and target information.

Review added plugins that may be leveraged by attackers via the registry path HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\WSMAN\Plugin.

Plugins are typically in the form of an XML file type and are registered using the ‘winrm create *xml’ command.

DeviceRegistryEvents
| where RegistryKey has @"\WSMAN\Plugin"
| project TimeGenerated, ActionType, DeviceName, RegistryKey, RegistryValueData, RegistryValueName, RegistryValueType, InitiatingProcessCommandLine

Identify where WinRM is used to load DLLs or WinRM plugins in the form of a DLL. Filtering C:\Windows\assembly\NativeImages to reduce .NET and CLR background noise.

DeviceImageLoadEvents
| where InitiatingProcessFileName =~ 'wsmprovhost.exe' 
| where FolderPath !startswith @'C:\Windows\assembly\NativeImages'
| project TimeGenerated, DeviceName, FolderPath, SHA256, InitiatingProcessAccountName, InitiatingProcessCommandLine
DeviceImageLoadEvents
| where InitiatingProcessFileName =~ 'wsmprovhost.exe' 
| where FolderPath !startswith @'C:\Windows\assembly\NativeImages'
| project Timestamp, DeviceName, InitiatingProcessAccountName, InitiatingProcessCommandLine, FolderPath, SHA256
| invoke FileProfile(SHA256)
| project Timestamp, DeviceName, InitiatingProcessAccountName, InitiatingProcessCommandLine, FolderPath, SignatureState, GlobalPrevalence, SHA256
| where SignatureState == 'Unsigned' or GlobalPrevalence < 1000

Invoke the FileProfile function to retrieve signature status and file prevalence loaded by wsmprovhost.exe. This function is only available when querying through Microsoft Defender and may fail on large datasets, however, the applied filters should constrain results to a manageable scope.