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 @@ OpenSSF Best Practices codecov PSScriptAnalyzer 0 errors - 5495 structural tests + 5495 structural tests Pester 312 tests SLSA Level 3

@@ -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.