diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e9aa2bb..0f02a35 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -152,19 +152,14 @@ jobs:
"exists=$existsStr" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
exit 0
- - name: Install ps2exe
- if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false'
- run: |
- # Pinned. This module compiles the binary that ships to users, so an
- # unpinned install would let the released artifact change without a
- # commit — the same supply-chain exposure the SHA-pinning policy
- # closes for actions. Bump deliberately, never implicitly.
- $ps2exeVersion = '1.0.18'
- if (-not (Get-Module ps2exe -ListAvailable | Where-Object { $_.Version -eq $ps2exeVersion })) {
- Install-Module ps2exe -RequiredVersion $ps2exeVersion -Force -Scope CurrentUser -AllowClobber
- }
- Import-Module ps2exe -RequiredVersion $ps2exeVersion -Force
-
+ # The EXE is a small native host (dist/launcher/RackStack.Launcher.cs) that
+ # starts Windows PowerShell's own console host and runs the monolithic
+ # script, embedded as a plain-text resource. It is compiled with the C#
+ # compiler that ships inside Windows (.NET Framework 4.x), so nothing is
+ # downloaded or installed to produce the shipped binary. Releases through
+ # v1.122.4 used ps2exe, whose script-host wrapper is widely reused by
+ # malware droppers and drew heuristic antivirus detections on every build
+ # regardless of the script's content.
- name: Compile RackStack.exe
if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false'
run: |
@@ -172,24 +167,41 @@ jobs:
$mono = "builds\RackStack v$ver.ps1"
if (-not (Test-Path $mono)) { throw "Monolithic not found at $mono" }
if (-not (Test-Path 'RackStack.ico')) { throw "RackStack.ico missing — required for compile" }
- # Populate the full version resource. Company/Product/Description
- # were empty in every release through v1.122.3, which is both a mild
- # heuristic-AV signal (legitimate software fills these in) and a real
- # UX gap: -RequireAdmin raises a UAC prompt, and UAC displays
- # FileDescription as the program name — so users were being asked to
- # elevate a blank. Values match the identity already published in
- # RackStack.psd1, the Chocolatey nuspec, and the Scoop manifest.
- Invoke-PS2EXE -InputFile $mono -OutputFile 'builds\RackStack.exe' `
- -Version $ver `
- -RequireAdmin `
- -IconFile 'RackStack.ico' `
- -title 'RackStack - Windows Server configuration toolkit' `
- -product 'RackStack' `
- -company 'TheAbider' `
- -copyright 'Copyright (c) 2026 TheAbider' `
- -description 'Menu-driven configuration and automation for Windows Server hosts.'
+
+ $csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
+ if (-not (Test-Path $csc)) { throw "csc.exe not found at $csc" }
+ $gac = Join-Path $env:WINDIR 'Microsoft.Net\assembly\GAC_MSIL'
+ $sma = (Get-ChildItem "$gac\System.Management.Automation" -Recurse -Filter System.Management.Automation.dll | Select-Object -First 1).FullName
+ $chst = (Get-ChildItem "$gac\Microsoft.PowerShell.ConsoleHost" -Recurse -Filter Microsoft.PowerShell.ConsoleHost.dll | Select-Object -First 1).FullName
+ if (-not $sma -or -not $chst) { throw "Windows PowerShell 5.1 host assemblies not found in the GAC" }
+
+ # Stamp the release version into the assembly attributes. The version
+ # resource (Company/Product/Description/Copyright) is declared in the
+ # launcher source and must agree with RackStack.psd1 — Run-Tests
+ # Section 209 enforces that.
+ $stamped = Join-Path $env:RUNNER_TEMP 'RackStack.Launcher.cs'
+ $src = Get-Content 'dist\launcher\RackStack.Launcher.cs' -Raw
+ if ($src -notmatch 'Version = "0\.0\.0\.0"') { throw 'Version placeholder missing from launcher source' }
+ [IO.File]::WriteAllText($stamped, $src.Replace('Version = "0.0.0.0"', "Version = `"$ver.0`""))
+
+ # The resource name is fixed; the launcher looks it up by this exact string.
+ $embedded = Join-Path $env:RUNNER_TEMP 'RackStack.ps1'
+ Copy-Item -LiteralPath $mono -Destination $embedded -Force
+
+ & $csc /nologo /target:exe /platform:anycpu /optimize+ /debug- /warnaserror+ `
+ /r:$sma /r:$chst `
+ /win32icon:RackStack.ico `
+ /win32manifest:dist\launcher\app.manifest `
+ /resource:$embedded,RackStack.ps1 `
+ /out:builds\RackStack.exe $stamped
+ if ($LASTEXITCODE -ne 0) { throw "csc.exe exited $LASTEXITCODE" }
+
$info = Get-Item 'builds\RackStack.exe'
+ $vi = $info.VersionInfo
Write-Host "Compiled: $($info.FullName) ($([math]::Round($info.Length / 1MB, 2)) MB)"
+ Write-Host "Version resource: $($vi.CompanyName) / $($vi.ProductName) / $($vi.FileVersion) / $($vi.FileDescription)"
+ if ($vi.FileVersion -ne "$ver.0") { throw "FileVersion '$($vi.FileVersion)' does not match release version $ver" }
+ if ([string]::IsNullOrWhiteSpace($vi.CompanyName)) { throw 'CompanyName is empty in the compiled EXE' }
# Release integrity is provided by SHA-256 hashes, Sigstore cosign
# keyless signatures, and SLSA Level 3 build provenance (all below).
diff --git a/Changelog.md b/Changelog.md
index 6f31268..2376f30 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -1,5 +1,16 @@
# Changelog
+## v1.123.0
+
+Replaces the ps2exe wrapper with a native launcher, so the executable is no longer a packed script host.
+
+- **`RackStack.exe` is now a small launcher around Windows PowerShell's own console host.** Every release through v1.122.4 was produced by ps2exe, which wraps a script in its own host implementation. That wrapper is widely reused by malware droppers, so antivirus heuristics scored every build as a packed script host no matter what the script did: Microsoft re-flagged a hash it had cleared two weeks earlier, and the same file drifted from 8 to 19 VirusTotal detections without changing a byte. The new executable is compiled with the C# compiler that ships inside Windows, embeds the monolithic script as a plain-text resource, and runs it under the same engine and console as `powershell.exe`. Nothing is downloaded or installed to build it. Behaviour, parameters, elevation, self-update, and package-manager installs are unchanged.
+- **The self-destruct cleanup task now runs a readable script file instead of a base64-encoded command.** The file is written to a directory restricted to SYSTEM and Administrators, with the directory's owner verified before the task is registered. What the task will do can now be audited on the host; an encoded command could not be.
+- **Elevation from the executable no longer fails when UAC is off.** The relaunch path assumed a script file and passed an empty path to PowerShell; the executable now relaunches itself.
+- **The build-integrity tests pin the new arrangement**: no ps2exe, the in-box compiler by its fixed path, nothing downloaded during the compile, the elevation manifest present, and the version resource populated and matching the Gallery manifest.
+
+No module or CLI action changes (81 modules, 201 actions).
+
## v1.122.4
Hardens what the tool will let you exclude from Defender, and fixes an executable that shipped without a name.
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index d066c0d..86767be 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -67,8 +67,9 @@ limit the impact if the maintainer becomes unavailable:
- **All source is public** at https://github.com/TheAbider/RackStack
under the MIT License. Any forker can pick up development immediately.
- **Full release history is reproducible** from any tagged commit via
- `.\sync-to-monolithic.ps1` + `Invoke-PS2EXE`. The same source produces
- byte-identical output up to ps2exe's PE timestamp.
+ `.\sync-to-monolithic.ps1` + the `ci.yml` compile step (in-box `csc.exe`, see
+ `dist/launcher/`). The same source produces byte-identical output up to
+ the PE timestamp.
- **CI is fully automated and GitHub-hosted.** No self-hosted
infrastructure is on the critical path; GitHub-hosted `windows-2025`
runners are free for public repos.
diff --git a/Header.ps1 b/Header.ps1
index add4b85..6069207 100644
--- a/Header.ps1
+++ b/Header.ps1
@@ -5,7 +5,7 @@
.DESCRIPTION
This is the MONOLITHIC BUILD -- all 81 modules combined into a single file.
Generated by sync-to-monolithic.ps1 from the modular source in Modules/.
- The .exe is compiled from this file via ps2exe.
+ The .exe embeds this file and runs it under the Windows PowerShell console host (see dist/launcher/).
For development, use RackStack.ps1 (the modular loader) instead.
@@ -30,9 +30,9 @@
7h3 4b1d3r
.VERSION
- 1.122.4
+ 1.123.0
.LAST UPDATED
- 07/28/2026
+ 09/08/2026
.CHANGELOG v1.21.1
ROBUSTNESS, UX, CACHE CONSISTENCY:
diff --git a/Modules/00-Initialization.ps1 b/Modules/00-Initialization.ps1
index 3752548..6f33ee2 100644
--- a/Modules/00-Initialization.ps1
+++ b/Modules/00-Initialization.ps1
@@ -221,11 +221,11 @@ $script:StorageBackendType = "iSCSI"
# Store script path at startup (MUST be before functions for Exit-Script to work)
$script:ScriptPath = $PSCommandPath
if (-not $script:ScriptPath) {
- # ps2exe compiled exe: $PSCommandPath is empty, use process path instead
+ # Compiled exe: $PSCommandPath is empty, use process path instead
try { $script:ScriptPath = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName } catch {}
}
if (-not $script:ModuleRoot) { $script:ModuleRoot = $PSScriptRoot }
-# ps2exe: $PSScriptRoot may point to a temp extraction dir, not the EXE folder.
+# Compiled exe: $PSScriptRoot is empty or points elsewhere, not at the EXE folder.
# Always prefer the EXE directory when running compiled (detected by empty $PSCommandPath).
if (-not $PSCommandPath -and $script:ScriptPath) {
$script:ModuleRoot = [System.IO.Path]::GetDirectoryName($script:ScriptPath)
@@ -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.122.4"
+$script:ScriptVersion = "1.123.0"
$script:ScriptStartTime = Get-Date
# Post-update cleanup: UpdateSelf / Rollback leave a `.pending-delete` sibling next to RackStack.exe.
diff --git a/Modules/47-ExitCleanup.ps1 b/Modules/47-ExitCleanup.ps1
index 2faf63b..150d41d 100644
--- a/Modules/47-ExitCleanup.ps1
+++ b/Modules/47-ExitCleanup.ps1
@@ -234,7 +234,10 @@ function Exit-Script {
try { New-EventLog -LogName Application -Source $toolName -ErrorAction SilentlyContinue } catch { }
}
- # Schedule deletion after reboot using a scheduled task
+ # Schedule deletion after reboot using a scheduled task. The task runs a plain-text
+ # script file, not an encoded command: the file is readable by anyone auditing the
+ # host (and by the deletion manifest above), whereas an -EncodedCommand blob is the
+ # signature move of a dropper and reads as one to every antivirus heuristic.
try {
$cleanupCommands = "Start-Sleep 60`n"
foreach ($p in $uniquePaths) {
@@ -250,12 +253,37 @@ function Exit-Script {
# -EA SilentlyContinue when a task doesn't exist.
$cleanupCommands += "Unregister-ScheduledTask -TaskName '$($toolNameEsc)-ScheduledExport' -TaskPath '\$($toolNameEsc)\' -Confirm:`$false -ErrorAction SilentlyContinue`n"
$cleanupCommands += "Unregister-ScheduledTask -TaskName '$($toolNameEsc)_UpdateCheck' -Confirm:`$false -ErrorAction SilentlyContinue`n"
- $cleanupCommands += "Unregister-ScheduledTask -TaskName '$($toolNameEsc)Cleanup' -Confirm:`$false -ErrorAction SilentlyContinue"
+ $cleanupCommands += "Unregister-ScheduledTask -TaskName '$($toolNameEsc)Cleanup' -Confirm:`$false -ErrorAction SilentlyContinue`n"
- $bytes = [System.Text.Encoding]::Unicode.GetBytes($cleanupCommands)
- $encoded = [Convert]::ToBase64String($bytes)
+ # The script runs as SYSTEM at boot, so it must live where only SYSTEM and
+ # Administrators can write. %ProgramData% lets any user create subfolders, and a
+ # pre-planted folder would leave its creator as owner with implicit WRITE_DAC —
+ # so any existing folder is removed, a fresh one is created, inheritance is cut,
+ # the DACL is reduced to SYSTEM + Administrators, and the owner is verified before
+ # a SYSTEM task is ever pointed at it.
+ $cleanupDir = Join-Path $env:ProgramData "$($script:ToolName)-cleanup"
+ if (Test-Path -LiteralPath $cleanupDir) { Remove-Item -LiteralPath $cleanupDir -Recurse -Force -ErrorAction Stop }
+ New-Item -Path $cleanupDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
+ $adminsSid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')
+ $systemSid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')
+ $dirAcl = New-Object System.Security.AccessControl.DirectorySecurity
+ $dirAcl.SetAccessRuleProtection($true, $false)
+ foreach ($sid in @($systemSid, $adminsSid)) {
+ $dirAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
+ }
+ $dirAcl.SetOwner($adminsSid)
+ Set-Acl -LiteralPath $cleanupDir -AclObject $dirAcl -ErrorAction Stop
+ $ownerSid = (Get-Acl -LiteralPath $cleanupDir).GetOwner([System.Security.Principal.SecurityIdentifier]).Value
+ if ($ownerSid -ne $adminsSid.Value -and $ownerSid -ne $systemSid.Value) {
+ throw "cleanup directory owner is $ownerSid; refusing to schedule a SYSTEM task against it"
+ }
+
+ $cleanupScript = Join-Path $cleanupDir 'cleanup.ps1'
+ $cleanupDirEsc = $cleanupDir -replace "'", "''"
+ $cleanupCommands += "Remove-Item -LiteralPath '$cleanupDirEsc' -Recurse -Force -ErrorAction SilentlyContinue"
+ [System.IO.File]::WriteAllText($cleanupScript, $cleanupCommands, (New-Object System.Text.UTF8Encoding $true))
- $action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -EncodedCommand $encoded"
+ $action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$cleanupScript`""
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest
Register-ScheduledTask -TaskName "$($script:ToolName)Cleanup" -Action $action -Trigger $trigger -Principal $principal -Force | Out-Null
diff --git a/Modules/50-EntryPoint.ps1 b/Modules/50-EntryPoint.ps1
index d0253f1..059ef11 100644
--- a/Modules/50-EntryPoint.ps1
+++ b/Modules/50-EntryPoint.ps1
@@ -160,14 +160,28 @@ function Assert-Elevation {
Write-OutputColor " Refusing to elevate: -Config value contains disallowed characters." -color "Error"
throw "Invalid -Config value (contains quote/semicolon/backtick/ampersand/pipe)"
}
- $elevateArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath)
- if ($script:CLIAction) { $elevateArgs += @('-Action', $script:CLIAction) }
- if ($script:CLIProfile -ne 'Standard') { $elevateArgs += @('-Tier', $script:CLIProfile) }
- if ($script:CLIConfig) { $elevateArgs += @('-Config', $script:CLIConfig) }
- if ($script:CLISilent) { $elevateArgs += '-Silent' }
- if ($script:CLIQuiet) { $elevateArgs += '-Quiet' }
- if ($script:CLIOutputFormat -ne 'Console') { $elevateArgs += @('-OutputFormat', $script:CLIOutputFormat) }
- Start-Process powershell -ArgumentList $elevateArgs -Verb RunAs -ErrorAction Stop
+ $cliArgs = @()
+ if ($script:CLIAction) { $cliArgs += @('-Action', $script:CLIAction) }
+ if ($script:CLIProfile -ne 'Standard') { $cliArgs += @('-Tier', $script:CLIProfile) }
+ if ($script:CLIConfig) { $cliArgs += @('-Config', $script:CLIConfig) }
+ if ($script:CLISilent) { $cliArgs += '-Silent' }
+ if ($script:CLIQuiet) { $cliArgs += '-Quiet' }
+ if ($script:CLIOutputFormat -ne 'Console') { $cliArgs += @('-OutputFormat', $script:CLIOutputFormat) }
+ if ($PSCommandPath) {
+ # Script file: relaunch it under an elevated powershell.exe.
+ $elevateArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath) + $cliArgs
+ Start-Process powershell -ArgumentList $elevateArgs -Verb RunAs -ErrorAction Stop
+ }
+ else {
+ # Compiled EXE: $PSCommandPath is empty, so relaunch the executable itself.
+ # Its manifest already requests elevation; this path only runs when UAC is
+ # off or the caller bypassed the manifest. -ArgumentList rejects an empty
+ # array, so pass it only when there is something to forward.
+ $exePath = $script:ScriptPath
+ if (-not $exePath -or -not (Test-Path -LiteralPath $exePath)) { throw "Cannot locate the running executable to relaunch it elevated." }
+ if ($cliArgs.Count -gt 0) { Start-Process -FilePath $exePath -ArgumentList $cliArgs -Verb RunAs -ErrorAction Stop }
+ else { Start-Process -FilePath $exePath -Verb RunAs -ErrorAction Stop }
+ }
}
catch {
Write-OutputColor " Failed to elevate: $_" -color "Error"
diff --git a/Modules/61-ActiveDirectory.ps1 b/Modules/61-ActiveDirectory.ps1
index ca8cd37..f92406e 100644
--- a/Modules/61-ActiveDirectory.ps1
+++ b/Modules/61-ActiveDirectory.ps1
@@ -987,7 +987,7 @@ function Install-AdditionalDC {
return
}
- # Step 4: Domain admin credentials. In console mode (ps2exe-built RackStack) Get-Credential
+ # Step 4: Domain admin credentials. In console mode (the compiled RackStack.exe) Get-Credential
# may return a PSCredential with empty user/password instead of $null on cancel — explicitly
# check both halves. A blank password used to make it through to Install-ADDSDomainController
# which then blocked for many seconds before Kerberos rejected the empty secret.
@@ -1165,7 +1165,7 @@ function Install-ReadOnlyDC {
return
}
- # Step 4: Domain admin credentials. In console mode (ps2exe-built RackStack) Get-Credential
+ # Step 4: Domain admin credentials. In console mode (the compiled RackStack.exe) Get-Credential
# may return a PSCredential with empty user/password instead of $null on cancel — explicitly
# check both halves. A blank password used to make it through to Install-ADDSDomainController
# which then blocked for many seconds before Kerberos rejected the empty secret.
diff --git a/Modules/75-Compliance.ps1 b/Modules/75-Compliance.ps1
index 2aca73d..3629681 100644
--- a/Modules/75-Compliance.ps1
+++ b/Modules/75-Compliance.ps1
@@ -142,7 +142,7 @@ function Get-CISControlTable {
# Registry paths are inlined as literals in each Check below — the Check
# scriptblocks are invoked locally via `& $c.Check $probe`, where `$using:`
# does NOT resolve (it is a remoting/job-scope feature only), and closing
- # over loop/function variables is fragile under PS 5.1 + ps2exe.
+ # over loop/function variables is fragile under PS 5.1 in the compiled EXE.
return @(
# ---- 1.1 Password Policy (secedit [System Access]) ----
[ordered]@{ Id = "CIS-1.1.1"; Title = "Minimum password length >= 14"; Section = "1.1 Password Policy"; Severity = "High"
diff --git a/README.md b/README.md
index fcba1e0..269812c 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@
-
+
@@ -120,7 +120,7 @@ Grab `RackStack.exe` from the [latest release](https://github.com/TheAbider/Rack
Every release artifact is signed with [Sigstore](https://www.sigstore.dev/) cosign (keyless) and carries [SLSA Level 3](https://slsa.dev/) build provenance; each release page lists SHA-256 hashes and the verification commands. The EXE is not Authenticode-signed, so Windows SmartScreen may show an "Unknown publisher" prompt on first run.
-> **Antivirus false positives:** because the EXE is unsigned, packed by ps2exe, and manages Defender exclusions, ML-based engines sometimes flag it. See [Antivirus Detections](docs/Antivirus-Detections.md) for why it happens and how to verify the binary you hold is the genuine published build. If AV alerts are a problem in your environment, run the `.ps1` from the same release instead — it is the same code, unpacked.
+> **Antivirus false positives:** because the EXE is unsigned and manages Defender exclusions, ML-based engines sometimes flag it. See [Antivirus Detections](docs/Antivirus-Detections.md) for why it happens and how to verify the binary you hold is the genuine published build. If AV alerts are a problem in your environment, run the `.ps1` from the same release instead — it is the same code, unpacked.
On first launch, a setup wizard walks you through configuring your environment (domain, DNS, admin account, iSCSI subnet). Your settings are saved to `rackstack.config.json` next to the exe. To pre-configure, download `rackstack.config.example.json` from the release, rename it to `rackstack.config.json`, fill in your values, and place it alongside the exe. A legacy `defaults.json` from an earlier version is still read automatically when no `rackstack.config.json` exists -- no migration needed.
@@ -480,7 +480,7 @@ Run `RackStack.exe -ListActions` or `RackStack.exe -ListActions -OutputFormat JS
RackStack/
├── RackStack.ps1 # Modular loader -- dot-sources 81 modules (dev use)
├── RackStack v{version}.ps1 # Monolithic build -- all modules in one file (deploy/compile)
-├── RackStack.exe # Compiled from the monolithic .ps1 via ps2exe
+├── RackStack.exe # Native launcher with the monolithic .ps1 embedded (built in CI)
├── rackstack.config.json # Your environment config (gitignored; legacy defaults.json still read)
├── rackstack.config.example.json # Config template with examples
├── sync-to-monolithic.ps1 # Builds monolithic from Header.ps1 + Modules/
@@ -546,7 +546,7 @@ Tests cover parsing, module loading, function existence (615 functions), version
2. Test with `.\RackStack.ps1` (modular loader -- fast iteration, no build step)
3. Sync: `.\sync-to-monolithic.ps1` (builds `RackStack v{version}.ps1` monolithic)
4. Test: `.\Tests\Run-Tests.ps1`
-5. Compile: `Invoke-PS2EXE -InputFile 'RackStack v{ver}.ps1' -OutputFile 'RackStack.exe'`
+5. Compile: see [`dist/launcher/README.md`](dist/launcher/README.md) -- CI does this on release; a local build is only needed to test the EXE itself
The sync script matches `#region`/`#endregion` markers between modules and the monolithic file. All 77 region pairs are flat (non-nested). Use `-DryRun` to preview.
diff --git a/ROADMAP.md b/ROADMAP.md
index 494245a..b62531a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -65,7 +65,7 @@ of the next feature releases once they can be validated safely.
| Item | Why |
|---|---|
-| Optional: ARM64 EXE | If demand emerges. ps2exe + .NET on ARM64 is straightforward; CI matrix expansion only. |
+| Optional: ARM64 EXE | If demand emerges. The launcher is AnyCPU .NET Framework and already runs on ARM64 Windows; a native ARM64 build would be a CI matrix expansion only. |
| Optional: PowerShell 7 module path | The thin-wrapper module already supports both editions via the `.psd1` `CompatiblePSEditions = @('Desktop', 'Core')`. A PS7-only feature track is not currently planned. |
| `RackStack.exe -Action FleetScan` improvements (PSRemoting over WinRM HTTPS, parallel host limits) | Adoption-driven — only if a real multi-host operator surfaces concrete asks. |
diff --git a/RackStack.ps1 b/RackStack.ps1
index c537a44..fa42a79 100644
--- a/RackStack.ps1
+++ b/RackStack.ps1
@@ -13,7 +13,7 @@
Environment-specific settings are configured via rackstack.config.json (a legacy defaults.json is still read).
.VERSION
- 1.122.4
+ 1.123.0
.NOTES
- Requires Windows Server 2012 R2 or later (or Windows 10/11 for testing)
- Must be run as Administrator
diff --git a/RackStack.psd1 b/RackStack.psd1
index 2309036..7fc7425 100644
--- a/RackStack.psd1
+++ b/RackStack.psd1
@@ -1,6 +1,6 @@
@{
RootModule = 'RackStack.psm1'
- ModuleVersion = '1.122.4'
+ ModuleVersion = '1.123.0'
GUID = 'c19b8e71-4a35-4f2b-9d06-8a24f7bc0e91'
Author = 'TheAbider'
CompanyName = 'TheAbider'
diff --git a/SECURITY.md b/SECURITY.md
index 31ef363..427878c 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -72,7 +72,7 @@ Issues of particular concern:
Out of scope:
- Vulnerabilities in Windows itself or third-party modules (`Pester`,
- `PSScriptAnalyzer`, `ps2exe`) — please report those upstream
+ `PSScriptAnalyzer`) — please report those upstream
- Operator misconfiguration where the documented default is safe
- Findings that require an attacker who is already Administrator on the
same machine (RackStack runs as Administrator by design)
@@ -113,7 +113,7 @@ provenance are the integrity guarantees in the meantime.
## Antivirus detections
-Being unsigned, packed by ps2exe, and capable of managing Defender
+Being unsigned and capable of managing Defender
exclusions makes `RackStack.exe` score badly with machine-learning and
heuristic antivirus engines. Detections are reported periodically and are
false positives; they are disputed with vendors as they come in.
diff --git a/Tests/Run-Tests.ps1 b/Tests/Run-Tests.ps1
index 77d3ac5..bd01e19 100644
--- a/Tests/Run-Tests.ps1
+++ b/Tests/Run-Tests.ps1
@@ -1,6 +1,6 @@
<#
.SYNOPSIS
- Automated Test Runner for RackStack v1.122.4
+ Automated Test Runner for RackStack v1.123.0
.DESCRIPTION
Comprehensive non-interactive test suite covering:
@@ -10773,24 +10773,27 @@ catch {
}
# ============================================================================
-# SECTION 209: BUILD METADATA INTEGRITY (what ps2exe stamps into the EXE)
+# SECTION 209: BUILD INTEGRITY (how RackStack.exe is produced, and what it says it is)
# ============================================================================
-# Every release through v1.122.3 shipped a binary whose CompanyName,
-# ProductName, FileDescription and LegalCopyright were EMPTY — verified by
-# reading the version resource out of the published v1.122.3 artifact. Two
-# costs: an empty version resource is a mild heuristic-AV signal because
-# legitimate software populates it, and -RequireAdmin raises a UAC prompt that
-# displays FileDescription as the program name, so users were asked to elevate
-# a blank.
+# Releases through v1.122.4 were produced by ps2exe. Its script-host wrapper
+# is widely reused by malware droppers, so heuristic antivirus engines scored
+# every build as a packed script host no matter what the script contained —
+# the same file drifted from 8 to 19 VirusTotal detections in four weeks
+# without changing a byte, and Microsoft re-flagged a hash it had cleared.
#
-# The compiler version is pinned here too. ps2exe builds the binary that ships
-# to users, so an unpinned Install-Module let the released artifact change
-# without a commit — the exposure the SHA-pinning policy already closes for
-# actions, including transitive ones.
-Write-SectionHeader "SECTION 209: BUILD METADATA INTEGRITY"
+# The EXE is now a small launcher (dist/launcher/RackStack.Launcher.cs) that
+# starts Windows PowerShell's own console host and runs the monolithic script
+# from an embedded plain-text resource. It is compiled with the C# compiler
+# that ships inside Windows, so no compiler or wrapper is downloaded to build
+# the binary that ships to users. These checks pin that arrangement: a return
+# to ps2exe, an unpinned/downloaded compiler, a lost elevation manifest, or an
+# empty version resource all fail here rather than in a VirusTotal result.
+Write-SectionHeader "SECTION 209: BUILD INTEGRITY"
try {
$ciPath209 = Join-Path $script:ModuleRoot '.github\workflows\ci.yml'
+ $launcherPath209 = Join-Path $script:ModuleRoot 'dist\launcher\RackStack.Launcher.cs'
+ $manifestPath209 = Join-Path $script:ModuleRoot 'dist\launcher\app.manifest'
if (Test-Path -LiteralPath $ciPath209) {
# -Encoding UTF8 is load-bearing. ci.yml has no BOM, and Windows PowerShell
# 5.1 decodes BOM-less files as ANSI, so its em-dashes and box-drawing
@@ -10800,31 +10803,74 @@ try {
# non-ASCII text in this file pass in one host and fail in the other.
$ci209 = Get-Content -LiteralPath $ciPath209 -Raw -Encoding UTF8
- # Compiler must be pinned to an exact version, never floating.
- Write-TestResult "Build: ps2exe is pinned to an explicit version" `
- ([bool]($ci209 -match "\`$ps2exeVersion\s*=\s*'\d+\.\d+\.\d+'"))
- Write-TestResult "Build: ps2exe install uses -RequiredVersion" `
- ([bool]($ci209 -match 'Install-Module ps2exe -RequiredVersion'))
- Write-TestResult "Build: ps2exe install is not unpinned" `
- ([bool]($ci209 -notmatch 'Install-Module ps2exe -Force'))
-
- # The version resource must actually be populated.
- $p2e209 = [regex]::Match($ci209, '(?s)Invoke-PS2EXE.*?(?=\r?\n\s*\$info\s*=)')
- Write-TestResult "Build: Invoke-PS2EXE call is locatable" $p2e209.Success `
- "regex found no ps2exe invocation — the checks below would pass vacuously"
- $call209 = $p2e209.Value
- foreach ($flag in @('title', 'product', 'company', 'copyright', 'description')) {
- Write-TestResult "Build: EXE metadata sets -$flag" `
- ($p2e209.Success -and $call209 -match "-$flag\s+'")
+ # No ps2exe anywhere in the release path — not installed, not invoked.
+ Write-TestResult "Build: ci.yml no longer installs ps2exe" `
+ ([bool]($ci209 -notmatch 'Install-Module\s+ps2exe'))
+ Write-TestResult "Build: ci.yml no longer invokes ps2exe" `
+ ([bool]($ci209 -notmatch '(?i)Invoke-PS2EXE|Import-Module\s+ps2exe'))
+
+ # The compile step must be locatable, or every check on its body passes vacuously.
+ $compile209 = [regex]::Match($ci209, '(?s)- name: Compile RackStack\.exe.*?(?=\r?\n\s{6}- name:)')
+ Write-TestResult "Build: compile step is locatable" $compile209.Success `
+ "regex found no 'Compile RackStack.exe' step — the checks below would pass vacuously"
+ $cbody209 = $compile209.Value
+
+ # The compiler is the one inside Windows, referenced by its fixed path —
+ # nothing fetched from a gallery or the network decides what ships.
+ Write-TestResult "Build: compiles with the in-box .NET Framework csc.exe" `
+ ($compile209.Success -and $cbody209 -match 'Microsoft\.NET\\Framework64\\v4\.0\.30319\\csc\.exe')
+ Write-TestResult "Build: compile step downloads nothing" `
+ ($compile209.Success -and $cbody209 -notmatch '(?i)Invoke-WebRequest|Install-Module|Invoke-RestMethod|DownloadFile')
+ Write-TestResult "Build: compile step warns as errors" `
+ ($compile209.Success -and $cbody209 -match '/warnaserror\+')
+
+ # Inputs to the compile: the tracked launcher source, the tracked UAC
+ # manifest, the icon, and the monolithic embedded under its fixed name.
+ Write-TestResult "Build: compiles dist/launcher/RackStack.Launcher.cs" `
+ ($compile209.Success -and $cbody209 -match 'dist\\launcher\\RackStack\.Launcher\.cs')
+ Write-TestResult "Build: applies dist/launcher/app.manifest" `
+ ($compile209.Success -and $cbody209 -match '/win32manifest:dist\\launcher\\app\.manifest')
+ Write-TestResult "Build: embeds the icon" `
+ ($compile209.Success -and $cbody209 -match '/win32icon:RackStack\.ico')
+ Write-TestResult "Build: embeds the monolithic as resource 'RackStack.ps1'" `
+ ($compile209.Success -and $cbody209 -match '/resource:\$embedded,RackStack\.ps1')
+ Write-TestResult "Build: stamps the release version into the launcher" `
+ ($compile209.Success -and $cbody209 -match 'Replace\(''Version = "0\.0\.0\.0"''')
+ Write-TestResult "Build: verifies the compiled FileVersion equals the release version" `
+ ($compile209.Success -and $cbody209 -match 'FileVersion -ne "\$ver\.0"')
+ Write-TestResult "Build: refuses an EXE with an empty CompanyName" `
+ ($compile209.Success -and $cbody209 -match 'IsNullOrWhiteSpace\(\$vi\.CompanyName\)')
+
+ # The launcher source itself: version placeholder present (so stamping
+ # has something to replace), the embedded-resource name matches what the
+ # build embeds, and the version resource is fully populated.
+ $lsrc209 = if (Test-Path -LiteralPath $launcherPath209) { Get-Content -LiteralPath $launcherPath209 -Raw -Encoding UTF8 } else { '' }
+ Write-TestResult "Build: launcher source exists" ([bool]$lsrc209)
+ Write-TestResult "Build: launcher carries the version placeholder the build stamps" `
+ ([bool]($lsrc209 -match 'const string Version = "0\.0\.0\.0";'))
+ Write-TestResult "Build: launcher reads the resource the build embeds" `
+ ([bool]($lsrc209 -match 'ScriptName = "RackStack\.ps1";') -and [bool]($lsrc209 -match 'GetManifestResourceStream\(ScriptName\)'))
+ Write-TestResult "Build: launcher hosts PowerShell's own console host" `
+ ([bool]($lsrc209 -match 'ConsoleShell\.Start\('))
+ Write-TestResult "Build: launcher writes nothing to disk" `
+ ([bool]($lsrc209 -notmatch '(?i)File\.Write|WriteAllText|WriteAllBytes|FileStream\(|Process\.Start|Path\.GetTempPath'))
+ foreach ($attr in @('AssemblyTitle', 'AssemblyProduct', 'AssemblyCompany', 'AssemblyCopyright', 'AssemblyDescription', 'AssemblyFileVersion')) {
+ Write-TestResult "Build: launcher declares $attr" `
+ ([bool]($lsrc209 -match "\[assembly:\s*$attr\(`"?[^`")]"))
}
- Write-TestResult "Build: EXE still stamps -Version" `
- ($p2e209.Success -and $call209 -match '-Version\s+\$ver')
+
+ # The UAC manifest is what makes the EXE elevate; losing it silently
+ # produces a binary that fails at the first admin cmdlet.
+ $lman209 = if (Test-Path -LiteralPath $manifestPath209) { Get-Content -LiteralPath $manifestPath209 -Raw -Encoding UTF8 } else { '' }
+ Write-TestResult "Build: UAC manifest exists" ([bool]$lman209)
+ Write-TestResult "Build: UAC manifest requests requireAdministrator" `
+ ([bool]($lman209 -match 'requestedExecutionLevel\s+level="requireAdministrator"'))
# One identity across every published surface. The EXE's CompanyName
# must agree with the Gallery manifest rather than drifting on its own.
$psd209 = Get-Content (Join-Path $script:ModuleRoot 'RackStack.psd1') -Raw
$psdCompany209 = [regex]::Match($psd209, "CompanyName\s*=\s*'([^']+)'").Groups[1].Value
- $exeCompany209 = [regex]::Match($call209, "-company\s+'([^']+)'").Groups[1].Value
+ $exeCompany209 = [regex]::Match($lsrc209, 'AssemblyCompany\("([^"]+)"\)').Groups[1].Value
Write-TestResult "Build: EXE CompanyName matches RackStack.psd1 ('$psdCompany209')" `
($psdCompany209 -and $exeCompany209 -and $psdCompany209 -eq $exeCompany209) `
"psd1='$psdCompany209' exe='$exeCompany209'"
@@ -12778,7 +12824,7 @@ try {
Write-TestResult "50-EntryPoint: JSON includes Tool field" ($mod50 -match 'Tool\s*=\s*\$script:ToolFullName')
Write-TestResult "50-EntryPoint: JSON includes Version field" ($mod50 -match 'Version\s*=\s*\$script:ScriptVersion')
Write-TestResult "50-EntryPoint: JSON includes Action field" ($mod50 -match "Action\s*=\s*'HealthCheck'")
- Write-TestResult "50-EntryPoint: OutputFormat in re-elevation" ($mod50 -match 'CLIOutputFormat.*elevateArgs.*OutputFormat')
+ Write-TestResult "50-EntryPoint: OutputFormat in re-elevation" ($mod50 -match 'CLIOutputFormat.*(cliArgs|elevateArgs).*OutputFormat')
# HealthCheck structured report tests
Write-TestResult "37-HealthCheck: builds report hashtable" ($mod37 -match '\$report\s*=\s*@\{')
diff --git a/dist/launcher/README.md b/dist/launcher/README.md
new file mode 100644
index 0000000..a43a279
--- /dev/null
+++ b/dist/launcher/README.md
@@ -0,0 +1,64 @@
+# RackStack.exe launcher
+
+`RackStack.exe` is a small native host for the monolithic script, not a packed
+or converted copy of it.
+
+| File | Purpose |
+|---|---|
+| `RackStack.Launcher.cs` | The whole program. Starts Windows PowerShell's own console host in-process and runs the embedded script in it. |
+| `app.manifest` | Requests elevation (`requireAdministrator`), declares supported Windows versions, opts into long paths. |
+
+## How the executable is built
+
+CI compiles the launcher with `csc.exe` from the .NET Framework that is part of
+Windows, and embeds `RackStack v{version}.ps1` as a plain-text resource named
+`RackStack.ps1`. There is no third-party compiler, wrapper, or packer in the
+path from source to binary. The exact command is in
+[`.github/workflows/ci.yml`](../../.github/workflows/ci.yml), step
+"Compile RackStack.exe".
+
+The embedded script is byte-identical to the monolithic `.ps1` published in the
+same release, which is Sigstore-signed like every other release artifact.
+
+## Why the launcher exists
+
+Earlier releases were produced with ps2exe, which wraps a script in its own
+PowerShell host implementation. That wrapper is widely reused by malware
+droppers, so antivirus heuristics scored every RackStack build as a packed
+script host regardless of what the script did. Running the script under the
+genuine console host removes the wrapper entirely and gives users the same
+console behaviour as `powershell.exe`.
+
+## Local build
+
+From a Windows PowerShell 5.1 machine, with the monolithic already generated by
+`sync-to-monolithic.ps1`:
+
+```powershell
+$ver = '1.123.0'
+$csc = "$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\csc.exe"
+$gac = "$env:WINDIR\Microsoft.Net\assembly\GAC_MSIL"
+$sma = (Get-ChildItem "$gac\System.Management.Automation" -Recurse -Filter System.Management.Automation.dll | Select-Object -First 1).FullName
+$chst = (Get-ChildItem "$gac\Microsoft.PowerShell.ConsoleHost" -Recurse -Filter Microsoft.PowerShell.ConsoleHost.dll | Select-Object -First 1).FullName
+
+$src = (Get-Content dist\launcher\RackStack.Launcher.cs -Raw).Replace('Version = "0.0.0.0"', "Version = `"$ver.0`"")
+[IO.File]::WriteAllText("$env:TEMP\RackStack.Launcher.cs", $src)
+Copy-Item "builds\RackStack v$ver.ps1" "$env:TEMP\RackStack.ps1" -Force
+
+& $csc /nologo /target:exe /platform:anycpu /optimize+ /debug- /warnaserror+ `
+ /r:$sma /r:$chst `
+ /win32icon:RackStack.ico /win32manifest:dist\launcher\app.manifest `
+ /resource:"$env:TEMP\RackStack.ps1",RackStack.ps1 `
+ /out:builds\RackStack.exe "$env:TEMP\RackStack.Launcher.cs"
+```
+
+## Runtime behaviour
+
+- Arguments pass straight through: `RackStack.exe -Action HealthCheck -Silent`
+ behaves exactly like running the script with those parameters.
+- `$PSCommandPath` is empty inside the script (as it was under ps2exe); the
+ script already resolves its own location from the process path.
+- If the embedded resource is missing (a development build compiled without
+ `/resource:`), the launcher falls back to a `RackStack.ps1` beside the
+ executable and otherwise exits with code 2.
+- Exit code 3 means Windows PowerShell 5.1 is not installed.
diff --git a/dist/launcher/RackStack.Launcher.cs b/dist/launcher/RackStack.Launcher.cs
new file mode 100644
index 0000000..af765ad
--- /dev/null
+++ b/dist/launcher/RackStack.Launcher.cs
@@ -0,0 +1,106 @@
+// RackStack.exe — the native host for the RackStack PowerShell toolkit.
+//
+// This program does one thing: it starts Windows PowerShell's own console host
+// (the same engine and console UI that powershell.exe uses) and runs RackStack
+// in it. It contains no PowerShell of its own, performs no work beyond locating
+// the script and handing over, and writes nothing to disk.
+//
+// The script is embedded as a plain-text resource; it is byte-identical to the
+// monolithic .ps1 published (and Sigstore-signed) in the same release, so what
+// the EXE does can be read there.
+//
+// Build: csc.exe from the .NET Framework 4.x that ships inside Windows — no
+// third-party compiler, wrapper, or packer. See ci.yml "Compile RackStack.exe".
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Management.Automation.Runspaces;
+using Microsoft.PowerShell;
+
+[assembly: AssemblyTitle("RackStack - Windows Server configuration toolkit")]
+[assembly: AssemblyDescription("Menu-driven configuration and automation for Windows Server hosts.")]
+[assembly: AssemblyProduct("RackStack")]
+[assembly: AssemblyCompany("TheAbider")]
+[assembly: AssemblyCopyright("Copyright (c) 2026 TheAbider")]
+[assembly: AssemblyVersion(RackStack.Launcher.Version)]
+[assembly: AssemblyFileVersion(RackStack.Launcher.Version)]
+[assembly: AssemblyInformationalVersion(RackStack.Launcher.Version)]
+
+namespace RackStack
+{
+ internal static class Launcher
+ {
+ // Stamped by the build from Header.ps1's .VERSION; the placeholder never ships.
+ internal const string Version = "0.0.0.0";
+
+ // Name of the embedded resource AND of the optional sibling file used when
+ // the resource is absent (development builds).
+ private const string ScriptName = "RackStack.ps1";
+
+ private static int Main(string[] args)
+ {
+ var psArgs = new List
+ {
+ "-NoLogo",
+ "-NoProfile",
+ "-ExecutionPolicy", "Bypass"
+ };
+
+ string script = ReadEmbeddedScript();
+ if (script != null)
+ {
+ // The host joins everything after -Command with spaces, so the script
+ // becomes one anonymous script block and the user's arguments follow it
+ // exactly as they would after "& { ... }" at a PowerShell prompt.
+ psArgs.Add("-Command");
+ psArgs.Add("& {" + Environment.NewLine + script + Environment.NewLine + "}");
+ foreach (string a in args) psArgs.Add(QuoteForCommand(a));
+ }
+ else
+ {
+ string scriptPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ScriptName);
+ if (!File.Exists(scriptPath))
+ {
+ Console.Error.WriteLine("RackStack: this executable carries no embedded script and no " + ScriptName + " sits next to it.");
+ Console.Error.WriteLine("Re-download it from https://github.com/TheAbider/RackStack/releases");
+ return 2;
+ }
+ psArgs.Add("-File");
+ psArgs.Add(scriptPath);
+ psArgs.AddRange(args);
+ }
+
+ try
+ {
+ return ConsoleShell.Start(RunspaceConfiguration.Create(), string.Empty, string.Empty, psArgs.ToArray());
+ }
+ catch (FileNotFoundException ex)
+ {
+ Console.Error.WriteLine("RackStack: Windows PowerShell 5.1 (Windows Management Framework 5.1) is required but was not found.");
+ Console.Error.WriteLine(ex.Message);
+ return 3;
+ }
+ }
+
+ private static string ReadEmbeddedScript()
+ {
+ using (Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream(ScriptName))
+ {
+ if (s == null) return null;
+ using (var r = new StreamReader(s, true)) { return r.ReadToEnd(); }
+ }
+ }
+
+ // Parameter names (-Action, -Silent) must stay bare so PowerShell binds them;
+ // everything else is single-quoted so spaces and special characters survive.
+ private static string QuoteForCommand(string a)
+ {
+ bool looksLikeParameter = a.Length > 1 && a[0] == '-'
+ && a.IndexOfAny(new[] { ' ', '\t', '\'', '"', '`', '$', ';', '&', '|', '(', ')', '{', '}' }) < 0;
+ if (looksLikeParameter) return a;
+ return "'" + a.Replace("'", "''") + "'";
+ }
+ }
+}
diff --git a/dist/launcher/app.manifest b/dist/launcher/app.manifest
new file mode 100644
index 0000000..487503d
--- /dev/null
+++ b/dist/launcher/app.manifest
@@ -0,0 +1,29 @@
+
+
+
+ RackStack - Windows Server configuration toolkit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
diff --git a/dist/winget/README.md b/dist/winget/README.md
index 558a6a6..0a89ef3 100644
--- a/dist/winget/README.md
+++ b/dist/winget/README.md
@@ -18,7 +18,7 @@ ci.yml step -- do not hand-maintain these files after that.
## Notes on the manifest choices
- **`InstallerType: portable`** -- `RackStack.exe` is a standalone
- ps2exe-compiled executable, not an installer. winget installs it as a
+ self-contained executable (a native launcher with the script embedded), not an installer. winget installs it as a
portable package: it places the EXE and registers a PATH alias.
- **`Commands: [rackstack]`** -- so `rackstack` works from any shell
after install. The EXE auto-elevates itself when run.
diff --git a/docs/ASSURANCE_CASE.md b/docs/ASSURANCE_CASE.md
index 37c5ca4..015652a 100644
--- a/docs/ASSURANCE_CASE.md
+++ b/docs/ASSURANCE_CASE.md
@@ -186,7 +186,7 @@ added as part of the patch that introduces them, by policy.
| SHA-256 hash of every artifact published in `release-hashes.txt` | Every release. |
| Sigstore cosign keyless signature on every artifact (`.sig` + `.pem`) | Every release since v1.98.54; verification command in release notes. |
| SLSA Level 3 build provenance attestation | `actions/attest-build-provenance@v2` on every release; verifiable via `gh attestation verify`. |
-| Reproducible build from source | `.\sync-to-monolithic.ps1` produces deterministic monolithic; `Invoke-PS2EXE` output is byte-identical given the same source + version arguments. |
+| Reproducible build from source | `.\sync-to-monolithic.ps1` produces deterministic monolithic; the launcher compile (`ci.yml`, in-box `csc.exe`) is byte-identical given the same source + version, up to the PE timestamp. |
| SHA-pinned GitHub Actions enforced at the repo policy level | `gh api repos/TheAbider/RackStack/actions/permissions` shows `"sha_pinning_required": true`. |
**Counter-argument considered.** The maintainer's GitHub account could
@@ -248,14 +248,14 @@ following are acknowledged and tracked:
whole-codebase 96%; readers should weight the regex harness's
4990-pattern coverage alongside.
-### CR-4: ps2exe PE timestamp non-determinism
-- The compiled EXE has a PE COFF timestamp field that's set by ps2exe
+### CR-4: PE timestamp non-determinism
+- The compiled EXE has a PE COFF timestamp field that the in-box C# compiler sets
to "now" at build time. Two builds from the same source produce
different SHA-256 hashes for that reason alone.
- Mitigation: `release-hashes.txt` is signed per-build; downstream
verifiers check the cosign signature, not bit-for-bit reproducibility
with their own rebuild.
-- Tracking: a ps2exe patch upstream could fix this; not currently
+- Tracking: the Roslyn compiler's `/deterministic` switch would fix this; the in-box compiler predates it. Not currently
planned to fork.
### CR-5: Operator can disable defenses
diff --git a/docs/Antivirus-Detections.md b/docs/Antivirus-Detections.md
index 7ea7af4..693abd8 100644
--- a/docs/Antivirus-Detections.md
+++ b/docs/Antivirus-Detections.md
@@ -22,18 +22,26 @@ If you arrived here from a VirusTotal result or a quarantine notification, start
## Why it happens
-Three properties of RackStack combine to score badly with behavioral and static ML classifiers.
-All three are inherent to what the tool is and does.
+Two properties of RackStack combine to score badly with behavioral and static ML classifiers.
+Both are inherent to what the tool is and does.
| Property | Why a classifier dislikes it |
|---|---|
| **The EXE is not Authenticode-signed** | No publisher reputation exists to offset a heuristic score. Code-signing certificates that would fix this require a validated legal entity, which this project does not have. |
-| **It is a packed script host** | The EXE is a PowerShell script compiled by [ps2exe](https://github.com/MScholtes/PS2EXE) into a .NET assembly. Self-extracting script hosts are strongly associated with malware droppers, which is why detections usually carry `MSIL`, `assembly`, or generic packer labels. |
-| **It manages Defender exclusions and services** | RackStack applies Microsoft's own published antivirus exclusion recommendations for Hyper-V, Failover Clustering, and iSCSI/SAN workloads, and can disable optional Windows services. An unsigned packed binary adding its own antivirus exclusions is, behaviorally, the textbook opening move of a dropper. |
+| **It manages Defender exclusions and services** | RackStack applies Microsoft's own published antivirus exclusion recommendations for Hyper-V, Failover Clustering, and iSCSI/SAN workloads, and can disable optional Windows services. An unsigned binary whose contents mention antivirus exclusions is, to a classifier, the textbook opening move of a dropper. |
+
+A third property was removed in v1.123.0. Releases through v1.122.4 were produced by
+[ps2exe](https://github.com/MScholtes/PS2EXE), which wraps a script in its own PowerShell host
+implementation. That wrapper is widely reused by malware droppers, so every build scored as a
+packed script host regardless of the script's content: detections carried `MSIL`, `assembly`, and
+generic packer labels, a cleared hash was re-flagged within weeks, and the same file drifted from 8
+to 19 VirusTotal detections without changing a byte. The EXE is now a small launcher, compiled with
+the C# compiler that ships inside Windows, that starts Windows PowerShell's own console host and
+runs the script from an embedded plain-text resource. See [`dist/launcher/`](../dist/launcher/).
The most common result is a **behavioral** detection such as `Behavior:Win32/DefenseEvasion.A!ml`,
which fires on what the running process *does* — not on the file matching anything known. Static
-ML verdicts such as `Trojan:Win32/Sabsik.EN.A!ml` come from the same combination of features.
+ML verdicts such as `Trojan:Win32/Wacatac.B!ml` come from the same combination of features.
New releases are also **low-prevalence** files, which raises heuristic scores until download
history accumulates.
@@ -89,7 +97,7 @@ cosign verify-blob `
Every release also ships a CycloneDX SBOM. There is no manual or local step anywhere in the
release path — the published EXE is built entirely in GitHub-hosted CI from the public source in
-this repository, and the monolithic `.ps1` it was compiled from is published in the same release
+this repository, and the monolithic `.ps1` it embeds is published in the same release
so you can read exactly what the EXE does.
---
@@ -151,7 +159,7 @@ Add-MpPreference -ExclusionPath 'C:\Path\To\RackStack.exe'
## Avoiding it entirely: run the script
The monolithic `RackStack v{version}.ps1` published in every release is the *same code* the EXE
-is compiled from. It is unpacked, it is cosign-signed like every other release artifact, and it
+embeds. It is unpacked, it is cosign-signed like every other release artifact, and it
is never scored by the PE classifiers that produce these detections.
```powershell
@@ -160,7 +168,7 @@ Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process
```
The PowerShell Gallery module (`Install-Module RackStack`) is another script-based route that
-avoids the packed binary.
+avoids the executable entirely.
If antivirus alerts are a recurring problem in your environment, prefer one of these.
diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md
index e8f7a49..63a1349 100644
--- a/docs/Troubleshooting.md
+++ b/docs/Troubleshooting.md
@@ -666,7 +666,7 @@ The sweep tool uses parallel background jobs for speed:
### Cause
-`RackStack.exe` is unsigned, packed by ps2exe into a .NET assembly, and manages Windows Defender
+`RackStack.exe` is unsigned and manages Windows Defender
exclusions as a documented feature. That combination scores as evasion behaviour to ML
classifiers. These are false positives.