La solución fiable contra la inyección de comandos del sistema operativo es dejar de pasar al shell cadenas influenciadas por el usuario. Mejorar el escape es la opción más débil. El trabajo práctico consiste en localizar todos los puntos de su código que lanzan un proceso y decidir, en cada uno, si realmente tiene que existir.
La inyección de comandos se produce cuando una aplicación pasa datos de usuario no seguros a un shell del sistema, y los comandos inyectados suelen ejecutarse con los privilegios de la aplicación. CWE-78 añade un detalle incómodo: como es la aplicación quien ejecuta los comandos, la actividad puede parecer originada por la propia aplicación o por su propietario.
Por qué el escape es la respuesta equivocada como primer paso
La función escapeshellarg() de PHP envuelve un valor entre comillas simples para que el shell lo trate como un único argumento. Eso impide que & echo "hello" se ejecute como un segundo comando. Lo que no impide es que el atacante elija cuál es ese único argumento que recibe el programa.
Toda inyección de comandos del sistema operativo es también una inyección de argumentos. En la hoja de referencia de OWASP, escape("--help") pasado a curl sigue mostrando el texto de ayuda. Según el programa, el impacto va desde la divulgación de información hasta la ejecución remota de código.
Una nota de un lector en la página del manual de PHP sobre escapeshellarg() muestra el mismo fallo con tar. Cada nombre de fichero está correctamente escapado, pero un valor que empiece por un guion se sigue interpretando como una opción, y tar tiene una opción que ejecuta otro comando. Añadir -- antes de los nombres de fichero lo soluciona, pero la nota señala que no todos los programas admiten ese separador.
En Windows, el manual indica que escapeshellarg() usa comillas dobles y sustituye por espacios los signos de porcentaje, los signos de exclamación y las comillas dobles. Una nota de un lector añade que el resultado es seguro pero con pérdida de información. En cualquier caso, es una razón para evitar el shell, no una solución.
Encuentre las llamadas
Ejecute estas búsquedas en todos los repositorios. Ajuste los tipos de fichero a la estructura de su proyecto:
rg -n 'shell=True|os\.system|os\.popen' --type py
rg -n "subprocess\.(run|Popen|call|check_output)\(\s*[f\"']" --type py
rg -n '\b(system|exec|shell_exec|passthru|popen|proc_open)\s*\(' --type php
rg -n '`[^`]*\$' --type php
rg -n 'Runtime\.getRuntime\(\)\.exec|ProcessBuilder' --type java
rg -n 'ProcessStartInfo|Process\.Start|UseShellExecute' --type cs
rg -n '\bsystem\s*\(|popen\s*\(|exec[lv]p?e?\s*\(' --type c
El segundo patrón de Python detecta una única cadena pasada como comando. Sin shell=True, eso solo funciona cuando la cadena contiene únicamente el nombre de un programa, de modo que cada coincidencia exige una revisión manual. El patrón de las comillas invertidas de PHP genera mucho ruido, porque también coincide con comillas invertidas en comentarios y en SQL. Busque también en scripts de shell, scripts de despliegue y envoltorios de cron, que construyen comandos a partir de variables con la misma frecuencia que el código de la aplicación.
| Lenguaje | Qué buscar | Con qué sustituirlo |
|---|---|---|
| PHP | system(), exec(), shell_exec(), passthru(), popen(), proc_open(), el operador de comillas invertidas | Funciones integradas como mkdir() |
| Python | shell=True; os.system; os.popen; una única cadena de comando | subprocess.run con una lista |
| Java | Runtime.exec(String) | ProcessBuilder con el programa y cada argumento como elementos separados |
| .NET | cmd.exe, powershell.exe, sh -c, una cadena Arguments construida | ProcessStartInfo con ArgumentList |
| C | system() | Una llamada a biblioteca |
Incluya también los comandos fijos en el código. El artículo de la comunidad OWASP describe un programa CGI con privilegios que ejecuta cd /var/yp && make sin ninguna entrada de usuario en la cadena. No usaba una ruta absoluta para make, así que un atacante podía modificar $PATH y lograr que se ejecutara su propio binario con los privilegios del programa.
Clasifique las coincidencias
Una lista bruta de coincidencias no es una lista de hallazgos. Para cada una, siga el valor hacia atrás y pregúntese si una petición puede llegar hasta él. Los parámetros de ruta y de consulta, los campos de formulario, las cookies y las cabeceras HTTP son los orígenes evidentes. Los nombres de los ficheros subidos, los valores de una base de datos en la que los usuarios pueden escribir, los mensajes de cola y las variables de entorno son los que la gente olvida.
Reparta las coincidencias en tres grupos: alcanzables desde una petición no autenticada, alcanzables solo tras iniciar sesión y no alcanzables en absoluto. Corrija primero el primer grupo. Mantenga el tercero en la lista, porque una refactorización posterior puede conectarlo con una petición sin que nadie lo advierta.
Sustituya la llamada por una API
Las funciones integradas de las bibliotecas no pueden manipularse para que hagan algo distinto de su tarea prevista. Use mkdir() en lugar de system("mkdir /dir_name"). Use la API de correo de Java en javax.mail.* en lugar de invocar mail a través del shell. CWE-78 da el mismo consejo para el diseño: usar llamadas a bibliotecas en lugar de procesos externos.
Cuando no hay más remedio que lanzar un proceso
Algunas herramientas no tienen un equivalente en forma de biblioteca. En ese caso, use dos capas a la vez: una lista de argumentos sin shell y validación.
La documentación de subprocess de Python indica que, por lo general, es preferible una secuencia de argumentos, porque el módulo se encarga del escape y del entrecomillado. Incluye una nota que recomienda leer la sección de consideraciones de seguridad antes de usar shell=True. También recomienda una ruta completa para el ejecutable, lo que elimina la búsqueda en PATH que explota el ejemplo de make anterior.
import re, subprocess
if not re.fullmatch(r"[a-z0-9]{3,10}", name):
raise ValueError("rejected")
subprocess.run(["/usr/bin/tar", "cf", "out.tar", "--", name], check=True)
El patrón ya rechaza un guion inicial, por lo que -- parece redundante. Es defensa en profundidad: si alguien relaja la expresión regular más adelante, el separador sigue impidiendo que name se interprete como una opción.
En .NET, use una ruta fija y completa en FileName, establezca UseShellExecute = false y añada cada argumento a través de ArgumentList. No pase nunca entradas no confiables a cmd.exe, powershell.exe ni sh -c. Separar los argumentos no equivale a validar, ni en Java ni en .NET.
Después, valide los valores. Compruebe el comando frente a una lista de comandos permitidos y compruebe cada argumento frente a valores permitidos o a un patrón con una longitud máxima, sin metacaracteres ni espacios en blanco.
En el caso de curl, -- no basta, porque los argumentos posteriores siguen siendo operandos de URL y son posibles transferencias adicionales. Pase exactamente una URL validada, use --globoff y aplique protecciones contra SSRF.
Ejecute la aplicación con los privilegios mínimos que necesite y asigne a las tareas de un solo propósito su propia cuenta aislada. Un sandbox como chroot, AppArmor o SELinux ayuda, pero CWE-78 valora su eficacia como limitada. Reduce lo que un atacante puede alcanzar, pero no elimina el fallo.
Por dónde empezar
- Ejecute las búsquedas anteriores en todos los repositorios, incluidos los scripts de despliegue, los trabajos de CI y los envoltorios de cron. Anote cada coincidencia junto con la entrada que llega hasta ella y ordene la lista según si una petición puede alcanzarla.
- Para cada coincidencia, pregúntese si una biblioteca o una función integrada hace el mismo trabajo. Si es así, sustituya la llamada y elimine el lanzamiento del proceso.
- Reescriba las llamadas restantes para que usen una lista de argumentos, sin shell y con una ruta absoluta al ejecutable. Añada validación mediante lista de permitidos y el separador
--cuando el programa lo admita. - Ejecute los procesos con una cuenta dedicada y de bajos privilegios, para que un caso que se haya pasado por alto tenga menos margen de explotación.
- Pruebe cada llamada restante con un valor como
bob.txt;sleep 5y con un valor que empiece por un guion. Mida el tiempo de respuesta en lugar de esperar una salida. El valor con guion inicial debe rechazarse o tratarse como un nombre de fichero. Para una comprobación externa, una prueba de penetración probará ambos casos, o nuestro equipo de desarrollo seguro de software puede revisar el código con usted.
