Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ release notes.
- Validation round 10 (Findings, "PowerShell 7 at run time, a built credential, and the drives
SYSTEM sees"): seven remediations on the joined device, and `New-IslDriveFixture.ps1` in the kit
for the drive state one of them reports on.
- Validation kit: `Collect` failed on a device payload in which one chunk had arrived as another
chunk's text. The QEMU guest agent answers a status call with the oldest result it holds for a
process id, and Windows reuses the ids. `GuestAgent.ps1` now carries the guest calls for the
driver and `Invoke-LabGuestScript.ps1`: every command prints a marker of its own, a result
without it is passed over, a result lost to a timed-out status call makes the command run
again, and the payload is checked against the device's SHA-256.
- The README and `Get-IntuneAnalyzerRulePath`'s help say what `Invoke-ScriptAnalyzer -Severity`
does with the custom rules: PSScriptAnalyzer 1.25.0 filters on the rule's registered severity,
Warning for every custom rule, so `-Severity Error` returns none of the records and
Expand Down
217 changes: 217 additions & 0 deletions Validation/GuestAgent.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# Running PowerShell inside a lab VM through the QEMU guest agent, over SSH to the Proxmox host.
# Dot-sourced by Invoke-ValidationRound.ps1 and Invoke-LabGuestScript.ps1, whose $ProxmoxHost and
# $VmId parameters the function reads, and kept apart from them so it can be unit-tested with ssh
# mocked and without the driver's #Requires lines.

function Invoke-GuestPowerShell {
<#
.SYNOPSIS
Runs a PowerShell snippet as SYSTEM inside the VM and returns its stdout.

.DESCRIPTION
The guest agent starts a command and hands back its process id; the result is asked for by
that id and stays with the agent until a status call collects it. Measured on the lab
device (README.md, "Timing notes"):

A result nobody collects is kept. Windows reuses process ids within minutes, and the agent
answers a status call with the oldest result it holds for the id, so a later command given
the same id got an earlier command's output: the right shape, the wrong content. A payload
chunk replaced that way is what broke Collect on 2026-10-05. "qm guest exec" leaves such
results behind whenever its own status poll times out while the command is still running.

A status call that times out on the host has still been answered by the agent: if the
command had finished, its result was handed over and is gone.

So the command is started without waiting ("qm guest exec --synchronous 0"), its result is
asked for by id until it arrives, and every command prints a marker of its own first. A
reply without the marker is somebody else's result: asking for it collected it, and the
next reply under the id is this command's. When the agent holds nothing for the id after a
status call timed out, the result went with that call and the command is run again.

A snippet therefore has to be safe to repeat, as before; what it can no longer do is come
back with another command's output.

.PARAMETER Script
The PowerShell to run. A snippet, not a script with a param block: the marker line is put
in front of it. The guest agent's command line fails silently above a few KB.

.PARAMETER TimeoutSeconds
How long to wait for the command to finish before giving up. Default 120.

.EXAMPLE
Invoke-GuestPowerShell -Script '(Get-Service IntuneManagementExtension).Status'

The agent service's state on the VM named by the caller's $VmId.

.OUTPUTS
System.String. The command's stdout; its stderr and a non-zero exit become warnings.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)]
[string]$Script,

[int]$TimeoutSeconds = 120
)

$result = $null
for ($run = 1; -not $result; $run++) {
$marker = [guid]::NewGuid().ToString('N')
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes("'$marker'`n$Script"))
$processId = $null
for ($attempt = 1; -not $processId; $attempt++) {
$raw = ssh -o BatchMode=yes $ProxmoxHost ("qm guest exec $VmId --synchronous 0 -- powershell " +
"-NoProfile -NonInteractive -EncodedCommand $encoded") 2>&1
$text = ($raw -join "`n").Trim()
if ($LASTEXITCODE -eq 0 -and $text -match '"pid"\s*:\s*(\d+)') { $processId = $Matches[1]; break }
if ($attempt -ge 3) { throw "qm guest exec failed on ${ProxmoxHost}: $text" }
Write-Warning "qm guest exec attempt $attempt on ${ProxmoxHost}: $text"
Start-Sleep -Seconds 10
}

$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$unmarked = $null
$replyLost = $false
$failures = 0
while (-not $result) {
Start-Sleep -Seconds 1
$raw = ssh -o BatchMode=yes $ProxmoxHost "qm guest exec-status $VmId $processId" 2>&1
$text = ($raw -join "`n").Trim()
if ($LASTEXITCODE -eq 0 -and $text.StartsWith('{')) {
$failures = 0
$reply = $text | ConvertFrom-Json
if ($reply.exited) {
if ("$($reply.'out-data')".StartsWith($marker)) { $result = $reply; break }
# An earlier command's result under a reused process id; it is collected now
Write-Verbose "Process id $processId answered with another command's result; asking again"
$unmarked = $reply
}
}
elseif ($text -match 'does not exist') {
# Nothing more under this id. After a status call that timed out, the result went
# with that call's reply: run the command again
if ($replyLost) { break }
# Otherwise the reply without the marker was this command's after all: powershell.exe
# never reached the first line (a command line too long, for one)
if (-not $unmarked) {
throw "The guest agent on VM $VmId holds no result for process id $processId"
}
$result = $unmarked
break
}
else {
$failures++
$replyLost = $true
if ($failures -ge 10) { throw "qm guest exec-status failed on ${ProxmoxHost}: $text" }
Write-Verbose "qm guest exec-status attempt $failures on ${ProxmoxHost}: $text"
}
if ((Get-Date) -gt $deadline) {
throw ("The guest command (process id $processId on VM $VmId) did not finish within " +
"$TimeoutSeconds s")
}
}
if (-not $result) {
if ($run -ge 3) { throw "The guest command's result was lost to a timed-out status call $run times" }
Write-Warning ("The result of process id $processId went with a status call that timed out; " +
'running the command again')
}
}

