06 abril 2012

Troyanos en Linux (0.- Introducción)

Cuenta la Iliada que cuando los griegos se cansaron de asediar, sin resultado alguno, a la ciudad de Troya simularon que se marchaban dejando atrás un gigantesco caballo de madera como ofrenda al dios del mar. Los troyanos, felices del fin del asedio, sacaron de la playa al caballo de madera y lo llevaron a la plaza central de la ciudad a pesar de las advertencias de la sacedotisa Casandra quien, con lágrimas en los ojos, les advertía inútilmente de que aquel caballo traería la ruina de la ciudad. Tras la fiesta, los troyanos fueron cayendo fruto de la ebriedad y cuando por fin se hizo el silencio una portezuela se abrió en el caballo y un grupo de griegos surgió de ella. Esos griegos consiguieron llegar hasta las puertas de la ciudad y abrírsela al resto del ejército griego, el cual había había hecho retroceder a sus barcos a lo largo del día. Los resacosos y somnolientos troyanos poco pudieron hacer para repeler a los griegos y la ciudad que había aguantado diez años de ataques griegos se consumió en una sola noche.

Desde entonces se denomina "caballo de troya" o más sencillamente "troyano" a cualquier elemento que tras una apariencia inofensiva oculta un propósito diferente. En informática, un troyano podría tener la forma de un juego con la forma de, por ejemplo, buscaminas que mientras mantiene entretenido al usuario le roba información confidencial de su ordenador y la envía a un destino remoto. Es difícil defenderse de un troyano ya que, al fin y al cabo, es el mismo usuario el que decide ejecutarlo por lo que implícitamente le da al troyano los permisos que él mismo tiene. La única manera de detectar un troyano es monitorizar el programa sospechoso y ver si su comportamiento se aparta de lo que se supone normal. Obviamente esto resulta muy complicado y prácticamente imposible de aplicar a la totalidad de los programas, razón por la cual la regla número uno para evitar troyanos es usar sólo programas cuyo origen esté perfectamente claro.

Aunque la anterior es la definición clásica de troyano, también se puede entender por troyano a cualquier programa que se ejecuta de manera oculta al usuario haciéndose pasar ante el sistema como un servicio legítimo pero abusando en realidad de él.

Un rootkit es un conjunto de programas troyanos que un atacante instala en un equipo para asegurarse que en el futuro pueda seguir accediendo a él y ejecutar aplicaciones de manera inadvertida para el legítimo usuario del equipo. Lo normal es que un rootkit incluya programas que le permitan, al menos, lo siguiente:

  • Ocultar procesos en ejecución.
  • Ocultar conexiones entrantes y salientes de red.
  • Ocultar ficheros y directorios.
  • Falsificar la lista de usuarios conectados.
  • Borrado del historial de logs de aquellos que hagan referencia a las actividades del intruso.
  • Capturar las entradas de teclado que se produzcan en el teclado y grabarlas en un fichero oculto o bien enviarlas a un destino remoto.
  • Ofrecer al intruso un acceso oculto para que pueda volver a acceder al sistema.
Para conseguir todo esto los rootkits han utilizado tradicionalmente tres métodos principales:
  • Sustitución de los binarios ejecutables del sistema.
  • Suplantación de las librerías compartidas del sistema.
  • Cargar módulos de kernel ocultos.
La sustitución de los binarios ejecutables del sistema fue uno de los primeros métodos utilizados. Consiste en crear versiones modificadas de las herramientas utilizadas por los administradores del sistema. Para crear esta versión modificada el programador del rootkit debe obtener el código fuente de la herramienta, hecho lo cual debe modificarlo para que la herramienta modifique la información que le presente al usuario o realice las tareas ocultas deseadas por el programador del rootkit. Tras modificar el código fuente, el programa debe ser compilado y el ejecutable resultante debe ser utilizado para sustituir al original del sistema. Esto supone que el atacante ya tiene acceso al sistema (bien físico o bien remoto) con permisos suficientes para realizar la mencionada sustitución. No hay que olvidar que, por lo general, es más fácil obtener acceso a un sistema que mantener ese acceso a lo largo del tiempo sin que los administradores lo adviertan así que, una de las primeras cosas que hará un intruso medianamente solvente al entrar a un sistema será instalar un buen rootkit. Supongamos, por ejemplo, que el sistema comprometido es un Linux (concretamente un Ubuntu) y que el intruso quiere ocultar procesos utilizando este método, en ese caso uno de los primeros binarios que intentará sustituir será el comando ps. Lo primero que seguramente hará el atacante es simular la máquina atacada creando una máquina virtual con sus mismas características de tal manera que pueda experimentar con ella sin riesgo alguno. Hecho esto, averiguará de qué paquete concreto proviene el programa ps, en Ubuntu es muy fácil utilizando el comando dpkg-query:

