Seguridad para Meros Mortales. Arreglando TULF
SEGURIDAD
Seguridad para Meros Mortales. Arreglando TULF
2019-04-13
Por
Don Bit0

Los seguidores de esta serie conoceréis TULF. El infame programa lleno de agujeros de seguridad que utilizamos para hablar de exploits. Al final del artículo os preguntábamos como arreglar el problema... Aquí tenéis la respuesta.

Dejando a un lado los buffer overflows y centrándonos en el exploit de nuestro ejemplo, vamos a ver como parchear el programa TULF para eliminar el bug y por lo tanto hacer que el exploit que desarrollamos deje de funcionar.

Antecedentes

Antes de meternos en todo este fregado, vamos a refrescar la memoria. Este es nuestro programa vulnerable, el que tenemos que arreglar:

#include <stdio.h>
#include <string.h>

int main ()
{
  char cmd[512];
  char buffer[128];

  puts ("[TUFL] The Ultimate Folder Lister");
  puts ("Version 1.0");
  puts ("(c) RancioSA, 2018");
  puts ("Introduce el directorio que quieres listar:");
  flush (NULL);
  gets (buffer);
  sprintf (cmd, "ls %s", buffer);
  system (cmd);
}

Si recordáis, el bug que explotamos en el artículo anterior consiste en que el programa pasa la entrada de usuario directamente al comando system. Puesto que system lo que realmente hace es ejecutar /bin/sh -c lo_que_le_digamos, es fácil encadenar otros comandos utilizando el caracter ';'.

En concreto, nuestro exploit consistía en pasar como parámetro la cadena ; /bin/sh -i, la cual ejecuta una shell en modo interactivo (-i). Cuando el atacante introduce este texto, lo que finalmente ejecuta system es:

/bin/sh -c ls ;/bin/sh -i

Suficiente, recordatorio. Vamos a arreglar esto!

Parcheando

Hay distintas formas de abordar el problema que tenemos ente manos. Una opción podría ser el intentar sanear la entrada del usuario, eliminando caracteres como el ';' que puedan ser utilizados para inyectar comandos no deseados.

Otra opción es no utilizar system. El problema de system en nuestro ejemplo es que estamos ejecutando nuestro comando a través de un interprete de comandos.... En otras palabras... tenemos todo un lenguaje de programación a nuestra disposición accesible con cada llamada a system.

Así que una buena solución es, en lugar de hacer que una shell completa ejecute nuestro comando, ejecutar nuestro comando directamente. Esto es equivalente a escribir una versión personalizada de la función system... Y eso es lo que vamos a hacer.

Como funciona system

Para poder hacer nuestra propia versión de system, es conveniente saber como funciona esta función. Más que nada, porque lo que tendremos que hacer es algo bastante parecido.

Bien, system simplemente ejecuta un proceso. La forma estándar de ejecutar un proceso en UNIX es creando un nuevo proceso con la llamada al sistema fork y luego utilizar exec para sustituir el programa asociado al proceso creado con fork.

Esto es exactamente lo que hace system, solo que, en lugar de ejecutar el programa que nosotros queremos directamente, lanza una shell y le pasa nuestro programa como parámetro.

Llegados a este punto, estamos en condiciones de parchear nuestro programa vulnerable.

Arreglando TULF

Una posible forma de implementar todo lo que hemos comentado sería el siguiente código:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <errno.h>

int main ()
{
  char  buffer[128]; 
  pid_t pid;
  int   status;

  puts ("[TUFL] The Ultimate Folder Lister");
  puts ("Version 2.0");
  puts ("(c) RancioSA, 2018,2019");
  puts ("Introduce el directorio que quieres listar:");
  fflush (NULL);
  gets (buffer);
  
  // El código Nuevo empieza aquí
  if ((pid = fork ()) == -1)
    {
      fprintf (stderr, "Error creando proceso... :( Abortando\n");
      exit (EXIT_FAILURE);
    }
  else if (pid != 0) // Este es el proceso padre
    {
      // Básicamente tenemos que esperar a que la
      // criatura termine para que no se convierta en zombie
      pid_t c_pid;
      if ((c_pid = wait(&status)) < 0)
      {
          perror ("waitpid:");
          exit (EXIT_FAILURE);
      }
      printf ("El proceso terminó con  estado: %d\n", 
               WEXITSTATUS(status));
      exit (EXIT_SUCCESS);
    }
  else // Y este es el proceso hijo
    {
      if ( (execl ("/bin/ls", "ls", buffer, NULL)) < 0)
      {
          perror ("No puedo ejecutar ls...: ");
          return -1;
      }
    }
}

