Skip to content

PowerShell Fundamentals

If you cannot read PowerShell, you cannot investigate a Windows breach. Mimikatz, PowerSploit, Empire, Covenant, and almost every public red-team toolkit are PowerShell — and almost every EDR alert from Microsoft Defender for Endpoint in the last five years is a 4104 Script Block event with the actual command the attacker ran. The language is also the cleanest way to query WMI, run remote commands via WinRM, and pull forensic artifacts from a fleet. This module covers the pipeline object model, PSRemoting, Script Block Logging, AMSI, and Constrained Language Mode — the five things every Windows-side operator needs.

PowerShell is built on .NET. Every cmdlet returns one or more .NET objects, not text; the pipeline passes object references between cmdlets, not bytes. Get-Process | Where-Object {$_.CPU -gt 10} | Select-Object Name,CPU filters in-memory objects, not by parsing stdout. This is the single biggest difference from bash and the reason PowerShell scripts are short.

┌──────────────────────────────────────────────────────────┐
│ PowerShell pipeline │
│ Get-Process -> Where-Object -> Select-Object -> Out-Host│
│ each step passes PSObject references, not bytes │
├──────────────────────────────────────────────────────────┤
│ Underneath │
│ .NET / CoreCLR -> System.Management.Automation.dll │
│ PSHost -> RunspacePool -> WinRM (5985/5986) │
└──────────────────────────────────────────────────────────┘
  1. Verb-noun cmdlet convention. Approved verbs (Get-, Set-, New-, Remove-, Invoke-, ConvertTo-, Out-, Select-, Where-, ForEach-). Singular noun = single object; plural = collection. Get-Process returns System.Diagnostics.Process[].
  2. Pipeline objects. $_ (alias $PSItem) is the current pipeline object. Where-Object {<# predicate #>} filters; ForEach-Object {<# action #>} maps; Select-Object -First 10 slices. Property access with dot notation: $p.Name, $p.WorkingSet64.
  3. PSRemoting / WinRM:
    • Server: WinRM service (WinRM); HTTP listener on 5985, HTTPS on 5986.
    • Auth: Negotiate (Kerberos in-domain, NTLM fallback), Basic (cleartext username/password over TLS), CredSSP (delegation, dangerous), Kerberos (SPN HOST/server.domain.com).
    • Session configurations (endpoints): Get-PSSessionConfiguration — Microsoft.PowerShell (default 64-bit), Microsoft.PowerShell32 (32-bit), Microsoft.PowerShell.Workflow.
    • One-to-one: Enter-PSSession -ComputerName dc01 -Credential $c
    • One-to-many: Invoke-Command -ComputerName srv1,srv2 -ScriptBlock { Get-Process }
    • Connection via New-PSSession for reuse; close with Remove-PSSession.
  4. CIM vs WMI:
    • Get-WmiObject Win32_Process — DCOM, deprecated, slow.
    • Get-CimInstance Win32_Process — WS-Man (same protocol as WinRM), modern.
    • Invoke-CimMethod for method calls; Set-CimInstance for writes.
  5. PowerShell versions:
    • Windows PowerShell 5.1 — built into Windows Server 2016/2019/2022 and Windows 10/11. Runs on .NET Framework 4.x. Path %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe.
    • PowerShell 7.x — cross-platform, .NET Core/5+. Side-by-side install at C:\Program Files\PowerShell\7\pwsh.exe. Many security tools still target 5.1.
  6. Logging — Script Block Logging is the single most important defensive control:
    • Registry: HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging\EnableScriptBlockLogging=1.
    • Event log: Microsoft-Windows-PowerShell/Operational, Event ID 4104 (Script Block), 4103 (Module), 4100 (parse error), 800 (Pipeline Execution), 400 (Engine state).
    • 4104 fires for every script block (function, loop body, conditional). Includes the de-obfuscated text after IEX decoding if the script block ended in a decoded eval.
    • 4104 is logged regardless of CLM; even when an attacker obfuscates, the script block contains the deobfuscated source because the PowerShell engine logs at compile time, not at parse time.
  7. AMSI (Anti-Malware Scan Interface) — amsi.dll exposed to PowerShell, .NET, VBA, JavaScript. PowerShell calls AmsiScanBuffer(content) before Invoke-Expression evaluates any script block. If AMSI_RESULT_DETECTED (0x80070079), execution is blocked.
  8. Constrained Language Mode (CLM) — $ExecutionContext.SessionState.LanguageMode. Modes: FullLanguage (default), ConstrainedLanguage (set by WDAC policy), RestrictedLanguage, NoLanguage. CLM blocks direct .NET method invocation ([Activator]::CreateInstance), type accelerators, and Add-Type — but allows cmdlets and basic language constructs. WDAC enforces CLM system-wide; a script written before WDAC was enabled will fail mid-execution.
TechniqueDescriptionDetection Difficulty
AMSI memory patchOverwrite AmsiScanBuffer prologue with mov eax, 0x80070057; retMedium — EID 8 thread into amsi.dll + EID 4104 with amsiInitFailed
Reflection-based AMSI bypassSet amsiInitFailed=true field via reflectionEasy — EID 4104 with literal amsiInitFailed
CLM bypass via COM-interopUse New-Object -ComObject from CLM context (CLM does not block COM)Hard — by-design gap, fixed in recent Windows
Encoded command -encpowershell -EncodedCommand <base64-utf16le>Easy — EID 4104 logs decoded script block
AMSI provider unregistrationUnregister AMSI provider via registry / amsi.dll unloadMedium — registry write alert on HKLM\Software\Microsoft\AMSI\Providers
Runspace-based payloadsSystem.Management.Automation.Runspaces.Pipeline directlyHard — no powershell.exe host process
PowerShell-7 use of pwsh.exeDefenders usually log powershell.exe but not pwsh.exeEasy once alerted — add pwsh.exe to monitoring
Terminal window
# Get the current language mode and execution policy
$ExecutionContext.SessionState.LanguageMode
Get-ExecutionPolicy -List
# List every local session configuration (PSRemoting endpoints)
Get-PSSessionConfiguration | Select Name, PSVersion, Permission, RunAsUser
# Run a command on multiple hosts with implicit credentials (Kerberos)
Invoke-Command -ComputerName srv01,srv02,srv03 -ScriptBlock {
Get-CimInstance Win32_Process |
Where-Object { $_.Name -eq 'lsass.exe' } |
Select-Object Name, ProcessId, CommandLine
}
# Capture all logons in the last 24h from a DC
Get-WinEvent -LogName Security -MaxEvents 10000 |
Where-Object { $_.Id -eq 4624 -and $_.TimeCreated -gt (Get-Date).AddDays(-1) } |
ForEach-Object {
[PSCustomObject]@{
Time = $_.TimeCreated
User = $_.Properties[5].Value
SrcIP = $_.Properties[18].Value
Type = $_.Properties[8].Value
}
} | Sort-Object Time -Descending
# Use the AMSI bypass (reflection trick) — for the lab; do not use on production hosts
$a = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$b = $a.GetField('amsiInitFailed','NonPublic,Static')
$b.SetValue($null, $true)
# Confirm CLM is active (set by WDAC); this throws if CLM is on
[Activator]::CreateInstance([Type]::GetTypeFromProgID('Scripting.FileSystemObject'))
# Pipe to Out-GridView for spreadsheet-like filtering
Get-Process | Where-Object { $_.CPU -gt 10 } | Out-GridView
# Encode a script for use with -EncodedCommand (UTF-16LE base64)
$cmd = 'Get-Process | Where-Object {$_.CPU -gt 5}'
$bytes = [System.Text.Encoding]::Unicode.GetBytes($cmd)
[Convert]::ToBase64String($bytes)
# Decode an encoded command (defender side) — see what attackers ran
$enc = "RwBlAHQALQBQAHIAbwBjAGUAcwBzAA=="
[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($enc))

Sysmon Event IDs (the same numbers used on F3 — Sysmon for both Windows and Linux uses the same schema):

  • 1 — ProcessCreate (catch powershell.exe -enc, pwsh.dll, powershell.exe -ExecutionPolicy Bypass).
  • 3 — NetworkConnect (catch powershell.exe connecting to external IPs — usually suspicious).
  • 8 — CreateRemoteThread (catch AMSI patching into amsi.dll).
  • 10 — ProcessAccess (catch mimikatz-style LSASS access from powershell.exe).
  • 11 — FileCreate (catch payload extraction from base64).

Windows PowerShell operational log:

  • 4104 — Script Block (the deobfuscated source — even for -enc).
  • 4103 — Module (which module was loaded — catch PowerSploit, PowerView, Nishang).
  • 800 — Pipeline Execution (catch pipeline commands).
  • 400 — Engine state (catch pwsh startup; includes the version field).

MITRE ATT&CK T1059.001 — Command and Scripting Interpreter: PowerShell.

MITRE ATT&CK T1021.006 — Remote Services: Windows Remote Management (Invoke-Command, Enter-PSSession to remote hosts).

MITRE ATT&CK T1055 — Process Injection (AMSI patch via CreateRemoteThread from PowerShell host).

MITRE ATT&CK T1562.001 — Impair Defenses: Disable or Modify Tools (AMSI bypass fits here).

MITRE ATT&CK T1006 — Direct Volume Access (PowerShell’s Get-Content -Raw \\.\C: reads raw disk — used by some credential-dumping scripts).

Wazuh rule 60004 / 920 — Suspicious PowerShell command line containing bypass, -enc, DownloadString, IEX [verify — requires custom decoder].

Wazuh rule 92600 — PowerShell 4104 event with AmsiUtils or amsiInitFailed [verify — custom rule against the Windows PowerShell operational log].

AMSI bypass — memory patching. A CreateRemoteThread from powershell.exe into amsi.dll!AmsiScanBuffer patching the first 7 bytes to mov eax, 0x80070057; ret causes all subsequent script scans to return E_INVALIDARG (clean). Detection: Sysmon EID 8 with TargetImage containing amsi.dll, OR EID 7 with ImageLoaded=amsi.dll AND EID 8 within 5 seconds.

AMSI bypass — reflection. No CreateRemoteThread needed; the .NET reflection API can flip amsiInitFailed to true:

Terminal window
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
.GetField('amsiInitFailed','NonPublic,Static')
.SetValue($null,$true)

This works because PowerShell checks the static field before calling AmsiScanBuffer. Detection: EID 4104 with the literal string amsiInitFailed or AmsiUtils — alert on any script block containing these.

PowerShell 7 (pwsh.exe) vs PowerShell 5.1 (powershell.exe). Many EDR rules and Wazuh decoders only check for powershell.exe. An attacker who installs PowerShell 7 can use pwsh.exe and bypass legacy logging if Script Block Logging is only enabled for Windows PowerShell. Fix: enable Script Block Logging for both via Group Policy and ensure EDR monitors pwsh.exe (now in the Sysmon default configuration).

Runspace-based payloads. Using System.Management.Automation.Runspaces.Pipeline from a C# loader that does not load powershell.exe at all. The PowerShell engine (System.Management.Automation.dll) is invoked directly from any .NET process. Detection: alert on any process loading System.Management.Automation.dll that is not powershell.exe, pwsh.exe, or wsmprovhost.exe.

Script Block Logging bypass via selective disabling. An attacker with admin rights can clear the Microsoft-Windows-PowerShell/Operational event log (wevtutil cl Microsoft-Windows-PowerShell/Operational). Detection: monitor for wevtutil cl invocations from any process.

Constrained Language Mode bypass via COM. New-Object -ComObject ScriptControl or MSScriptControl.ScriptControl lets you run VBScript from within CLM. Recent Windows versions block this. Detection: alert on ScriptControl ProgID instantiation.

EvasionWhat it bypassesHow to detect
AMSI memory patchAntimalware scan of script contentEID 8 thread into amsi.dll; EID 4104 with amsiInitFailed
AMSI reflection bypassAntimalware scanEID 4104 with literal amsiInitFailed
pwsh.exe vs powershell.exeLegacy decodersMonitor both; Group Policy for both Script Block Logs
Runspace directpowershell.exe process alertAlert on System.Management.Automation.dll load outside known hosts
wevtutil clForensic trailAlert on event-log clear operations (Security EID 1102)
CLM COM bypassConstrained Language ModeAlert on ScriptControl ProgID

⚠️ What caught me: A red team left a long-running PowerShell script that enumerated the domain every 30 minutes. The customer had Script Block Logging on and 4104 events in their SIEM. They missed the activity because their 4104 rule excluded anything with severity=Information (the default level for 4104). The script was 8 KB of legitimate-looking cmdlet calls, including a benign-looking loop, but the inner pipeline had Get-ADUser -Filter * -Properties * and dumped to a CSV. Lesson: 4104 events are Information by design; severity is set by your SIEM, not by Windows. Filter on content, not on severity.

VM: Windows Server 2019 with PowerShell 5.1 + PowerShell 7.4 installed side-by-side. Enable Script Block Logging via Group Policy (Computer Configuration → Administrative Templates → Windows Components → Windows PowerShell → Turn on PowerShell Script Block Logging = Enabled).

Objective:

  1. Run powershell.exe -EncodedCommand <base64-of 'Get-Process | Out-GridView'> and confirm the decoded text appears in EID 4104 of the PowerShell Operational log.
  2. Run the AMSI reflection bypass in a fresh PowerShell 5.1 host; then run Invoke-Mimikatz (use a benign variant for the lab). Confirm Defender blocks the second invocation if AMSI is enabled; confirm the bypass defeats the block.
  3. Demonstrate a one-to-many Invoke-Command against three lab hosts; show the EID 4624 LogonType=3 on each target.
  4. Use pwsh.exe (PowerShell 7) to run the same encoded command; verify that the 4104 event is logged for both powershell.exe and pwsh.exe.

Expected flag format: flag{eid_4104_decoded_observed_amsi_bypassed_then_blocked_psr_4624_type3_pwsh_logged_too}.

Detection that should fire: EID 4104 with the decoded command (regardless of -enc); EID 8 if the AMSI memory-patch path was used; Security EID 4624 LogonType=3 on each target; if Defender was on, the Invoke-Mimikatz script block triggers a Defender alert.

Conceptual: explain why Get-Process | Where-Object {$_.CPU -gt 10} is structurally different from ps aux | awk '$3 > 10'. What is the cost and what is the benefit?

Technical: walk through what happens when Invoke-Command -ComputerName srv01 -ScriptBlock {...} runs. Name every authentication protocol attempted, every endpoint used, and the WinRM HTTP verb.

Scenario: a customer disabled Script Block Logging “for performance.” List three events they will no longer see, and propose an alternative control for each.

What is Constrained Language Mode, what triggers it (name the policy mechanism), and what is the one feature that CLM blocks but PowerShell cmdlets remain able to do? Why does that matter?

Technical: explain the AMSI reflection bypass (amsiInitFailed = true). Why does it work, and what single rule on Event ID 4104 catches it in every variant of the technique?

→ F3 (Windows Internals) · F5 (Python for Security) · F8 (Git & Version Control for Security)