DEV Community

TiltedLunar123
TiltedLunar123

Posted on

my one-line PowerShell installer downloaded the script twice, and the second one was the weak spot

I have a little DNS benchmarking tool that you install the way half of PowerShell-land installs things:

irm https://raw.githubusercontent.com/TiltedLunar123/DNS-Benchmark/master/install.ps1 | iex
Enter fullscreen mode Exit fullscreen mode

irm pulls install.ps1, iex runs it. Simple.

Changing your DNS settings needs admin rights though, and the window you paste that line into usually isn't admin. So the installer pops a UAC prompt and relaunches itself as admin. Pretty standard.

Here's the part I didn't think hard enough about for way too long.

The first run (non-admin) downloaded install.ps1. Then, to hand the work off to the admin run, it downloaded install.ps1 again from the same URL. Two separate fetches of the same file, a second or two apart.

That second fetch is the problem.

why two downloads is worse than one

When you run irm | iex, you're trusting whatever comes back over the wire that exact moment. Fine, that's the deal you signed up for. But you only really get to "trust" the bytes you can reason about, and in my case the bytes that actually ran with admin rights were from the second download, not the first.

Picture someone sitting on the network path between you and GitHub's CDN. A sketchy coffee-shop router, a poisoned resolver cache, a compromised middlebox on a corporate LAN. They can hand the first (harmless, non-admin) download a clean script, and hand the second (admin) download something else. The run you'd inspect if you were paranoid is not the run that gets the keys.

It's a small window. It's also exactly the kind of thing that's invisible until you draw the data flow out and go "wait, why is this downloading itself again."

And the odds aren't the point. The point is that I'd handed the most dangerous action in the whole script, running code as admin, to the input I'd inspected the least. I checked the first download because that's the one staring back at me in the terminal. The one that actually mattered slipped in behind it. That asymmetry is the bug, more than any specific attacker.

what I changed

The fix wasn't clever. It was "stop doing the dumb thing."

The first run already has the full script text sitting in memory. So instead of telling the admin process to go re-download from the URL, I write that exact in-memory text to a temp file and point the admin process at the local copy:

# already have $scriptText in memory from the first fetch
$temp = Join-Path $env:TEMP "DNS-Benchmark-install.ps1"
Set-Content -Path $temp -Value $scriptText -Encoding UTF8

Start-Process powershell.exe -Verb RunAs -ArgumentList @(
    '-ExecutionPolicy','Bypass','-File', $temp
)
Enter fullscreen mode Exit fullscreen mode

One download. The admin run executes the same bytes the non-admin run already had. No second trip over the network to second-guess.

then I went one step further

One download can still be tampered with in transit. So before the installer runs the actual benchmark, it now pulls a checksums.txt and compares the SHA-256 of what it downloaded against the expected hash. Mismatch, it stops and yells instead of running.

This bit me immediately on a fresh checkout, and the file was completely fine. Line endings. Git checked the script out as CRLF on Windows, my stored checksum was computed against LF, so the hashes never matched. Now I normalize line endings before hashing:

$normalized = (Get-Content -Raw $path) -replace "`r`n", "`n"
$bytes = [Text.Encoding]::UTF8.GetBytes($normalized)
$sha   = [BitConverter]::ToString(
    [Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
).Replace('-','').ToLower()
Enter fullscreen mode Exit fullscreen mode

I also added a Pester test that recomputes the hash and compares it to checksums.txt. If I edit the benchmark and forget to refresh the checksum, that test fails in CI instead of every install silently breaking in the wild.

the honest limit

The checksum protects the path between GitHub and your machine. It does not stop a full source compromise. If someone pushes bad code to the repo and updates checksums.txt to match, the check passes and waves it right through. I wrote that limitation straight into SECURITY.md rather than letting the word "checksum" imply more safety than it actually buys. It closes the on-path gap, nothing more.

the rest of the tool, quickly

The reason any of this exists: it benchmarks 17 public DNS resolvers across 10 domains, a few queries each, and scores them on a weighted composite. Speed 40%, reliability 25%, security 25% (DNSSEC, DoH/DoT, logging policy, threat blocking), consistency 10%. Letter grades A+ through F, top three starred. On my connection Quad9 and Cloudflare keep trading first place, both landing around 8 to 12ms average. There's a -SkipApply if you just want the numbers and don't want it touching your adapter, and it backs up your current DNS to a timestamped JSON before changing anything.

It works. The code isn't the prettiest I've written, but the install path is a lot tighter than it was a week ago, and I learned more from the irm | iex footgun than from the actual benchmarking.

Repo, if you want to pick it apart: https://github.com/TiltedLunar123/DNS-Benchmark

Top comments (1)

Collapse
 
circuit profile image
Rahul S

The asymmetry you described — inspecting the first fetch but trusting the second — is basically a TOCTOU bug in your install pipeline, and it's way more common than people think. The curl | sh pattern on Unix has a related variant: bash processes the stream as it arrives, so a slow or interrupted connection can execute a partial script that sets up half the state and leaves the system in a broken/exploitable configuration. Your version is worse though, because the second fetch could be entirely different content, not just truncated.

The temp file fix is solid, but there's a subtlety worth noting: the temp directory itself is a shared namespace on Windows. Another process (or malware already on the machine) could race to overwrite that file between Set-Content and Start-Process. Low probability, but if you wanted to close it completely you'd generate a random filename and set restrictive ACLs on it before writing. At that point you're basically reinventing what sigstore/cosign does for container images — binding a specific artifact to a cryptographic identity rather than just a hash of the content.