Cuando llamas a subprocess.run() con shell=True, no estás ejecutando tu programa directamente. Estás ejecutando /bin/sh -c "tu comando" en Unix, o cmd.exe /c "tu comando" en Windows. La shell recibe un string y lo interpreta antes de lanzar el proceso real. Ese paso de interpretación es exactamente lo que te da poder… y lo que abre la puerta a desastres.
El diseño tiene sentido: la shell sabe hacer globbing (*.txt), expandir variables de entorno, manejar pipelines (|), redirecciones (>), y encadenar comandos con ; o &&. Sin shell=True, subprocess invoca el ejecutable directamente con una lista de argumentos — más eficiente, más predecible, y sin superficie de ataque.
El problema aparece en el momento en que metes input externo en ese string. Si un usuario controla cualquier parte del comando y usas shell=True, tienes una inyección de comandos clásica. No es teórica: filename = "informe.txt; rm -rf /" seguido de subprocess.run(f"cat {filename}", shell=True) ejecuta ambos comandos con tus privilegios. La shell no distingue entre lo que tú escribiste y lo que el usuario inyectó — para ella todo es texto a interpretar.
Entonces, ¿cuándo sí tiene sentido? Las razones legítimas son contadas:
- Necesitas un pipeline entre procesos (
grep foo | sort | uniq) y no quieres montar tresPopenmanualmente. - Necesitas globbing nativo del sistema y
glob.glob()no es conveniente en ese contexto. - Estás prototipando rápido un script interno donde sabes exactamente qué entra.
En producción, con datos externos, casi nunca lo quieres.
import subprocess
import glob
import shlex
from pathlib import Path
# ─── Caso 1: la forma peligrosa ───────────────────────────────────────────
def buscar_peligroso(nombre_archivo: str) -> None:
# ❌ Si nombre_archivo viene de fuera, esto es una bomba de tiempo
subprocess.run(f"grep 'error' {nombre_archivo}", shell=True)
# ─── Caso 2: lista de argumentos, la forma correcta ──────────────────────
def buscar_seguro(nombre_archivo: str) -> None:
# subprocess no invoca ninguna shell; el argumento es un dato, no código
subprocess.run(["grep", "error", nombre_archivo], check=True)
# ─── Caso 3: globbing sin shell ───────────────────────────────────────────
def procesar_logs(directorio: str) -> None:
archivos = glob.glob(f"{directorio}/*.log")
if not archivos:
print("No hay logs.")
return
# Pasamos la lista expandida directamente — cero interpretación de shell
subprocess.run(["grep", "-l", "CRITICAL"] + archivos, check=True)
# ─── Caso 4: pipeline legítimo con shell=True (sin input externo) ─────────
def pipeline_fijo() -> subprocess.CompletedProcess:
# El string es una constante del desarrollador, no contiene datos externos
return subprocess.run(
"ps aux | grep python | awk '{print $2}'",
shell=True,
capture_output=True,
text=True,
)
# ─── Caso 5: pipeline sin shell usando Popen encadenados ─────────────────
def pipeline_seguro(patron: str) -> str:
# Alternativa explícita: encadenamos stdout → stdin manualmente
ps = subprocess.Popen(["ps", "aux"], stdout=subprocess.PIPE)
grep = subprocess.Popen(
["grep", patron], # patron es un argumento, no se interpreta como código
stdin=ps.stdout,
stdout=subprocess.PIPE,
)
ps.stdout.close() # permite a ps recibir SIGPIPE si grep termina antes
salida, _ = grep.communicate()
return salida.decode()
# ─── Caso 6: parsear un string de comando de forma segura ─────────────────
def ejecutar_comando_string(cmd_string: str) -> None:
# shlex.split entiende comillas y escapes; devuelve lista segura para subprocess
args = shlex.split(cmd_string)
subprocess.run(args, check=True)
if __name__ == "__main__":
# Simula recibir un string de configuración con espacios en argumentos
ejecutar_comando_string('echo "hola mundo desde shlex"')
resultado = pipeline_fijo()
print("PIDs de Python encontrados:")
print(resultado.stdout)
Lo que está pasando en cada decisión
buscar_peligroso es el antipatrón canónico. El f-string concatena texto del usuario directamente en el comando. La shell lo ve como instrucciones, no como datos. No hay ninguna barrera.
buscar_seguro invierte el modelo completamente: en lugar de un string, pasas una lista. subprocess llama a execvp() directamente con grep como ejecutable y nombre_archivo como argumento literal. No hay shell de por medio, así que no hay interpretación. Un atacante puede pasar "; rm -rf /" y lo único que pasará es que grep buscará ese string literalmente en el archivo — y fallará con un error de “archivo no encontrado”, que es exactamente el comportamiento correcto.
procesar_logs resuelve el caso de uso más común de shell=True: el globbing. glob.glob() hace la expansión en Python puro, devuelve una lista de rutas reales, y esa lista va directa a subprocess. El resultado es idéntico funcionalmente, pero sin shell.
pipeline_fijo muestra el único caso donde shell=True es razonable: un pipeline completamente hardcodeado, escrito por ti, sin ningún dato externo interpolado. Incluso aquí, fíjate que es una constante — si en el futuro alguien añade f"...{algo}", la vulnerabilidad aparece instantáneamente.
pipeline_seguro es la alternativa explícita con Popen. Más verbosa, lo admito. La línea ps.stdout.close() en el proceso padre es importante: si no la cierras, ps no recibe SIGPIPE cuando grep termina, y el pipeline puede quedarse colgado. Es el tipo de detalle que la shell maneja por ti automáticamente, y que tienes que gestionar tú cuando bajas al nivel de Popen.
shlex.split es la herramienta para cuando recibes un comando como string — por ejemplo, desde un archivo de configuración — y necesitas parsearlo antes de pasárselo a subprocess. Entiende comillas simples, dobles y escapes, igual que lo haría bash. La diferencia clave respecto a split(" "): shlex.split('echo "hola mundo"') devuelve ['echo', 'hola mundo'] (dos elementos), mientras que .split(" ") daría ['echo', '"hola', 'mundo"'] (tres, con las comillas incluidas en los strings).
Errores que debes conocer
Error: usar shell=True con check=True cuando el pipeline contiene varios comandos encadenados — solo el código de salida del último comando determina si check=True lanza excepción.
# ❌ Wrong
# Si 'grep' no encuentra nada (exit 1), check=True lanza CalledProcessError
# aunque 'ps aux' funcionó perfectamente
result = subprocess.run("ps aux | grep proceso_inexistente", shell=True, check=True)
# ✅ Right
# Captura la salida y evalúa tú mismo qué significa el código de retorno
result = subprocess.run(
"ps aux | grep proceso_inexistente",
shell=True,
capture_output=True,
text=True,
)
if result.returncode not in (0, 1): # grep: 0=encontró, 1=no encontró, 2+=error
raise RuntimeError(f"Fallo inesperado: {result.stderr}")
El código de salida de grep es 1 cuando simplemente no hay coincidencias — no es un error, es un resultado válido. check=True no sabe eso.
Error: asumir que escapar el input manualmente con shlex.quote() hace seguro usar shell=True.
# ❌ Wrong — frágil: dependes de que quote() cubra todos los casos en todos los OS
nombre = shlex.quote(user_input)
subprocess.run(f"cat {nombre}", shell=True)
# ✅ Right — elimina la shell completamente
subprocess.run(["cat", user_input], check=True)
shlex.quote() es útil para construir comandos que tú vas a ejecutar o mostrar, no como sustituto de la arquitectura correcta. Si puedes usar lista, usa lista.
N° 195