Chapter 16Lesson 03~215 minutes

Injection, Invoke-Expression, Native Arguments, and Untrusted Input

Defend PowerShell automation against injection by preserving code/data boundaries, avoiding routine Invoke-Expression, handling native arguments deliberately, validating inputs, and containing paths.

InjectionUntrusted inputNative toolsValidation

Learning objectives

  • Explain injection as unintended interpretation of data as instructions.
  • Demonstrate why routine Invoke-Expression construction is unsafe.
  • Use parameters, arrays, splatting, and the call operator for structured invocation.
  • Distinguish PowerShell parsing from downstream native-tool argument semantics.
  • Prevent wildcard/path-traversal surprises with LiteralPath and canonical containment checks.
  • Build a constrained wrapper that accepts untrusted values without granting arbitrary execution.

1. Injection happens when data crosses into a code interpreter

An injection vulnerability appears when untrusted data is interpreted as instructions by a language, shell, query engine, regex engine, path parser, or downstream native tool. The secure design goal is to preserve the boundary: values remain values.

PowerShell already has structured parameters, arrays, splatting, script blocks, and the call operator. Most automation therefore has no reason to construct source-code strings and evaluate them.

2. A harmless sandbox example shows the failure mode

The following intentionally broken example demonstrates the mental model using only console output. The input contains a semicolon, so text intended as “data” becomes another PowerShell statement when evaluated.

# INTENTIONALLY UNSAFE TRAINING EXAMPLE — do not copy this pattern.
$name = 'alpha; Write-Output "INJECTED-AS-CODE"'
$commandText = "Write-Output $name"

# The vulnerability is evaluating data as PowerShell source code:
Invoke-Expression $commandText

The problem is not the semicolon itself. The problem is choosing an API that reparses a string as PowerShell code. Quoting tricks become brittle because the attacker-controlled value is already sharing a language grammar with instructions.

3. Pass values as parameters instead of building source code

If the operation is a PowerShell command, invoke the command and bind its parameters normally. PowerShell's parameter binder treats the argument as a value rather than reparsing it as a statement.

$name = 'alpha; Write-Output "INJECTED-AS-CODE"'
Write-Output -InputObject $name

# The entire string is one data value.
[pscustomobject]@{
    Value = $name
    Type  = $name.GetType().FullName
}

4. Splatting keeps dynamic command construction structured

When parameters are conditional, build a hashtable of parameter values and splat it into a known command. This is clearer and safer than concatenating a command string.

$path = Join-Path ([IO.Path]::GetTempPath()) 'ps academy [training].txt'
Set-Content -LiteralPath $path -Value 'training' -Encoding utf8

$params = @{
    LiteralPath = $path
    ErrorAction = 'Stop'
}
$item = Get-Item @params
$item | Select-Object FullName,Length

Remove-Item -LiteralPath $path -Force

Notice -LiteralPath. A path containing brackets or wildcard characters is treated literally, which prevents accidental wildcard expansion.

5. The call operator invokes a resolved command; it does not evaluate source text

The call operator & is appropriate when the executable/command path is stored in a variable. It invokes that command. An argument array remains separate from the command name at the PowerShell parsing layer.

$pwsh = (Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue).Source
if ($pwsh) {
    $arguments = @('-NoProfile','-Command','param($x) $x','literal;still-data')
    & $pwsh @arguments
    $exit = $LASTEXITCODE
    [pscustomobject]@{ Tool=$pwsh; ExitCode=$exit }
}

This does not make every native program safe. The child program still interprets its own arguments. If an argument controls a template, SQL query, archive path, compiler option, or shell fragment, you must follow that program's grammar and trust model.

6. Validation narrows accepted data; it does not replace authorization or escaping

Validation should reject values outside the domain your command supports. It reduces ambiguity and attack surface. But a syntactically valid project name can still refer to a project the caller is not authorized to access, and a valid string can still require context-specific encoding when sent to another interpreter.