Como podemos ver, nuestro programa es ahora bastante más largo, pero parece que hemos eliminado la vulnerabilidad. Veamos:

$ ./tulf1
[TUFL] The Ultimate Folder Lister
Version 2.0
(c) RancioSA, 2018,2019
Introduce el directorio que quieres listar:
.
parche.md  parche.md~  tulf  tulf1  tulf1.c  tulf1.c~  tulf.c  tulf.c~
El proceso retorno estado: 0

$ ./tulf1
[TUFL] The Ultimate Folder Lister
Version 2.0
(c) RancioSA, 2018,2019
Introduce el directorio que quieres listar:
;xeyes
ls: cannot access ';xeyes': No such file or directory
El proceso retorno estado: 2

Pues parece que funciona. Genial!!!. Parece que este es un buen momento para comentar el código.

Comentando el Código

Obviando los nuevos ficheros de cabecera que hemos tenido que añadir para definir nuestras nuevas funciones, el primer cambio que encontramos en el código es este:

  puts ("Version 2.0");
  puts ("(c) RancioSA, 2018,2019");

Si. Es importante actualizar las versiones y las notas de copyright. La genete de RacioSA son bastante cutres programando pero saben lo que se hacen con los temas burocráticos.

Bromas a parte, la modificación de TULF comienza donde antes se encontraba la llamada a system. Lo primero que encontramos es el código que crea el proceso:

  if ((pid = fork ()) == -1)
    {
      fprintf (stderr, "Error creando proceso... :( Abortando\n");
      exit (EXIT_FAILURE);
    }
  else if (pid != 0) // Este es el proceso padre
    {
    }
  else // Y este es el proceso hijo
    {
    }

Esta es la típica estructura que nos encontramos al crear un proceso. Llamamos a fork y comprobamos si el proceso se a creado correctamente, es decir, que fork no ha devuelto -1.

A partir de este punto, el proceso padre y el proceso hijo están ejecutando el mismo código. Esto es, la instrucción justo después de fork. La diferencia es que el proceso padre recibe como salida de fork el PID (Process Identifier Identificador de Proceso) del proceso hijo, más que nada, porque en la mayoría de los casos tiene que hacer algunas cosas con él. Por su parte, el proceso hijo recibe un 0 como resultado de fork.

Usando esta información podemos hacer, por obra y gracia del bendito if que cada proceso ejecute código diferente.

Veámoslo. Empecemos por el hijo.

El proceso hijo

Nuestro proceso hijo no va a ser otra cosa que el programa ls ejecutado utilizando como parámetro lo que el usuario haya entrado... el contenido de buffer en este caso.

Para conseguir esto, el proceso debe utilizar la llamada al sistema exec, la cual sustituye el código del proceso actual, con el código de otro programa que se encuentra almacenado en el disco. En nuestro caso ls.

Algo como esto:

  else // Y este es el proceso hijo
    {
      if ( (execl ("/bin/ls", "ls", buffer, NULL)) < 0)
      {
          perror ("No puedo ejecutar ls : ");
          return -1;
      }
    }

Si bien, la llamada al sistema es exec, la librería C estándar nos proporciona diferentes funciones para llamarla. Cada función nos ofrece distintas opciones para pasar al proceso que queremos ejecutar la información que nos interese. Utilizad man execv para ver las opciones.

Nosotros hemos elegido execl, más que nada para ahorrarnos la declaración de una variable y hacer el código más corto. Esta función espera como primer parámetro el programa a ejecutar (observad que tenemos que utilizar el path completo), y a continuación la lista de parámetros que espera el programa, seguida del valor NULL.

Recordad que el primer parámetro que recibe cualquier programa es su nombre, de ahí lo de "ls", como primer parámetro.

