«La simplicidad es la máxima sofisticación». Leonardo da Vinci
Las vulnerabilidades informáticas suelen originarse debido a la falta de sanitización de las entradas de los usuarios. En efecto, si un usuario malintencionado puede interactuar con el sistema de manera que le permite ejecutar acciones no autorizadas, es porque en algún punto del software, dicha acción debería haber sido bloqueada antes de su ejecución, y no lo fue.
Un sistema informático puede ser visualizado de forma metafórica como una red de cuerdas, similar a una tela de araña. Los usuarios remotos pueden agitar algunas de estas cuerdas que están expuestas, lo que puede hacer vibrar toda la estructura. Ciertas oscilaciones maliciosas pueden permitir la ejecución de acciones no autorizadas en un punto remoto de la red que no debería ser accesible.
El camino de los datos, o las oscilaciones de las cuerdas en la metáfora, puede ser extenso. Además, la búsqueda de la vulnerabilidad y su explotación para llegar a la ejecución remota de código suelen ser demandantes en tiempo de trabajo humano. Pero existe un caso en el que el camino es directo y el error está localizado en una única instrucción, la función «system». Esta función es vulnerable a la inyección de código más directa, conocida como inyección de código del sistema. Aunque probablemente no sea la vulnerabilidad más frecuente, es fácil de detectar de manera automatizada y directa de explotar.
Ejemplo concreto
«Un buen ejemplo vale más que un buen consejo». Albert Schweitzer
Código vulnerable
Por ejemplo, supondremos que este código PHP se encuentra accesible en un servidor de una empresa.
<?php system("find plugins/${_GET['pluginid']}"); ?>
Este código concatena el parámetro «pluginid» en una cadena de caracteres. Sin embargo, este parámetro proviene de una petición HTTP GET de origen externa, potencialmente maliciosa. Se supone que este parámetro corresponde a un nombre de directorio. Después, el código ejecuta la cadena de caracteres obtenida como un comando Shell y retorna la salida al usuario.
Explotación
Un usuario externo puede enviar un parámetro «pluginid» arbitrario. Para simular un ataque, se intentará imprimir la palabra «hacked» mediante código que se ejecuta en el servidor. Para este efecto, se puede asignar el valor «1; echo hacked» al parámetro «pluginid» para obtener un efecto similar al siguiente código PHP. Se debe notar que en un caso real, los actores de amenaza suelen abrir un Shell inverso para poder controlar el computador de sus víctimas más que únicamente imprimir «hacked».
<?php system("find plugins/1; echo hacked"); ?>
El código PHP precedente ejecutaría el comando Shell que sigue a continuación.
find plugins/1; echo hacked
El Shell de Linux considera el carácter punto y coma como el separador de comandos. En este caso, ejecutaría los dos comandos: «find plugins/1» y «echo hacked». El segundo comando es un comando enteramente controlado por un usuario remoto, lo que corresponde a una vulnerabilidad de ejecución remota de código.
Antipatrones de mitigación
En primera instancia, para mitigar esta vulnerabilidad, se podría considerar implementar una lista de caracteres no permitidos que contuviesen el carácter punto y coma. Pero un actor de amenaza podría también ocupar otros caracteres como el carácter de nueva línea o uno de estos caracteres «|&<>$`» u otras técnicas según su imaginación y conocimiento del lenguaje Shell. Por lo tanto, es recomendable implementar una lista de los caracteres permitidos en lugar de una lista de los caracteres denegados.
En segunda instancia, el comando «find» también acepta una potencial función de devolución de llamada como argumento. Un actor de amenaza podría ejecutar el siguiente comando en el servidor de la empresa, lo que también permitiría la ejecución del mismo código malicioso, este demostrativo que imprime «hacked» en la pantalla.
find plugins/1 -exec echo hacked \;
En ningún caso la vulnerabilidad anterior se debe mitigar con otra lista de palabras permitidas. Por ejemplo, aún bloqueada la cadena de caracteres «-exec», un actor de amenaza podría contornar esta restricción construyendo argumentos con técnicas avanzadas de expansión de parámetros de la sintaxis del Shell.
{find,plugins${PATH:0:1}1,$(rev<<<ce'x'e'-'),echo,hacked,${LS_COLORS:10:1}}
Se debe notar que el comando anterior no necesitó espacios ni tabulaciones para separar argumentos.
En última instancia, no se debe ocupar la función System en PHP, ya que puede aceptar como argumento un programa entero en Shell. Para crear un proceso, mejor no se debe pasar por el Shell como proxy, sino que se debe hacer directamente con las utilidades de lenguaje. En PHP por ejemplo, la función «proc_open» asegura la creación de un único proceso.
El mundo real
La vulnerabilidad descrita previamente permite tomar el control de un servidor mediante una simple petición HTTP GET. Por esta razón, probablemente no parezca realista sino únicamente educacional.
Sin embargo, los casos reales de tales vulnerabilidades abundan en Internet. Para comprobarlo, se puede realizar la siguiente búsqueda de código en Github "language:php system(" para encontrar códigos vulnerables en menos de cinco (5) minutos. Por razones éticas no se enumerarán aquí los resultados encontrados.
Ejemplo seguro
Se presenta a continuación una versión segura de código PHP para invocar otro proceso. Se evidencia la extensión, complejidad y consumo de tiempo asociados con la programación segura, lo que explica por qué muchos desarrolladores optan por la versión corta y sencilla, aunque insegura. Esto es especialmente relevante considerando la escasa recompensa que podrían esperar por este trabajo adicional que no aporta ninguna funcionalidad.
<?php
// Validar que existe la entrada del usuario
if (!isset($_GET['pluginid'])) {
exit("Error: No se proporcionó ningún ID de plugin.");
}
// Obtener el parámetro en una variable
$s_pluginid = $_GET['pluginid'];
// Asegurarse de que el parámetro pluginid solo contenga caracteres alfanuméricos
if (!preg_match('/^[a-zA-Z0-9]+$/', $s_pluginid)) {
exit("Error: ID de plugin no válido.");
}
// Preparar el comando separando el comando y cada argumento en diferentes celdas de la tabla
$a_command = ["find", "./plugins/$s_pluginid"];
// Preparar la tabla de descriptores estándares para poder recuperar la salida estándar
$a_descriptorspec = array(1 => array("pipe", "w"));
// Abrir el proceso, es decir ejecutarlo
$process = proc_open($a_command, $a_descriptorspec, $pipes);
// Verificar si el proceso se creó de forma correcta
if (!is_resource($process)) {
exit("Error: No se pudo ejecutar el proceso.");
}
// Leer y cerrar la salida estándar del programa
$s_stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
// Cerrar el proceso y obtener el valor de retorno
$i_return_value = proc_close($process);
// Verificar que el proceso se ejecutó de forma exitosa
if (0 != $i_return_value){
exit("Error: El proceso falló.");
}
// Sanitizar la salida para evitar una vulnerabilidad de tipo XSS almacenado mediante la creación de archivos con nombres maliciosos
$s_safe_stdout = htmlspecialchars($s_stdout, ENT_QUOTES, 'UTF-8');
// Mostrar el resultado
echo "<pre>$s_safe_stdout</pre>";
?>
Esta versión es más segura, por las siguientes razones.
- Asegura que el argumento no tenga caracteres especiales.
- Garantiza la ejecución de solo un comando.
- Retorna rápidamente en caso de error, sin filtrar ninguna información.
- Separa explícitamente los argumentos, y así evita una potencial inyección de argumentos.
La función System y el Shell
«Realmente deberías haber robado todo el libro porque las advertencias... las advertencias vienen después de los hechizos». (Película Doctor Strange, 2016)
Aunque es sumamente inseguro, la mayoría de los lenguajes de programación incluyen por practicidad la función «system» o su equivalente.
C
En C, la función «system» ejecuta un comando Shell como «sh -c "comando"» (ver man 3 system). El manual oficial estipula, en la parte Advertencia, que «cualquier entrada de usuario que se utilice como parte de un comando debe ser cuidadosamente sanitizada, para asegurar que comandos de Shell inesperados u opciones de comando no sean ejecutados».
PHP
En PHP, la función System es similar a la versión en C de la función, en el sentido de que ejecuta el comando dado y muestra el resultado (ver phpdoc system). El manual oficial también tiene la advertencia siguiente: «cuando se permite que los datos proporcionados por el usuario se pasen a esta función, utilice escapeshellarg() o escapeshellcmd() para asegurarse de que los usuarios no puedan engañar al sistema para ejecutar comandos arbitrarios».
Advertencia
Para empezar, aunque que la función System generalmente se utiliza para ejecutar un solo comando, no existe una garantía de que se ejecute únicamente un comando. Sería más preciso afirmar que recibe un código Shell en lugar de un simple comando.
Por otra parte, este comando puede ser compuesto, por ejemplo, puede ser un ciclo o la definición de una función.
Y, para acabar, la separación de parámetros tanto como las redirecciones pueden ser modificadas por el usuario externo; por ejemplo, incrustando espacio o expansiones de llaves en el contenido del parámetro. Esto podría llegar a un comportamiento no definido, como la inyección de comando mediante los parámetros de un comando que admite como argumento una función de devolución de llamada Shell (por ejemplo, git, rsync, find, dd; ver Anexo 3).
Sintaxis
La sintaxis del Shell es muy antigua, compleja, críptica y engañosa. Según el manual de Bash, el orden de las expansiones es el siguiente.
1 | Expansión de llaves | chown root /usr/{ucb/{ex,edit},lib/{ex?.?*,how_ex}} |
2 | Expansión de tilde | cd ~username/Documents |
3 | Expansión de parámetros y variables | echo ${BASH_SOURCE[0]} |
4 | Expansión aritmética y sustitución de comandos. | content=$( <filename></filename> |
5 | División de palabras | ls first_word "second word" multiple words |
6 | Expansión de ruta de acceso. | du -sh * |
Cabe notar que la lista anterior es tan difícil de entender como fundamental para el análisis sintáctico de un código Shell. Lo interesante de un punto de vista de ciberseguridad es que la división de palabra se ejecuta después de la expansión de parámetro. Entonces, si un parámetro (también llamado variable) no está entre comillas y su expansión (también llamado valor) contiene espacios, será interpretado como múltiples argumentos para el comando que ejecutará el Shell. Este estándar histórico subóptimo explica también porque los usuarios de Linux evitan poner espacios en los nombres de ruta (carpeta o archivo), por miedo a que sean interpretados por comandos como múltiples argumentos separados por estos espacios.
Recomendaciones
«Un viaje de mil leguas comienza con un solo paso». (Lao Tse)
Higiene de código
No ocupar la función System es solo un primer paso en la mejora continua de las buenas prácticas de desarrollos. A continuación, se presenta una lista de recomendaciones y sus justificaciones detalladas, ya que es fundamental proporcionar una comprensión clara de cómo una idea conduce a la siguiente.
Ocupar listas estrictas de los valores permitidos para toda las entradas. | Si el código espera datos con algún formato, es mejor validar al principio que los datos recibidos sean efectivamente de esta forma, para evitar sorpresas más adelante. |
Ocupar la versión segura de una función si existe. | Utilizar versiones seguras de funciones, como evitar «system», previene inyecciones de comandos al validar y sanitizar entradas. Además, facilita el mantenimiento del código, y establece mejores prácticas de programación segura. |
Escribir pruebas de regresiones agresivas sobre estas entradas. | El código va cambiando con el tiempo. Para asegurar que una funcionalidad que solía ser segura lo permanecerá con el tiempo, es recomendable tener una serie de pruebas automatizadas que verifique que el código hace bien lo que tiene que hacer y que no hace lo que no tiene que hacer. |
Tener un entorno de pruebas separado del entorno de producción. | Mantener el entorno de pruebas separado favorece el buen funcionamiento del entorno de producción y permite a los desarrolladores hacer pruebas potencialmente destructivas de forma segura. Además, se recomienda monitorear en una base de datos el histórico de los resultados de las pruebas y felicitar los desarrolladores de pruebas que detectaron errores para promover y concienciar sobre la importancia del desarrollo de pruebas. Las pruebas pueden ser 1. unitarias, 2. de regresión, 3. funcionales o 4. de penetración. |
Concienciar y alocar recursos exclusivos para la ciberseguridad. | La seguridad es un asunto que concierne a todas y todos. Sin embargo, su naturaleza descentralizada no debe conducir a la desresponsabilización del tipo «la seguridad es asunto de los demás». Dado que las personas responden a los incentivos, es crucial destacar y retribuir los esfuerzos en ciberdefensa. Cabe recordar que la ciberdefensa es una profesión, no un pasatiempo. Organizar el ejercicio de redactar en grupo listas de recomendaciones similares a la presente puede ser una de las mejores iniciativas para caminar hacia el reforzamiento de un sistema. |
Hacer mutuo el código de verificación. | La reutilización de código también disminuye el tamaño del código y de las pruebas unitarias por escribir. |
Hacer cumplir el uso de un método único para validar todas las entradas. | Ya que todas las entradas tienen que ser verificadas, y que debería existir un código de verificación, es recomendable garantizar el uso de este código. Eso disminuye el tamaño del código crítico y, por ende, la superficie de exposición. |
Agregar un prefijo a todo los nombres de ruta, si es relativa, agregar «./» al principio. | Este patrón de desarrollo defensivo permite evitar que un usuario tenga control sobre el principio de un argumento y que un comando lo interprete como una opción si empieza con un guion. |
Diseño seguro
Cuando un lenguaje de bajo nivel realiza una llamada de tan alto nivel como hacia el Shell, es un indicio de un diseño deficiente. Por ejemplo, se suele recurrir a esto para propósitos que no son buenas prácticas, como tener un efecto secundario (también llamando «de borde»), verificar algo con el código de estado o recuperar rápidamente y suciamente un texto desde la salida estándar. Si no se puede evitar el uso de otro proceso, es recomendable realizar directamente la llamada a este otro proceso, sin pasar por el Shell. Esta llamada se puede implementar mediante la creación de un proceso de manera nativa o mediante un protocolo de comunicación entre procesos con una interfaz explícita y probada.
Referencias
A continuación, se presentan anexos que se pueden utilizar como referencia y los enlaces de la documentación relevantes para este artículo.
Anexo 1: Lista de alternativas más seguras por lenguaje
Los lenguajes de programación incorporan funciones más seguras para interactuar con el sistema operativo. Estas alternativas seguras si bien no previenen por completo las inyecciones de código, al menos garantizan la ejecución de un único comando con argumentos explícitamente separados. Además de la seguridad, la separación de los argumentos y la realización de la llamada en el lenguaje nativo pueden mejorar significativamente la claridad, portabilidad y mantenibilidad del código.
PHP | system("find plugins/") | proc_open |
Python | os.system("find plugins/") | subprocess.run |
Java | Runtime.getRuntime().exec("find plugins/") | ProcessBuilder |
C | status = system("find plugins/") | execvpe |
Anexo 2: Caracteres peligrosos del Shell
El hecho de tolerar los caracteres siguientes en una parte de una cadena de caracteres que se ejecuta como un programa Shell puede ser explotado directamente por un usuario remoto para poder ejecutar código arbitrario.
; | find plugins/1;echo hacked | Operador de control de separación de comandos |
newline | find plugins/1 | Operador de control de separación de comandos |
| | find plugins/1|echo hacked | Operador tubo. También presente en || y |& |
& | find plugins/1&echo hacked | Operador control de trabajo |
$ | find plugins/1$(echo hacked) | Substitución de comando en Bash |
` | find plugins/1`echo hacked` | Substitución de comando en los Shell Posix |
space | find plugins/1 -exec echo hacked \; | Separación de palabras |
Anexo 3: Comandos que admiten parámetros inseguros
Los siguientes comandos aceptan parámetros que no deberían ser accesibles por un usuario externo, ya que pueden llegar a la inyección de comandos sistema. Aquí los parámetros que contienen la palabra «legit» son considerados legítimos o, en caso de explotación maliciosa, se ocupan para que el comando funcione correctamente.
find | find ./legit/ -exec cat /etc/passwd \; | Find es una utilidad para listar rutas. Puede recibir como parámetro un comando Shell que va a correr para cada ruta encontrada. |
dd | dd if=legit1.txt of=legit2.txt if=/etc/passwd of=/dev/stdout | Dd es una utilidad para copiar un archivo. En caso de recibir múltiples argumentos, el ultimo será considerado. |
perl | perl -e 'open my $fh, "<", "/etc/passwd"; print while <$fh>;' legit.pl | Perl es un lenguaje de programación interpretado. Puede recibir código en parámetro. |
git | git rebase --exec "cat /etc/passwd" | Git es un programa de control de versión. Algunos de sus sub-comandos pueden recibir callback Shell. |
rsync | rsync -av ./legit1/ user@localhost:./legit2/ --rsync-path='cat /etc/passwd > /dev/stderr #' | Rsync es una herramienta para copiar archivos. Puede recibir comandos Shell en argumento y los va a correr en la máquina de destino. |
sed | sed 's/legit1/legit2/g;$r /etc/passwd' | Sed es un editor de flujo. Se utiliza para realizar transformaciones básicas de texto. Las operaciones pueden ser tan complejas como las de un lenguaje de programación, aunque su sintaxis sea muy críptica. |
test | test -v 'x[$(cat /etc/passwd)]' | Test es una utilidad de línea de comandos para verificar tipos de archivos, variables y comparar valores. Su bandera «-v» verifica si una variable existe y eso puede incluir elementos de listas cuyos índices serán interpretados como un expresión por el Shell. |
printf | printf '-va[$(cat /etc/passwd >&2)]' x | Printf es una utilidad de línea de comandos para formatear e imprimir datos. En Bash su bandera «-v» permite asignar una variable. En el caso de que la variable sea una lista, su índice se evalúa como una expresión, y puede incluir comandos con la sintaxis de la substitución de comandos. |
tar | tar -xf legit.tar --to-command='cat /etc/passwd' | Tar es un programa para archivar archivos. Puede recibir un comando como parámetro para ejecutar y abrir un tubo entre su salida y este comando. |
scp | scp -oProxyCommand='; cat /etc/passwd >&2' ./legit1 user@localhost:legit2 | Scp es una utilidad para copiar archivos de forma segura. Acepta un parámetro que puede especificar el comando a utilizar para conectarse al servidor conformemente a la configuración de OpenSSH. |
openssl | openssl enc -aes-256-cbc -in legit.txt -out legit2.txt -in /etc/passwd -out /dev/stdout -pass pass:mysecretpassword | OpenSSL es un conjunto de herramientas de criptografía. Puede recibir nombres de archivo y claves como argumentos. |
docker | docker run -v /etc/passwd:/tmp/passwd legit-image cat /tmp/passwd | Docker es un comando para tener una interfase con los contenedores Dockers. Su sub-comando «run» puede correr comandos Shell en el contenedor. Como el comando «docker» puede recibir argumentos adicionales especificando qué volúmenes montar y cuáles puertos abrir, la inyección de argumentos puede llegar a tener impacto incluso en el huésped. |
Enlaces para consultar
- man 3 system: https://man7.org/linux/man-pages/man3/system.3.html
- man 1 bash: https://man7.org/linux/man-pages/man1/bash.1.html
- man 1 sh: https://man7.org/linux/man-pages/man1/dash.1.html
- phpdoc proc_open: https://www.php.net/manual/en/function.proc-open.php
- phpdoc system: https://www.php.net/manual/en/function.system.php