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
13 changes: 13 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## v1.121.9

Installer download & agent-install hardening — fixes found by a deep audit of the download → verify → execute chain (FileServer, Agent Installer, ISO download).

- **Your configured installer hash is actually checked now.** If you set a known-good SHA256 for an agent installer (the recommended path when your file server doesn't publish `.sha256` sidecars), it was being silently ignored — two separate bugs meant the hash never loaded and, even if it had, it was skipped when no sidecar existed. Both are fixed: a configured hash is now enforced, and a mismatch fails the install closed instead of falling through to a weaker size-only prompt.
- **Reinstalling an agent can't falsely report success anymore.** When re-running over an already-installed agent, an install that timed out or was skipped could report "SUCCESS" (and leave a hung installer running) because the *old* service was still present. It now only counts a detected service as success on a fresh install, and a skipped-but-still-running installer is left to finish instead of being killed.
- **The verified installer is run from a protected location.** After its hash is verified, the installer is moved into an Administrators/SYSTEM-only folder before it runs — closing a window where another process could swap the file between verification and execution.
- **The file-server access token is never sent to an unexpected server.** Downloads no longer follow HTTP redirects while carrying the Cloudflare Access token, so the token can't be leaked to a redirect target on another host.
- **Installer arguments with spaces are passed correctly.** A token like `/token "value with spaces"` is no longer re-split into separate arguments.
- **A low-disk-space ISO re-download restores your existing ISO.** Bailing out for insufficient space now puts the previous ISO back and clears its rollback marker, instead of stranding it and confusing the next download.

No module or CLI action changes (81 modules, 201 actions).

## v1.121.8

Cleanup and encryption safety — fixes found by a deep audit of the destructive/data-affecting modules.
Expand Down
2 changes: 1 addition & 1 deletion Header.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
7h3 4b1d3r

.VERSION
1.121.8
1.121.9
.LAST UPDATED
07/01/2026

Expand Down
2 changes: 1 addition & 1 deletion Modules/00-Initialization.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ if (-not $PSCommandPath -and $script:ScriptPath) {
if (-not $script:ModuleRoot -and $script:ScriptPath) {
$script:ModuleRoot = [System.IO.Path]::GetDirectoryName($script:ScriptPath)
}
$script:ScriptVersion = "1.121.8"
$script:ScriptVersion = "1.121.9"
$script:ScriptStartTime = Get-Date

# Post-update cleanup: UpdateSelf / Rollback leave a `.pending-delete` sibling next to RackStack.exe.
Expand Down
48 changes: 48 additions & 0 deletions Modules/35-Utilities.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,54 @@ function Test-ScriptUpdate {
Write-PressEnter
}

# Move a just-verified file out of a world-/user-writable temp location into an
# Administrators+SYSTEM-only staging directory BEFORE it is executed, closing the TOCTOU
# window where a lower-privileged process watching the temp path could swap the file between
# the hash verification and the execution/use. Returns the new (protected) path, or $null if
# staging could not be established — in which case the caller keeps using the original path.
#
# The restrictive DACL is (re-)asserted on EVERY call, not just at creation: %ProgramData% lets
# authenticated users create subdirectories, so a lower-privileged actor could pre-create the
# stage dir with weak permissions; re-applying Administrators+SYSTEM-only (no inheritance) each
# time prevents that from defeating the staging. Same SID/DACL pattern as the self-update stage.
function Move-ToProtectedStaging {
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$SourcePath,

[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$Leaf,

[string]$SubDir = 'stage'
)
try {
if (-not (Test-Path -LiteralPath $SourcePath)) { return $null }
$stageDir = Join-Path $env:ProgramData $script:ToolName | Join-Path -ChildPath $SubDir
if (-not (Test-Path -LiteralPath $stageDir)) {
$null = New-Item -Path $stageDir -ItemType Directory -Force -ErrorAction Stop
}
# Re-assert Administrators + SYSTEM only, no inheritance (idempotent hardening).
$stageAcl = Get-Acl -LiteralPath $stageDir
$stageAcl.SetAccessRuleProtection($true, $false)
$stageAcl.Access | ForEach-Object { [void]$stageAcl.RemoveAccessRule($_) }
$adminsSid = New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-544'
$systemSid = New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-18'
$stageAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($adminsSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
$stageAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($systemSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
Set-Acl -LiteralPath $stageDir -AclObject $stageAcl -ErrorAction Stop

$destPath = Join-Path $stageDir $Leaf
if (Test-Path -LiteralPath $destPath) { Remove-Item -LiteralPath $destPath -Force -ErrorAction SilentlyContinue }
Move-Item -LiteralPath $SourcePath -Destination $destPath -Force -ErrorAction Stop
return $destPath
}
catch {
return $null
}
}

# Function to download and install an update from a GitHub release
function Install-ScriptUpdate {
param (
Expand Down
94 changes: 80 additions & 14 deletions Modules/39-FileServer.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ function Find-FileServerFile {
}

# Reusable function to download files from FileServer via HTTP GET
# Uses WebClient.DownloadFile() to avoid Invoke-WebRequest keep-alive hang.
# Streams via HttpWebRequest (in a background job) with AllowAutoRedirect disabled — avoids
# Invoke-WebRequest keep-alive hang AND keeps the Cloudflare Access token from being resent to
# a redirect target on another host.
# Shows rich progress bar with speed/ETA, retries once on failure, and
# verifies integrity via size check + SHA256 hash.
# Supports HTTP Range-based resume for partial downloads and optional
Expand Down Expand Up @@ -375,6 +377,11 @@ function Get-FileServerFile {
$resumeRequest = [System.Net.HttpWebRequest]::Create($downloadUrl)
$resumeRequest.AddRange($existingSize)
$resumeRequest.Timeout = 30000
# Do NOT auto-follow redirects: this request carries the Cloudflare Access
# service token, which .NET would resend verbatim to a redirect target on a
# different host, exfiltrating the secret. A CF-protected origin redirecting an
# authenticated range request elsewhere is anomalous — fail closed (restart).
$resumeRequest.AllowAutoRedirect = $false
foreach ($key in $downloadHeaders.Keys) {
$resumeRequest.Headers.Add($key, $downloadHeaders[$key])
}
Expand Down Expand Up @@ -426,31 +433,59 @@ function Get-FileServerFile {
}

if (-not $resumed) {
# Background job using WebClient (closes connection immediately, no hang).
# Wrapped in try/finally to GUARANTEE job cleanup even if an exception fires
# inside the progress-monitoring loop (Get-Item race, console KeyAvailable on
# a remote session, etc.). Without this, every exception path in the loop
# left the background runspace alive and accumulated across the retry loop.
# Background download job (closes connection immediately, no hang). Wrapped in
# try/finally to GUARANTEE job cleanup even if an exception fires inside the
# progress-monitoring loop (Get-Item race, console KeyAvailable on a remote
# session, etc.). Without this, every exception path in the loop left the
# background runspace alive and accumulated across the retry loop.
$downloadJob = $null
try {
$downloadJob = Start-Job -ScriptBlock {
param($url, $headersJson, $destPath)
$webClient = New-Object System.Net.WebClient
# Stream via HttpWebRequest with AllowAutoRedirect DISABLED. The previous
# WebClient.DownloadFile auto-followed 3xx redirects and RESENT caller headers —
# including the Cloudflare Access service token — to the redirect target on ANY
# host, exfiltrating the secret. WebClient exposes no switch for this and
# subclassing needs Add-Type, which can't compile the same source on both hosts
# (WebClient is obsolete under .NET 5+/pwsh7 → warning-as-error, while PS 5.1's
# older compiler rejects the SYSLIB0014 suppression pragma as an invalid number).
# HttpWebRequest is used at RUNTIME only (obsolete = warning, never a compile
# error) so it works on both hosts and lets us refuse redirects outright — a
# CF-protected origin redirecting an authenticated download is anomalous; fail
# closed rather than leak the token. The file grows on disk as CopyTo writes, so
# the parent's size-based progress monitor is unaffected.
$req = [System.Net.HttpWebRequest]::Create($url)
$req.AllowAutoRedirect = $false
$req.Timeout = 100000
$req.ReadWriteTimeout = 300000
if ($headersJson) {
$hdrs = $headersJson | ConvertFrom-Json
foreach ($prop in $hdrs.PSObject.Properties) {
$req.Headers.Add($prop.Name, $prop.Value)
}
}
$resp = $null; $inStream = $null; $outStream = $null
try {
if ($headersJson) {
$hdrs = $headersJson | ConvertFrom-Json
foreach ($prop in $hdrs.PSObject.Properties) {
$webClient.Headers.Add($prop.Name, $prop.Value)
}
$resp = $req.GetResponse()
# With redirects disabled a 3xx is returned (not thrown) — treat it as a
# hard failure so the secret is never carried to the Location target and we
# don't write the (empty) redirect body over the destination as "success".
$statusCode = [int]$resp.StatusCode
if ($statusCode -ge 300 -and $statusCode -lt 400) {
return @{ Success = $false; Error = "Server returned redirect ($statusCode) to '$($resp.Headers['Location'])'; refusing to follow with auth headers attached." }
}
$webClient.DownloadFile($url, $destPath)
$inStream = $resp.GetResponseStream()
$outStream = [System.IO.File]::Create($destPath)
$inStream.CopyTo($outStream)
return @{ Success = $true; Error = $null }
}
catch {
return @{ Success = $false; Error = $_.Exception.Message }
}
finally {
$webClient.Dispose()
if ($outStream) { $outStream.Close() }
if ($inStream) { $inStream.Close() }
if ($resp) { $resp.Close() }
}
} -ArgumentList $downloadUrl, ($downloadHeaders | ConvertTo-Json -Compress), $destFile

Expand Down Expand Up @@ -604,6 +639,29 @@ function Get-FileServerFile {
# Every other outcome (hash mismatch, compute error) is a genuine fail-closed:
# show the error and delete so a tampered/unverifiable file never lingers on disk.
if ($integrity.Reason -eq 'NoSidecar') {
# No server-published .sha256 sidecar. But if the CALLER supplied a locally-trusted
# reference hash (e.g. the AgentInstaller HashManifest configured in defaults.json),
# that hash is authoritative and independent of the server — enforce it HERE rather
# than deferring to a weak size-only trust-on-first-use prompt. This is the documented
# RMM primary path (unique per-site installers with no pre-published sidecar). A match
# is full cryptographic verification; a mismatch is a hard fail-closed.
if (-not [string]::IsNullOrWhiteSpace($ExpectedHash)) {
if ([string]::IsNullOrWhiteSpace($integrity.Hash)) {
Write-OutputColor " Integrity check failed: could not compute SHA256 to compare against the expected hash." -color "Error"
Remove-Item -LiteralPath $destFile -Force -ErrorAction SilentlyContinue
return @{ Success = $false; Error = "Could not compute SHA256 for expected-hash comparison"; Reason = 'HashComputeFailed'; FilePath = $null }
}
if ($integrity.Hash -ne $ExpectedHash.ToUpper()) {
Write-OutputColor " Hash verification FAILED (operator-configured reference)" -color "Error"
Write-OutputColor " Expected: $($ExpectedHash.ToUpper())" -color "Error"
Write-OutputColor " Actual: $($integrity.Hash)" -color "Error"
Remove-Item -LiteralPath $destFile -Force -ErrorAction SilentlyContinue
return @{ Success = $false; Error = "SHA256 hash mismatch against expected hash"; FilePath = $null }
}
Write-OutputColor " Hash verified: SHA256 match (operator-configured reference)" -color "Success"
Write-TransferComplete -TotalBytes $fileSize -ElapsedSeconds $totalElapsed -Hash $integrity.Hash -HashMatch $true
return @{ Success = $true; Error = $null; FilePath = $destFile; FileSize = $fileSize; Hash = $integrity.Hash }
}
return @{ Success = $false; Error = $integrity.Error; Reason = 'NoSidecar'; FilePath = $destFile; FileSize = $fileSize; Hash = $integrity.Hash }
}
Write-OutputColor " Integrity check failed: $($integrity.Error)" -color "Error"
Expand Down Expand Up @@ -659,6 +717,10 @@ function Get-FileServerFileSize {
$request = [System.Net.WebRequest]::Create($url)
$request.Method = "HEAD"
$request.Timeout = 15000
# Don't auto-follow redirects with the Cloudflare Access token attached — .NET would
# resend the secret to a cross-host redirect target. An unexpected redirect just means
# no size (non-fatal), never a leaked service token.
if ($request -is [System.Net.HttpWebRequest]) { $request.AllowAutoRedirect = $false }

$headers = Get-FileServerHeaders
foreach ($key in $headers.Keys) {
Expand Down Expand Up @@ -696,6 +758,10 @@ function Get-FileServerHashFile {
$request = [System.Net.WebRequest]::Create($url)
$request.Method = "GET"
$request.Timeout = 10000
# Don't auto-follow redirects with the Cloudflare Access token attached — .NET would
# resend the secret to a cross-host redirect target. An unexpected redirect just means
# no sidecar found (handled downstream as NoSidecar), never a leaked service token.
if ($request -is [System.Net.HttpWebRequest]) { $request.AllowAutoRedirect = $false }

$headers = Get-FileServerHeaders
foreach ($key in $headers.Keys) {
Expand Down
15 changes: 15 additions & 0 deletions Modules/42-ISODownload.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,21 @@ function Get-ServerISO {
if ($freeGB -lt 10) {
Write-OutputColor " ERROR: Only $freeGB GB free on ${isoDriveLetter}: drive." -color "Error"
Write-OutputColor " Not enough space for ISO download (4-6 GB typically needed)." -color "Warning"
# Restore the .old fallback we renamed above (if any) before bailing, and clear the
# script-scope rollback pointer. Otherwise the renamed ISO is stranded (Test-CachedISO
# won't match `.iso.old`) AND a later Get-ServerISO for ANY version acts on this stale
# path — deleting it as "cleanup" on success, or Move-Item'ing it onto a different
# version's .iso name on failure, cross-contaminating that version.
if ($script:_isoRollbackPath -and (Test-Path -LiteralPath $script:_isoRollbackPath)) {
try {
$restorePath = $script:_isoRollbackPath -replace '\.old$', ''
Move-Item -LiteralPath $script:_isoRollbackPath -Destination $restorePath -Force -ErrorAction Stop
Write-OutputColor " Restored previous ISO from rollback: $restorePath" -color "Info"
} catch {
Write-OutputColor " Could not restore previous ISO: $($_.Exception.Message)" -color "Warning"
}
}
$script:_isoRollbackPath = $null
return $null
}
}
Expand Down
Loading
Loading