function Get-TrainingEnvironment {
    param(
        [Parameter(Mandatory)]
        [ValidateSet('dev','test','stage')]
        [string]$Environment,

        [Parameter(Mandatory)]
        [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$')]
        [string]$Application
    )

    [pscustomobject]@{ Environment=$Environment; Application=$Application }
}

Get-TrainingEnvironment -Environment test -Application api-v2

7. Paths need containment checks, not string-prefix guesses

Path traversal occurs when input such as .. escapes an intended root directory. Use canonical path APIs and ask whether the resolved path is still inside the root. Do not “sanitize” by deleting dots or slashes; that changes names and misses edge cases.

function Resolve-SafeChildPath {
    param(
        [Parameter(Mandatory)][string]$Root,
        [Parameter(Mandatory)][string]$RelativePath
    )

    $rootFull = [IO.Path]::GetFullPath($Root)
    $candidate = [IO.Path]::GetFullPath((Join-Path $rootFull $RelativePath))
    $relative = [IO.Path]::GetRelativePath($rootFull,$candidate)

    if ([IO.Path]::IsPathRooted($relative) -or $relative -eq '..' -or $relative.StartsWith('..' + [IO.Path]::DirectorySeparatorChar)) {
        throw 'Requested path escapes the allowed root.'
    }
    $candidate
}

$root = Join-Path ([IO.Path]::GetTempPath()) 'ps-academy-safe-root'
New-Item -ItemType Directory -Path $root -Force | Out-Null
Resolve-SafeChildPath -Root $root -RelativePath 'reports/output.json'
try { Resolve-SafeChildPath -Root $root -RelativePath '../outside.txt' } catch { $_.Exception.Message }
Remove-Item -LiteralPath $root -Recurse -Force

8. Regex input is another interpreter boundary

Regular expressions are a mini-language. If a user supplies ordinary text that you intend to search literally, interpolating it directly into a regex gives that user regex semantics. Escape it first or use an operation that performs literal matching.

$userText = 'api[01].example'
$pattern = [regex]::Escape($userText)

'api[01].example' -match $pattern
'api1.example'     -match $pattern

[pscustomobject]@{ Input=$userText; RegexPattern=$pattern }

9. HTTP and API values belong in URI/query/header/body structures

Do not concatenate untrusted values into JSON text, query strings, or Authorization headers. Build PowerShell objects and let serializers/URI builders handle syntax where possible. Then validate server-side authorization separately.

$payload = [ordered]@{
    application = 'training-app'
    environment = 'test'
    enabled     = $true
}

$json = $payload | ConvertTo-Json -Depth 5
$json

# For real HTTP calls, pass the JSON through -Body and ContentType,
# rather than concatenating a JSON source string by hand.

10. Build a secure wrapper with an allowlist and structured arguments

The wrapper below accepts only two known actions and a constrained target name. The action maps to an internal script block; input never selects arbitrary source code.

function Invoke-TrainingAction {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet('inspect','validate')][string]$Action,
        [Parameter(Mandatory)][ValidatePattern('^[A-Za-z0-9._-]{1,40}$')][string]$Target
    )

    $handlers = @{
        inspect = { param($value) [pscustomobject]@{ Action='inspect'; Target=$value; Length=$value.Length } }
        validate = { param($value) [pscustomobject]@{ Action='validate'; Target=$value; Valid=$true } }
    }

    & $handlers[$Action] $Target
}

Invoke-TrainingAction -Action inspect -Target 'api-01' 

11. For native tools, define an argument policy and an exit-code policy

A production wrapper should resolve the expected executable, construct an argument array, reject unsupported options, and interpret exit codes explicitly. Never let untrusted input become the executable path or a “raw extra arguments” string unless that is the deliberate, authorized interface.

function Get-NativeInvocationPlan {
    param(
        [ValidateSet('status','version')][string]$Operation,
        [ValidatePattern('^[A-Za-z0-9._-]{1,40}$')][string]$Target = 'training'
    )

    $arguments = switch ($Operation) {
        'status'  { @('--status',$Target) }
        'version' { @('--version') }
    }

    [pscustomobject]@{
        Operation = $Operation
        Arguments = $arguments
        Note      = 'Resolve the approved executable separately; child-tool semantics still apply.'
    }
}
Get-NativeInvocationPlan -Operation status -Target api-01

12. Common injection mistakes

MistakeFailure modeSafer replacement
Concatenate a command string then evaluate itData shares the PowerShell grammar with instructions.Call a known command with parameters/splatting.
Use -Path when input should be literalWildcard characters may select additional items.Use -LiteralPath and containment checks.
Escape everything with one homemade functionEscaping rules differ between PowerShell, native tools, regex, JSON, URLs, SQL, and shells.Keep structured values until the final context and use context-specific APIs.
Validate syntax and assume authorization is solvedA valid identifier can still refer to a forbidden resource.Perform authorization at the resource/identity boundary.

13. Lab — reject traversal and preserve literal input

The lab creates a disposable root, accepts a small file name, resolves it safely, writes a training record, and then demonstrates rejection of an escape attempt.

$labRoot = Join-Path ([IO.Path]::GetTempPath()) 'ps-academy-ch16-input'
New-Item -ItemType Directory -Path $labRoot -Force | Out-Null
try {
    $safeName = 'report[1].json'
    $target = Resolve-SafeChildPath -Root $labRoot -RelativePath $safeName
    @{ status='ok'; name=$safeName } | ConvertTo-Json | Set-Content -LiteralPath $target -Encoding utf8
    Get-Item -LiteralPath $target | Select-Object Name,Length

    try {
        Resolve-SafeChildPath -Root $labRoot -RelativePath '../escape.json'
    } catch {
        [pscustomobject]@{ Rejected=$true; Reason=$_.Exception.Message }
    }
}
finally {
    Remove-Item -LiteralPath $labRoot -Recurse -Force -ErrorAction SilentlyContinue
}

14. Verification checklist

  • You can explain code-versus-data boundaries.
  • You know why Invoke-Expression is a poor default for command construction.
  • You can use parameters, splatting, arrays, and the call operator.
  • You distinguish PowerShell parser safety from a native program's own argument grammar.
  • You can contain file paths using canonical path logic and LiteralPath.
  • You know validation does not replace authorization or context-specific encoding.

15. Knowledge check

Question 1. What made the unsafe example injectable?

Question 2. What is the default safer pattern for dynamic parameters?

Question 3. Does the call operator make every native argument safe?

Question 4. Why prefer -LiteralPath for untrusted file names?

Question 5. Why is validation not authorization?

16. Summary and next bridge

Secure PowerShell keeps untrusted values in structured data channels and avoids reparsing them as code. Parameters, splatting, literal paths, canonical path checks, serializers, and explicit native argument policies preserve trust boundaries. Lesson 4 turns to observability: how to record enough evidence to investigate automation without turning the logs themselves into a secret leak.

17. Authoritative references

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.