dante@Winterfell:~$ dpkg-query -S /bin/ps procps: /bin/ps dante@Winterfell:~$


Como se puede ver, el comando ps proviene del paquete procps, el cual contiene no sólo el binarios del comando ps sino el de muchos otros como el de top (por ejemplo):

dante@Winterfell:~$ dpkg-query -L procps [...] /bin/kill /bin/ps [...] /sbin/sysctl [...] /usr/bin/free /usr/bin/pgrep /usr/bin/pmap /usr/bin/pwdx /usr/bin/skill /usr/bin/slabtop /usr/bin/tload /usr/bin/top /usr/bin/uptime /usr/bin/vmstat /usr/bin/w.procps /usr/bin/watch [... ] /usr/bin/pkill /usr/bin/snice dante@Winterfell:~$


Ahora el atacante necesitará obtener el código fuente de los comandos contenidos en el paquete procps. Obtener el código fuente de las aplicaciones puede ser complicado en Windows, pero en Linux no puede ser más sencillo dada la naturaleza "open source" del sistema. Basta con una llamada al comando apt-get desde el directorio donde queremos que se guarde el código fuente:

dante@Winterfell:~$ mkdir temp dante@Winterfell:~$ cd temp dante@Winterfell:~/temp$ apt-get source procps Leyendo lista de paquetes... Hecho Creando árbol de dependencias     Leyendo la información de estado... Hecho NOTA: el empaquetamiento de «procps» se mantiene en el sistema de control de versiones «Git» en: git://git.debian.org/collab-maint/procps.git Necesito descargar 393 kB de archivos fuente. Des:1 http://es.archive.ubuntu.com/ubuntu/ natty/main procps 1:3.2.8-10ubuntu3 (dsc) [1928 B] Des:2 http://es.archive.ubuntu.com/ubuntu/ natty/main procps 1:3.2.8-10ubuntu3 (tar) [286 kB] Des:3 http://es.archive.ubuntu.com/ubuntu/ natty/main procps 1:3.2.8-10ubuntu3 (diff) [105 kB] Descargados 393 kB en 1seg. (299 kB/s) gpgv: Firmado el jue 07 abr 2011 17:25:35 CEST usando clave RSA ID A744BE93 gpgv: Imposible comprobar la firma: Clave pública no encontrada dpkg-source: aviso: se ha detectado un fallo al verificar la firma de ./procps_3.2.8-10ubuntu3.dsc dpkg-source: información: extrayendo procps en procps-3.2.8 dpkg-source: información: desempaquetando procps_3.2.8.orig.tar.gz dpkg-source: información: desempaquetando procps_3.2.8-10ubuntu3.debian.tar.gz dpkg-source: información: aplicando «slabtop_once.patch» dpkg-source: información: aplicando «free.1.patch» [...] dante@Winterfell:~/temp$ ls procps-3.2.8                          procps_3.2.8-10ubuntu3.dsc procps_3.2.8-10ubuntu3.debian.tar.gz  procps_3.2.8.orig.tar.gz 
dante@Winterfell:~/temp$ cd procps-3.2.8/ dante@Winterfell:~/temp/procps-3.2.8$ ls AUTHORS      free.1     pgrep.c      ps          slabtop.1      t        uptime.1  watch.c BUGS         free.c     pkill.1      pwdx.1      slabtop.c      tload.1  uptime.c  w.c CodingStyle  kill.1     pmap.1       pwdx.c      snice.1        tload.c  v COPYING      Makefile   pmap.c       README      sysctl.8       TODO     vmstat.8 COPYING.LIB  minimal.c  proc         README.top  sysctl.c       top.1    vmstat.c debian       NEWS       procps.lsm   skill.1     sysctl.conf    top.c    w.1 dummy.c      pgrep.1    procps.spec  skill.c     sysctl.conf.5  top.h    watch.1 dante@Winterfell:~/temp/procps-3.2.8$