$output = "$($result.'out-data')"
if ($output.StartsWith($marker)) { $output = $output.Substring($marker.Length) -replace '^\r?\n', '' }

# Windows PowerShell writes stderr as CLIXML: progress records ("Preparing modules for first
# use") are noise, error records are what the caller needs to read
$errorText = "$($result.'err-data')"
if ($errorText -match '#< CLIXML') {
$messages = foreach ($chunk in ($errorText -split '#< CLIXML')) {
if (-not $chunk.Trim()) { continue }
try {
foreach ($item in [System.Management.Automation.PSSerializer]::Deserialize($chunk.Trim())) {
if ($item -is [string]) { $item }
elseif ($item.PSObject.TypeNames -match 'ErrorRecord') { "$item" }
}
}
catch { $chunk.Trim() }
}
$errorText = ($messages -join "`n")
}
if ($result.exitcode -ne 0) { Write-Warning "Guest script exited $($result.exitcode): $errorText" }
elseif ($errorText.Trim()) { Write-Warning $errorText.Trim() }
elseif (-not $output) { Write-Verbose "Guest script produced no output (process id $processId)" }
$output
}

function Read-GuestPayload {
<#
.SYNOPSIS
Fetches a base64 text file from the VM in chunks and checks it against the VM's own hash.

.DESCRIPTION
A payload of megabytes returned in one reply makes the guest agent time out its own status
call, so it is read 100,000 characters at a time. A chunk that comes back short is asked
again. The whole text is then hashed and compared with the SHA-256 the VM computed over its
copy: a chunk with the right length and the wrong content is otherwise found only as a
parse error somewhere in the middle of the result, if at all.

.PARAMETER RemotePath
The file on the VM. ASCII text (base64).

.PARAMETER Size
Its length in characters, as the VM reported it.

.PARAMETER Sha256
The SHA-256 of its text as the VM computed it, in hex.

.PARAMETER ChunkSize
Characters per call. Default 100000.

.EXAMPLE
Read-GuestPayload -RemotePath 'C:\ProgramData\IntuneScriptLab\collect.b64' -Size 17838300 -Sha256 $hash

The file's text, or an error naming both hashes when it did not arrive intact.

.OUTPUTS
System.String.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)]
[string]$RemotePath,

[Parameter(Mandatory)]
[int]$Size,

[Parameter(Mandatory)]
[string]$Sha256,

[int]$ChunkSize = 100000
)