La función execl puede fallar por distintas razones. En este caso concreto lo más probable es que hayamos escrito mal el path, o que el programa que queremos ejecutar se encuentre en una ubicación distinta a la que pensábamos. En ese caso, utilizamos perror para obtener más información sobre el error.

El programa Padre

Ostras, viendo el título de la sección me acaba de recorrer un escalofrío por toda la espalda... Programa Padre... Hacienda... brrr....

En serio, el programa padre en este caso solo tiene que esperar a que el hijo termine. No se trata de tener hijos y dejarlos por ahí a su suerte, sobre todo, porque cuando se trata del hijo de un proceso Unix, pasar de él suele convertirlo en un proceso zombie... Un proceso que todavía se ejecuta, pero cuya proceso padre ya a terminado.... y no tiene a nadie al que retornar el resultado de su operación... Bueno, pues eso, lo correcto es esperar a que el proceso hijo termine, antes de terminar el proceso padre.

Así es como lo hemos hecho

      int   status;
      (...)
      pid_t c_pid;
      if ((c_pid = wait(&status)) < 0)
      {
          perror ("waitpid:");
          exit (EXIT_FAILURE);
      }
      printf ("El proceso retorno estado: %d\n", WEXITSTATUS(status));
      exit (EXIT_SUCCESS);

Pues si, simplemente llamamos a la función del sistema wait para esperar a que cualquiera de nuestros procesos hijos termine. En este caso solo tenemos uno, y además uno que empieza y termina así que el padre no tiene realmente mucho que hacer.

Un padre más atento

La versión anterior del proceso padre es, digamos, lo mínimo que tenemos que hacer. Una versión más adecuada sería la siguiente:

  do
    {
      pid_t c_pid;
      if ((c_pid = waitpid (pid, &status, 0)) < 0)
        {
          perror ("waitpid:");
          exit (EXIT_FAILURE);
        }
    } while (!WIFEXITED(status) && !WIFSIGNALED(status));
    
      printf ("El proceso retorno estado: %d\n", WEXITSTATUS(status));
      exit (EXIT_SUCCESS);

En este caso, en lugar de usar wait utilizamos waitpid. La diferencia es que wait espera por cualquier proceso hijo, mientras que waitpid nos permite especificar el proceso por el que queremos esperar. Además nos da más control sobre como hacer esa espera a través del último parámetro (el cual no estamos utilizando en este caso).

Como podéis ver waitpid se ejecuta en un bucle que se repite hasta que el programa no termine. Lo que sucede es que la llamada al sistema waitpid va a retornar cada vez que el estado del proceso cambie. Esto puede ocurrir por que el programa ha terminado normalmente, o porque algún cambio en el proceso hijo haya generado una señal SIGCHLD hacia el padre.

Resumiendo, el bucle de arriba nos asegura que esperamos a que el proceso haya terminado y no que haya ocurrido alguna otra cosa (que alguien lo haya suspendido pulsando CTRL+Z o que el proceso este siendo depurado, por ejemplo).

Podéis encontrar una lista completa de los macros WIFXXXXX con su correspondiente explicación en la página del manual de wait (man wait).

CONCLUSIÓN

Pues hasta aquí esta nueva entrega de Seguridad para Meros Mortales. En este número hemos hecho una pequeña incursión en el concepto Programación Segura, tomando como ejemplo el arreglo el infame TULF. Hemos aprendido un poco más sobre los peligros de utilizar system y como sortearlos. Y también hemos vislumbrado las tortuosas relaciones familiares entre procesos.

Como siempre, esperamos vuestros comentarios, sugerencias o lo que sea. Podéis utilizar los comentarios abajo, las redes sociales o el formulario de contacto de este web... Por si alguno se preguntaba como.

Header Image Credits: Jia Ye

SOBRE Don Bit0
No os podemos contar mucho sobre Don Bit0. Es un tipo misterioso que de vez en cuando colabora con nosotros y luego, simplemente se desvanece. Como os podéis imaginar por su nick, Don Bit0, está cómodo en el bajo nivel, entre bits, y cerquita del HW que lo mantiene calentito.