Como se puede ver, el comando apt-get a creado un subdirectorio con una serie de archivos que corresponden a los códigos fuentes de los comandos, los makefiles necesarios para su compilación y algunos ficheros de documentación. Con esto y, por supuesto, sabiendo programar en C o C++ el atacante tiene todo lo necesario para modificar el código de ps para que el binario resultante oculte los procesos que ejecute el atacante en la máquina víctima. Una vez que haya compilado el código fuente modificado en un binario ejecutable lo único que quedará es sustituir el original de la máquina atacada con este sin que el legítimo dueño del sistema se de cuenta.

El problema de este método es que no siempre hay un único comando para hacer cada cosa. Por ejemplo, en el caso de la ocultación de procesos el administrador puede usar el comando ps, top, o alguna aplicación gráfica. El atacante debería averiguar con cuantas aplicaciones cuenta el administrador para listar los procesos en ejecución en su máquina y modificarlas todas ellas para asegurarse de que arrojan resultados igualmente falseados. Obviamente, esto puede resultar un trabajo muy duro, razón por la que este método sólo se usa en sistemas tremendamente sencillos y puntuales.

El siguiente método es el de suplantación de las librerías compartidas del sistema. Se basa en que muchas veces la función que queremos modificar no reside en el binario mismo del comando sino en una librería compartida por varias aplicaciones del sistema. La ventaja de este método es que sustituyendo esta librería por una modificada se alterará al mismo tiempo el comportamiento de todas las aplicaciones que hacen uso de ella. Para saber las librerías que utiliza un comando podemos utilizar el comando strace. Este comando sirve para depurar el comportamiento de un binario, nos permite llamarlo y ver qué librerías y llamadas del sistema va utilizando. Si lo utilizamos con ps veremos que arroja la siguiente información:

