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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 41 additions & 29 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,44 +152,56 @@ 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: |
$ver = '${{ steps.vercheck.outputs.version }}'
$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).
Expand Down
11 changes: 11 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
5 changes: 3 additions & 2 deletions GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions Header.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions Modules/00-Initialization.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -221,19 +221,19 @@ $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)
}
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.
Expand Down
38 changes: 33 additions & 5 deletions Modules/47-ExitCleanup.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
30 changes: 22 additions & 8 deletions Modules/50-EntryPoint.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions Modules/61-ActiveDirectory.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Modules/75-Compliance.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading