← Flight log

What You Read Isn't What Runs: The Case Against curl | bash

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.

← All entries Reply by email →