Every install page has the same friendly one-liner:
curl -fsSL https://example.dev/install.sh | sudo bash
And the same reassurance a paragraph down: “Don’t trust it? Inspect it first —
curl https://example.dev/install.sh | less.”
Here’s the problem: those are two different requests, and the server knows which one you’re making.
The server can tell you’re piping to bash
I recently dusted off and ported a little proof-of-concept that does exactly this. It’s an HTTPS server that decides, per request, whether to serve you a harmless script or a malicious one — based on nothing but how you’re consuming the response.
The mechanism is a timing side channel. When you run curl … | bash, bash
executes the stream as it arrives, so it drains the TCP receive buffer at the
pace the script runs — a sleep near the top opens a nice wide window. When you
run curl … | less or curl -o install.sh, the client slurps the whole
response at once. The server pads its socket buffer and measures how fast you
accept the bytes:
- a large initial jump followed by low-variance gaps → something downstream is
consuming the stream in real time → that’s a shell → serve
bad.sh - one fast gulp → a human is probably reading it → serve
good.sh
It doesn’t even need the fancy timing. The server sees your User-Agent; it can
branch on curl/Wget just as easily. The point stands either way:
What you reviewed is not guaranteed to be what executed.
It’s the trust model, not the trick
The timing channel is a clever demo, but the real issue is that piping a remote response straight into a shell throws away every safety property you’d want:
| Failure mode | What it means for you |
|---|---|
| Review ≠ execution | The bytes you read and the bytes that run can differ (TOCTOU, UA sniffing, the trick above). |
| No integrity | No signature, no checksum, no version pin. A MITM, a compromised CDN, or a bad day upstream all run as you, immediately. |
| Runs while it streams | A truncated or interrupted download still executes whatever arrived — half a command can be worse than none. |
| Runs as your user | Usually with sudo, because the one-liner told you to. |
You’re not “installing software.” You’re giving a remote host an interactive shell on your machine and agreeing to run whatever it feels like sending, right now, sight unseen.
What to do instead
Break the pipe. Fetch, inspect, verify, then run — so the bytes you checked are the bytes that execute:
# 1. download to a file (bytes you can actually inspect)
curl -fsSL https://example.dev/install.sh -o install.sh
# 2. read it
less install.sh
# 3. verify against a published checksum / signature
echo "<expected-sha256> install.sh" | sha256sum --check
# or: gpg --verify install.sh.sig install.sh
# 4. only then
sudo bash install.sh
Better still, install through a package manager (apt, dnf, brew, the language’s own registry) that does signature verification for you, and pin versions.
None of this is new, and the convenience of the one-liner is real. But so is the asymmetry: you save about four seconds, and in exchange you hand a stranger’s server a shell on your box. The detector was a fun thing to build — it’s a lot less fun to be on the receiving end of.
The proof-of-concept detector (now ported to Python 3) is here if you want to poke at it.