guest@6admin.io:~$

Hardening WinRM

WinRM is the engine under PowerShell remoting and most remote Windows management, which makes it both indispensable and a tidy path from one shell to a whole estate. Standing it up is one command. Standing it up safely means undoing two of the defaults that command leaves behind, choosing a transport that means something and constraining what a remote session may actually do. The order matters, because the convenient setup is the insecure one.

The two defaults to undo first

winrm quickconfig and Enable-PSRemoting -Force get you running by creating an HTTP listener on 5985 and opening the firewall for it. On a domain the message layer is still encrypted when the auth is Kerberos or NTLM, so this is not a plaintext disaster by itself, but it is an exposure you did not deliberately choose and it accepts Basic if anything ever flips that on. Treat the quick setup as a starting point to tighten, not a finished state.

The second default is the one that actually burns people and it is client-side:

winrm set winrm/config/client @{TrustedHosts="*"}   # do not do this

TrustedHosts="*" tells the client to skip server-identity verification for any host, which is precisely how a man in the middle harvests the credentials you were about to send. It shows up in tutorials because it makes workgroup testing “just work”. In production it is a standing vulnerability. Name the hosts explicitly, or on a domain leave it empty and let Kerberos verify identity properly:

winrm set winrm/config/client '@{TrustedHosts="dc1.company.internal,app1.company.internal"}'

Transport: HTTPS and nothing weaker

The posture is HTTPS on 5986 with a real certificate, Basic off, unencrypted off and the HTTP listener removed so there is nothing to fall back to:

# HTTPS listener bound to a real cert
$cert = Get-ChildItem Cert:\LocalMachine\My |
        Where-Object Subject -like "*server.company.internal*" |
        Sort-Object NotAfter -Descending | Select-Object -First 1
New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * `
    -Hostname server.company.internal -CertificateThumbprint $cert.Thumbprint -Force

# drop HTTP, forbid Basic and cleartext
winrm delete winrm/config/listener?Address=*+Transport=HTTP
winrm set winrm/config/service @{AllowUnencrypted="false"}
winrm set winrm/config/service/auth @{Basic="false"; Kerberos="true"; CredSSP="false"}

The certificate is the part people shortcut with New-SelfSignedCertificate, which is fine to prove the listener works and worthless as trust, because the client then has to either verify a self-signed cert it has no reason to trust or skip verification and skipping verification is the TrustedHosts mistake wearing a different hat. On a domain this is what the internal CA is for.

CredSSP stays off for the reason it always does: it delegates the full credential to the target, so enabling it estate-wide turns every managed host into a place your credentials can be stolen and replayed. The double-hop problem it solves has safer answers and WinRM is not the place to solve it by default.

Constrain what a session can do: JEA

The transport decides who connects; it does not decide what they can run once connected and by default a remote session is a full shell. Just Enough Administration is the lever that changes that and it is the real least-privilege win for WinRM. A constrained endpoint exposes only the specific commands a role needs, runs them under a dedicated virtual account rather than the caller’s privileges and transcribes everything:

# a role that may restart one service and read logs, nothing else
New-PSRoleCapabilityFile -Path C:\JEA\Operator.psrc `
    -VisibleCmdlets 'Get-Service', @{Name='Restart-Service'; Parameters=@{Name='Name'; ValidateSet='Spooler'}}, 'Get-WinEvent'

New-PSSessionConfigurationFile -Path C:\JEA\Operator.pssc `
    -SessionType RestrictedRemoteServer -RunAsVirtualAccount -TranscriptDirectory C:\JEA\Transcripts `
    -RoleDefinitions @{ 'COMPANY\ServiceOperators' = @{ RoleCapabilities = 'Operator' } }

Register-PSSessionConfiguration -Name Operator -Path C:\JEA\Operator.pssc

An operator connected to that endpoint can restart the Spooler and read events and cannot open a shell, read arbitrary files, or pivot, even though they authenticated successfully. That gap, between “allowed to connect” and “allowed to do anything”, is where WinRM hardening earns its keep.

Scope the listener and audit it

The endpoint should answer only the admin network, which is a firewall rule applied to the HTTPS port, with the HTTP rule gone:

New-NetFirewallRule -DisplayName "WinRM-HTTPS-admin" -Direction Inbound `
    -LocalPort 5986 -Protocol TCP -Action Allow -RemoteAddress 10.0.10.0/24
Disable-NetFirewallRule -DisplayName "Windows Remote Management (HTTP-In)"

For accounting, three channels carry the story: the WinRM operational log for listener and connection events, the PowerShell operational log (event 4103 for pipeline execution, plus the script-block log) for what was actually run and the Security log for the service logons behind each connection:

Get-WinEvent -LogName "Microsoft-Windows-WinRM/Operational"
Get-WinEvent -LogName "Microsoft-Windows-PowerShell/Operational" | Where-Object Id -eq 4103
Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4624,4625}

JEA transcripts close the loop, because they record each constrained session verbatim, under the account that ran it.

Where to land

HTTPS on 5986 with a CA-issued certificate, HTTP and Basic and CredSSP gone, TrustedHosts named rather than wildcarded, the listener firewalled to the admin network and JEA constraining remote sessions to the commands a role actually needs under a dedicated account. The convenient quickconfig default is the thing to harden away from and the distance between it and the configuration above is the distance between “remote management is on” and “remote management is a control rather than a liability”.