dante@Winterfell:~$ strace ps execve("/bin/ps", ["ps"], [/* 42 vars */]) = 0 brk(0)                                  = 0x8eaf000 access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory) mmap2(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb77ed000 access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory) open("/etc/ld.so.cache", O_RDONLY)      = 3 fstat64(3, {st_mode=S_IFREG|0644, st_size=57336, ...}) = 0 mmap2(NULL, 57336, PROT_READ, MAP_PRIVATE, 3, 0) = 0xb77df000 close(3)     access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory) open("/lib/libproc-3.2.8.so", O_RDONLY) = 3 read(3, "\177ELF\1\1\1\0\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0\240$\0\0004\0\0\0"..., 512) = 512 fstat64(3, {st_mode=S_IFREG|0644, st_size=59108, ...}) = 0 mmap2(NULL, 136600, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0xaf9000 mmap2(0xb07000, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0xd) = 0xb07000 mmap2(0xb09000, 71064, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xb09000 close(3)     [...] open("/proc/version", O_RDONLY)         = 3 fstat64(3, {st_mode=S_IFREG|0444, st_size=0, ...}) = 0 mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb77ec000 read(3, "Linux version 2.6.38-11-generic "..., 1024) = 147 close(3)                                = 0 munmap(0xb77ec000, 4096)                = 0 open("/proc/stat", O_RDONLY|O_CLOEXEC)  = 3 read(3, "cpu  441030 2045117 417556 42613"..., 8192) = 806 close(3)                   [...]


Como se puede ver en la captura anterior, cuando llamamos a ps en realidad se ejecuta un execve de /bin/ps, esto es muy útil para asegurarnos de que el comando que se ejecuta es el de la ruta correcta. Vemos a continuación la serie de llamadas al sistema de las que se compone este comando, entre las que destaca para nuestros fines la llamada a open de /lib/libproc-3.2.8.so que es precisamente una librería compartida. En Linux, las librerías compartidas son de 2 tipos:

  • Librerías enlazadas en tiempo de compilación: Son los ficheros .o de C/C++ generados por defecto por el compilador (si lo ejecutamos sin opciones) y que el linkador enlaza en tiempo de compilación para generar los ejecutables.
  • Librerías dinámicas: Tienen extensión .so y se enlazan en tiempo de ejecución. Esto tiene como ventajas que al no ser incluidas en el ejecutable en tiempo de compilación este (el ejecutable) ve reducido su tamaño final y, por otro lado, permite que múltiples ejecutables compartan las mismas funciones y que estas puedan ser actualizadas sustituyendo simplemente la librería en vez de obligando a una recompilación de todos y cada uno de los ejecutables.
Las librerías dinámicas son los objetivos naturales del ataque que vamos a explicar. Básicamente se trata de engañar al ejecutable para que utilice una librería modificada por el atacante en vez de la librería original del sistema. En esta librería modificada el atacante habrá alterado las funciones utilizadas por el ejecutable para hacer que este se comporte de la manera deseada. La manera de obtener el código fuente de la librería para modificarlo es exactamente la misma que la que vimos en el apartado anterior para obtener el código fuente de los binarios. Aunque existen varias maneras de realizar la sustitución de la librería original:
  • Sobreescritura: es decir, sustitución directa de la librería original. Es la opción más sencilla pero también la que tenga menos tiempo de vida ya que los ficheros de librerías suelen ser actualizados con cierta frecuencia por lo que nos arriesgamos a perder nuestra librería modificada en cuanto el sistema se baje la siguiente actualización del original.
  • Desvío de la llamada: Generalmente los equipos de programación que mantienen los ejecutables y los que mantienen las librerías compartidas son distintos, eso quiere decir que el equipo que desarrolla la librería puede sacar nuevas versiones sin que lo sepa el equipo que mantiene el ejecutable que queremos atacar. Es por ello que cuando se compilan los ejecutables y se enlazan con una librería dinámica no se usa un número de versión concreto de la librería para hacerlo ya que se correría el riesgo de tener que recompilar en caso de que se actualizase la librería y cambiase el número de versión. Usando como ejemplo el programa ps, este en realidad está compilado enlazando con la librería libproc.so, sin embargo vemos que en ejecución llama a una versión concreta (libproc-3.2.8.so) ¿cómo puede ser?. Si hacemos un "ls -l" en /usr/lib (donde se encuentran gran parte de estas librerías) veremos que hay muchos enlaces llamados como librerías de versión genérica pero que en realidad apuntan a una versión concreta. Son los programadores de las librerías los encargados de preparar los instaladores de las librerías para mantener actualizados estos enlaces. Un posible ataque sería alterar estos enlaces para que en vez de apuntar a la librería original lo hiciesen a la modificada. La pega es que siempre existiría la posibilidad de que el administrador actualizase la librería lo que haría que el correspondiente instalador corrigiese los enlaces modificados.
  • Variables de entorno: La variable de entorno LD_LIBRARY_PATH contiene los directorios en los que el sistema buscará las librerías antes de hacerlo en los directorios habituales. Su uso habitual se da durante el desarrollo y pruebas de nuevas librerías, para forzar al sistema a utilizar las librerías en desarrollo en vez de las originales. Sin embargo, esta variable de entorno también puede ser utilizada por un atacante si el administrador del sistema ignora su existencia.
El último método pero a la vez el más poderoso es la carga de módulos de kernel ocultos. Los módulos de kernel se ejecutan en el nivel más bajo de abstracción y en el de máximo privilegio. En un sistema Linux se distinguen dos planos de ejecución:
  • El espacio de usuario: Es donde se ejecutan las aplicaciones habituales como el navegador, el procesador de texto, etc, es decir la mayor parte de las aplicaciones. Estos programas se basan como hemos visto en las llamadas a determinadas librerías de sistemas, las cuales ejecutan llamadas al sistema cuando quieren acceder a recursos del mismo. Las llamadas a sistema son funciones de bajo nivel ofrecidas por el kernel mismo y son el único medio de acceder a los recursos del sistema.
  • El espacio de kernel: Es donde se ejecuta el kernel o núcleo, tiene acceso directo a todos los recursos del sistema razón por la cual es el nivel más privilegiado. Su función principal es regular el acceso a dichos sistemas desde el espacio de usuario a través de las llamadas de sistema. Inicialmente, la instalación de un nuevo recurso de sistema (como por ejemplo una impresora) obligaba a recompilar el núcleo pero pronto se optó por dotarle de la capacidad de cargar módulos especializados en hacer de intermediarios con cada elemento de hardware concreto.
La programación de módulos tiene una serie de particularidades que hay que tener en cuenta antes de lanzarse a ella. Para empezar, los módulos sólo pueden programarse en C (nada de C++). Esto es así porque el núcleo mismo está programado en C y es incompatible con las estructuras, datos y funciones de C++. Existen en la web discusiones interminables acerca de si el núcleo de Linux debería modificarse para admitir C++ pero hasta el mismo Linus Torvalds se opone alegando que las pruebas de rendimiento que se hicieron en su momento arrojaron resultados muy pobres y que además usar C++ iría (a la larga) en contra del principio de mantener el núcleo lo más simple posible. Ese deseo de mantener el núcleo lo más simple posible se debe a que así se asegura su rendimiento y estabilidad. Por si fuera poco no poder usar C++ para simplificar la programación de módulos, resulta que tampoco se pueden usar las librerías habituales de C. En vez de eso, hay que utilizar una serie de "versiones" de dichas librerías preparadas para ser usadas en la programación del kernel. Estas "versiones" sólo cubren un subconjunto de las funciones habituales en la programación para el espacio de usuario por lo que a veces nos veremos teniendo que reimplementar nosotros mismos funciones que solíamos cargar de librerías o teniendo que adaptar nuestra forma de programar a otras funciones que sí estén disponibles en las librerías de kernel. Por otro lado hay que tener en cuenta que programar módulos para el kernel es hacer neurocirugía con el sistema por lo que el más mínimo error puede dejarlo irremisiblemente catatónico. Por eso, lo mejor cuando se programa un módulo de kernel es crear una máquina virtual de características similares a aquella en la que se vaya a instalar el módulo y hacer las sucesivas pruebas en dicha máquina virtual. Si algo falla y la máquina virtual se fastidia (habitual en la fase de desarrollo) siempre podremos restaurar una snapshot anterior. Nunca, y repito, nunca hay que probar un módulo en desarrollo en una máquina real o podemos inutilizarla completamente. Superadas todas estas dificultades, que no son pocas, el uso de módulos de kernel para implementar rootkits es la técnica más efectiva ya que da un control total sobre la máquina y las aplicaciones que se ejecutan en ella.
Para programar un módulo de kernel hay que descargar las librerías básicas para su compilación. En un sistema Ubuntu, las librerías que yo suelo instalar:
  • linux-libc-dev
  • linux-header-XXXXX ---> Donde XXXXX corresponde al número de versión del kernel para el que queremos programar el módulo.
  • gcc
  • make
Los Makefile necesarios para compilar un módulo de kernel son algo diferentes a los que se usarían para compilar una aplicación de espacio de usuario. Un ejemplo de Makefile para un módulo de kernel es:

obj-m += nombre_modulo.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

Se puede ver de entrada el uso de la cláusula obj-m que ya señala que el objeto a compilar es un módulo de kernel. El resultado de compilar un módulo de kernel con este Makefile es un fichero nombre_modulo.ko que es nuestro módulo compilado. Este módulo puede ser cargado en el kernel con una orden: sudo insmod nombre_modulo.ko

Una vez probado el módulo, para descargarlo del kernel no hay más que hacer: sudo rmmod nombre_modulo

Observe que para insertar el módulo se usa su extensión .ko pero para quitarlo no. Por cierto, esta extensión .ko identifica a los módulos de kernel (kernel object) de las librerías normales que sólo tienen extensión .o.

Vistas sus particularidades y dado que es la técnica más potente, en uno de mis próximos artículos detallaré como hacer un módulo de kernel que permita ocultar procesos. Esto servirá de ejemplo práctico de esta técnica y permitirá vislumbrar cómo se puede extender a otras tareas ofensivas.