Recently, I stumbled again upon an old bad habit from the Linux/DevOps/Cloud world: installing a tool with a command like:
curl -sSL https://example.com/install.sh | bash
Or worse:
curl -sSL https://example.com/install.sh | sudo bash
We have all seen it before.
We have probably all used it at some point.
And let’s be honest for a minute: in many official documentations, this is still presented as the “simple” way to install a tool.
Except this command raises a real security issue. It basically means:
“Download a script from the Internet and execute it immediately on my machine.”
Put like that, it suddenly sounds a bit less appealing.
But the problem is even sneakier than that.
Because you might think:
“Yes, but I’m careful, I read the script first.”
Very well.
Except what you read is not necessarily what you are going to execute.
And that is exactly what I am going to show in this article.
The basic problem
When you run:
curl https://example.com/install.sh | bash
You create a pipeline.
curl downloads the remote content and writes it to standard output.
bash, on its side, reads from that standard output and starts executing what it receives.
The important subtlety is that bash does not necessarily wait for the entire script to be downloaded before starting to execute it.
It reads and executes progressively.
And that changes everything.
Because a remote server can potentially adapt its response depending on the context:
- browser or curl
- IP address
- User-Agent
- HTTP headers
- operating system
- presence of a proxy or VPN
- request coming from a CI/CD system
- or even network behavior suggesting that the content is being piped directly into
bash
So no, simply running:
curl https://example.com/install.sh
before running:
curl https://example.com/install.sh | bash
does not guarantee that you saw exactly the same script.
It is counterintuitive, but that is the whole point.
“But HTTPS protects me, right?”
Yes.
But not against this.
HTTPS mainly protects against content modification during transport.
It helps ensure that you are talking to the expected server and that the content has not been modified by a network intermediary.
But HTTPS absolutely does not guarantee that the server is honest.
If the server decides to serve a clean script when you display it, then a different script when you execute it directly, HTTPS will not save you.
You will simply have an encrypted and authenticated connection to a server that deliberately serves you different content.
A first very simple demonstration
The most basic case is the User-Agent.
A server can do this:
- if the request comes from a browser: show a clean script
- if the request comes from
curl: show something else - if the request comes from a CI/CD system: show yet another thing
This is completely standard HTTP behavior, and it is even the intended behavior for some automation tools.
But there is an even more interesting technique: detecting the typical behavior of curl | bash.
Can a server detect curl | bash?
Yes, in some cases.
This idea has already been documented by several people, notably around the technique often called curlbash detection. The principle relies on Unix pipeline behavior and on timing signals observable from the server side.
The general idea is this:
- The server starts sending a script.
- At the beginning of the script, it places a slow command, for example
sleep 2. - If the script is simply downloaded, this
sleep 2remains plain text. - If the script is piped into
bash, thenbashactually executes thesleep 2. - While
bashexecutes the command, it stops reading from the pipe. - During that time, the server sends a large amount of data to fill the pipe.
curleventually blocks while trying to write.- The server observes an abnormal delay while sending data.
- The server can then infer: “interesting, this content is probably being executed directly by bash”.
And at that point, it can decide to send a different continuation.
Again: the server does not read your terminal.
It simply observes the side effects of the pipeline.
That is what makes it interesting.
And also what makes it dangerous.
Harmless demo
The goal here is obviously not to provide malware or a real offensive payload.
We are simply going to create a local demo showing that the server can respond differently depending on what we do:
curl http://127.0.0.1:8000/install.sh
or:
curl -sS http://127.0.0.1:8000/install.sh | bash
In the first case, the script will display something ordinary.
In the second case, it will display a message saying that curl | bash behavior was detected, then create a harmless file in /tmp.
Nothing destructive.
Nothing hidden.
Just an educational proof of concept.
The demonstration server
Create a server.py file:
#!/usr/bin/env python3
import socket
import time
HOST = "127.0.0.1"
PORT = 8000
# Deliberately large amount of data to fill client-side buffers.
# Adjustable depending on OS / kernel / curl / TCP buffers.
FILLER_CHUNKS = 65536
FILLER_SIZE = 256
DETECTION_THRESHOLD_SECONDS = 1.2
def send_chunk(conn, data: bytes) -> float:
"""
Send an HTTP/1.1 chunked chunk and return the send duration.
If curl is blocked because bash is not reading the pipe fast enough,
sendall() can take noticeably longer.
"""
chunk = b"%X\r\n" % len(data) + data + b"\r\n"
start = time.monotonic()
conn.sendall(chunk)
return time.monotonic() - start
def handle_client(conn, addr):
try:
request = conn.recv(4096)
headers = (
b"HTTP/1.1 200 OK\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Connection: close\r\n"
b"\r\n"
)
conn.sendall(headers)
detected_pipe_to_bash = False
prefix = """#!/usr/bin/env bash
# Demo curl|bash detection - harmless.
# If you inspect this script client-side, it looks perfectly ordinary.
# The sleep command is just text.
# If you pipe it into Bash, the sleep command is executed on the fly by the client.
sleep 2
"""
send_chunk(conn, prefix.encode("utf-8"))
filler = b"#" + (b"A" * (FILLER_SIZE - 2)) + b"\n"
max_delay = 0.0
for _ in range(FILLER_CHUNKS):
delay = send_chunk(conn, filler)
max_delay = max(max_delay, delay)
if delay > DETECTION_THRESHOLD_SECONDS:
detected_pipe_to_bash = True
break
if detected_pipe_to_bash:
payload = f"""
echo "[DEMO] curl | bash detected server-side."
echo "[DEMO] The server just delivered different content."
echo "[DEMO] Harmless payload: creating /tmp/curl-bash-demo-piped"
echo "Teddy was there" > /tmp/curl-bash-demo-piped
echo "[DEMO] Maximum send delay observed server-side: {max_delay:.2f}s"
"""
else:
payload = f"""
echo "[DEMO] Simple download/inspection detected."
echo "[DEMO] The displayed script looks perfectly ordinary."
echo "[DEMO] Maximum send delay observed server-side: {max_delay:.2f}s"
"""
send_chunk(conn, payload.encode("utf-8"))
conn.sendall(b"0\r\n\r\n")
print(
f"{addr[0]}:{addr[1]} - "
f"pipe_to_bash={detected_pipe_to_bash} "
f"max_send_delay={max_delay:.2f}s"
)
except BrokenPipeError:
print(f"{addr[0]}:{addr[1]} - client disconnected")
finally:
conn.close()
def main():
print(f"Listening on http://{HOST}:{PORT}/install.sh")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.listen(10)
while True:
conn, addr = server.accept()
handle_client(conn, addr)
if __name__ == "__main__":
main()
Run it:
python3 server.py
Test 1: simply downloading the script
In another terminal:
curl http://127.0.0.1:8000/install.sh
You should see a script that looks relatively ordinary.
The server should log something like:
127.0.0.1:58772 - pipe_to_bash=False max_send_delay=0.01s
Client-side, you should see a long output filled with AAA strings, used simply to create a large payload volume. Note that in “real life”, this payload could simply be a long comment and look completely harmless.
#!/usr/bin/env bash
# Demo curl|bash detection - harmless.
# If you inspect this script client-side, it looks perfectly ordinary.
# The sleep command is just text.
# If you pipe it into Bash, the sleep command is executed on the fly by the client.
sleep 2
#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
..........
#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
echo "[DEMO] Simple download/inspection detected."
echo "[DEMO] The displayed script looks perfectly ordinary."
echo "[DEMO] Maximum send delay observed server-side: 0.01s"
The important point here: the sleep 2 is present in the script, but it is not executed.
It is merely displayed.
Test 2: piping into bash
Now run:
curl -sS http://127.0.0.1:8000/install.sh | bash
This time, you should see something like:
[DEMO] curl | bash detected server-side.
[DEMO] The server just delivered different content.
[DEMO] Harmless payload: creating /tmp/curl-bash-demo-piped
[DEMO] Maximum send delay observed server-side: 1.98s
And you can verify it:
less /tmp/curl-bash-demo-piped
Teddy was there
Then clean up:
rm -f /tmp/curl-bash-demo-piped
The demo did nothing dangerous.
But it shows something very important: the content that gets executed is not necessarily the content you saw during a simple inspection.
Why does it work?
When you run:
curl -s http://127.0.0.1:8000/install.sh
curl simply reads the HTTP response and displays it in your terminal.
The server sends the data, the client receives it, and everything goes fast.
When you run:
curl -sS http://127.0.0.1:8000/install.sh | bash
bash starts reading and executing the script.
If it reaches:
sleep 2
it stops for two seconds.
During that time, it no longer reads incoming data.
The pipe between curl and bash can fill up, because we are hammering it with a large payload. Eventually, this can block curl itself and put it “on pause” because it can no longer write into the pipe.
And depending on buffers, the server can eventually observe that sending data takes longer than expected.
That delay becomes a signal.
An imperfect signal, but more than enough for a proof of concept.
What this demonstration does not say
Let’s be clear: this demo does not mean that every installation script using curl | bash is malicious.
That would be stupid.
Many well-known projects use this mechanism to simplify installation.
And in some contexts, with a sufficient level of trust, it can be acceptable.
The real issue is when this practice becomes automatic.
When we copy and paste a command found in documentation, a GitHub README, a Jira ticket, Slack, a forum, or an old onboarding script without thinking.
At that point, we move from a conscious choice to a bad habit.
And in security, bad habits always end up costing something.
The real danger: sudo
The really ugly version is this one:
curl -sSL https://example.com/install.sh | sudo bash
Here, we are no longer just saying:
“Execute this remote script.”
We are saying:
“Execute this remote script with administrator privileges.”
In other words, if the server is compromised, if the domain expires, if DNS is hijacked, if the maintainer’s account is stolen, or if the script changes behavior, you may have just handed over the keys to the machine.
It is a bit like letting someone into your home to install a shelf, but also giving them a spare key, the alarm code, and access to the safe.
“But I trust the project”
I hear this sentence often.
And in some cases, it is legitimate.
But it is not always enough.
Trusting a project does not only mean trusting its developers.
It also means trusting:
- the domain
- DNS
- hosting
- the CDN
- the GitHub account
- CI/CD secrets
- the release pipeline
- dependencies
- current and future maintainers
- the absence of compromise at the exact moment you execute the command
That is starting to be a lot of people in the trust chain.
How can we do better?
The bare minimum is to avoid directly executing what comes from the network.
Instead of:
curl -sSL https://example.com/install.sh | bash
You can already do:
curl -fsSLo install.sh https://example.com/install.sh
less install.sh
bash install.sh
It is not perfect.
But at least the file you execute is the one you downloaded and inspected.
Even better: use a precise version.
curl -fsSLo install.sh https://example.com/releases/v1.2.3/install.sh
less install.sh
bash install.sh
Even better: verify a checksum, when one is available.
curl -fsSLo tool.tar.gz https://example.com/releases/tool-v1.2.3-linux-amd64.tar.gz
curl -fsSLo tool.tar.gz.sha256 https://example.com/releases/tool-v1.2.3-linux-amd64.tar.gz.sha256
sha256sum -c tool.tar.gz.sha256
And even better: use signatures.
Depending on the project, this can involve:
- GPG
- Sigstore
- Cosign
- signed package repositories
- signed GitHub releases
What should we do in companies?
In an enterprise context, I would be fairly strict.
Commands like:
curl https://... | bash
or:
wget -qO- https://... | sh
should be avoided in:
- installation scripts
- onboarding scripts
- CI/CD pipelines
- GitHub Actions / GitLab CI runners
- Dockerfiles
- administration procedures
- scripts executed as root
If they cannot be avoided, they should at least be controlled:
- versioned URL
- trusted domain
- verified hash
- verified signature
- execution without privileges when possible
- script review
- execution in a disposable environment
- no reflexive
sudo - retained logs
- pinned dependencies
And above all: no magic command copied from the Internet into a root session.
Yes, I know.
It is less sexy than a one-liner.
But it is also significantly less stupid.
Example of a simple rule
Personally, I think the policy can be summarized like this:
- If the script comes from the network, I download it first.
- If I have to execute it, I want to know what it does.
- If I have to execute it as root, I want a real reason.
- If it is in production, I want a pinned and verified version.
Simple.
Not perfect.
But already much better than:
curl https://random-url/install.sh | sudo bash
Conclusion
The problem with curl | bash is not only that it is ugly.
The problem is that this command short-circuits several important steps:
- inspection
- verification
- integrity
- versioning
- privilege control
- reproducibility
And above all, it creates an illusion of security.
Sometimes we think:
“I read the script first, so it’s fine.”
But the real question is not:
“Did I read a script?”
The real question is:
“Am I certain that the script I read is exactly the one I executed?”
With curl | bash, the honest answer is often:
No.
And in security, when the answer is “no”, it is better not to pretend that it is “yes”.
Sources
- Luke Spademan — The Dangers of curl | bash
https://lukespademan.com/blog/the-dangers-of-curlbash/ - GitHub PoC
Stijn-K/curlbash_detect
https://github.com/Stijn-K/curlbash_detect - Security Stack Exchange discussion on the risks of
curl ... | sudo bash
https://security.stackexchange.com/questions/213401/is-curl-something-sudo-bash-a-reasonably-safe-installation-method