$parts = for ($offset = 0; $offset -lt $Size; $offset += $ChunkSize) {
$length = [Math]::Min($ChunkSize, $Size - $offset)
$read = "[IO.File]::ReadAllText('$RemotePath').Substring($offset, $length)"
# A reply without its output (the agent occasionally returns none for a large chunk) is asked again
$part = $null
for ($try = 1; $try -le 3 -and "$part".Trim().Length -ne $length; $try++) {
if ($try -gt 1) { Write-Warning "Payload chunk at $offset came back short; retrying"; Start-Sleep 5 }
$part = Invoke-GuestPowerShell -TimeoutSeconds 120 -Script $read
}
if ("$part".Trim().Length -ne $length) { throw "Payload chunk at $offset could not be read" }
"$part".Trim()
}
$text = -join $parts
$sha = [Security.Cryptography.SHA256]::Create()
try {
$digest = $sha.ComputeHash([Text.Encoding]::ASCII.GetBytes($text))
$actual = [BitConverter]::ToString($digest) -replace '-', ''
}
finally { $sha.Dispose() }
if ($actual -ne $Sha256) {
throw ("The payload from $RemotePath did not arrive intact: $($text.Length) characters with SHA-256 " +
"$actual, the VM's copy has $Sha256")
}
$text
}
42 changes: 6 additions & 36 deletions Validation/Invoke-LabGuestScript.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@
System.String. The script's stdout on the VM; its stderr and a non-zero exit become warnings.

.NOTES
Author: Jeffrey Stuhr. Never run two guest execs at once on the same VM; a status poll that
times out is retried, and a second exec would start a second copy.
Author: Jeffrey Stuhr. The guest agent calls are GuestAgent.ps1's: each command is started
once and its result asked for by process id, so a status call that times out is asked
again without running the script a second time. A start call that times out is retried and
can run it twice.
#>
[CmdletBinding()]
# ProxmoxHost is read inside Invoke-GuestPowerShell; the analyzer cannot see that
# ProxmoxHost is read inside Invoke-GuestPowerShell (GuestAgent.ps1); the analyzer cannot see that
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'ProxmoxHost')]
param(
[Parameter(Mandatory)]
Expand All @@ -75,39 +77,7 @@ param(

$ErrorActionPreference = 'Stop'

function Invoke-GuestPowerShell {
param([Parameter(Mandatory)][string]$Script, [int]$TimeoutSeconds = 120)
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($Script))
for ($attempt = 1; ; $attempt++) {
$raw = ssh -o BatchMode=yes $ProxmoxHost ("qm guest exec $VmId --timeout $TimeoutSeconds -- powershell " +
"-NoProfile -NonInteractive -EncodedCommand $encoded") 2>&1
$text = ($raw -join "`n").Trim()
if ($LASTEXITCODE -eq 0 -and $text.StartsWith('{')) { break }
if ($attempt -ge 3) { throw "qm guest exec failed on ${ProxmoxHost}: $text" }
Write-Warning "qm guest exec attempt $attempt on ${ProxmoxHost}: $text"
Start-Sleep -Seconds 10
}
$result = $text | ConvertFrom-Json
# Windows PowerShell writes stderr as CLIXML: progress records ("Preparing modules for first
# use") are noise, error records are what the caller needs to read
$errorText = "$($result.'err-data')"
if ($errorText -match '#< CLIXML') {
$messages = foreach ($chunk in ($errorText -split '#< CLIXML')) {
if (-not $chunk.Trim()) { continue }
try {
foreach ($item in [System.Management.Automation.PSSerializer]::Deserialize($chunk.Trim())) {
if ($item -is [string]) { $item }
elseif ($item.PSObject.TypeNames -match 'ErrorRecord') { "$item" }
}
}
catch { $chunk.Trim() }
}
$errorText = ($messages -join "`n")
}
if ($result.exitcode -ne 0) { Write-Warning "Guest script exited $($result.exitcode): $errorText" }
elseif ($errorText.Trim()) { Write-Warning $errorText.Trim() }
$result.'out-data'
}
. (Join-Path -Path $PSScriptRoot -ChildPath 'GuestAgent.ps1')

$remote = "C:\ProgramData\IntuneScriptLab\$RemoteName"
$content = Get-Content -Path (Resolve-Path -Path $ScriptPath) -Raw
Expand Down
Loading
Loading