I have a PowerShell project that builds Fedora VMs in VirtualBox with no clicks. Pick a distro, run one command, walk away, come back to a booted VM. It also does CentOS-Stream, AlmaLinux, and Rocky.
The way most people start it is the classic one-liner:
irm https://raw.githubusercontent.com/.../install.ps1 | iex
Last week I finally read what install.ps1 actually does with the thing it pulls down. It wasn't good.
The old logic was basically this: if the downloaded content is longer than 500 bytes, save it to disk and run it. That was the whole check. One number. 500.
Think about everything that clears a 500-byte bar.
A truncated download that died halfway through. A GitHub error page, which is HTML and way over 500 bytes. A captive-portal redirect from some coffee shop wifi, also HTML, also way over 500 bytes. Every one of those gets saved and executed as PowerShell. On an admin shell. On the user's machine. With my name on the repo it came from.
And I'd shipped it. CI was green. It "worked" in the only sense I'd ever tested, which was the happy path where the download succeeds and returns my real script. I never once tested the sad path.
what I tried first
My first instinct was a hash check. Download the file, compare its SHA256 against a known good value. Clean idea, except the known good value has to live somewhere the installer trusts, and right now that's the same repo over the same connection. If an attacker can swap the script they can swap the hash next to it. So a hash pinned in the repo doesn't actually buy much here. Real code signing does, but that needs signing setup I don't have yet.
So I went with something smaller that I could ship today: refuse to run bytes I can't recognize as my actual script.
what worked
Two gates before anything executes.
First, the content has to parse as PowerShell with zero errors. I run it through the language parser and check the error list. An HTML page throws parse errors on the first <.
Second, it has to contain two markers only my real script carries: the admin-requirement header and the New-FedoraVM entry point. A valid but unrelated script won't have those.
Here's the validator, trimmed down:
function Test-DownloadedScript {
param([string]$Content)
$errors = $null
[System.Management.Automation.Language.Parser]::ParseInput(
$Content, [ref]$null, [ref]$errors)
if ($errors.Count -gt 0) { return $false }
if ($Content -notmatch '#Requires -RunAsAdministrator') { return $false }
if ($Content -notmatch 'function New-FedoraVM') { return $false }
return $true
}
I pulled it into its own function on purpose, so I could test it without touching the network. Then I fed it the real script, a minimal valid script, and a pile of garbage: empty string, a 3-byte file, a full HTML page, a truncated copy, one with a deliberate syntax error, one missing the header. Accept path and reject path are both pinned down now.
the bug that was actually worse
While I was in there I found something nastier than the length check.
The installer asks for admin rights. If you didn't start it as admin, it relaunches itself as admin. The way it did that relaunch was to run irm <url> | iex again, inside the new admin process.
So the order of events was: your non-admin run downloads the script, you glance at it, looks fine, you approve the UAC prompt. Then the admin run downloads the script a second time, from scratch, and runs that. Two separate fetches of the same URL. Nothing guaranteed the bytes you looked at and the bytes that ran as admin were the same file.
That's a time-of-check to time-of-use window. If the URL content changed between the two pulls, or the second pull got tampered with, the admin process executes something you never saw.
The fix was boring and correct. Take the source that's already running, write it to a temp file, and launch the admin process with -File against that copy. Same bytes both times. It works whether you ran it as a saved .ps1 or piped it through iex.
what's still broken
I want to be straight about what this is. Parsing as PowerShell and matching two known strings means "this is plausibly my script," not "this is provably my script." Someone who knows the markers could write a payload that passes both gates. The real fix is Authenticode signing on the payload plus a signature check before it runs, and that's still an open issue on the repo. I narrowed the hole. I didn't close it.
The other thing that bugs me: the 500-byte check sat there for months and CI never caught it, because CI only ran the main script's tests. The installer wasn't even linted. So I added it to the PSScriptAnalyzer step too. If it had been drifting, nobody would have known.
The takeaway I keep relearning is that the dangerous code is rarely the complicated code. The VM provisioning logic got all my attention because it was the hard part. The six lines that download a script and run it as admin got none, because they were easy and they looked done.
Repo if you want the whole thing: https://github.com/TiltedLunar123/Fedora-VirtualBox-Auto-Installer-PowerShell
It works. Not bulletproof yet, but a lot less embarrassing than it was a week ago.
Top comments (0)